monkeybot 2.1.1__py3-none-any.whl

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 (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,200 @@
1
+ """REST control plane for prompt-first scheduled loops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from typing import Any, cast
8
+
9
+ from fastapi import APIRouter, Depends, Request
10
+ from pydantic import BaseModel, Field
11
+
12
+ from monkeybot.core.persistence.backends import ScheduledLoopStore, StorageBackend
13
+ from monkeybot.core.persistence.scheduled_loops import ScheduledLoopCreate, ScheduledLoopRow
14
+ from monkeybot.core.types.content_blocks import Text
15
+ from monkeybot.gateway.sse.loop_port import LoopPort
16
+ from monkeybot.gateway.sse.models import APIError
17
+ from monkeybot.gateway.sse.scheduler_wiring import GatewaySessionEnsurer
18
+ from monkeybot.gateway.sse.session_bus import SessionRegistry
19
+ from monkeybot.scheduler.interval import parse_interval_ms, parse_optional_duration_ms
20
+
21
+
22
+ def _get_registry(request: Request) -> SessionRegistry:
23
+ return cast(SessionRegistry, request.app.state.registry)
24
+
25
+
26
+ def _storage_backend(request: Request) -> StorageBackend:
27
+ backend: StorageBackend | None = getattr(request.app.state, "storage", None)
28
+ if backend is None:
29
+ raise APIError(503, "STORAGE_NOT_READY", "Storage backend not initialized", uuid.uuid4().hex)
30
+ return backend
31
+
32
+
33
+ def _loop_store(request: Request) -> ScheduledLoopStore:
34
+ return _storage_backend(request).scheduled_loops()
35
+
36
+
37
+ class CreateLoopRequest(BaseModel):
38
+ prompt: str = Field(..., min_length=1)
39
+ interval: str | int | float
40
+ session_id: str = "loop-main"
41
+ loop_id: str | None = None
42
+ max_ticks: int | None = Field(default=None, ge=1)
43
+ max_runtime: str | int | float | None = None
44
+ skip_if_busy: bool = True
45
+ unbounded: bool = False
46
+ confirmed: bool = False
47
+
48
+
49
+ class InvokeTickRequest(BaseModel):
50
+ session_id: str
51
+ request_id: str
52
+ message: str
53
+
54
+
55
+ def _row_dict(row: ScheduledLoopRow) -> dict[str, Any]:
56
+ return {
57
+ "loop_id": row.loop_id,
58
+ "session_id": row.session_id,
59
+ "status": row.status,
60
+ "prompt": row.prompt,
61
+ "interval_ms": row.interval_ms,
62
+ "max_ticks": row.max_ticks,
63
+ "max_runtime_ms": row.max_runtime_ms,
64
+ "skip_if_busy": row.skip_if_busy,
65
+ "tick_index": row.tick_index,
66
+ "next_tick_at_ms": row.next_tick_at_ms,
67
+ "started_at_ms": row.started_at_ms,
68
+ "last_tick_at_ms": row.last_tick_at_ms,
69
+ "last_error": row.last_error,
70
+ "stop_reason": row.stop_reason,
71
+ "tick_in_flight": row.tick_in_flight,
72
+ }
73
+
74
+
75
+ def build_scheduler_router(*, loop_port: LoopPort, registry: SessionRegistry) -> APIRouter:
76
+ router = APIRouter(prefix="/scheduler", tags=["scheduler"])
77
+ ensurer = GatewaySessionEnsurer(registry)
78
+
79
+ @router.post("/loops", status_code=201)
80
+ async def create_loop(
81
+ body: CreateLoopRequest,
82
+ request: Request,
83
+ ) -> dict[str, Any]:
84
+ store = _loop_store(request)
85
+ if not body.confirmed:
86
+ raise APIError(
87
+ 400,
88
+ "CONFIRMATION_REQUIRED",
89
+ "confirmed=true is required to register a scheduled loop via REST/CLI",
90
+ uuid.uuid4().hex,
91
+ )
92
+ try:
93
+ interval_ms = parse_interval_ms(body.interval)
94
+ max_runtime_ms = parse_optional_duration_ms(body.max_runtime)
95
+ except ValueError as exc:
96
+ raise APIError(400, "INVALID_INTERVAL", str(exc), uuid.uuid4().hex) from exc
97
+ spec = ScheduledLoopCreate(
98
+ prompt=body.prompt,
99
+ interval_ms=interval_ms,
100
+ session_id=body.session_id,
101
+ loop_id=body.loop_id,
102
+ max_ticks=body.max_ticks,
103
+ max_runtime_ms=max_runtime_ms,
104
+ skip_if_busy=body.skip_if_busy,
105
+ unbounded=body.unbounded,
106
+ )
107
+ try:
108
+ row = await store.create(spec)
109
+ except ValueError as exc:
110
+ msg = str(exc)
111
+ if "scheduled loops require" in msg:
112
+ raise APIError(400, "INVALID_GUARDS", msg, uuid.uuid4().hex) from exc
113
+ raise APIError(409, "LOOP_EXISTS", msg, uuid.uuid4().hex) from exc
114
+ await ensurer.ensure_session(row.session_id)
115
+ return {"loop": _row_dict(row)}
116
+
117
+ @router.get("/loops")
118
+ async def list_loops(request: Request) -> dict[str, Any]:
119
+ rows = await _loop_store(request).list_all()
120
+ return {"loops": [_row_dict(r) for r in rows]}
121
+
122
+ @router.get("/loops/{loop_id}")
123
+ async def get_loop(loop_id: str, request: Request) -> dict[str, Any]:
124
+ row = await _loop_store(request).get(loop_id)
125
+ if row is None:
126
+ raise APIError(404, "LOOP_NOT_FOUND", f"Unknown loop {loop_id}", uuid.uuid4().hex)
127
+ usage = await _storage_backend(request).usage().summary(
128
+ thread_id=row.session_id,
129
+ since_ms=row.started_at_ms,
130
+ )
131
+ return {
132
+ "loop": _row_dict(row),
133
+ "usage": {
134
+ "session_id": row.session_id,
135
+ "since_ms": row.started_at_ms,
136
+ "turns": usage.turns,
137
+ "input_tokens": usage.input_tokens,
138
+ "output_tokens": usage.output_tokens,
139
+ "cached_tokens": usage.cached_tokens,
140
+ "cache_read_tokens": usage.cache_read_tokens,
141
+ "cache_creation_tokens": usage.cache_creation_tokens,
142
+ "cost_usd": usage.cost_usd,
143
+ },
144
+ }
145
+
146
+ @router.post("/loops/{loop_id}/pause")
147
+ async def pause_loop(loop_id: str, request: Request) -> dict[str, Any]:
148
+ ok = await _loop_store(request).pause(loop_id)
149
+ if not ok:
150
+ raise APIError(404, "LOOP_NOT_FOUND", f"Unknown loop {loop_id}", uuid.uuid4().hex)
151
+ return {"loop_id": loop_id, "status": "paused"}
152
+
153
+ @router.post("/loops/{loop_id}/resume")
154
+ async def resume_loop(loop_id: str, request: Request) -> dict[str, Any]:
155
+ ok = await _loop_store(request).resume(loop_id)
156
+ if not ok:
157
+ raise APIError(404, "LOOP_NOT_FOUND", f"Paused loop {loop_id} not found", uuid.uuid4().hex)
158
+ return {"loop_id": loop_id, "status": "active"}
159
+
160
+ @router.post("/loops/{loop_id}/stop")
161
+ async def stop_loop(loop_id: str, request: Request) -> dict[str, Any]:
162
+ ok = await _loop_store(request).stop(loop_id)
163
+ if not ok:
164
+ raise APIError(404, "LOOP_NOT_FOUND", f"Unknown loop {loop_id}", uuid.uuid4().hex)
165
+ return {"loop_id": loop_id, "status": "completed"}
166
+
167
+ @router.post("/invoke-tick")
168
+ async def invoke_tick_sync(
169
+ body: InvokeTickRequest,
170
+ request: Request,
171
+ reg_dep: SessionRegistry = Depends(_get_registry),
172
+ ) -> dict[str, Any]:
173
+ """Run one agent turn synchronously (scheduler worker / internal automation)."""
174
+ if reg_dep.get(body.session_id) is None:
175
+ await ensurer.ensure_session(body.session_id)
176
+ turn_locks = _storage_backend(request).session_turns()
177
+ acquired = await turn_locks.try_acquire(body.session_id, body.request_id)
178
+ if not acquired:
179
+ raise APIError(
180
+ 409,
181
+ "SESSION_BUSY",
182
+ "Session already processing a request",
183
+ uuid.uuid4().hex,
184
+ )
185
+ bus = reg_dep.get(body.session_id)
186
+ if bus is not None:
187
+ bus.current_request_id = body.request_id
188
+ try:
189
+ await loop_port.start_turn(
190
+ body.session_id,
191
+ body.request_id,
192
+ [Text(text=body.message)],
193
+ )
194
+ finally:
195
+ await turn_locks.release(body.session_id, body.request_id)
196
+ if bus is not None and bus.current_request_id == body.request_id:
197
+ bus.current_request_id = None
198
+ return {"ok": True, "session_id": body.session_id, "request_id": body.request_id}
199
+
200
+ return router
@@ -0,0 +1,96 @@
1
+ """Gateway adapters for the scheduled-loop engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import time
8
+
9
+ from monkeybot.core.persistence.backends import SessionTurnLockStore, StorageBackend
10
+ from monkeybot.core.types.content_blocks import ContentBlock
11
+ from monkeybot.gateway.sse.loop_port import LoopPort
12
+ from monkeybot.gateway.sse.session_bus import SessionAlreadyExistsError, SessionRegistry
13
+ from monkeybot.scheduler.tick_result import TickInvokeResult
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class StorageSessionBusyChecker:
19
+ """Check session busy state via durable storage (multi-replica safe)."""
20
+
21
+ def __init__(self, turn_locks: SessionTurnLockStore) -> None:
22
+ self._turn_locks = turn_locks
23
+
24
+ def is_busy(self, session_id: str) -> bool:
25
+ del session_id
26
+ return False
27
+
28
+ async def is_busy_async(self, session_id: str) -> bool:
29
+ return await self._turn_locks.is_busy(session_id)
30
+
31
+
32
+ class GatewaySessionBusyChecker:
33
+ """Legacy in-process checker; prefer :class:`StorageSessionBusyChecker`."""
34
+
35
+ def __init__(self, registry: SessionRegistry) -> None:
36
+ self._registry = registry
37
+
38
+ def is_busy(self, session_id: str) -> bool:
39
+ bus = self._registry.get(session_id)
40
+ return bus is not None and bus.current_request_id is not None
41
+
42
+
43
+ class GatewaySessionEnsurer:
44
+ def __init__(self, registry: SessionRegistry) -> None:
45
+ self._registry = registry
46
+
47
+ async def ensure_session(self, session_id: str) -> None:
48
+ if self._registry.get(session_id) is not None:
49
+ return
50
+ agent_md = os.environ.get("AGENT_MD")
51
+ try:
52
+ self._registry.create(
53
+ session_id,
54
+ agent_md=agent_md,
55
+ created_at_ms=int(time.time() * 1000),
56
+ )
57
+ logger.info("scheduler created session_id=%s", session_id)
58
+ except SessionAlreadyExistsError:
59
+ return
60
+
61
+
62
+ class GatewayTickInvoker:
63
+ """Invoke ``LoopPort.start_turn`` and surface turn-level outcomes."""
64
+
65
+ def __init__(
66
+ self,
67
+ loop_port: LoopPort,
68
+ registry: SessionRegistry,
69
+ turn_locks: SessionTurnLockStore,
70
+ ) -> None:
71
+ self._loop_port = loop_port
72
+ self._registry = registry
73
+ self._turn_locks = turn_locks
74
+
75
+ async def invoke_tick(
76
+ self,
77
+ session_id: str,
78
+ request_id: str,
79
+ user_content: list[ContentBlock],
80
+ ) -> TickInvokeResult:
81
+ acquired = await self._turn_locks.try_acquire(session_id, request_id)
82
+ if not acquired:
83
+ return TickInvokeResult.session_busy()
84
+ bus = self._registry.get(session_id)
85
+ if bus is not None:
86
+ bus.current_request_id = request_id
87
+ try:
88
+ await self._loop_port.start_turn(session_id, request_id, user_content)
89
+ except Exception as exc:
90
+ return TickInvokeResult.fail(str(exc))
91
+ finally:
92
+ await self._turn_locks.release(session_id, request_id)
93
+ bus = self._registry.get(session_id)
94
+ if bus is not None and bus.current_request_id == request_id:
95
+ bus.current_request_id = None
96
+ return TickInvokeResult.ok()
@@ -0,0 +1,226 @@
1
+ """
2
+ In-memory per-session SSE bus with replay buffer and live subscribers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ import os
9
+ from collections import deque
10
+ from typing import Any, Literal
11
+
12
+ from monkeybot.core.attachments.catalog import SessionAttachmentCatalog
13
+ from monkeybot.core.persistence.transcript import TranscriptWriter
14
+ from monkeybot.core.runtime.input_admission import InputAdmission
15
+ from monkeybot.core.tools.permission import SessionApprovals
16
+
17
+ from .sse import format_data_event
18
+
19
+ PENDING_RESPONSE_TIMEOUT_SEC: float = float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300"))
20
+
21
+
22
+ def _replay_maxlen_from_env() -> int:
23
+ raw = os.environ.get("SSE_REPLAY_MAX", "256")
24
+ try:
25
+ n = int(raw)
26
+ return max(1, n)
27
+ except ValueError:
28
+ return 256
29
+
30
+
31
+ class SessionAlreadyExistsError(Exception):
32
+ """Raised when POST /sessions repeats an existing client-supplied id."""
33
+
34
+
35
+ class SessionBus:
36
+ """Broadcasts framed SSE events; buffers numbered data events for replay."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ created_at_ms: int,
42
+ agent_md: str | None,
43
+ replay_maxlen: int | None = None,
44
+ provider: Any | None = None,
45
+ model_name: str | None = None,
46
+ ) -> None:
47
+ self.created_at_ms = created_at_ms
48
+ self.agent_md = agent_md
49
+ self.provider = provider
50
+ self.model_name = model_name
51
+ self.current_request_id: str | None = None
52
+ self.cancel_requested_for: str | None = None
53
+ self._seq = 0
54
+ maxlen = replay_maxlen if replay_maxlen is not None else _replay_maxlen_from_env()
55
+ self._replay: deque[tuple[int, str]] = deque(maxlen=maxlen)
56
+ self._subscribers: set[asyncio.Queue[str]] = set()
57
+ self._lock = asyncio.Lock()
58
+ self.pending_responses: dict[str, asyncio.Future[Any]] = {}
59
+ self.terminated_pending_keys: deque[str] = deque(maxlen=256)
60
+ self.attachment_catalog: SessionAttachmentCatalog | None = None
61
+ self.transcript_writer: TranscriptWriter | None = None
62
+ """Lazily-created ``TranscriptWriter`` (internal debugging only); None when disabled."""
63
+ self.admission = InputAdmission()
64
+ """Process-local steer + follow-up queues (not shared across gateway replicas)."""
65
+ self.session_approvals = SessionApprovals()
66
+ """Process-local 'always allow' rememberies (not shared across gateway replicas)."""
67
+ self.follow_up_retry_task: asyncio.Task[None] | None = None
68
+ """Scheduled drain retry after a failed durable turn-lock acquire."""
69
+
70
+ def cancel_follow_up_retry(self) -> None:
71
+ """Cancel any pending follow-up lock-retry task."""
72
+ task = self.follow_up_retry_task
73
+ self.follow_up_retry_task = None
74
+ if task is not None and not task.done():
75
+ task.cancel()
76
+
77
+ def register_pending(self, pending_key: str) -> asyncio.Future[Any]:
78
+ fut = asyncio.get_running_loop().create_future()
79
+ self.pending_responses[pending_key] = fut
80
+ return fut
81
+
82
+ def resolve_pending(self, pending_key: str, payload: Any) -> bool:
83
+ fut = self.pending_responses.get(pending_key)
84
+ if fut is None or fut.done():
85
+ return False
86
+ fut.set_result(payload)
87
+ self.pending_responses.pop(pending_key, None)
88
+ self.terminated_pending_keys.append(pending_key)
89
+ return True
90
+
91
+ def abandon_pending_timeout(self, pending_key: str) -> None:
92
+ fut = self.pending_responses.pop(pending_key, None)
93
+ if fut is None:
94
+ return
95
+ if not fut.done():
96
+ fut.cancel()
97
+ self.terminated_pending_keys.append(pending_key)
98
+
99
+ def abandon_pending_cancel_all(self) -> None:
100
+ for pending_key in list(self.pending_responses.keys()):
101
+ fut = self.pending_responses.pop(pending_key, None)
102
+ if fut is not None and not fut.done():
103
+ fut.cancel()
104
+ self.terminated_pending_keys.append(pending_key)
105
+
106
+ def is_pending_or_terminal(self, pending_key: str) -> Literal["pending", "terminated", "unknown"]:
107
+ if pending_key in self.pending_responses:
108
+ return "pending"
109
+ if pending_key in self.terminated_pending_keys:
110
+ return "terminated"
111
+ return "unknown"
112
+
113
+ async def publish_data(self, data_json: str) -> int:
114
+ """Buffer and broadcast one JSON data event; returns monotonic sequence id."""
115
+ async with self._lock:
116
+ self._seq += 1
117
+ seq = self._seq
118
+ frame = format_data_event(seq, data_json)
119
+ self._replay.append((seq, frame))
120
+ subscribers = list(self._subscribers)
121
+ for q in subscribers:
122
+ await q.put(frame)
123
+ return seq
124
+
125
+ async def publish_comment(self, comment_line: str) -> None:
126
+ """Send a comment/heartbeat line to live subscribers only (no replay)."""
127
+ async with self._lock:
128
+ subscribers = list(self._subscribers)
129
+ for q in subscribers:
130
+ await q.put(comment_line)
131
+
132
+ async def subscribe(
133
+ self, last_event_id: int | None
134
+ ) -> tuple[list[str], asyncio.Queue[str]]:
135
+ """
136
+ Register a subscriber and return buffered frames after last_event_id.
137
+
138
+ If last_event_id is None, replay all buffered frames (seq > 0).
139
+ """
140
+ async with self._lock:
141
+ q: asyncio.Queue[str] = asyncio.Queue()
142
+ self._subscribers.add(q)
143
+ cutoff = last_event_id if last_event_id is not None else 0
144
+ replay_frames = [frame for seq, frame in self._replay if seq > cutoff]
145
+ return replay_frames, q
146
+
147
+ async def unsubscribe(self, queue: asyncio.Queue[str]) -> None:
148
+ """Remove a subscriber queue (call from SSE disconnect finally)."""
149
+ async with self._lock:
150
+ self._subscribers.discard(queue)
151
+
152
+
153
+ async def _await_user_response(
154
+ bus: SessionBus,
155
+ *,
156
+ pending_key: str,
157
+ timeout_sec: float | None = None,
158
+ ) -> dict[str, Any]:
159
+ """Block until POST resolves *pending_key*, timeout, cancel, or disconnect policy.
160
+
161
+ Returns:
162
+ Normal POST payloads (structure depends on flow).
163
+ On timeout: ``{"_timeout": True}`` (sentinel).
164
+
165
+ Raises:
166
+ asyncio.CancelledError: When the backing Future was cancelled (Stop button path).
167
+ """
168
+ from monkeybot.core.runtime.loop import _await_user_response_any
169
+
170
+ fut = bus.pending_responses[pending_key]
171
+ return await _await_user_response_any(bus, fut, pending_key, timeout_sec=timeout_sec)
172
+
173
+
174
+ class SessionRegistry:
175
+ """Process-local registry of SessionBus instances."""
176
+
177
+ def __init__(self) -> None:
178
+ self._sessions: dict[str, SessionBus] = {} # ponytail: in-process registry, use Redis pub/sub for multi-instance deployments
179
+
180
+ def get(self, session_id: str) -> SessionBus | None:
181
+ """Return the bus for id or None."""
182
+ return self._sessions.get(session_id)
183
+
184
+ def create(
185
+ self,
186
+ session_id: str,
187
+ *,
188
+ agent_md: str | None,
189
+ created_at_ms: int,
190
+ provider: Any | None = None,
191
+ model_name: str | None = None,
192
+ ) -> SessionBus:
193
+ """Create a new session bus or raise SessionAlreadyExistsError."""
194
+ if session_id in self._sessions:
195
+ raise SessionAlreadyExistsError(session_id)
196
+ bus = SessionBus(
197
+ created_at_ms=created_at_ms,
198
+ agent_md=agent_md,
199
+ provider=provider,
200
+ model_name=model_name,
201
+ )
202
+ bus.attachment_catalog = SessionAttachmentCatalog(session_id=session_id)
203
+ self._sessions[session_id] = bus
204
+ return bus
205
+
206
+ def remove(self, session_id: str) -> bool:
207
+ """Drop a session bus and any auxiliary per-session state keyed by it.
208
+
209
+ Cancels outstanding pending-response futures so awaiting callers don't
210
+ hang, then evicts the memory-curation cache entry for this thread id
211
+ (see ``memory_prompt._curation_cache``) so both structures share the
212
+ same lifecycle instead of growing unbounded for the life of the process.
213
+
214
+ Returns True if a session was found and removed, False otherwise.
215
+ """
216
+ bus = self._sessions.pop(session_id, None)
217
+ if bus is None:
218
+ return False
219
+ bus.abandon_pending_cancel_all()
220
+ bus.cancel_follow_up_retry()
221
+ bus.admission.clear_all()
222
+
223
+ from monkeybot.core.context.memory_prompt import evict_curation_cache
224
+
225
+ evict_curation_cache(session_id)
226
+ return True
@@ -0,0 +1,46 @@
1
+ """
2
+ SSE wire framing and mapping from agent events (kind) to SSE JSON (type).
3
+ """
4
+
5
+ import json
6
+ from dataclasses import fields as dc_fields
7
+ from typing import Any, cast
8
+
9
+ from monkeybot.core.runtime.events import AgentEvent
10
+
11
+
12
+ def format_data_event(seq: int, data: str) -> str:
13
+ """Build one SSE data event with an `id` line (stored in replay buffer)."""
14
+ return f"id: {seq}\ndata: {data}\n\n"
15
+
16
+
17
+ def format_ping(n: int) -> str:
18
+ """Heartbeat comment line; not buffered for replay."""
19
+ return f": ping {n}\n\n"
20
+
21
+
22
+ def format_active_requests(request_ids: list[str]) -> str:
23
+ """ActiveRequests frame without an `id:` line."""
24
+ payload = json_dumps_wire({"type": "ActiveRequests", "request_ids": request_ids})
25
+ return f"data: {payload}\n\n"
26
+
27
+
28
+ def agent_event_to_wire_dict(event: AgentEvent) -> dict[str, Any]:
29
+ """Map an AgentEvent to an SSE JSON dict with ``type`` + ``chat_request_id``."""
30
+ kind = str(getattr(event, "kind"))
31
+ payload = {
32
+ f.name: getattr(event, f.name)
33
+ for f in dc_fields(cast(Any, event))
34
+ if f.name != "kind"
35
+ }
36
+ rid = str(payload.get("request_id", ""))
37
+ out: dict[str, Any] = {"type": kind, "request_id": rid, "chat_request_id": rid}
38
+ for key, val in payload.items():
39
+ if key != "request_id":
40
+ out[key] = val
41
+ return out
42
+
43
+
44
+ def json_dumps_wire(obj: dict[str, Any]) -> str:
45
+ """Stable JSON for SSE data lines (no extra whitespace)."""
46
+ return json.dumps(obj, separators=(",", ":"))
@@ -0,0 +1,7 @@
1
+ """Agent-facing workspace root (workspace file API + loop ``workspace_root``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot.core.workspace_layout import resolve_agent_workspace_root
6
+
7
+ __all__ = ["resolve_agent_workspace_root"]