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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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
|
+
]
|