@aliwey/bmo 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +90 -0
  2. package/bin/bmo.js +188 -0
  3. package/cli.py +1129 -0
  4. package/config/__init__.py +0 -0
  5. package/config/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/config/__pycache__/settings.cpython-313.pyc +0 -0
  7. package/config/__pycache__/system-prompt.cpython-313.pyc +0 -0
  8. package/config/settings.py +104 -0
  9. package/config/system-prompt.json +18 -0
  10. package/core/__init__.py +0 -0
  11. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  12. package/core/__pycache__/bfp_a2a_bridge.cpython-313.pyc +0 -0
  13. package/core/__pycache__/bfp_agent.cpython-313.pyc +0 -0
  14. package/core/__pycache__/bfp_agent_card.cpython-313.pyc +0 -0
  15. package/core/__pycache__/bfp_connector.cpython-313.pyc +0 -0
  16. package/core/__pycache__/bfp_discovery.cpython-313.pyc +0 -0
  17. package/core/__pycache__/bfp_identity.cpython-313.pyc +0 -0
  18. package/core/__pycache__/bfp_tasks.cpython-313.pyc +0 -0
  19. package/core/__pycache__/bfp_transport.cpython-313.pyc +0 -0
  20. package/core/__pycache__/bmo_engine.cpython-313.pyc +0 -0
  21. package/core/__pycache__/bot_client.cpython-313.pyc +0 -0
  22. package/core/__pycache__/budget_tracker.cpython-313.pyc +0 -0
  23. package/core/__pycache__/cli_renderer.cpython-313.pyc +0 -0
  24. package/core/__pycache__/goal_runner.cpython-313.pyc +0 -0
  25. package/core/__pycache__/request_worker.cpython-313.pyc +0 -0
  26. package/core/__pycache__/security.cpython-313.pyc +0 -0
  27. package/core/__pycache__/shared_state.cpython-313.pyc +0 -0
  28. package/core/__pycache__/worker_manager.cpython-313.pyc +0 -0
  29. package/core/__pycache__/worker_multiproc.cpython-313.pyc +0 -0
  30. package/core/__pycache__/worker_protocol.cpython-313.pyc +0 -0
  31. package/core/__pycache__/worker_subprocess.cpython-313.pyc +0 -0
  32. package/core/bfp_a2a_bridge.py +399 -0
  33. package/core/bfp_agent.py +98 -0
  34. package/core/bfp_agent_card.py +161 -0
  35. package/core/bfp_connector.py +177 -0
  36. package/core/bfp_discovery.py +105 -0
  37. package/core/bfp_identity.py +83 -0
  38. package/core/bfp_tasks.py +70 -0
  39. package/core/bfp_transport.py +368 -0
  40. package/core/bmo_engine.py +405 -0
  41. package/core/bot_client.py +838 -0
  42. package/core/budget_tracker.py +62 -0
  43. package/core/cli_renderer.py +177 -0
  44. package/core/goal_runner.py +129 -0
  45. package/core/request_worker.py +242 -0
  46. package/core/security.py +42 -0
  47. package/core/shared_state.py +4 -0
  48. package/core/worker_manager.py +71 -0
  49. package/core/worker_multiproc.py +155 -0
  50. package/core/worker_protocol.py +30 -0
  51. package/core/worker_subprocess.py +222 -0
  52. package/handlers/__init__.py +0 -0
  53. package/handlers/__pycache__/__init__.cpython-313.pyc +0 -0
  54. package/handlers/__pycache__/messages.cpython-313.pyc +0 -0
  55. package/handlers/messages.py +2761 -0
  56. package/main.py +125 -0
  57. package/memory.md +43 -0
  58. package/models/__init__.py +0 -0
  59. package/models/__pycache__/__init__.cpython-313.pyc +0 -0
  60. package/models/__pycache__/chat_models.cpython-313.pyc +0 -0
  61. package/models/chat_models.py +143 -0
  62. package/package.json +50 -0
  63. package/registry/worker.js +108 -0
  64. package/registry/wrangler.toml +11 -0
  65. package/requirements.txt +13 -0
  66. package/scripts/bmo_init.js +115 -0
  67. package/scripts/postinstall.js +265 -0
  68. package/scripts/relay_cmd.js +276 -0
  69. package/scripts/web_cmd.js +136 -0
  70. package/setup.py +26 -0
  71. package/storage/__init__.py +0 -0
  72. package/storage/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/storage/__pycache__/sqlite_storage.cpython-313.pyc +0 -0
  74. package/storage/__pycache__/storage.cpython-313.pyc +0 -0
  75. package/storage/sqlite_storage.py +658 -0
  76. package/storage/storage.py +265 -0
  77. package/tools/__pycache__/bfp_relay.cpython-313.pyc +0 -0
  78. package/tools/__pycache__/get_session_summaries.cpython-313.pyc +0 -0
  79. package/tools/__pycache__/mcp_bridge.cpython-313.pyc +0 -0
  80. package/tools/__pycache__/mcp_server.cpython-313.pyc +0 -0
  81. package/tools/__pycache__/run_mcp_standalone.cpython-313.pyc +0 -0
  82. package/tools/__pycache__/task_registry.cpython-313.pyc +0 -0
  83. package/tools/__pycache__/test_mcp_connection.cpython-313.pyc +0 -0
  84. package/tools/bfp_relay.py +359 -0
  85. package/tools/bot.db +0 -0
  86. package/tools/get_session_summaries.py +45 -0
  87. package/tools/mcp_bridge.py +109 -0
  88. package/tools/mcp_server.py +531 -0
  89. package/tools/register_mcp_task.py +20 -0
  90. package/tools/run_detached.bat +32 -0
  91. package/tools/run_mcp_standalone.py +16 -0
  92. package/tools/task_registry.py +184 -0
  93. package/tools/test_mcp_connection.py +80 -0
  94. package/webchat/package-lock.json +1528 -0
  95. package/webchat/package.json +12 -0
  96. package/webchat/public/app.js +1293 -0
  97. package/webchat/public/index.html +226 -0
  98. package/webchat/public/index.html.bak +416 -0
  99. package/webchat/public/styles.css +2435 -0
  100. package/webchat/server.js +645 -0
@@ -0,0 +1,71 @@
1
+ import logging
2
+ import os
3
+ from typing import Callable, Optional
4
+
5
+ from core.worker_subprocess import AsyncSubprocessWorker
6
+ from core.worker_multiproc import MultiprocessWorker
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class WorkerManager:
12
+ """Unified facade over both worker backends.
13
+ Selects backend via env WORKER_BACKEND (subprocess|multiproc).
14
+ """
15
+
16
+ def __init__(self, backend: Optional[str] = None):
17
+ if backend is None:
18
+ backend = os.getenv("WORKER_BACKEND", "subprocess")
19
+ self.backend = backend.lower()
20
+
21
+ if self.backend == "subprocess":
22
+ self._worker = AsyncSubprocessWorker()
23
+ elif self.backend == "multiproc":
24
+ self._worker = MultiprocessWorker()
25
+ else:
26
+ raise ValueError(f"Unknown worker backend: {backend}")
27
+
28
+ logger.info(f"WorkerManager using backend: {self.backend}")
29
+
30
+ async def start(self):
31
+ await self._worker.start()
32
+
33
+ async def send_query(
34
+ self,
35
+ session_id: str,
36
+ payload: dict,
37
+ base_url: str,
38
+ poll_interval: float = 2.0,
39
+ poll_timeout: float = 600.0,
40
+ callback: Optional[Callable] = None,
41
+ ) -> str:
42
+ return await self._worker.send_query(
43
+ session_id=session_id,
44
+ payload=payload,
45
+ base_url=base_url,
46
+ poll_interval=poll_interval,
47
+ poll_timeout=poll_timeout,
48
+ callback=callback,
49
+ )
50
+
51
+ async def connect(self, base_url: str):
52
+ await self._worker.connect(base_url)
53
+
54
+ async def shutdown(self):
55
+ await self._worker.shutdown()
56
+
57
+ async def cancel_current(self):
58
+ """Cancel the in-flight request by terminating the underlying worker.
59
+
60
+ Delegates to the active backend (subprocess or multiproc). Called by
61
+ the CLI SIGINT handler so Ctrl+C interrupts the actual model request,
62
+ not just the asyncio task wrapping it.
63
+ """
64
+ try:
65
+ await self._worker.cancel_current()
66
+ except Exception as e:
67
+ logger.warning("WorkerManager.cancel_current error: %s", e)
68
+
69
+ @property
70
+ def is_alive(self) -> bool:
71
+ return self._worker.is_alive
@@ -0,0 +1,155 @@
1
+ import asyncio
2
+ import logging
3
+ import multiprocessing
4
+ import os
5
+ import signal
6
+ import sys
7
+ import time
8
+ from typing import Callable, Optional
9
+
10
+ from core.request_worker import worker_main
11
+ from core.worker_protocol import (
12
+ MSG_RESULT, MSG_UPDATE, MSG_ERROR, MSG_CANCELLED,
13
+ )
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ HEARTBEAT_INTERVAL = 5.0
18
+ HEARTBEAT_TIMEOUT = 12.0
19
+ MAX_RESPAWN_DELAY = 30.0
20
+
21
+
22
+ class MultiprocessWorker:
23
+ """Approach 2: Multiprocessing.Process + Queue worker.
24
+ Uses loop.run_in_executor for non-blocking result reads.
25
+ """
26
+
27
+ def __init__(self):
28
+ self.task_queue: multiprocessing.Queue = multiprocessing.Queue()
29
+ self.result_queue: multiprocessing.Queue = multiprocessing.Queue()
30
+ self.process: Optional[multiprocessing.Process] = None
31
+ self._running = False
32
+ self._spawn_count = 0
33
+ self._closed = False
34
+
35
+ async def start(self):
36
+ if self._running:
37
+ return
38
+ self._running = True
39
+ self._spawn_worker()
40
+
41
+ def _spawn_worker(self):
42
+ if self.process and self.process.is_alive():
43
+ self._kill_worker()
44
+
45
+ self._spawn_count += 1
46
+ self.process = multiprocessing.Process(
47
+ target=worker_main,
48
+ args=(self.task_queue, self.result_queue),
49
+ daemon=True,
50
+ name=f"request-worker-{self._spawn_count}",
51
+ )
52
+ self.process.start()
53
+ logger.info(f"Spawned multiproc worker (PID {self.process.pid}, spawn #{self._spawn_count})")
54
+
55
+ def _kill_worker(self):
56
+ if self.process and self.process.is_alive():
57
+ try:
58
+ if sys.platform == "win32":
59
+ self.process.terminate()
60
+ else:
61
+ os.kill(self.process.pid, signal.SIGKILL)
62
+ self.process.join(timeout=5)
63
+ except Exception as e:
64
+ logger.warning(f"Error killing worker: {e}")
65
+ finally:
66
+ self.process = None
67
+
68
+ def _on_worker_dead(self):
69
+ if self._closed:
70
+ return
71
+ logger.warning("Multiproc worker dead, respawning...")
72
+ self._kill_worker()
73
+ self._spawn_worker()
74
+
75
+ async def send_query(
76
+ self,
77
+ session_id: str,
78
+ payload: dict,
79
+ base_url: str,
80
+ poll_interval: float = 2.0,
81
+ poll_timeout: float = 600.0,
82
+ callback: Optional[Callable] = None,
83
+ ) -> str:
84
+ task = {
85
+ "type": "query",
86
+ "session_id": session_id,
87
+ "payload": payload,
88
+ "base_url": base_url,
89
+ "poll_interval": poll_interval,
90
+ "poll_timeout": poll_timeout,
91
+ }
92
+ self.task_queue.put(task)
93
+
94
+ loop = asyncio.get_event_loop()
95
+ full_text = ""
96
+ deadline = time.monotonic() + poll_timeout
97
+
98
+ while time.monotonic() < deadline:
99
+ try:
100
+ result = await loop.run_in_executor(
101
+ None, self.result_queue.get, True, 5.0
102
+ )
103
+ except Exception:
104
+ if not self.process or not self.process.is_alive():
105
+ return "Error: Worker process died"
106
+ continue
107
+
108
+ if not isinstance(result, dict):
109
+ continue
110
+
111
+ msg_type = result.get("type")
112
+ if msg_type == MSG_UPDATE:
113
+ diff = result.get("content", "")
114
+ full_text = result.get("full_text", full_text + diff)
115
+ if callback and diff:
116
+ callback(full_text)
117
+ elif msg_type == MSG_RESULT:
118
+ content = result.get("content", "")
119
+ if callback and content:
120
+ callback(content)
121
+ return content
122
+ elif msg_type == MSG_ERROR:
123
+ return f"Error: {result.get('message', 'Unknown worker error')}"
124
+ elif msg_type == MSG_CANCELLED:
125
+ return "Error: Request was cancelled."
126
+
127
+ return "Error: Request timed out"
128
+
129
+ async def connect(self, base_url: str):
130
+ self.task_queue.put({"type": "connect", "base_url": base_url})
131
+
132
+ async def shutdown(self):
133
+ self._closed = True
134
+ self._running = False
135
+ try:
136
+ self.task_queue.put({"type": "shutdown"})
137
+ except Exception:
138
+ pass
139
+ self._kill_worker()
140
+
141
+ async def cancel_current(self):
142
+ """Cancel the in-flight request by killing and respawning the multiproc worker.
143
+
144
+ Mirrors AsyncSubprocessWorker.cancel_current() for the multiprocessing
145
+ backend so the SIGINT handler in CLI works uniformly.
146
+ """
147
+ logger.info("Cancelling in-flight request by killing multiproc worker (PID %s)", self.process.pid if self.process else None)
148
+ self._kill_worker()
149
+ # Don't respawn immediately — let the next send_query call it via
150
+ # _on_worker_dead pattern, or let BotClient ensure a fresh one.
151
+ self._running = False
152
+
153
+ @property
154
+ def is_alive(self) -> bool:
155
+ return self.process is not None and self.process.is_alive()
@@ -0,0 +1,30 @@
1
+ import json
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+ MSG_QUERY = "query"
7
+ MSG_RESULT = "result"
8
+ MSG_UPDATE = "update"
9
+ MSG_ERROR = "error"
10
+ MSG_CANCELLED = "cancelled"
11
+ MSG_CONNECT = "connect"
12
+ MSG_PING = "ping"
13
+ MSG_PONG = "pong"
14
+ MSG_SHUTDOWN = "shutdown"
15
+
16
+
17
+ def encode_msg(msg_id: int, msg_type: str, **kwargs) -> str:
18
+ data = {"id": msg_id, "type": msg_type, **kwargs}
19
+ return json.dumps(data, default=str) + "\n"
20
+
21
+
22
+ def decode_msg(line: str) -> dict | None:
23
+ line = line.strip().lstrip("\ufeff")
24
+ if not line:
25
+ return None
26
+ try:
27
+ return json.loads(line)
28
+ except json.JSONDecodeError as e:
29
+ logger.warning("Failed to decode worker message: %s", e)
30
+ return None
@@ -0,0 +1,222 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import os
5
+ import sys
6
+ import time
7
+ from typing import Callable, Optional
8
+
9
+ from core.worker_protocol import (
10
+ encode_msg, decode_msg, MSG_QUERY, MSG_RESULT, MSG_UPDATE,
11
+ MSG_ERROR, MSG_CANCELLED, MSG_CONNECT, MSG_SHUTDOWN,
12
+ )
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ WORKER_SCRIPT = os.path.join(os.path.dirname(__file__), "request_worker.py")
17
+
18
+
19
+ class AsyncSubprocessWorker:
20
+ """Approach 1: Spawn worker via asyncio.create_subprocess_exec.
21
+ Communicates via JSON-RPC over stdin/stdout.
22
+ """
23
+
24
+ def __init__(self):
25
+ self._process: Optional[asyncio.subprocess.Process] = None
26
+ self._msg_id = 0
27
+ self._pending: dict[int, asyncio.Future] = {}
28
+ self._running = False
29
+ self._reader_task: Optional[asyncio.Task] = None
30
+
31
+ async def start(self):
32
+ if self._running:
33
+ return
34
+ self._running = True
35
+
36
+ python = sys.executable
37
+ self._process = await asyncio.create_subprocess_exec(
38
+ python, WORKER_SCRIPT, "--mode", "stdio",
39
+ stdin=asyncio.subprocess.PIPE,
40
+ stdout=asyncio.subprocess.PIPE,
41
+ stderr=asyncio.subprocess.PIPE,
42
+ cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
43
+ )
44
+ logger.info(f"Subprocess worker started (PID {self._process.pid})")
45
+
46
+ self._reader_task = asyncio.create_task(self._read_stdout())
47
+
48
+ async def _read_stdout(self):
49
+ while self._running and self._process and not self._process.stdout.at_eof():
50
+ line = await self._process.stdout.readline()
51
+ if not line:
52
+ break
53
+ msg = decode_msg(line.decode("utf-8", errors="replace"))
54
+ if not msg:
55
+ continue
56
+
57
+ msg_id = msg.get("id")
58
+ if msg_id in self._pending:
59
+ future = self._pending.pop(msg_id)
60
+ if not future.done():
61
+ future.set_result(msg)
62
+
63
+ logger.info("Subprocess worker stdout closed")
64
+
65
+ async def send_query(
66
+ self,
67
+ session_id: str,
68
+ payload: dict,
69
+ base_url: str,
70
+ poll_interval: float = 2.0,
71
+ poll_timeout: float = 600.0,
72
+ callback: Optional[Callable] = None,
73
+ ) -> str:
74
+ self._msg_id += 1
75
+ msg_id = self._msg_id
76
+ future: asyncio.Future = asyncio.get_event_loop().create_future()
77
+ self._pending[msg_id] = future
78
+
79
+ cmd = {
80
+ "id": msg_id,
81
+ "type": MSG_QUERY,
82
+ "session_id": session_id,
83
+ "payload": payload,
84
+ "base_url": base_url,
85
+ "poll_interval": poll_interval,
86
+ "poll_timeout": poll_timeout,
87
+ }
88
+ await self._write_cmd(cmd)
89
+
90
+ full_text = ""
91
+ deadline = time.monotonic() + poll_timeout
92
+ while time.monotonic() < deadline:
93
+ try:
94
+ result = await asyncio.wait_for(
95
+ asyncio.shield(future), timeout=5.0
96
+ )
97
+ except asyncio.TimeoutError:
98
+ if msg_id in self._pending:
99
+ future = self._pending[msg_id]
100
+ if future.done():
101
+ result = future.result()
102
+ else:
103
+ continue
104
+ else:
105
+ break
106
+
107
+ msg_type = result.get("type")
108
+ if msg_type == MSG_UPDATE:
109
+ diff = result.get("content", "")
110
+ full_text = result.get("full_text", full_text + diff)
111
+ if callback:
112
+ callback(full_text)
113
+ new_future = asyncio.get_event_loop().create_future()
114
+ self._pending[msg_id] = new_future
115
+ future = new_future
116
+ elif msg_type == MSG_RESULT:
117
+ content = result.get("content", "")
118
+ if callback:
119
+ callback(content)
120
+ return content
121
+ elif msg_type == MSG_ERROR:
122
+ return f"Error: {result.get('message', 'Unknown worker error')}"
123
+ elif msg_type == MSG_CANCELLED:
124
+ return "Error: Request was cancelled."
125
+ else:
126
+ if msg_id in self._pending:
127
+ del self._pending[msg_id]
128
+ return full_text or "Error: Unexpected response"
129
+
130
+ if msg_id in self._pending:
131
+ del self._pending[msg_id]
132
+ return full_text or "Error: Request timed out"
133
+
134
+ async def connect(self, base_url: str):
135
+ self._msg_id += 1
136
+ cmd = {"id": self._msg_id, "type": MSG_CONNECT, "base_url": base_url}
137
+ await self._write_cmd(cmd)
138
+
139
+ async def _write_cmd(self, cmd: dict):
140
+ line = json.dumps(cmd, default=str) + "\n"
141
+ if self._process and self._process.stdin:
142
+ self._process.stdin.write(line.encode("utf-8"))
143
+ await self._process.stdin.drain()
144
+
145
+ async def shutdown(self):
146
+ self._running = False
147
+ try:
148
+ if self._process:
149
+ # Close stdin first to signal EOF to worker (graceful path)
150
+ try:
151
+ if self._process.stdin and not self._process.stdin.is_closing():
152
+ self._process.stdin.close()
153
+ except Exception:
154
+ pass
155
+ # Send shutdown command (may fail if stdin already closed)
156
+ try:
157
+ await self._write_cmd({"type": MSG_SHUTDOWN})
158
+ except Exception:
159
+ pass
160
+ try:
161
+ await asyncio.wait_for(self._process.wait(), timeout=5.0)
162
+ except asyncio.TimeoutError:
163
+ self._process.kill()
164
+ await self._process.wait()
165
+ except Exception as e:
166
+ logger.warning(f"Subprocess worker shutdown error: {e}")
167
+ finally:
168
+ if self._reader_task:
169
+ self._reader_task.cancel()
170
+ try:
171
+ await self._reader_task
172
+ except (asyncio.CancelledError, Exception):
173
+ pass
174
+ self._reader_task = None
175
+ # Explicitly close all pipe transports to prevent ResourceWarning
176
+ # when the proactor loop tears them down at GC time. On Windows
177
+ # these are `_ProactorBasePipeTransport` whose __del__ tries
178
+ # `fileno()` on a closed pipe and raises ValueError.
179
+ if self._process is not None:
180
+ for pipe_name in ('stdin', 'stdout', 'stderr'):
181
+ pipe = getattr(self._process, pipe_name, None)
182
+ if pipe is not None:
183
+ try:
184
+ transport = getattr(pipe, '_transport', None) or pipe
185
+ if hasattr(transport, 'close'):
186
+ transport.close()
187
+ except Exception:
188
+ pass
189
+ self._process = None
190
+
191
+ async def cancel_current(self):
192
+ """Cancel the in-flight request by killing and respawning the subprocess.
193
+
194
+ Used by SIGINT handler in CLI when user presses Ctrl+C. The pending
195
+ future in self._pending will never resolve (subprocess is dead), so
196
+ the awaiter will hit its timeout and propagate the cancellation.
197
+ """
198
+ if not self._process or self._process.returncode is not None:
199
+ return
200
+ logger.info("Cancelling in-flight request by terminating subprocess (PID %s)", self._process.pid)
201
+ try:
202
+ self._process.kill()
203
+ except Exception as e:
204
+ logger.warning("Error killing subprocess: %s", e)
205
+ # Mark all pending futures as cancelled so awaiters unblock immediately
206
+ for msg_id, future in list(self._pending.items()):
207
+ if not future.done():
208
+ future.cancel()
209
+ self._pending.pop(msg_id, None)
210
+ # Wait for the process to actually die, then respawn so the next
211
+ # request gets a fresh worker (the old one is in a bad state).
212
+ try:
213
+ await asyncio.wait_for(self._process.wait(), timeout=2.0)
214
+ except asyncio.TimeoutError:
215
+ pass
216
+ self._process = None
217
+ self._running = False
218
+ # The next send_query() will trigger a fresh start() via BotClient.ensure_worker()
219
+
220
+ @property
221
+ def is_alive(self) -> bool:
222
+ return self._process is not None and self._process.returncode is None
File without changes