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,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()