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,179 @@
|
|
|
1
|
+
"""Accumulate continuous realtime input into finalized utterances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from monkeybot.core.llm.realtime_provider import AudioFormat, RealtimeToolCall
|
|
9
|
+
from monkeybot.core.types.content_blocks import ContentBlock, Text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _chunk_duration_ms(chunk: bytes, fmt: AudioFormat) -> int:
|
|
13
|
+
"""Return duration of an audio chunk in milliseconds."""
|
|
14
|
+
bytes_per_sample = 2 if fmt.encoding.startswith("pcm_s") else 1
|
|
15
|
+
frame_size = bytes_per_sample * fmt.channels
|
|
16
|
+
if frame_size == 0:
|
|
17
|
+
return 0
|
|
18
|
+
samples = len(chunk) // frame_size
|
|
19
|
+
return int(samples * 1000 / fmt.sample_rate_hz)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _user_text_from_content(blocks: Sequence[ContentBlock]) -> str:
|
|
23
|
+
return " ".join(
|
|
24
|
+
b.text.strip() for b in blocks if isinstance(b, Text) and b.text.strip()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class FinalizedUtterance:
|
|
30
|
+
"""User utterance ready to commit to HistoryStore."""
|
|
31
|
+
|
|
32
|
+
text: str
|
|
33
|
+
audio_duration_ms: int = 0
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_empty(self) -> bool:
|
|
37
|
+
return not self.text and self.audio_duration_ms == 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class AssistantTurn:
|
|
42
|
+
"""Assistant output accumulated during one turn."""
|
|
43
|
+
|
|
44
|
+
text: str = ""
|
|
45
|
+
tool_calls: list[RealtimeToolCall] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_empty(self) -> bool:
|
|
49
|
+
return not self.text and not self.tool_calls
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UtteranceBuffer:
|
|
53
|
+
"""In-memory buffer for one realtime session.
|
|
54
|
+
|
|
55
|
+
Accumulates user audio/text and assistant output. On a user turn boundary the
|
|
56
|
+
finalized user utterance is returned. On an interrupt, in-flight assistant output
|
|
57
|
+
is discarded while user input accumulated during the interruption is kept.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self._user_text_parts: list[str] = []
|
|
62
|
+
self._user_audio_duration_ms = 0
|
|
63
|
+
self._assistant = AssistantTurn()
|
|
64
|
+
self._in_user_turn = False
|
|
65
|
+
self._in_assistant_turn = False
|
|
66
|
+
self._last_user_text: str = ""
|
|
67
|
+
self._user_committed = False
|
|
68
|
+
self._last_assistant_turn: AssistantTurn = AssistantTurn()
|
|
69
|
+
|
|
70
|
+
def add_user_text(self, text: str) -> None:
|
|
71
|
+
"""Add a partial or final user text chunk."""
|
|
72
|
+
self._in_user_turn = True
|
|
73
|
+
self._user_text_parts.append(text)
|
|
74
|
+
|
|
75
|
+
def apply_final_user_transcript(self, text: str) -> None:
|
|
76
|
+
"""Attach a provider final transcript to the current or just-finished utterance.
|
|
77
|
+
|
|
78
|
+
Push-to-talk often finalizes the audio turn before Gemini emits the final
|
|
79
|
+
input transcription. In that case update ``_last_user_text`` so history
|
|
80
|
+
commit still sees the spoken content.
|
|
81
|
+
"""
|
|
82
|
+
cleaned = text.strip()
|
|
83
|
+
if not cleaned:
|
|
84
|
+
return
|
|
85
|
+
if self._in_user_turn:
|
|
86
|
+
self._user_text_parts.append(cleaned)
|
|
87
|
+
return
|
|
88
|
+
if self._last_user_text:
|
|
89
|
+
self._last_user_text = f"{self._last_user_text} {cleaned}".strip()
|
|
90
|
+
else:
|
|
91
|
+
self._last_user_text = cleaned
|
|
92
|
+
# A late transcript means the prior empty commit (if any) was incomplete.
|
|
93
|
+
self._user_committed = False
|
|
94
|
+
|
|
95
|
+
def add_user_audio(self, chunk: bytes, *, fmt: AudioFormat) -> None:
|
|
96
|
+
"""Add a user audio chunk; tracks duration only."""
|
|
97
|
+
self._in_user_turn = True
|
|
98
|
+
self._user_audio_duration_ms += _chunk_duration_ms(chunk, fmt)
|
|
99
|
+
|
|
100
|
+
def add_assistant_text(self, text: str) -> None:
|
|
101
|
+
"""Add a partial or final assistant text chunk."""
|
|
102
|
+
self._in_assistant_turn = True
|
|
103
|
+
self._assistant.text += text
|
|
104
|
+
|
|
105
|
+
def add_assistant_tool_call(self, call: RealtimeToolCall) -> None:
|
|
106
|
+
"""Register a tool call emitted by the assistant during this turn."""
|
|
107
|
+
self._in_assistant_turn = True
|
|
108
|
+
self._assistant.tool_calls.append(call)
|
|
109
|
+
|
|
110
|
+
def mark_user_turn_boundary(self) -> FinalizedUtterance:
|
|
111
|
+
"""Finalize the current user utterance and reset user state."""
|
|
112
|
+
self._in_user_turn = False
|
|
113
|
+
text = "".join(self._user_text_parts).strip()
|
|
114
|
+
self._last_user_text = text
|
|
115
|
+
self._user_committed = False
|
|
116
|
+
duration = self._user_audio_duration_ms
|
|
117
|
+
self._user_text_parts = []
|
|
118
|
+
self._user_audio_duration_ms = 0
|
|
119
|
+
return FinalizedUtterance(text=text, audio_duration_ms=duration)
|
|
120
|
+
|
|
121
|
+
def consume_user_text_for_commit(self) -> str:
|
|
122
|
+
"""Return finalized user text once per utterance for HistoryStore commit.
|
|
123
|
+
|
|
124
|
+
Subsequent assistant boundaries (e.g. tool-call then prose) return ``""``
|
|
125
|
+
so the same spoken turn is not duplicated in history. Empty results do
|
|
126
|
+
not mark the utterance committed, so a late final transcript can still
|
|
127
|
+
be picked up on the next boundary.
|
|
128
|
+
"""
|
|
129
|
+
if self._in_user_turn:
|
|
130
|
+
return "".join(self._user_text_parts).strip()
|
|
131
|
+
if self._user_committed:
|
|
132
|
+
return ""
|
|
133
|
+
text = self._last_user_text
|
|
134
|
+
if text:
|
|
135
|
+
self._user_committed = True
|
|
136
|
+
return text
|
|
137
|
+
|
|
138
|
+
def mark_assistant_turn_boundary(self) -> AssistantTurn:
|
|
139
|
+
"""Finalize the current assistant turn and reset assistant state."""
|
|
140
|
+
self._in_assistant_turn = False
|
|
141
|
+
turn = AssistantTurn(
|
|
142
|
+
text=self._assistant.text.strip(),
|
|
143
|
+
tool_calls=list(self._assistant.tool_calls),
|
|
144
|
+
)
|
|
145
|
+
self._last_assistant_turn = turn
|
|
146
|
+
self._assistant = AssistantTurn()
|
|
147
|
+
return turn
|
|
148
|
+
|
|
149
|
+
def interrupt(self) -> None:
|
|
150
|
+
"""User barge-in: keep any new user input, discard assistant output."""
|
|
151
|
+
self._in_assistant_turn = False
|
|
152
|
+
self._assistant = AssistantTurn()
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def in_user_turn(self) -> bool:
|
|
156
|
+
return self._in_user_turn
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def in_assistant_turn(self) -> bool:
|
|
160
|
+
return self._in_assistant_turn
|
|
161
|
+
|
|
162
|
+
def assistant_tool_calls(self) -> list[RealtimeToolCall]:
|
|
163
|
+
"""Return tool calls accumulated during the in-flight assistant turn."""
|
|
164
|
+
return list(self._assistant.tool_calls)
|
|
165
|
+
|
|
166
|
+
def current_user_text(self) -> str:
|
|
167
|
+
"""Return the finalized user text, or the in-flight text if not yet finalized."""
|
|
168
|
+
if self._in_user_turn:
|
|
169
|
+
return "".join(self._user_text_parts).strip()
|
|
170
|
+
return self._last_user_text
|
|
171
|
+
|
|
172
|
+
def current_assistant_turn(self) -> AssistantTurn:
|
|
173
|
+
"""Return the last finalized assistant turn, or the in-flight one if still active."""
|
|
174
|
+
if self._in_assistant_turn:
|
|
175
|
+
return AssistantTurn(
|
|
176
|
+
text=self._assistant.text.strip(),
|
|
177
|
+
tool_calls=list(self._assistant.tool_calls),
|
|
178
|
+
)
|
|
179
|
+
return self._last_assistant_turn
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Subprocess subagent worker and wire protocol."""
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Subagent subprocess protocol: JSON envelope and NDJSON event streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from monkeybot.core.config.settings import SubagentConfig
|
|
16
|
+
from monkeybot.core.logging_utils import kv
|
|
17
|
+
from monkeybot.core.runtime.events import (
|
|
18
|
+
AgentEvent,
|
|
19
|
+
Error,
|
|
20
|
+
EventDecodeError,
|
|
21
|
+
event_from_json,
|
|
22
|
+
event_to_json,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _memory_storage_uri_from_dict(decoded: dict[str, Any]) -> str:
|
|
29
|
+
"""Resolve memory URI from envelope fields; empty string means no memory."""
|
|
30
|
+
if "memory_storage_uri" in decoded:
|
|
31
|
+
uri_val = decoded["memory_storage_uri"]
|
|
32
|
+
if not isinstance(uri_val, str):
|
|
33
|
+
raise ValueError("envelope: 'memory_storage_uri' must be a string")
|
|
34
|
+
return uri_val.strip()
|
|
35
|
+
legacy = decoded.get("memory_path")
|
|
36
|
+
if isinstance(legacy, str) and legacy.strip():
|
|
37
|
+
lp = legacy.strip()
|
|
38
|
+
if lp.startswith("gcs://") or lp.startswith("s3://") or lp.startswith("local://"):
|
|
39
|
+
return lp
|
|
40
|
+
return "local://" + lp
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def default_subagent_script() -> Path:
|
|
45
|
+
"""Bundled ``subagent_worker.py`` next to this module."""
|
|
46
|
+
return Path(__file__).resolve().parent / "subagent_worker.py"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_subagent_script() -> Path:
|
|
50
|
+
"""Resolve worker script from ``MONKEYBOT_SUBAGENT_SCRIPT`` or the bundled default."""
|
|
51
|
+
return Path(
|
|
52
|
+
os.environ.get(
|
|
53
|
+
"MONKEYBOT_SUBAGENT_SCRIPT",
|
|
54
|
+
str(default_subagent_script()),
|
|
55
|
+
)
|
|
56
|
+
).resolve()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_agent_project_root() -> Path:
|
|
60
|
+
"""Bot project root (config, AGENT.md, data/) — not the workspace file-tool sandbox."""
|
|
61
|
+
raw = os.environ.get("MONKEYBOT_AGENT_ROOT", "").strip()
|
|
62
|
+
if raw:
|
|
63
|
+
return Path(raw).expanduser().resolve()
|
|
64
|
+
return Path.cwd().resolve()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_project_path(raw: str, agent_root: Path | None = None) -> Path:
|
|
68
|
+
"""Resolve a config path relative to ``agent_root`` (default: env or cwd)."""
|
|
69
|
+
root = agent_root if agent_root is not None else resolve_agent_project_root()
|
|
70
|
+
p = Path(raw).expanduser()
|
|
71
|
+
if p.is_absolute():
|
|
72
|
+
return p.resolve()
|
|
73
|
+
return (root / p).resolve()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_subagent_agent_md_path(agent_root: Path | None = None) -> Path | None:
|
|
77
|
+
"""Effective subagent AGENT.md path; ``MONKEYBOT_SUBAGENT_AGENT_MD`` wins over ``AGENT_MD``."""
|
|
78
|
+
root = agent_root if agent_root is not None else resolve_agent_project_root()
|
|
79
|
+
raw = os.environ.get("MONKEYBOT_SUBAGENT_AGENT_MD", "").strip()
|
|
80
|
+
if not raw:
|
|
81
|
+
raw = os.environ.get("AGENT_MD", "").strip()
|
|
82
|
+
if not raw:
|
|
83
|
+
return None
|
|
84
|
+
return resolve_project_path(raw, root)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_task_agent_md_path(
|
|
88
|
+
*,
|
|
89
|
+
subagent_type: str | None,
|
|
90
|
+
registry: dict[str, SubagentConfig],
|
|
91
|
+
agent_root: Path | None = None,
|
|
92
|
+
) -> Path:
|
|
93
|
+
"""Resolve AGENT.md for a ``task`` spawn: registry type, then global subagent/parent defaults."""
|
|
94
|
+
root = agent_root if agent_root is not None else resolve_agent_project_root()
|
|
95
|
+
key = (subagent_type or "").strip()
|
|
96
|
+
if key:
|
|
97
|
+
cfg = registry.get(key)
|
|
98
|
+
if cfg is None:
|
|
99
|
+
known = ", ".join(sorted(registry)) if registry else "(none configured)"
|
|
100
|
+
raise ValueError(f"Unknown subagent_type {key!r}. Configured types: {known}")
|
|
101
|
+
if not cfg.agent_md:
|
|
102
|
+
raise ValueError(f"subagent_type {key!r} has no agent_md in monkeybot.yaml")
|
|
103
|
+
path = resolve_project_path(cfg.agent_md, root)
|
|
104
|
+
if not path.is_file():
|
|
105
|
+
raise ValueError(f"agent_md for subagent_type {key!r} not found: {path}")
|
|
106
|
+
return path
|
|
107
|
+
|
|
108
|
+
fallback = resolve_subagent_agent_md_path(root)
|
|
109
|
+
if fallback is not None and fallback.is_file():
|
|
110
|
+
return fallback
|
|
111
|
+
raw = os.environ.get("AGENT_MD", "").strip()
|
|
112
|
+
if raw:
|
|
113
|
+
path = resolve_project_path(raw, root)
|
|
114
|
+
if path.is_file():
|
|
115
|
+
return path
|
|
116
|
+
default = (root / "AGENT.md").resolve()
|
|
117
|
+
if default.is_file():
|
|
118
|
+
return default
|
|
119
|
+
raise ValueError("No AGENT.md found for subagent (set subagent.agent_md or paths.agent_md)")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def normalize_sqlite_db_url(db_url: str, agent_root: Path | None = None) -> str:
|
|
123
|
+
"""Rewrite relative ``sqlite:///`` paths against project root (not workspace cwd)."""
|
|
124
|
+
root = agent_root if agent_root is not None else resolve_agent_project_root()
|
|
125
|
+
stripped = db_url.strip()
|
|
126
|
+
prefix = "sqlite:///"
|
|
127
|
+
if not stripped.lower().startswith(prefix):
|
|
128
|
+
return db_url
|
|
129
|
+
remainder = stripped[len(prefix) :]
|
|
130
|
+
if not remainder or remainder == ":memory:":
|
|
131
|
+
return db_url
|
|
132
|
+
p = Path(remainder)
|
|
133
|
+
if p.is_absolute():
|
|
134
|
+
return db_url
|
|
135
|
+
abs_path = (root / p).resolve()
|
|
136
|
+
return f"sqlite:///{abs_path}"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class SubagentEnvelope:
|
|
141
|
+
"""Inputs forwarded to a child Python worker via stdin JSON."""
|
|
142
|
+
|
|
143
|
+
task: str
|
|
144
|
+
context: str
|
|
145
|
+
memory_storage_uri: str
|
|
146
|
+
parent_run_id: str
|
|
147
|
+
model: str = "gemini-2.5-flash"
|
|
148
|
+
traceparent: str | None = None
|
|
149
|
+
agent_md: str | None = None
|
|
150
|
+
subagent_type: str | None = None
|
|
151
|
+
|
|
152
|
+
def to_json(self) -> str:
|
|
153
|
+
"""Serialize to a compact JSON object for stdin (UTF-8)."""
|
|
154
|
+
payload: dict[str, str] = {
|
|
155
|
+
"task": self.task,
|
|
156
|
+
"context": self.context,
|
|
157
|
+
"memory_storage_uri": self.memory_storage_uri,
|
|
158
|
+
"parent_run_id": self.parent_run_id,
|
|
159
|
+
"model": self.model,
|
|
160
|
+
}
|
|
161
|
+
if self.traceparent is not None:
|
|
162
|
+
payload["traceparent"] = self.traceparent
|
|
163
|
+
if self.agent_md is not None:
|
|
164
|
+
payload["agent_md"] = self.agent_md
|
|
165
|
+
if self.subagent_type is not None:
|
|
166
|
+
payload["subagent_type"] = self.subagent_type
|
|
167
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_json(cls, raw: str) -> SubagentEnvelope:
|
|
171
|
+
"""Parse and validate envelope JSON."""
|
|
172
|
+
try:
|
|
173
|
+
decoded: Any = json.loads(raw)
|
|
174
|
+
except json.JSONDecodeError as exc:
|
|
175
|
+
raise ValueError("envelope: invalid JSON") from exc
|
|
176
|
+
if not isinstance(decoded, dict):
|
|
177
|
+
raise ValueError("envelope: root must be an object")
|
|
178
|
+
uri = _memory_storage_uri_from_dict(decoded)
|
|
179
|
+
return cls(
|
|
180
|
+
task=_req_str(decoded, "task"),
|
|
181
|
+
context=_req_str(decoded, "context"),
|
|
182
|
+
memory_storage_uri=uri,
|
|
183
|
+
parent_run_id=_req_str(decoded, "parent_run_id"),
|
|
184
|
+
model=_opt_model(decoded),
|
|
185
|
+
traceparent=_opt_traceparent(decoded),
|
|
186
|
+
agent_md=_opt_str_field(decoded, "agent_md"),
|
|
187
|
+
subagent_type=_opt_str_field(decoded, "subagent_type"),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _req_str(data: dict[str, Any], key: str) -> str:
|
|
192
|
+
val = data.get(key)
|
|
193
|
+
if not isinstance(val, str):
|
|
194
|
+
raise ValueError(f"envelope: {key!r} must be a string")
|
|
195
|
+
return val
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _opt_traceparent(data: dict[str, Any]) -> str | None:
|
|
199
|
+
if "traceparent" not in data:
|
|
200
|
+
return None
|
|
201
|
+
val = data.get("traceparent")
|
|
202
|
+
if not isinstance(val, str):
|
|
203
|
+
raise ValueError("envelope: 'traceparent' must be a string")
|
|
204
|
+
stripped = val.strip()
|
|
205
|
+
return stripped or None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _opt_str_field(data: dict[str, Any], key: str) -> str | None:
|
|
209
|
+
if key not in data:
|
|
210
|
+
return None
|
|
211
|
+
val = data.get(key)
|
|
212
|
+
if not isinstance(val, str):
|
|
213
|
+
raise ValueError(f"envelope: {key!r} must be a string")
|
|
214
|
+
stripped = val.strip()
|
|
215
|
+
return stripped or None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _opt_model(data: dict[str, Any]) -> str:
|
|
219
|
+
if "model" not in data:
|
|
220
|
+
return "gemini-2.5-flash"
|
|
221
|
+
val = data.get("model")
|
|
222
|
+
if not isinstance(val, str) or not val:
|
|
223
|
+
raise ValueError("envelope: 'model' must be a non-empty string")
|
|
224
|
+
return val
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def spawn_subagent(
|
|
228
|
+
script: str,
|
|
229
|
+
envelope: SubagentEnvelope,
|
|
230
|
+
*,
|
|
231
|
+
scratch_dir: Path,
|
|
232
|
+
on_event: Callable[[AgentEvent], Awaitable[None]] | None = None,
|
|
233
|
+
subprocess_exec: Callable[..., Awaitable[asyncio.subprocess.Process]] | None = None,
|
|
234
|
+
extra_env: dict[str, str] | None = None,
|
|
235
|
+
) -> AsyncIterator[AgentEvent]:
|
|
236
|
+
"""Run ``script`` under ``python -u``, stream NDJSON stdout as ``AgentEvent`` values.
|
|
237
|
+
|
|
238
|
+
Stdout lines are UTF-8 NDJSON. Each line is appended to ``progress.jsonl`` under
|
|
239
|
+
``scratch_dir``. Parse failures yield :class:`Error` and continue.
|
|
240
|
+
|
|
241
|
+
After the process exits with code 0, writes ``output.json`` with
|
|
242
|
+
``event_to_json`` of the last successfully parsed event.
|
|
243
|
+
"""
|
|
244
|
+
scratch_dir.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
progress_path = scratch_dir / "progress.jsonl"
|
|
246
|
+
|
|
247
|
+
if subprocess_exec is not None:
|
|
248
|
+
exec_fn = subprocess_exec
|
|
249
|
+
else:
|
|
250
|
+
merged_env = dict(extra_env) if extra_env else None
|
|
251
|
+
|
|
252
|
+
async def exec_fn(*cmd: str | bytes) -> asyncio.subprocess.Process:
|
|
253
|
+
return await _default_subprocess_exec(*cmd, extra_env=merged_env)
|
|
254
|
+
|
|
255
|
+
proc = await exec_fn(sys.executable, "-u", script)
|
|
256
|
+
|
|
257
|
+
stdin = proc.stdin
|
|
258
|
+
stdout = proc.stdout
|
|
259
|
+
if stdin is None or stdout is None:
|
|
260
|
+
logger.warning(
|
|
261
|
+
"subagent subprocess missing pipes %s",
|
|
262
|
+
kv(script=script, parent_run_id=envelope.parent_run_id),
|
|
263
|
+
)
|
|
264
|
+
yield Error(request_id="", error="subagent: subprocess missing stdin/stdout pipes")
|
|
265
|
+
await proc.wait()
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
stdin.write(envelope.to_json().encode("utf-8"))
|
|
269
|
+
await stdin.drain()
|
|
270
|
+
stdin.close()
|
|
271
|
+
|
|
272
|
+
last_evt: AgentEvent | None = None
|
|
273
|
+
|
|
274
|
+
while True:
|
|
275
|
+
line_b = await stdout.readline()
|
|
276
|
+
if not line_b:
|
|
277
|
+
break
|
|
278
|
+
raw_line = line_b.decode("utf-8").rstrip("\r\n")
|
|
279
|
+
_append_progress_line(progress_path, raw_line)
|
|
280
|
+
if not raw_line.strip():
|
|
281
|
+
continue
|
|
282
|
+
try:
|
|
283
|
+
evt = event_from_json(raw_line)
|
|
284
|
+
except EventDecodeError as exc:
|
|
285
|
+
logger.warning(
|
|
286
|
+
"subagent NDJSON parse error %s",
|
|
287
|
+
kv(script=script, parent_run_id=envelope.parent_run_id, error=exc),
|
|
288
|
+
)
|
|
289
|
+
yield Error(request_id="", error=f"NDJSON parse error: {exc}")
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
last_evt = evt
|
|
293
|
+
if on_event is not None:
|
|
294
|
+
await on_event(evt)
|
|
295
|
+
yield evt
|
|
296
|
+
|
|
297
|
+
code = await proc.wait()
|
|
298
|
+
if code != 0:
|
|
299
|
+
logger.warning(
|
|
300
|
+
"subagent process exited nonzero %s",
|
|
301
|
+
kv(script=script, parent_run_id=envelope.parent_run_id, exit_code=code),
|
|
302
|
+
)
|
|
303
|
+
yield Error(request_id="", error=f"subagent process exited with code {code}")
|
|
304
|
+
|
|
305
|
+
if code == 0 and last_evt is not None:
|
|
306
|
+
out_path = scratch_dir / "output.json"
|
|
307
|
+
out_path.write_text(event_to_json(last_evt) + "\n", encoding="utf-8")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def _default_subprocess_exec(
|
|
311
|
+
*cmd: str | bytes,
|
|
312
|
+
extra_env: dict[str, str] | None = None,
|
|
313
|
+
) -> asyncio.subprocess.Process:
|
|
314
|
+
"""Spawn with ``PYTHONUNBUFFERED=1``; CLI also uses ``python -u``."""
|
|
315
|
+
env = dict(os.environ)
|
|
316
|
+
if extra_env:
|
|
317
|
+
env.update(extra_env)
|
|
318
|
+
env["PYTHONUNBUFFERED"] = "1"
|
|
319
|
+
return await asyncio.create_subprocess_exec(
|
|
320
|
+
*cmd,
|
|
321
|
+
stdin=asyncio.subprocess.PIPE,
|
|
322
|
+
stdout=asyncio.subprocess.PIPE,
|
|
323
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
324
|
+
env=env,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _append_progress_line(progress_path: Path, line: str) -> None:
|
|
329
|
+
with progress_path.open("a", encoding="utf-8") as fh:
|
|
330
|
+
fh.write(line + "\n")
|
|
331
|
+
fh.flush()
|