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,41 @@
1
+ """Convert agent-facing messages into the provider-facing payload.
2
+
3
+ Pipeline stage after :func:`transform_context`. Resolves attachments and applies
4
+ optional pressure-tier tool-result shaping without mutating persisted history.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from typing import Literal
11
+
12
+ from monkeybot.core.attachments.resolve import resolve_messages_for_provider
13
+ from monkeybot.core.attachments.store import AttachmentStore
14
+ from monkeybot.core.context.tool_shapers import shape_messages_tool_results
15
+ from monkeybot.core.llm.provider import Message
16
+
17
+
18
+ def convert_to_provider(
19
+ messages: Sequence[Message],
20
+ *,
21
+ attachment_store: AttachmentStore | None,
22
+ session_id: str,
23
+ pressure_tier: Literal["none", "moderate", "aggressive"] = "none",
24
+ protect_recent: int = 6,
25
+ ) -> list[Message]:
26
+ """Agent messages → provider-ready messages (attachments resolved, shaped).
27
+
28
+ Persisted history is never mutated; shaping and resolution are view-only.
29
+ """
30
+ working: Sequence[Message] = messages
31
+ if pressure_tier in ("moderate", "aggressive"):
32
+ working = shape_messages_tool_results(
33
+ working,
34
+ protect_recent=protect_recent,
35
+ pressure_tier=pressure_tier,
36
+ )
37
+ return resolve_messages_for_provider(
38
+ working,
39
+ attachment_store=attachment_store,
40
+ session_id=session_id,
41
+ )
@@ -0,0 +1,262 @@
1
+ """In-memory repair for broken tool-call turns before provider replay.
2
+
3
+ Repairs are applied on every harness turn after ``history.load()`` and are never
4
+ persisted. Structured warnings are logged for Langfuse / log aggregation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import Sequence
11
+ from typing import cast
12
+
13
+ from monkeybot.core.llm.provider import Message
14
+ from monkeybot.core.logging_utils import kv
15
+ from monkeybot.core.types.content_blocks import (
16
+ ContentBlock,
17
+ Text,
18
+ ToolRequest,
19
+ ToolResponse,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _SYNTHETIC_MISSING_RESULT = "Tool call interrupted or result missing"
25
+ _UNKNOWN_TOOL = "unknown_tool"
26
+
27
+
28
+ def _log_repair(action: str, **fields: object) -> None:
29
+ logger.warning("tool_integrity_repair %s", kv(action=action, **fields))
30
+
31
+
32
+ def _tool_responses(msg: Message) -> list[ToolResponse]:
33
+ return [b for b in msg.content if isinstance(b, ToolResponse)]
34
+
35
+
36
+ def _tool_requests(msg: Message) -> list[ToolRequest]:
37
+ return [b for b in msg.content if isinstance(b, ToolRequest)]
38
+
39
+
40
+ def _repair_tool_request(block: ToolRequest) -> ToolRequest:
41
+ args = block.args
42
+ if isinstance(args, dict):
43
+ return block
44
+ _log_repair(
45
+ "malformed_stored_args",
46
+ call_id=block.id,
47
+ tool_name=block.name,
48
+ )
49
+ return ToolRequest(
50
+ id=block.id,
51
+ name=block.name,
52
+ args={},
53
+ parse_error=block.parse_error,
54
+ metadata=block.metadata,
55
+ )
56
+
57
+
58
+ def _repair_assistant_message(msg: Message) -> Message:
59
+ new_blocks: list[ContentBlock] = []
60
+ changed = False
61
+ for block in msg.content:
62
+ if isinstance(block, ToolRequest):
63
+ fixed = _repair_tool_request(block)
64
+ if fixed is not block:
65
+ changed = True
66
+ new_blocks.append(fixed)
67
+ else:
68
+ new_blocks.append(block)
69
+ if not changed:
70
+ return msg
71
+ return Message(role=msg.role, content=new_blocks)
72
+
73
+
74
+ def _tool_name_for_response(
75
+ block: ToolResponse,
76
+ request_by_id: dict[str, ToolRequest],
77
+ ) -> str:
78
+ name = (block.tool_name or "").strip()
79
+ if name:
80
+ return name
81
+ req = request_by_id.get(block.id)
82
+ if req is not None and req.name.strip():
83
+ return req.name
84
+ return _UNKNOWN_TOOL
85
+
86
+
87
+ def _repair_tool_response(
88
+ block: ToolResponse,
89
+ request_by_id: dict[str, ToolRequest],
90
+ ) -> ToolResponse:
91
+ tool_name = _tool_name_for_response(block, request_by_id)
92
+ if tool_name == block.tool_name:
93
+ return block
94
+ _log_repair(
95
+ "empty_tool_name",
96
+ call_id=block.id,
97
+ tool_name=tool_name,
98
+ )
99
+ return ToolResponse(
100
+ id=block.id,
101
+ tool_name=tool_name,
102
+ result=list(block.result),
103
+ is_error=block.is_error,
104
+ )
105
+
106
+
107
+ def _synthetic_error_response(req: ToolRequest) -> ToolResponse:
108
+ _log_repair(
109
+ "synthetic_tool_result",
110
+ call_id=req.id,
111
+ tool_name=req.name,
112
+ )
113
+ return ToolResponse(
114
+ id=req.id,
115
+ tool_name=req.name,
116
+ result=[Text(text=_SYNTHETIC_MISSING_RESULT)],
117
+ is_error=True,
118
+ )
119
+
120
+
121
+ def _synthetic_tool_request(
122
+ block: ToolResponse,
123
+ request_by_id: dict[str, ToolRequest],
124
+ ) -> ToolRequest | None:
125
+ name = _tool_name_for_response(block, request_by_id)
126
+ if name == _UNKNOWN_TOOL:
127
+ _log_repair(
128
+ "skip_synthetic_tool_request",
129
+ call_id=block.id,
130
+ reason="unresolvable_tool_name",
131
+ )
132
+ return None
133
+ _log_repair(
134
+ "synthetic_tool_request",
135
+ call_id=block.id,
136
+ tool_name=name,
137
+ )
138
+ return ToolRequest(id=block.id, name=name, args={})
139
+
140
+
141
+ def _synthetic_tool_requests(
142
+ blocks: Sequence[ToolResponse],
143
+ request_by_id: dict[str, ToolRequest],
144
+ ) -> list[ToolRequest]:
145
+ out: list[ToolRequest] = []
146
+ for block in blocks:
147
+ req = _synthetic_tool_request(block, request_by_id)
148
+ if req is not None:
149
+ out.append(req)
150
+ return out
151
+
152
+
153
+ def repair_tool_turn_integrity(messages: Sequence[Message]) -> list[Message]:
154
+ """Return a copy of ``messages`` with tool-turn structural issues synthesized away."""
155
+ if not messages:
156
+ return []
157
+
158
+ out: list[Message] = []
159
+ i = 0
160
+ while i < len(messages):
161
+ msg = messages[i]
162
+
163
+ if msg.role == "assistant":
164
+ repaired = _repair_assistant_message(msg)
165
+ requests = _tool_requests(repaired)
166
+ if requests:
167
+ request_map = {r.id: r for r in requests}
168
+ request_ids = set(request_map)
169
+
170
+ if i + 1 < len(messages) and messages[i + 1].role == "user":
171
+ user_msg = messages[i + 1]
172
+ user_responses = _tool_responses(user_msg)
173
+ orphan_responses = [b for b in user_responses if b.id not in request_ids]
174
+
175
+ repaired_blocks: list[ContentBlock] = []
176
+ user_changed = False
177
+ for block in user_msg.content:
178
+ if isinstance(block, ToolResponse):
179
+ fixed = _repair_tool_response(block, request_map)
180
+ if fixed is not block:
181
+ user_changed = True
182
+ repaired_blocks.append(fixed)
183
+ else:
184
+ repaired_blocks.append(block)
185
+
186
+ response_ids = {b.id for b in repaired_blocks if isinstance(b, ToolResponse)}
187
+ missing_ids = sorted(request_ids - response_ids)
188
+ if missing_ids:
189
+ for req_id in missing_ids:
190
+ repaired_blocks.append(_synthetic_error_response(request_map[req_id]))
191
+ user_changed = True
192
+
193
+ repaired_user = Message(role="user", content=repaired_blocks)
194
+ out.append(repaired)
195
+ synthetic_orphans = _synthetic_tool_requests(orphan_responses, request_map)
196
+ if synthetic_orphans:
197
+ out.append(
198
+ Message(
199
+ role="assistant",
200
+ content=cast(list[ContentBlock], synthetic_orphans),
201
+ )
202
+ )
203
+ out.append(repaired_user if user_changed else user_msg)
204
+ i += 2
205
+ continue
206
+
207
+ out.append(repaired)
208
+ out.append(
209
+ Message(
210
+ role="user",
211
+ content=[_synthetic_error_response(r) for r in requests],
212
+ )
213
+ )
214
+ i += 1
215
+ continue
216
+
217
+ out.append(repaired)
218
+ i += 1
219
+ continue
220
+
221
+ if msg.role == "user":
222
+ user_responses = _tool_responses(msg)
223
+ if user_responses:
224
+ prior_requests = {
225
+ b.id: b
226
+ for m in reversed(out)
227
+ if m.role == "assistant"
228
+ for b in m.content
229
+ if isinstance(b, ToolRequest)
230
+ }
231
+ orphan_responses = [b for b in user_responses if b.id not in prior_requests]
232
+ synthetic_orphans = _synthetic_tool_requests(orphan_responses, prior_requests)
233
+ if synthetic_orphans:
234
+ out.append(
235
+ Message(
236
+ role="assistant",
237
+ content=cast(list[ContentBlock], synthetic_orphans),
238
+ )
239
+ )
240
+
241
+ user_blocks: list[ContentBlock] = []
242
+ changed = False
243
+ for block in msg.content:
244
+ if isinstance(block, ToolResponse):
245
+ fixed = _repair_tool_response(block, prior_requests)
246
+ if fixed is not block:
247
+ changed = True
248
+ user_blocks.append(fixed)
249
+ else:
250
+ user_blocks.append(block)
251
+
252
+ out.append(Message(role="user", content=user_blocks) if changed else msg)
253
+ i += 1
254
+ continue
255
+
256
+ out.append(msg)
257
+ i += 1
258
+
259
+ return out
260
+
261
+
262
+ __all__ = ["repair_tool_turn_integrity"]
@@ -0,0 +1,84 @@
1
+ """Agent-facing history transforms (before provider conversion).
2
+
3
+ Keeps UI-only / internal blocks out of the agent working set and repairs tool
4
+ turn integrity for replay. Does not resolve attachments or apply pressure shaping
5
+ — those belong in :mod:`monkeybot.core.messages.convert_provider`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Sequence
12
+
13
+ from monkeybot.core.llm.provider import Message
14
+ from monkeybot.core.logging_utils import kv
15
+ from monkeybot.core.messages.tool_integrity import repair_tool_turn_integrity
16
+ from monkeybot.core.types.content_blocks import (
17
+ ActionRequired,
18
+ AttachmentDescriptor,
19
+ ContentBlock,
20
+ FrontendToolRequest,
21
+ SystemNotification,
22
+ ToolConfirmationRequest,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Blocks that may appear in agent/history views but must not reach providers.
28
+ _PROVIDER_EXCLUDED_BLOCKS: tuple[type[ContentBlock], ...] = (
29
+ AttachmentDescriptor,
30
+ ToolConfirmationRequest,
31
+ FrontendToolRequest,
32
+ ActionRequired,
33
+ SystemNotification,
34
+ )
35
+
36
+
37
+ def _strip_provider_excluded(messages: Sequence[Message]) -> list[Message]:
38
+ out: list[Message] = []
39
+ for msg in messages:
40
+ kept = [b for b in msg.content if not isinstance(b, _PROVIDER_EXCLUDED_BLOCKS)]
41
+ if not kept and msg.content:
42
+ logger.debug(
43
+ "transform_context drop_ui_only_turn %s",
44
+ kv(action="drop_ui_only_turn", role=msg.role, blocks=len(msg.content)),
45
+ )
46
+ continue
47
+ if len(kept) == len(msg.content):
48
+ out.append(msg)
49
+ else:
50
+ out.append(Message(role=msg.role, content=kept))
51
+ return out
52
+
53
+
54
+ def _coalesce_adjacent_same_role(messages: Sequence[Message]) -> list[Message]:
55
+ """Merge consecutive same-role messages after UI-only turns are dropped.
56
+
57
+ Dropping an interior all-UI row can leave adjacent equal roles
58
+ (``user → assistant → user(UI) → assistant`` → ``user → assistant → assistant``).
59
+ Anthropic and Gemini reject that wire shape, so coalesce content into one
60
+ message per run of the same role.
61
+ """
62
+ if not messages:
63
+ return []
64
+ out: list[Message] = [messages[0]]
65
+ for msg in messages[1:]:
66
+ prev = out[-1]
67
+ if msg.role == prev.role:
68
+ out[-1] = Message(role=prev.role, content=[*prev.content, *msg.content])
69
+ else:
70
+ out.append(msg)
71
+ return out
72
+
73
+
74
+ def transform_context(messages: Sequence[Message]) -> list[Message]:
75
+ """Agent-facing history → cleaned message list for conversion.
76
+
77
+ Steps:
78
+ 1. Repair broken tool-call / tool-result pairing (in-memory only).
79
+ 2. Strip UI-only content blocks that must never reach a provider.
80
+ 3. Coalesce adjacent same-role messages left by dropped UI-only turns.
81
+ """
82
+ repaired = repair_tool_turn_integrity(list(messages))
83
+ stripped = _strip_provider_excluded(repaired)
84
+ return _coalesce_adjacent_same_role(stripped)
@@ -0,0 +1,11 @@
1
+ """Helpers for safe filesystem paths from user-supplied identifiers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def sanitize_path_component(name: str) -> str:
7
+ """Sanitize a string for use as a single path component (no directory separators)."""
8
+ return name.replace("/", "_").replace("..", "_")
9
+
10
+
11
+ __all__ = ["sanitize_path_component"]
@@ -0,0 +1,17 @@
1
+ """SQLite and filesystem persistence for conversations and runs."""
2
+
3
+ from monkeybot.core.persistence.backends import (
4
+ HistoryStore,
5
+ RunStore,
6
+ StorageBackend,
7
+ UsageStore,
8
+ create_storage_backend,
9
+ )
10
+
11
+ __all__ = [
12
+ "HistoryStore",
13
+ "RunStore",
14
+ "StorageBackend",
15
+ "UsageStore",
16
+ "create_storage_backend",
17
+ ]
@@ -0,0 +1,236 @@
1
+ """Storage backend protocols and factory for monkeybot persistence.
2
+
3
+ ``backends.py`` is protocol-only — no implementation code is imported at module
4
+ level. Both ``SQLiteStorageBackend`` and ``PostgresStorageBackend`` are lazy-
5
+ imported inside :func:`create_storage_backend` so that neither SQLite nor
6
+ Postgres implementation code loads until the factory is actually called.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
13
+ from urllib.parse import parse_qs, unquote, urlparse
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ from monkeybot.core.llm.provider import Message
19
+ from monkeybot.core.llm.usage import Usage, UsageSummary
20
+ from monkeybot.core.persistence.durable_runs import SubagentEnvelope, SubagentRunRow
21
+ from monkeybot.core.persistence.scheduled_loops import (
22
+ ScheduledLoopCreate,
23
+ ScheduledLoopRow,
24
+ )
25
+ from monkeybot.core.persistence.thread_summary import ChatThreadSummary
26
+
27
+
28
+ @runtime_checkable
29
+ class HistoryStore(Protocol):
30
+ """Protocol for conversation history persistence."""
31
+
32
+ async def load(self, thread_id: str, limit: int = 100) -> list[Message]: ...
33
+
34
+ async def append(self, thread_id: str, message: Message) -> None: ...
35
+
36
+ async def reset(self, thread_id: str, messages: list[Message]) -> None: ...
37
+
38
+ async def list_threads(self, limit: int = 50) -> list[ChatThreadSummary]: ...
39
+
40
+
41
+ @runtime_checkable
42
+ class UsageStore(Protocol):
43
+ """Protocol for per-turn token/cost accounting."""
44
+
45
+ async def record(
46
+ self,
47
+ thread_id: str,
48
+ model: str,
49
+ usage: Usage,
50
+ run_id: str | None = None,
51
+ ) -> None: ...
52
+
53
+ async def summary(
54
+ self,
55
+ thread_id: str | None = None,
56
+ since_ms: int | None = None,
57
+ ) -> UsageSummary: ...
58
+
59
+
60
+ @runtime_checkable
61
+ class RunStore(Protocol):
62
+ """Protocol for subagent run lifecycle tracking."""
63
+
64
+ async def record_pending(
65
+ self,
66
+ run_id: str,
67
+ parent_run_id: str | None,
68
+ script: str,
69
+ envelope: SubagentEnvelope,
70
+ scratch_dir: Path,
71
+ ) -> None: ...
72
+
73
+ async def record_started(
74
+ self,
75
+ run_id: str,
76
+ parent_run_id: str | None,
77
+ script: str,
78
+ envelope: SubagentEnvelope,
79
+ scratch_dir: Path,
80
+ ) -> None: ...
81
+
82
+ async def claim(self, run_id: str, worker_id: str) -> bool: ...
83
+
84
+ async def reset_stale_claims(self, stale_after_ms: int) -> int: ...
85
+
86
+ async def record_completed(self, run_id: str, result_json: str) -> None: ...
87
+
88
+ async def record_failed(self, run_id: str, error: str) -> None: ...
89
+
90
+ async def pending_runs(self) -> list[SubagentRunRow]: ...
91
+
92
+ async def get_run(self, run_id: str) -> SubagentRunRow | None: ...
93
+
94
+
95
+ @runtime_checkable
96
+ class ScheduledLoopStore(Protocol):
97
+ """Protocol for prompt-first scheduled agent loops."""
98
+
99
+ async def create(self, spec: ScheduledLoopCreate) -> ScheduledLoopRow: ...
100
+
101
+ async def get(self, loop_id: str) -> ScheduledLoopRow | None: ...
102
+
103
+ async def list_all(self) -> list[ScheduledLoopRow]: ...
104
+
105
+ async def list_due(self, now_ms: int) -> list[ScheduledLoopRow]: ...
106
+
107
+ async def claim_tick(self, loop_id: str, worker_id: str) -> ScheduledLoopRow | None: ...
108
+
109
+ async def release_stale_claims(self, stale_after_ms: int) -> int: ...
110
+
111
+ async def complete_tick(
112
+ self,
113
+ loop_id: str,
114
+ *,
115
+ worker_id: str,
116
+ error: str | None = None,
117
+ ) -> ScheduledLoopRow | None: ...
118
+
119
+ async def defer_tick(self, loop_id: str, *, worker_id: str, reason: str) -> None: ...
120
+
121
+ async def renew_tick_claim(self, loop_id: str, worker_id: str) -> bool: ...
122
+
123
+ async def pause(self, loop_id: str) -> bool: ...
124
+
125
+ async def resume(self, loop_id: str) -> bool: ...
126
+
127
+ async def stop(self, loop_id: str, *, stop_reason: str = "manual") -> bool: ...
128
+
129
+
130
+ @runtime_checkable
131
+ class SessionTurnLockStore(Protocol):
132
+ """Exclusive turn lock per session for multi-replica gateways."""
133
+
134
+ async def try_acquire(self, session_id: str, request_id: str) -> bool: ...
135
+
136
+ async def release(self, session_id: str, request_id: str) -> None: ...
137
+
138
+ async def is_busy(self, session_id: str) -> bool: ...
139
+
140
+ async def release_stale_claims(self, stale_after_ms: int) -> int: ...
141
+
142
+
143
+ @runtime_checkable
144
+ class StorageBackend(Protocol):
145
+ """Owns a storage connection lifecycle and vends all stores."""
146
+
147
+ async def open(self, *, run_schema: bool = True) -> None: ...
148
+
149
+ async def close(self) -> None: ...
150
+
151
+ def history(self) -> HistoryStore: ...
152
+
153
+ def usage(self) -> UsageStore: ...
154
+
155
+ def runs(self) -> RunStore: ...
156
+
157
+ def scheduled_loops(self) -> ScheduledLoopStore: ...
158
+
159
+ def session_turns(self) -> SessionTurnLockStore: ...
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class FirestoreConfig:
164
+ """Parsed ``firestore://`` storage URL."""
165
+
166
+ project: str
167
+ database: str
168
+ prefix: str = ""
169
+
170
+
171
+ def _normalize_postgres_db_url(db_url: str) -> str | None:
172
+ """Return a ``postgresql://`` URL for Postgres backends, or ``None`` if not Postgres.
173
+
174
+ Accepts both ``postgresql://`` (SQLAlchemy/asyncpg style) and ``postgres://``
175
+ (common alias, e.g. Heroku-style URLs). asyncpg expects ``postgresql://``.
176
+ """
177
+ stripped = db_url.strip()
178
+ if stripped.startswith("postgresql://"):
179
+ return stripped
180
+ if stripped.startswith("postgres://"):
181
+ return "postgresql://" + stripped[len("postgres://") :]
182
+ return None
183
+
184
+
185
+ def _parse_firestore_config(db_url: str) -> FirestoreConfig | None:
186
+ """Return Firestore settings for ``firestore://`` URLs, or ``None`` if not Firestore."""
187
+ stripped = db_url.strip()
188
+ if not stripped.startswith("firestore://"):
189
+ return None
190
+ parsed = urlparse(stripped)
191
+ project = unquote(parsed.hostname or "")
192
+ if not project:
193
+ raise ValueError(f"Firestore URL missing project id: {db_url!r}")
194
+ database = unquote(parsed.path.lstrip("/")) or "(default)"
195
+ prefix = parse_qs(parsed.query).get("prefix", [""])[0]
196
+ return FirestoreConfig(project=project, database=database, prefix=prefix)
197
+
198
+
199
+ def create_storage_backend(db_url: str) -> StorageBackend:
200
+ """Factory that returns the right backend for ``db_url``.
201
+
202
+ Neither SQLite nor Postgres implementation code is imported until this
203
+ function is called with a matching URL scheme.
204
+
205
+ - ``sqlite://...`` → :class:`~monkeybot.core.persistence.sqlite_backend.SQLiteStorageBackend`
206
+ (zero extra deps beyond core)
207
+ - ``postgresql://...`` or ``postgres://...`` → :class:`~monkeybot.core.persistence.postgres.PostgresStorageBackend`
208
+ (requires ``pip install 'monkeybot[postgres]'``)
209
+ - ``firestore://PROJECT/DATABASE`` → :class:`~monkeybot.core.persistence.firestore.FirestoreStorageBackend`
210
+ (requires ``pip install 'monkeybot[firestore]'``)
211
+ """
212
+ if db_url.startswith("sqlite://"):
213
+ from monkeybot.core.persistence.sqlite_backend import SQLiteStorageBackend
214
+
215
+ return SQLiteStorageBackend(db_url)
216
+ pg_url = _normalize_postgres_db_url(db_url)
217
+ if pg_url is not None:
218
+ try:
219
+ from monkeybot.core.persistence.postgres import PostgresStorageBackend
220
+ except ImportError as exc:
221
+ raise RuntimeError(
222
+ "asyncpg is not installed. "
223
+ "Run: pip install 'monkeybot[postgres]'"
224
+ ) from exc
225
+ return PostgresStorageBackend(pg_url)
226
+ fs_config = _parse_firestore_config(db_url)
227
+ if fs_config is not None:
228
+ try:
229
+ from monkeybot.core.persistence.firestore import FirestoreStorageBackend
230
+ except ImportError as exc:
231
+ raise RuntimeError(
232
+ "google-cloud-firestore is not installed. "
233
+ "Run: pip install 'monkeybot[firestore]'"
234
+ ) from exc
235
+ return FirestoreStorageBackend(fs_config)
236
+ raise ValueError(f"Unsupported DB URL scheme: {db_url!r}")
@@ -0,0 +1,28 @@
1
+ """Legacy import path for SQLite persistence helpers.
2
+
3
+ Prefer :mod:`monkeybot.core.persistence.sqlite` for new code. This module
4
+ re-exports the same symbols that lived here before ``db.py`` was renamed to
5
+ ``sqlite.py``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from monkeybot.core.persistence.sqlite import (
11
+ DEFAULT_DB_URL,
12
+ SCHEMA_DDLS,
13
+ _LEGACY_SCHEMA_MESSAGE,
14
+ apply_schema,
15
+ configure_connection,
16
+ open_connection,
17
+ sqlite_path_from_db_url,
18
+ )
19
+
20
+ __all__ = [
21
+ "DEFAULT_DB_URL",
22
+ "SCHEMA_DDLS",
23
+ "_LEGACY_SCHEMA_MESSAGE",
24
+ "apply_schema",
25
+ "configure_connection",
26
+ "open_connection",
27
+ "sqlite_path_from_db_url",
28
+ ]