crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,1731 @@
1
+ """Managed multi-agent runtime for CrabCode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Awaitable, Callable
10
+
11
+ from crabcode_core.logging_utils import get_logger
12
+ from crabcode_core.prompts.profile import PromptProfile, resolve_agent_prompt, resolve_compact_prompt
13
+ from crabcode_core.query.loop import QueryParams, query_loop
14
+ from crabcode_core.types.config import AgentSettings, AgentTypeConfig, CrabCodeSettings
15
+ from crabcode_core.types.event import (
16
+ AgentOutputEvent,
17
+ AgentStateEvent,
18
+ ChoiceRequestEvent,
19
+ ChoiceResponseEvent,
20
+ CompactEvent,
21
+ CoreEvent,
22
+ ErrorEvent,
23
+ PermissionRequestEvent,
24
+ PermissionResponseEvent,
25
+ StreamModeEvent,
26
+ StreamRetryEvent,
27
+ StreamTextEvent,
28
+ ToolResultEvent,
29
+ ToolUseEvent,
30
+ TurnCompleteEvent,
31
+ )
32
+ from crabcode_core.types.message import (
33
+ Message,
34
+ find_assistant_reply,
35
+ message_from_entry,
36
+ create_assistant_message,
37
+ create_user_message,
38
+ )
39
+ from crabcode_core.types.tool import Tool, ToolContext
40
+
41
+ logger = get_logger(__name__)
42
+
43
+
44
+ def _now_iso() -> str:
45
+ return datetime.now(timezone.utc).isoformat()
46
+
47
+
48
+ def _truncate_result(text: str, max_chars: int | None) -> str:
49
+ """Bound model-facing agent output while preserving its conclusion."""
50
+ if max_chars is None or max_chars <= 0 or len(text) <= max_chars:
51
+ return text
52
+ marker = "\n... [agent result truncated; see transcript for full output] ...\n"
53
+ if max_chars <= len(marker):
54
+ return text[-max_chars:]
55
+ keep = max(0, max_chars - len(marker))
56
+ head = keep // 3
57
+ tail = keep - head
58
+ return text[:head] + marker + text[-tail:]
59
+
60
+
61
+ @dataclass
62
+ class AgentSnapshot:
63
+ agent_id: str
64
+ parent_agent_id: str | None
65
+ parent_tool_use_id: str | None
66
+ title: str
67
+ subagent_type: str
68
+ status: str
69
+ model: str
70
+ created_at: str
71
+ # Named settings profile used for this run. Persisting the profile keeps
72
+ # send_input() on a resumed agent from silently falling back to the
73
+ # session's current model.
74
+ model_profile: str | None = None
75
+ session_id: str = ""
76
+ started_at: str | None = None
77
+ finished_at: str | None = None
78
+ updated_at: str = field(default_factory=_now_iso)
79
+ usage: dict[str, Any] = field(default_factory=dict)
80
+ final_result: str = ""
81
+ error: str = ""
82
+ depth: int = 0
83
+ transcript_path: str | None = None
84
+ callback_enabled: bool = False
85
+ callback_state: str = "disabled"
86
+ callback_message_id: str | None = None
87
+ callback_epoch: int = 0
88
+ loaded_tools: list[str] = field(default_factory=list)
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ return {
92
+ "agent_id": self.agent_id,
93
+ "parent_agent_id": self.parent_agent_id,
94
+ "parent_tool_use_id": self.parent_tool_use_id,
95
+ "title": self.title,
96
+ "subagent_type": self.subagent_type,
97
+ "status": self.status,
98
+ "model": self.model,
99
+ "model_profile": self.model_profile,
100
+ "created_at": self.created_at,
101
+ "session_id": self.session_id,
102
+ "started_at": self.started_at,
103
+ "finished_at": self.finished_at,
104
+ "updated_at": self.updated_at,
105
+ "usage": dict(self.usage),
106
+ "final_result": self.final_result,
107
+ "error": self.error,
108
+ "depth": self.depth,
109
+ "transcript_path": self.transcript_path,
110
+ "callback_enabled": self.callback_enabled,
111
+ "callback_state": self.callback_state,
112
+ "callback_message_id": self.callback_message_id,
113
+ "callback_epoch": self.callback_epoch,
114
+ "loaded_tools": list(self.loaded_tools),
115
+ }
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class AgentCompletion:
120
+ """Immutable terminal result delivered to the owning session or agent."""
121
+
122
+ session_id: str
123
+ agent_id: str
124
+ parent_agent_id: str | None
125
+ parent_tool_use_id: str | None
126
+ title: str
127
+ subagent_type: str
128
+ status: str
129
+ final_result: str
130
+ error: str
131
+ usage: dict[str, Any]
132
+ completed_at: str
133
+ transcript_path: str | None
134
+ callback_state: str = "pending"
135
+ callback_message_id: str | None = None
136
+ callback_epoch: int = 0
137
+ # Internal ownership token. It is deliberately not persisted in the
138
+ # public snapshot: the manager assigns a fresh value whenever the active
139
+ # session context changes, so a late completion from an abandoned run can
140
+ # be rejected even when the replacement session reuses the same ID.
141
+ run_generation: int | None = None
142
+
143
+ @classmethod
144
+ def from_snapshot(
145
+ cls,
146
+ snapshot: AgentSnapshot,
147
+ *,
148
+ run_generation: int | None = None,
149
+ ) -> "AgentCompletion":
150
+ return cls(
151
+ session_id=snapshot.session_id,
152
+ agent_id=snapshot.agent_id,
153
+ parent_agent_id=snapshot.parent_agent_id,
154
+ parent_tool_use_id=snapshot.parent_tool_use_id,
155
+ title=snapshot.title,
156
+ subagent_type=snapshot.subagent_type,
157
+ status=snapshot.status,
158
+ final_result=snapshot.final_result,
159
+ error=snapshot.error,
160
+ usage=dict(snapshot.usage),
161
+ completed_at=snapshot.finished_at or snapshot.updated_at,
162
+ transcript_path=snapshot.transcript_path,
163
+ callback_state=snapshot.callback_state,
164
+ callback_message_id=snapshot.callback_message_id,
165
+ callback_epoch=snapshot.callback_epoch,
166
+ run_generation=run_generation,
167
+ )
168
+
169
+
170
+ @dataclass
171
+ class _AgentRun:
172
+ snapshot: AgentSnapshot
173
+ task: asyncio.Task[None] | None = None
174
+ messages: list[Message] = field(default_factory=list)
175
+ active_model_profile: str | None = None
176
+ # Capture session-scoped execution context at spawn time. The owning
177
+ # CoreSession can switch sessions while cancellation is being delivered;
178
+ # an in-flight run must continue to identify itself as the old run.
179
+ run_cwd: str = "."
180
+ run_env: dict[str, str] = field(default_factory=dict)
181
+ run_generation: int = 0
182
+ # Private routing identity for the foreground event stream that launched
183
+ # (or most recently restarted) this run. It is process-local by design and
184
+ # must never be serialized with AgentSnapshot.
185
+ event_stream_token: object | None = None
186
+ run_settings: CrabCodeSettings | None = None
187
+ run_agent_settings: AgentSettings | None = None
188
+ run_permission_manager: Any = None
189
+ run_prompt_profile: PromptProfile | None = None
190
+ run_hook_manager: Any = None
191
+ run_lsp_manager: Any = None
192
+ run_ai_reviewer: Any = None
193
+ run_team_manager: Any = None
194
+ run_tools: list[Tool] = field(default_factory=list)
195
+ run_adapter: Any = None
196
+ start_event: asyncio.Event = field(default_factory=asyncio.Event)
197
+ control_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
198
+ permission_queue: asyncio.Queue[PermissionResponseEvent] = field(default_factory=asyncio.Queue)
199
+ choice_queue: asyncio.Queue[ChoiceResponseEvent] = field(default_factory=asyncio.Queue)
200
+ output_chunks: list[str] = field(default_factory=list)
201
+ done_event: asyncio.Event = field(default_factory=asyncio.Event)
202
+ final_text: str = ""
203
+ cancelled: bool = False
204
+ detached: bool = False
205
+
206
+
207
+ class AgentManager:
208
+ """Owns all managed sub-agents for a CoreSession."""
209
+
210
+ def __init__(
211
+ self,
212
+ *,
213
+ settings: CrabCodeSettings,
214
+ agent_settings: AgentSettings,
215
+ tools_provider: Callable[[], list[Tool]],
216
+ adapter_provider: Callable[[str | None], Any],
217
+ event_sink: Callable[[CoreEvent], Awaitable[None]],
218
+ permission_manager: Any,
219
+ prompt_profile: PromptProfile | None,
220
+ cwd: str,
221
+ env: dict[str, str],
222
+ session_id: str,
223
+ completion_sink: Callable[[AgentCompletion], Awaitable[None]] | None = None,
224
+ current_model_name: str | None = None,
225
+ persistence_callback: Callable[[list[dict[str, Any]]], None] | None = None,
226
+ transcript_writer: Callable[[str, list[Message]], None] | None = None,
227
+ transcript_loader: Callable[[str], list[dict[str, Any]]] | None = None,
228
+ transcript_path_getter: Callable[[str], str] | None = None,
229
+ hook_manager: Any = None,
230
+ lsp_manager: Any = None,
231
+ ai_reviewer: Any = None,
232
+ schedule_manager: Any = None,
233
+ event_stream_token_provider: Callable[[], object | None] | None = None,
234
+ computer_use_release: Callable[[str, str], Awaitable[None]] | None = None,
235
+ ) -> None:
236
+ self._settings = settings
237
+ self._agent_settings = agent_settings
238
+ self._tools_provider = tools_provider
239
+ self._adapter_provider = adapter_provider
240
+ self._event_sink = event_sink
241
+ self._completion_sink = completion_sink
242
+ self._permission_manager = permission_manager
243
+ self._prompt_profile = prompt_profile
244
+ self._cwd = cwd
245
+ self._env = env
246
+ self._session_id = session_id
247
+ self._current_model_name = current_model_name
248
+ self._persistence_callback = persistence_callback
249
+ self._transcript_writer = transcript_writer
250
+ self._transcript_loader = transcript_loader
251
+ self._transcript_path_getter = transcript_path_getter
252
+ self._hook_manager = hook_manager
253
+ self._lsp_manager = lsp_manager
254
+ self._ai_reviewer = ai_reviewer
255
+ self._schedule_manager = schedule_manager
256
+ self._event_stream_token_provider = event_stream_token_provider
257
+ self._computer_use_release = computer_use_release
258
+ self._team_manager: Any = None # Set by CoreSession after construction
259
+ self._runs: dict[str, _AgentRun] = {}
260
+ # Runs disappear from _runs when a different session projection is
261
+ # restored, but their coroutines remain owned by this manager until
262
+ # they actually terminate.
263
+ self._detached_tasks: set[asyncio.Task[None]] = set()
264
+ self._lock = asyncio.Lock()
265
+ self._close_lock = asyncio.Lock()
266
+ self._close_task: asyncio.Task[None] | None = None
267
+ self._semaphore = asyncio.Semaphore(max(1, agent_settings.max_concurrency))
268
+ self._closed = False
269
+ self._session_generation = 0
270
+
271
+ def _capture_event_stream_token(self) -> object | None:
272
+ provider = self._event_stream_token_provider
273
+ if provider is None:
274
+ return None
275
+ try:
276
+ return provider()
277
+ except Exception:
278
+ logger.warning("Managed-agent event token provider failed", exc_info=True)
279
+ return None
280
+
281
+ @staticmethod
282
+ def truncate_result(text: str, max_chars: int | None) -> str:
283
+ return _truncate_result(text, max_chars)
284
+
285
+ @staticmethod
286
+ def _consume_task_result(task: asyncio.Task[Any]) -> None:
287
+ """Drain terminal exceptions from detached agent tasks.
288
+
289
+ Agent tasks are intentionally fire-and-forget from the caller's point
290
+ of view. A provider or event sink failure must still be observable in
291
+ logs, but it must not produce an unhandled-task warning during later
292
+ session shutdown.
293
+ """
294
+ try:
295
+ task.result()
296
+ except asyncio.CancelledError:
297
+ return
298
+ except Exception:
299
+ logger.error("Managed agent task failed", exc_info=True)
300
+
301
+ def _track_detached_task(self, task: asyncio.Task[None] | None) -> None:
302
+ if task is None or task.done():
303
+ return
304
+ self._detached_tasks.add(task)
305
+ task.add_done_callback(self._detached_tasks.discard)
306
+
307
+ @staticmethod
308
+ def format_snapshot(
309
+ snapshot: AgentSnapshot,
310
+ *,
311
+ max_result_chars: int | None = None,
312
+ ) -> str:
313
+ result = AgentManager.truncate_result(
314
+ snapshot.final_result.strip() or "(no result)",
315
+ max_result_chars,
316
+ )
317
+ usage = snapshot.usage or {}
318
+ usage_line = ", ".join(f"{k}={v}" for k, v in usage.items()) or "none"
319
+ summary = [
320
+ f"status: {snapshot.status}",
321
+ f"agent_id: {snapshot.agent_id}",
322
+ f"title: {snapshot.title}",
323
+ f"subagent_type: {snapshot.subagent_type}",
324
+ f"model: {snapshot.model or '(default)'}",
325
+ f"usage: {usage_line}",
326
+ ]
327
+ if snapshot.error:
328
+ summary.append(f"error: {snapshot.error}")
329
+ if snapshot.transcript_path:
330
+ summary.append(f"transcript_path: {snapshot.transcript_path}")
331
+ if snapshot.callback_enabled:
332
+ callback = (
333
+ f"{snapshot.callback_state} (epoch={snapshot.callback_epoch})"
334
+ )
335
+ if snapshot.callback_message_id:
336
+ callback += f", message_id={snapshot.callback_message_id}"
337
+ summary.append(f"callback: {callback}")
338
+ summary.append("result:")
339
+ summary.append(result)
340
+ return "\n".join(summary)
341
+
342
+ def list_agents(self) -> list[AgentSnapshot]:
343
+ return [
344
+ run.snapshot
345
+ for run in sorted(
346
+ self._runs.values(),
347
+ key=lambda r: r.snapshot.created_at,
348
+ reverse=True,
349
+ )
350
+ if not self._session_id or run.snapshot.session_id == self._session_id
351
+ ]
352
+
353
+ def get_agent(self, agent_id: str) -> AgentSnapshot | None:
354
+ run = self._runs.get(agent_id)
355
+ if run is None:
356
+ return None
357
+ if self._session_id and run.snapshot.session_id != self._session_id:
358
+ return None
359
+ return run.snapshot
360
+
361
+ def is_agent_active(self, agent_id: str) -> bool:
362
+ run = self._runs.get(agent_id)
363
+ return bool(
364
+ run
365
+ and (not self._session_id or run.snapshot.session_id == self._session_id)
366
+ and run.task
367
+ and not run.task.done()
368
+ )
369
+
370
+ def get_agent_reply(self, agent_id: str, message_id: str) -> Message | None:
371
+ run = self._runs.get(agent_id)
372
+ if run is not None and self._session_id and run.snapshot.session_id != self._session_id:
373
+ return None
374
+ return find_assistant_reply(run.messages, message_id) if run else None
375
+
376
+ def _is_current_run(self, run: _AgentRun) -> bool:
377
+ """Return whether *run* is still owned by the active session.
378
+
379
+ Session switches are synchronous from the manager's point of view, but
380
+ callers such as ``send_input`` can be suspended while a task is being
381
+ cancelled or while an event sink is consuming a state event. Checking
382
+ the map as well as the session id prevents those stale callers from
383
+ resurrecting a detached run after the switch.
384
+ """
385
+ return (
386
+ not self._closed
387
+ and not run.detached
388
+ and self._runs.get(run.snapshot.agent_id) is run
389
+ and bool(self._session_id)
390
+ and run.snapshot.session_id == self._session_id
391
+ and run.run_generation == self._session_generation
392
+ )
393
+
394
+ def has_agent_answered(self, agent_id: str, message_id: str) -> bool:
395
+ return self.get_agent_reply(agent_id, message_id) is not None
396
+
397
+ @property
398
+ def max_output_chars(self) -> int:
399
+ return max(1, self._agent_settings.max_output_chars)
400
+
401
+ async def spawn_agent(
402
+ self,
403
+ *,
404
+ prompt: str,
405
+ subagent_type: str = "generalPurpose",
406
+ name: str | None = None,
407
+ model_profile: str | None = None,
408
+ parent_agent_id: str | None = None,
409
+ parent_tool_use_id: str | None = None,
410
+ depth: int = 1,
411
+ callback: bool = False,
412
+ ) -> str:
413
+ if self._closed:
414
+ raise RuntimeError("Agent manager is closed")
415
+ if not self._session_id:
416
+ raise RuntimeError("Cannot spawn an agent before a session is active")
417
+
418
+ # Reserve the active-agent slot before the first await. Counting only
419
+ # live asyncio tasks allowed concurrent callers to all pass the limit
420
+ # while their runs were still being queued.
421
+ async with self._lock:
422
+ if self._closed:
423
+ raise RuntimeError("Agent manager is closed")
424
+ if depth > self._agent_settings.max_depth:
425
+ raise ValueError(
426
+ f"Maximum agent depth exceeded ({depth} > {self._agent_settings.max_depth})"
427
+ )
428
+
429
+ active_runs = sum(
430
+ 1
431
+ for existing in self._runs.values()
432
+ if not existing.done_event.is_set()
433
+ )
434
+ if active_runs >= self._agent_settings.max_active_agents_per_run:
435
+ raise ValueError(
436
+ "Maximum active agent count exceeded "
437
+ f"({self._agent_settings.max_active_agents_per_run})"
438
+ )
439
+
440
+ profile_cfg = self._resolve_type_config(subagent_type)
441
+ requested_model_profile = (
442
+ model_profile if model_profile is not None else profile_cfg.model_profile
443
+ )
444
+ if (
445
+ requested_model_profile is not None
446
+ and requested_model_profile not in self._settings.models
447
+ ):
448
+ available = ", ".join(sorted(self._settings.models)) or "(none configured)"
449
+ raise ValueError(
450
+ f"Unknown model profile '{requested_model_profile}'. "
451
+ "Model profiles must be names configured under settings.models. "
452
+ f"Available profiles: {available}"
453
+ )
454
+
455
+ model_name = (
456
+ requested_model_profile
457
+ if requested_model_profile is not None
458
+ else self._current_model_name
459
+ )
460
+ api_cfg = self._settings.get_api_config(model_name)
461
+ # Capture all mutable runtime dependencies before the first await.
462
+ # A session switch can replace these manager fields while a
463
+ # provider is still unwinding cancellation; old runs must keep the
464
+ # resources they were created with and never execute in the new
465
+ # project's context.
466
+ run_adapter = self._adapter_provider(model_name)
467
+ run_tools = list(self._tools_provider())
468
+ agent_id = str(uuid.uuid4())
469
+ snapshot = AgentSnapshot(
470
+ agent_id=agent_id,
471
+ parent_agent_id=parent_agent_id,
472
+ parent_tool_use_id=parent_tool_use_id,
473
+ title=(name or prompt.strip().splitlines()[0][:80] or f"{subagent_type} agent"),
474
+ subagent_type=subagent_type,
475
+ status="queued",
476
+ model=api_cfg.model or "",
477
+ model_profile=model_name,
478
+ created_at=_now_iso(),
479
+ session_id=self._session_id,
480
+ updated_at=_now_iso(),
481
+ depth=depth,
482
+ transcript_path=self._transcript_path_getter(agent_id) if self._transcript_path_getter else None,
483
+ callback_enabled=callback,
484
+ callback_state="pending" if callback else "disabled",
485
+ )
486
+ run = _AgentRun(
487
+ snapshot=snapshot,
488
+ messages=[create_user_message(content=prompt)],
489
+ active_model_profile=model_name,
490
+ run_cwd=self._cwd,
491
+ run_env=dict(self._env),
492
+ run_generation=self._session_generation,
493
+ event_stream_token=self._capture_event_stream_token(),
494
+ run_settings=self._settings,
495
+ run_agent_settings=self._agent_settings,
496
+ run_permission_manager=self._permission_manager,
497
+ run_prompt_profile=self._prompt_profile,
498
+ run_hook_manager=self._hook_manager,
499
+ run_lsp_manager=self._lsp_manager,
500
+ run_ai_reviewer=self._ai_reviewer,
501
+ run_team_manager=self._team_manager,
502
+ run_tools=run_tools,
503
+ run_adapter=run_adapter,
504
+ )
505
+ self._runs[agent_id] = run
506
+ # Create the task before yielding so cancel_agent can cancel a run
507
+ # while its initial queued event is being delivered. The guarded
508
+ # runner waits on start_event to preserve queued -> running order.
509
+ run.task = asyncio.create_task(
510
+ self._run_agent_guarded(
511
+ run=run,
512
+ model_profile=model_name,
513
+ profile_cfg=profile_cfg,
514
+ )
515
+ )
516
+ run.task.add_done_callback(self._consume_task_result)
517
+
518
+ try:
519
+ await self._emit_state(run, "queued", "Agent queued")
520
+ async with self._lock:
521
+ valid = (
522
+ self._runs.get(agent_id) is run
523
+ and not run.detached
524
+ and not self._closed
525
+ and run.snapshot.session_id == self._session_id
526
+ and run.run_generation == self._session_generation
527
+ )
528
+ run.start_event.set()
529
+ task = run.task
530
+ except BaseException:
531
+ async with self._lock:
532
+ run.cancelled = True
533
+ run.detached = True
534
+ run.start_event.set()
535
+ task = run.task
536
+ # The queued-state sink can raise CancelledError before the
537
+ # newly created task gets its first scheduling turn. In that
538
+ # case _run_agent_guarded() never enters its cancellation
539
+ # handler, so settle and unregister the failed reservation
540
+ # here instead of leaking an active-agent slot forever.
541
+ if self._runs.get(agent_id) is run:
542
+ self._runs.pop(agent_id, None)
543
+ run.done_event.set()
544
+ if task is not None:
545
+ task.cancel()
546
+ await asyncio.gather(task, return_exceptions=True)
547
+ raise
548
+ if not valid:
549
+ async with self._lock:
550
+ run.cancelled = True
551
+ run.detached = True
552
+ if self._runs.get(agent_id) is run:
553
+ self._runs.pop(agent_id, None)
554
+ run.done_event.set()
555
+ if task is not None:
556
+ task.cancel()
557
+ await asyncio.gather(task, return_exceptions=True)
558
+ raise RuntimeError("Session changed while spawning agent")
559
+ return agent_id
560
+
561
+ async def wait_agent(
562
+ self, agent_id: str, timeout_ms: int | None = None
563
+ ) -> AgentSnapshot | None:
564
+ run = self._runs.get(agent_id)
565
+ if not run or (
566
+ self._session_id and run.snapshot.session_id != self._session_id
567
+ ):
568
+ return None
569
+ # A task can be terminal while its coroutine's final callback has not
570
+ # published ``done_event`` yet (notably for integrations that provide
571
+ # their own task or when cancellation lands at the task boundary).
572
+ # Fence that state before waiting so callers cannot block forever on a
573
+ # run that has already stopped executing.
574
+ self._fence_finished_task(run)
575
+ timeout = None if timeout_ms is None else max(timeout_ms / 1000.0, 0)
576
+ try:
577
+ # ``wait_for(..., timeout=0)`` can time out before a waiter gets a
578
+ # scheduling turn, even when the event was set already. Inspect
579
+ # the state synchronously first so a zero-timeout poll remains a
580
+ # useful non-blocking completion check.
581
+ if not run.done_event.is_set():
582
+ if timeout == 0:
583
+ return None
584
+ await asyncio.wait_for(run.done_event.wait(), timeout=timeout)
585
+ except asyncio.TimeoutError:
586
+ return None
587
+ # A session restore can replace an agent with the same id (for
588
+ # example, when a persisted snapshot is reloaded). The old waiter
589
+ # must not return that run's terminal state as if it belonged to the
590
+ # replacement. Keep the historical behavior for a run that was
591
+ # removed altogether: restore_snapshots() settles such waiters by
592
+ # setting done_event before dropping the map entry.
593
+ current = self._runs.get(agent_id)
594
+ if current is not None and (
595
+ current is not run
596
+ or run.detached
597
+ or run.run_generation != self._session_generation
598
+ ):
599
+ return None
600
+ if self._session_id and run.snapshot.session_id != self._session_id:
601
+ return None
602
+ return run.snapshot
603
+
604
+ async def wait_any(
605
+ self, agent_ids: list[str], timeout_ms: int | None = None
606
+ ) -> AgentSnapshot | None:
607
+ runs = [
608
+ self._runs[agent_id]
609
+ for agent_id in agent_ids
610
+ if agent_id in self._runs
611
+ and (
612
+ not self._session_id
613
+ or self._runs[agent_id].snapshot.session_id == self._session_id
614
+ )
615
+ ]
616
+ if not runs:
617
+ return None
618
+ for run in runs:
619
+ self._fence_finished_task(run)
620
+ timeout = None if timeout_ms is None else max(timeout_ms / 1000.0, 0)
621
+
622
+ def _valid_snapshot(run: _AgentRun) -> AgentSnapshot | None:
623
+ current = self._runs.get(run.snapshot.agent_id)
624
+ if current is not None and (
625
+ current is not run
626
+ or run.detached
627
+ or run.run_generation != self._session_generation
628
+ ):
629
+ return None
630
+ if self._session_id and run.snapshot.session_id != self._session_id:
631
+ return None
632
+ return run.snapshot
633
+
634
+ # Handle completed runs before creating zero-timeout waiter tasks. A
635
+ # task scheduled by a just-finished run gets one event-loop turn below,
636
+ # which keeps timeout_ms=0 deterministic without blocking.
637
+ for run in runs:
638
+ if run.done_event.is_set():
639
+ snapshot = _valid_snapshot(run)
640
+ if snapshot is not None:
641
+ return snapshot
642
+ if timeout == 0:
643
+ await asyncio.sleep(0)
644
+ for run in runs:
645
+ if run.done_event.is_set():
646
+ snapshot = _valid_snapshot(run)
647
+ if snapshot is not None:
648
+ return snapshot
649
+ return None
650
+
651
+ waiter_map = {
652
+ asyncio.create_task(run.done_event.wait()): run
653
+ for run in runs
654
+ }
655
+ done: set[asyncio.Task[bool]] = set()
656
+ try:
657
+ done, _pending = await asyncio.wait(
658
+ set(waiter_map),
659
+ timeout=timeout,
660
+ return_when=asyncio.FIRST_COMPLETED,
661
+ )
662
+ finally:
663
+ pending_waiters = [task for task in waiter_map if not task.done()]
664
+ for task in pending_waiters:
665
+ task.cancel()
666
+ if pending_waiters:
667
+ await asyncio.gather(*pending_waiters, return_exceptions=True)
668
+ if not done:
669
+ return None
670
+ # Prefer a still-owned completed run. A same-id replacement can race
671
+ # the wake-up; returning its predecessor would let callers act on a
672
+ # stale snapshot. If the old map entry was removed completely, retain
673
+ # the established settled-waiter behavior used by session restore.
674
+ valid_run = None
675
+ for waiter in done:
676
+ candidate = waiter_map[waiter]
677
+ if _valid_snapshot(candidate) is not None:
678
+ valid_run = candidate
679
+ break
680
+ if valid_run is None:
681
+ return None
682
+ snapshot = valid_run.snapshot
683
+ if self._session_id and snapshot.session_id != self._session_id:
684
+ return None
685
+ return snapshot
686
+
687
+ @staticmethod
688
+ def _fence_finished_task(run: _AgentRun) -> None:
689
+ """Wake waiters when a terminal task missed its final event fence.
690
+
691
+ Managed runs normally set ``done_event`` from their ``finally`` block,
692
+ but callers may inject a completed task or observe the tiny scheduling
693
+ window between task completion and a done callback. Setting the event
694
+ is idempotent and prevents that run from retaining waiters forever.
695
+ """
696
+ task = run.task
697
+ if task is not None and task.done() and not run.done_event.is_set():
698
+ run.done_event.set()
699
+
700
+ async def cancel_agent(self, agent_id: str) -> bool:
701
+ if self._closed:
702
+ return False
703
+ run = self._runs.get(agent_id)
704
+ if not run or (
705
+ self._session_id and run.snapshot.session_id != self._session_id
706
+ ):
707
+ return False
708
+ async with run.control_lock:
709
+ # The session can be replaced while waiting to acquire this lock.
710
+ # Re-check ownership before touching messages or cancelling the
711
+ # previous task; otherwise a callback from the old session can
712
+ # restart a detached agent in the new session.
713
+ if not self._is_current_run(run):
714
+ return False
715
+ task = run.task
716
+ if task is None:
717
+ self._fence_finished_task(run)
718
+ return False
719
+ if task.done():
720
+ self._fence_finished_task(run)
721
+ return False
722
+ run.cancelled = True
723
+ run.start_event.set()
724
+ task.cancel()
725
+ try:
726
+ await task
727
+ except asyncio.CancelledError:
728
+ pass
729
+ if not self._is_current_run(run):
730
+ return False
731
+ await self._finalize_cancelled_run(run)
732
+ return True
733
+
734
+ async def send_input(
735
+ self,
736
+ agent_id: str,
737
+ prompt: str,
738
+ *,
739
+ interrupt: bool = False,
740
+ message_id: str | None = None,
741
+ message_origin: str | None = None,
742
+ ) -> bool:
743
+ if self._closed:
744
+ return False
745
+ run = self._runs.get(agent_id)
746
+ if not run or (
747
+ self._session_id and run.snapshot.session_id != self._session_id
748
+ ):
749
+ return False
750
+ if not prompt.strip():
751
+ return False
752
+
753
+ # Serialize restart/callback-input operations for one run. Without a
754
+ # per-run lock, two concurrent completion deliveries could both cancel
755
+ # the old task, append duplicate messages, and launch two replacements.
756
+ async with run.control_lock:
757
+ if not self._is_current_run(run):
758
+ return False
759
+ existing_index = next(
760
+ (
761
+ index
762
+ for index, message in enumerate(run.messages)
763
+ if message_id is not None and message.uuid == message_id
764
+ ),
765
+ None,
766
+ )
767
+ if (
768
+ run.snapshot.status in {"completed", "failed", "stopped", "cancelled"}
769
+ and run.snapshot.callback_enabled
770
+ and run.snapshot.callback_state in {"pending", "injected"}
771
+ and existing_index is None
772
+ ):
773
+ return False
774
+ if existing_index is not None and find_assistant_reply(run.messages, message_id or ""):
775
+ return True
776
+
777
+ profile_cfg = self._resolve_type_config(run.snapshot.subagent_type)
778
+ model_profile = run.active_model_profile
779
+
780
+ # Treat a completed task as historical before clearing the event
781
+ # for a replacement run. Without this fence a stale task that
782
+ # missed its final callback could wake waiters for the new run.
783
+ self._fence_finished_task(run)
784
+ if run.task and not run.task.done():
785
+ if existing_index is not None:
786
+ return True
787
+ if not interrupt:
788
+ return False
789
+ run.cancelled = True
790
+ run.start_event.set()
791
+ run.task.cancel()
792
+ try:
793
+ await run.task
794
+ except asyncio.CancelledError:
795
+ pass
796
+
797
+ # ``abandon_active_agents``/``restore_snapshots`` may have
798
+ # switched the session while cancellation was in flight.
799
+ if not self._is_current_run(run):
800
+ return False
801
+
802
+ # The run may have become detached while the caller was waiting on
803
+ # the previous state event. Keep this check immediately before
804
+ # mutating the message history as well as after the event below.
805
+ if not self._is_current_run(run):
806
+ return False
807
+
808
+ if existing_index is None:
809
+ message_kwargs: dict[str, Any] = {}
810
+ if message_id:
811
+ message_kwargs["uuid"] = message_id
812
+ if message_origin:
813
+ message_kwargs["origin"] = message_origin
814
+ run.messages.append(create_user_message(content=prompt, **message_kwargs))
815
+ run.done_event.clear()
816
+ run.cancelled = False
817
+ run.detached = False
818
+ run.final_text = ""
819
+ run.output_chunks.clear()
820
+ run.snapshot.error = ""
821
+ run.snapshot.final_result = ""
822
+ run.snapshot.finished_at = None
823
+ if run.snapshot.callback_enabled and existing_index is None:
824
+ run.snapshot.callback_epoch += 1
825
+ run.snapshot.callback_state = "pending"
826
+ run.snapshot.callback_message_id = None
827
+ run.snapshot.status = "queued"
828
+ run.snapshot.updated_at = _now_iso()
829
+ run.start_event = asyncio.Event()
830
+ # A continuation is a new execution boundary. In particular, a
831
+ # foreground AgentSendInput tool must not retain the token from the
832
+ # turn that originally spawned this agent.
833
+ run.event_stream_token = self._capture_event_stream_token()
834
+ self._persist_transcript(run)
835
+ try:
836
+ await self._emit_state(run, "queued", "Agent received new input")
837
+ except BaseException:
838
+ # Cancellation can arrive while a frontend is consuming the
839
+ # queued-state event, before the replacement task is created.
840
+ # Settle the run here so it cannot occupy an active slot or
841
+ # leave wait_agent() blocked forever.
842
+ run.cancelled = True
843
+ run.start_event.set()
844
+ if not run.detached:
845
+ run.snapshot.status = "stopped"
846
+ run.snapshot.error = "stopped"
847
+ run.snapshot.updated_at = _now_iso()
848
+ run.snapshot.finished_at = run.snapshot.updated_at
849
+ run.done_event.set()
850
+ self._persist()
851
+ raise
852
+ if not self._is_current_run(run):
853
+ # A session restore can clear the run map while the queued
854
+ # state event is awaiting a frontend sink. Do not resurrect
855
+ # that stale run after the switch.
856
+ run.cancelled = True
857
+ run.start_event.set()
858
+ run.done_event.set()
859
+ return False
860
+ run.task = asyncio.create_task(
861
+ self._run_agent_guarded(
862
+ run=run,
863
+ model_profile=model_profile,
864
+ profile_cfg=profile_cfg,
865
+ )
866
+ )
867
+ run.task.add_done_callback(self._consume_task_result)
868
+ run.start_event.set()
869
+ return True
870
+
871
+ async def route_permission(self, response: PermissionResponseEvent) -> bool:
872
+ if not response.agent_id:
873
+ return False
874
+ run = self._runs.get(response.agent_id)
875
+ if (
876
+ not run
877
+ or not self._is_current_run(run)
878
+ or run.done_event.is_set()
879
+ or run.task is None
880
+ or run.task.done()
881
+ or run.snapshot.status not in {"queued", "running"}
882
+ ):
883
+ return False
884
+ await run.permission_queue.put(response)
885
+ return True
886
+
887
+ async def route_choice(self, response: ChoiceResponseEvent) -> bool:
888
+ if not response.agent_id:
889
+ return False
890
+ run = self._runs.get(response.agent_id)
891
+ if (
892
+ not run
893
+ or not self._is_current_run(run)
894
+ or run.done_event.is_set()
895
+ or run.task is None
896
+ or run.task.done()
897
+ or run.snapshot.status not in {"queued", "running"}
898
+ ):
899
+ return False
900
+ await run.choice_queue.put(response)
901
+ return True
902
+
903
+ def update_session(
904
+ self,
905
+ *,
906
+ env: dict[str, str],
907
+ session_id: str,
908
+ cwd: str | None = None,
909
+ force_generation: bool = False,
910
+ ) -> None:
911
+ """Switch the manager's default context for subsequently spawned runs."""
912
+ if (
913
+ force_generation
914
+ or
915
+ session_id != self._session_id
916
+ or dict(env) != self._env
917
+ or (cwd is not None and cwd != self._cwd)
918
+ ):
919
+ self._session_generation += 1
920
+ self._env = dict(env)
921
+ self._session_id = session_id
922
+ if cwd is not None:
923
+ self._cwd = cwd
924
+
925
+ def abandon_active_agents(self, reason: str) -> None:
926
+ """Cancel active runs and persist their terminal state before a session switch."""
927
+ changed = False
928
+ for run in self._runs.values():
929
+ if run.done_event.is_set():
930
+ continue
931
+ changed = True
932
+ run.cancelled = True
933
+ run.detached = True
934
+ run.start_event.set()
935
+ if run.snapshot.status not in {"completed", "failed", "stopped", "cancelled"}:
936
+ run.snapshot.status = "stopped"
937
+ run.snapshot.error = reason
938
+ run.snapshot.updated_at = _now_iso()
939
+ run.snapshot.finished_at = run.snapshot.updated_at
940
+ run.done_event.set()
941
+ if run.task is not None and not run.task.done():
942
+ self._track_detached_task(run.task)
943
+ run.task.cancel()
944
+ if changed:
945
+ self._persist()
946
+
947
+ async def wait_for_detached_agents(self) -> None:
948
+ """Wait until runs detached by a session switch have stopped.
949
+
950
+ ``abandon_active_agents`` is intentionally synchronous so the
951
+ synchronous ``new_session`` API can mark ownership immediately. An
952
+ async resume can use this companion method before tearing down
953
+ cwd-scoped resources such as LSP/MCP clients.
954
+ """
955
+ tasks = [task for task in self._detached_tasks if not task.done()]
956
+ if tasks:
957
+ await asyncio.gather(*tasks, return_exceptions=True)
958
+
959
+ async def close(self) -> None:
960
+ """Cancel and settle all managed runs exactly once.
961
+
962
+ The cleanup task is owned by the manager rather than by the caller.
963
+ If a transport request awaiting ``close`` is cancelled, a later caller
964
+ still joins the same task instead of observing ``_closed`` and
965
+ returning while agent tasks are alive.
966
+ """
967
+ async with self._close_lock:
968
+ if self._close_task is None:
969
+ if self._closed:
970
+ return
971
+ # Mark the manager closed before scheduling teardown so no new
972
+ # run can be admitted while the owned cleanup task is pending.
973
+ self._closed = True
974
+ self._close_task = asyncio.create_task(self._close_impl())
975
+ self._close_task.add_done_callback(self._finish_close_task)
976
+ task = self._close_task
977
+ await asyncio.shield(task)
978
+
979
+ def _finish_close_task(self, task: asyncio.Task[None]) -> None:
980
+ """Consume terminal errors from an owned cleanup task."""
981
+ if task.cancelled():
982
+ return
983
+ try:
984
+ task.exception()
985
+ except Exception:
986
+ logger.warning("Managed agent cleanup task failed", exc_info=True)
987
+
988
+ async def _close_impl(self) -> None:
989
+ current_task = asyncio.current_task()
990
+ active_runs: list[_AgentRun] = []
991
+ owned_tasks: set[asyncio.Task[None]] = {
992
+ task for task in self._detached_tasks if not task.done()
993
+ }
994
+ # Serialize the snapshot/mark phase with spawn_agent so a run cannot
995
+ # be inserted into ``_runs`` while this loop is traversing the dict.
996
+ async with self._lock:
997
+ runs = list(self._runs.values())
998
+ for run in runs:
999
+ if run.done_event.is_set():
1000
+ continue
1001
+ if run.task and not run.task.done():
1002
+ run.cancelled = True
1003
+ run.detached = True
1004
+ run.start_event.set()
1005
+ if run.task is not current_task:
1006
+ self._track_detached_task(run.task)
1007
+ run.task.cancel()
1008
+ active_runs.append(run)
1009
+ owned_tasks.add(run.task)
1010
+ else:
1011
+ # A task can already be terminal while its finally block
1012
+ # has not published done_event yet. Settle waiters before
1013
+ # close returns instead of leaving them attached forever.
1014
+ run.done_event.set()
1015
+ for task in owned_tasks:
1016
+ if task is not current_task and not task.done():
1017
+ task.cancel()
1018
+ if owned_tasks:
1019
+ await asyncio.gather(
1020
+ *owned_tasks,
1021
+ return_exceptions=True,
1022
+ )
1023
+ if active_runs:
1024
+ for run in active_runs:
1025
+ await self._finalize_cancelled_run(run)
1026
+ for run in runs:
1027
+ if not run.done_event.is_set():
1028
+ run.done_event.set()
1029
+ self._persist()
1030
+
1031
+ def set_current_model(self, model_name: str | None) -> None:
1032
+ self._current_model_name = model_name
1033
+
1034
+ def restore_snapshots(self, snapshots: list[dict[str, Any]]) -> list[AgentCompletion]:
1035
+ if self._closed:
1036
+ return []
1037
+ for run in self._runs.values():
1038
+ if run.task and not run.task.done():
1039
+ run.detached = True
1040
+ run.cancelled = True
1041
+ run.snapshot.callback_enabled = False
1042
+ run.snapshot.callback_state = "disabled"
1043
+ run.start_event.set()
1044
+ self._track_detached_task(run.task)
1045
+ run.task.cancel()
1046
+ if run.done_event.is_set():
1047
+ continue
1048
+ # The old run is about to be removed from the map. Wake any
1049
+ # caller that still holds its handle immediately, including the
1050
+ # task-done/finally-not-yet-run edge case.
1051
+ run.done_event.set()
1052
+ self._runs.clear()
1053
+ pending_callbacks: list[AgentCompletion] = []
1054
+ changed = False
1055
+ for item in snapshots:
1056
+ try:
1057
+ snapshot = AgentSnapshot(**item)
1058
+ except Exception:
1059
+ logger.warning("Skipping invalid agent snapshot during restore", exc_info=True)
1060
+ # Drop malformed entries from the persisted projection so a
1061
+ # bad record does not trigger the same warning on every resume.
1062
+ changed = True
1063
+ continue
1064
+ if snapshot.session_id and self._session_id and snapshot.session_id != self._session_id:
1065
+ logger.warning(
1066
+ "Skipping agent %s restored for session %s while active session is %s",
1067
+ snapshot.agent_id,
1068
+ snapshot.session_id,
1069
+ self._session_id,
1070
+ )
1071
+ # Persist the filtered projection below so stale snapshots do
1072
+ # not get reconsidered (and warned about) on every resume.
1073
+ changed = True
1074
+ continue
1075
+ if not snapshot.session_id:
1076
+ # Snapshots written by pre-session-id versions can still be
1077
+ # resumed, but must be bound to the active session now.
1078
+ snapshot.session_id = self._session_id
1079
+ if snapshot.status in {"queued", "running"}:
1080
+ # Background agents are owned by the session process. Once that
1081
+ # process is gone there is no coroutine to resume, so never expose
1082
+ # a phantom running task after --resume. Settle it as stopped and,
1083
+ # when callbacks were enabled, deliver that terminal state exactly
1084
+ # like any other completion.
1085
+ snapshot.status = "stopped"
1086
+ snapshot.error = snapshot.error or "stopped because the session ended"
1087
+ snapshot.updated_at = _now_iso()
1088
+ snapshot.finished_at = snapshot.updated_at
1089
+ changed = True
1090
+ run = _AgentRun(
1091
+ snapshot=snapshot,
1092
+ active_model_profile=(
1093
+ snapshot.model_profile
1094
+ if snapshot.model_profile in self._settings.models
1095
+ else self._current_model_name
1096
+ ),
1097
+ run_cwd=self._cwd,
1098
+ run_env=dict(self._env),
1099
+ run_generation=self._session_generation,
1100
+ run_settings=self._settings,
1101
+ run_agent_settings=self._agent_settings,
1102
+ run_permission_manager=self._permission_manager,
1103
+ run_prompt_profile=self._prompt_profile,
1104
+ run_hook_manager=self._hook_manager,
1105
+ run_lsp_manager=self._lsp_manager,
1106
+ run_ai_reviewer=self._ai_reviewer,
1107
+ run_team_manager=self._team_manager,
1108
+ run_tools=list(self._tools_provider()),
1109
+ )
1110
+ if self._transcript_loader is not None:
1111
+ try:
1112
+ raw_messages = self._transcript_loader(snapshot.agent_id)
1113
+ except Exception:
1114
+ logger.warning(
1115
+ "Failed to load transcript for restored agent %s",
1116
+ snapshot.agent_id,
1117
+ exc_info=True,
1118
+ )
1119
+ raw_messages = []
1120
+ if not isinstance(raw_messages, list):
1121
+ raw_messages = []
1122
+ for raw in raw_messages:
1123
+ message = message_from_entry(raw)
1124
+ if message is not None:
1125
+ run.messages.append(message)
1126
+ if snapshot.final_result:
1127
+ run.final_text = snapshot.final_result
1128
+ # Restored runs never have a live coroutine. Mark their waiter as settled
1129
+ # even when an older snapshot still says queued/running; callers can then
1130
+ # inspect that stale state instead of waiting forever for an impossible
1131
+ # transition. send_input() clears this event before starting a new run.
1132
+ run.done_event.set()
1133
+ if snapshot.status in {"completed", "failed", "stopped", "cancelled"}:
1134
+ if snapshot.callback_enabled and snapshot.callback_state in {"pending", "injected"}:
1135
+ pending_callbacks.append(
1136
+ AgentCompletion.from_snapshot(
1137
+ snapshot,
1138
+ run_generation=run.run_generation,
1139
+ )
1140
+ )
1141
+ self._runs[snapshot.agent_id] = run
1142
+ if changed:
1143
+ self._persist()
1144
+ return pending_callbacks
1145
+
1146
+ def mark_callback_injected(
1147
+ self,
1148
+ agent_id: str,
1149
+ *,
1150
+ session_id: str,
1151
+ message_id: str,
1152
+ callback_epoch: int,
1153
+ ) -> bool:
1154
+ run = self._runs.get(agent_id)
1155
+ if (
1156
+ not run
1157
+ or run.snapshot.callback_state != "pending"
1158
+ or run.snapshot.session_id != session_id
1159
+ or run.snapshot.callback_epoch != callback_epoch
1160
+ ):
1161
+ return False
1162
+ run.snapshot.callback_state = "injected"
1163
+ run.snapshot.callback_message_id = message_id
1164
+ run.snapshot.updated_at = _now_iso()
1165
+ self._persist()
1166
+ return True
1167
+
1168
+ def mark_callback_delivered(
1169
+ self,
1170
+ agent_id: str,
1171
+ *,
1172
+ session_id: str,
1173
+ callback_epoch: int,
1174
+ ) -> bool:
1175
+ run = self._runs.get(agent_id)
1176
+ if (
1177
+ not run
1178
+ or run.snapshot.callback_state != "injected"
1179
+ or run.snapshot.session_id != session_id
1180
+ or run.snapshot.callback_epoch != callback_epoch
1181
+ ):
1182
+ return False
1183
+ run.snapshot.callback_state = "delivered"
1184
+ run.snapshot.updated_at = _now_iso()
1185
+ self._persist()
1186
+ return True
1187
+
1188
+ def _persist(self) -> None:
1189
+ if self._persistence_callback is None:
1190
+ return
1191
+ snapshots = self.list_agents()
1192
+ if self._session_id:
1193
+ snapshots = [
1194
+ snapshot
1195
+ for snapshot in snapshots
1196
+ if snapshot.session_id == self._session_id
1197
+ ]
1198
+ try:
1199
+ self._persistence_callback([snapshot.to_dict() for snapshot in snapshots])
1200
+ except Exception:
1201
+ # Persistence is important for resume, but a transient disk error
1202
+ # must not terminate the running agent coroutine and strand its
1203
+ # waiters before the terminal state is set.
1204
+ logger.warning("Failed to persist managed-agent snapshots", exc_info=True)
1205
+
1206
+ def _persist_transcript(self, run: _AgentRun) -> None:
1207
+ # A detached run belongs to a previous session. Its cancellation can
1208
+ # arrive after CoreSession has switched this manager to a new storage
1209
+ # object, so never write that stale run through the new writer.
1210
+ if (
1211
+ self._transcript_writer is None
1212
+ or run.detached
1213
+ or run.snapshot.session_id != self._session_id
1214
+ or run.run_generation != self._session_generation
1215
+ ):
1216
+ return
1217
+ try:
1218
+ self._transcript_writer(run.snapshot.agent_id, run.messages)
1219
+ except Exception:
1220
+ logger.warning(
1221
+ "Failed to persist transcript for managed agent %s",
1222
+ run.snapshot.agent_id,
1223
+ exc_info=True,
1224
+ )
1225
+
1226
+ def _capture_partial_output(
1227
+ self,
1228
+ run: _AgentRun,
1229
+ params: QueryParams | None,
1230
+ baseline_message_ids: set[str] | None = None,
1231
+ ) -> None:
1232
+ """Retain a streamed assistant prefix when a run is interrupted."""
1233
+ if params is not None:
1234
+ run.messages = params.messages
1235
+ if run.detached or not run.final_text.strip():
1236
+ return
1237
+ # ``query_loop`` can complete one or more model/tool turns before the
1238
+ # cancellation arrives. Those assistant messages are already in the
1239
+ # projection, while ``final_text`` contains the concatenated stream for
1240
+ # the whole run. Append only the uncommitted suffix; appending the
1241
+ # cumulative value would duplicate text from earlier tool turns.
1242
+ committed_text = ""
1243
+ baseline = baseline_message_ids or set()
1244
+ for message in run.messages:
1245
+ if (
1246
+ getattr(message, "role", None) == "assistant"
1247
+ and getattr(message, "uuid", None) not in baseline
1248
+ ):
1249
+ committed_text += getattr(message, "text_content", "")
1250
+ partial_text = run.final_text
1251
+ if committed_text:
1252
+ if partial_text.startswith(committed_text):
1253
+ partial_text = partial_text[len(committed_text):]
1254
+ elif partial_text == committed_text:
1255
+ partial_text = ""
1256
+ if not partial_text.strip():
1257
+ return
1258
+ parent_uuid = run.messages[-1].uuid if run.messages else None
1259
+ run.messages.append(
1260
+ create_assistant_message(
1261
+ content=partial_text,
1262
+ parent_uuid=parent_uuid,
1263
+ )
1264
+ )
1265
+
1266
+ def _resolve_type_config(self, subagent_type: str) -> AgentTypeConfig:
1267
+ cfg = self._agent_settings.types.get(subagent_type)
1268
+ if cfg:
1269
+ return cfg
1270
+ if subagent_type == "explore":
1271
+ return AgentTypeConfig(allowed_tools=[])
1272
+ return AgentTypeConfig()
1273
+
1274
+ def _resolve_tools(
1275
+ self,
1276
+ subagent_type: str,
1277
+ profile_cfg: AgentTypeConfig,
1278
+ base_tools: list[Tool] | None = None,
1279
+ ) -> list[Tool]:
1280
+ tools = list(self._tools_provider()) if base_tools is None else list(base_tools)
1281
+ allowed = list(profile_cfg.allowed_tools)
1282
+ if not allowed and subagent_type == "explore":
1283
+ allowed = [tool.name for tool in tools if tool.is_read_only]
1284
+ if allowed:
1285
+ allowed_set = set(allowed)
1286
+ tools = [tool for tool in tools if tool.name in allowed_set]
1287
+ return tools
1288
+
1289
+ async def _emit_state(self, run: _AgentRun, status: str, message: str) -> None:
1290
+ run.snapshot.status = status
1291
+ run.snapshot.updated_at = _now_iso()
1292
+ if status == "running" and run.snapshot.started_at is None:
1293
+ run.snapshot.started_at = run.snapshot.updated_at
1294
+ if status in {"completed", "failed", "stopped", "cancelled"}:
1295
+ run.snapshot.finished_at = run.snapshot.updated_at
1296
+ if not self._is_current_run(run):
1297
+ return
1298
+ await self._safe_event_sink(
1299
+ AgentStateEvent(
1300
+ agent_id=run.snapshot.agent_id,
1301
+ parent_agent_id=run.snapshot.parent_agent_id,
1302
+ status=run.snapshot.status,
1303
+ subagent_type=run.snapshot.subagent_type,
1304
+ title=run.snapshot.title,
1305
+ message=message,
1306
+ usage=run.snapshot.usage,
1307
+ ),
1308
+ run=run,
1309
+ )
1310
+ if not self._is_current_run(run):
1311
+ return
1312
+ self._persist()
1313
+
1314
+ async def _safe_event_sink(
1315
+ self,
1316
+ event: CoreEvent,
1317
+ *,
1318
+ run: _AgentRun | None = None,
1319
+ ) -> None:
1320
+ """Forward an event without letting a broken client sink kill a run."""
1321
+ if run is not None and not self._is_current_run(run):
1322
+ return
1323
+ if run is not None:
1324
+ # CoreSession uses these private routing hints to reject an event
1325
+ # that was already inside an async sink when the session switched.
1326
+ # They are intentionally not part of the public event schema.
1327
+ try:
1328
+ setattr(event, "_crabcode_session_id", run.snapshot.session_id)
1329
+ setattr(event, "_crabcode_session_generation", run.run_generation)
1330
+ setattr(event, "_crabcode_event_stream_token", run.event_stream_token)
1331
+ except Exception:
1332
+ pass
1333
+ try:
1334
+ await self._event_sink(event)
1335
+ except asyncio.CancelledError:
1336
+ raise
1337
+ except Exception:
1338
+ logger.warning(
1339
+ "Managed-agent event sink failed for %s",
1340
+ type(event).__name__,
1341
+ exc_info=True,
1342
+ )
1343
+
1344
+ async def _emit_completion(self, run: _AgentRun) -> None:
1345
+ if (
1346
+ not self._is_current_run(run)
1347
+ or self._completion_sink is None
1348
+ or not run.snapshot.callback_enabled
1349
+ or run.snapshot.callback_state != "pending"
1350
+ ):
1351
+ return
1352
+ try:
1353
+ await self._completion_sink(
1354
+ AgentCompletion.from_snapshot(
1355
+ run.snapshot,
1356
+ run_generation=run.run_generation,
1357
+ )
1358
+ )
1359
+ except Exception:
1360
+ logger.exception(
1361
+ "Failed to enqueue completion for managed agent %s",
1362
+ run.snapshot.agent_id,
1363
+ )
1364
+
1365
+ async def _run_agent_guarded(
1366
+ self,
1367
+ *,
1368
+ run: _AgentRun,
1369
+ model_profile: str | None,
1370
+ profile_cfg: AgentTypeConfig,
1371
+ ) -> None:
1372
+ """Finalize cancellation even if it happens while waiting for a slot."""
1373
+ try:
1374
+ await run.start_event.wait()
1375
+ if run.cancelled or run.done_event.is_set():
1376
+ return
1377
+ await self._run_agent(
1378
+ run=run,
1379
+ model_profile=model_profile,
1380
+ profile_cfg=profile_cfg,
1381
+ )
1382
+ except asyncio.CancelledError:
1383
+ # _run_agent handles cancellation after entering the semaphore. A
1384
+ # queued task can be cancelled before that inner try/finally starts.
1385
+ await self._finalize_cancelled_run(run)
1386
+
1387
+ async def _finalize_cancelled_run(self, run: _AgentRun) -> None:
1388
+ """Settle a cancelled run exactly once, including never-started tasks."""
1389
+ if run.done_event.is_set():
1390
+ return
1391
+ if not run.detached:
1392
+ run.snapshot.error = "stopped"
1393
+ run.snapshot.final_result = run.final_text.strip()
1394
+ self._persist_transcript(run)
1395
+ try:
1396
+ await self._emit_state(run, "stopped", "Agent stopped")
1397
+ finally:
1398
+ run.done_event.set()
1399
+ await self._emit_completion(run)
1400
+
1401
+ async def _run_agent(
1402
+ self,
1403
+ *,
1404
+ run: _AgentRun,
1405
+ model_profile: str | None,
1406
+ profile_cfg: AgentTypeConfig,
1407
+ ) -> None:
1408
+ async with self._semaphore:
1409
+ params: QueryParams | None = None
1410
+ baseline_message_ids = {message.uuid for message in run.messages}
1411
+ try:
1412
+ await self._emit_state(run, "running", "Agent started")
1413
+ settings = (
1414
+ run.run_settings
1415
+ if run.run_settings is not None
1416
+ else self._settings
1417
+ )
1418
+ agent_settings = (
1419
+ run.run_agent_settings
1420
+ if run.run_agent_settings is not None
1421
+ else self._agent_settings
1422
+ )
1423
+ tools = self._resolve_tools(
1424
+ run.snapshot.subagent_type,
1425
+ profile_cfg,
1426
+ run.run_tools,
1427
+ )
1428
+ adapter = (
1429
+ run.run_adapter
1430
+ if run.run_adapter is not None
1431
+ else self._adapter_provider(model_profile)
1432
+ )
1433
+
1434
+ resolved_cw = 0
1435
+ if hasattr(adapter, "resolve_context_window"):
1436
+ try:
1437
+ resolved_cw = await adapter.resolve_context_window()
1438
+ except Exception:
1439
+ pass
1440
+ if resolved_cw == 0:
1441
+ from crabcode_core.api.model_info import DEFAULT_CONTEXT_WINDOW
1442
+ resolved_cw = DEFAULT_CONTEXT_WINDOW
1443
+
1444
+ agent_api_config = None
1445
+ if model_profile and hasattr(settings, "get_api_config"):
1446
+ agent_api_config = settings.get_api_config(model_profile)
1447
+ elif hasattr(settings, "get_api_config"):
1448
+ agent_api_config = settings.get_api_config(None)
1449
+
1450
+ agent_prompt = (
1451
+ profile_cfg.prompt
1452
+ if profile_cfg.prompt is not None
1453
+ else resolve_agent_prompt(
1454
+ run.run_prompt_profile
1455
+ if run.run_prompt_profile is not None
1456
+ else self._prompt_profile
1457
+ )
1458
+ )
1459
+ tool_context = ToolContext(
1460
+ cwd=run.run_cwd,
1461
+ messages=run.messages,
1462
+ session_id=run.snapshot.session_id,
1463
+ env=run.run_env,
1464
+ choice_queue=run.choice_queue,
1465
+ tool_event_queue=asyncio.Queue(),
1466
+ agent_id=run.snapshot.agent_id,
1467
+ agent_depth=run.snapshot.depth,
1468
+ agent_manager=self,
1469
+ lsp_manager=(
1470
+ run.run_lsp_manager if profile_cfg.enable_lsp else None
1471
+ ),
1472
+ team_manager=(
1473
+ run.run_team_manager
1474
+ if run.run_team_manager is not None
1475
+ else self._team_manager
1476
+ ),
1477
+ schedule_manager=self._schedule_manager,
1478
+ snapshot_enabled=settings.snapshot.enabled,
1479
+ snapshot_max_size_mb=settings.snapshot.max_size_mb,
1480
+ filesystem_timeout=settings.filesystem_timeout,
1481
+ )
1482
+ from crabcode_core.tools.loading import ToolLoadingState
1483
+
1484
+ def persist_loaded_tools(names: list[str]) -> None:
1485
+ run.snapshot.loaded_tools = list(names)
1486
+ if not run.detached:
1487
+ self._persist()
1488
+
1489
+ params = QueryParams(
1490
+ messages=list(run.messages),
1491
+ system_prompt=[agent_prompt],
1492
+ user_context={},
1493
+ system_context={},
1494
+ tools=tools,
1495
+ tool_context=tool_context,
1496
+ api_adapter=adapter,
1497
+ tool_loading=settings.tool_loading,
1498
+ tool_loading_state=ToolLoadingState.restore(run.snapshot.loaded_tools),
1499
+ on_tools_loaded=persist_loaded_tools,
1500
+ max_turns=agent_settings.max_turns,
1501
+ permission_manager=(
1502
+ run.run_permission_manager
1503
+ if run.run_permission_manager is not None
1504
+ else self._permission_manager
1505
+ ),
1506
+ permission_queue=run.permission_queue,
1507
+ hook_manager=(
1508
+ run.run_hook_manager
1509
+ if run.run_hook_manager is not None
1510
+ else self._hook_manager
1511
+ ),
1512
+ agent_mode="agent",
1513
+ api_config=agent_api_config,
1514
+ context_window=resolved_cw,
1515
+ ai_reviewer=(
1516
+ run.run_ai_reviewer
1517
+ if run.run_ai_reviewer is not None
1518
+ else self._ai_reviewer
1519
+ ),
1520
+ tool_call_timeout=settings.tool_call_timeout,
1521
+ auto_compact_enabled=settings.auto_compact_enabled,
1522
+ compact_buffer_tokens=settings.compact_buffer_tokens,
1523
+ compact_threshold=settings.max_context_length,
1524
+ compact_prompt=resolve_compact_prompt(
1525
+ run.run_prompt_profile
1526
+ if run.run_prompt_profile is not None
1527
+ else self._prompt_profile
1528
+ ),
1529
+ reply_to_uuid=(
1530
+ run.messages[-1].uuid
1531
+ if run.messages and run.messages[-1].origin == "task-notification"
1532
+ else None
1533
+ ),
1534
+ )
1535
+ final_usage: dict[str, Any] = {}
1536
+ async for event in query_loop(params):
1537
+ await self._handle_agent_event(run, event)
1538
+ if isinstance(event, TurnCompleteEvent):
1539
+ final_usage = event.usage
1540
+ run.messages = params.messages
1541
+ run.snapshot.usage = dict(final_usage)
1542
+ run.snapshot.final_result = run.final_text.strip()
1543
+ self._persist_transcript(run)
1544
+ if run.snapshot.error:
1545
+ await self._emit_state(
1546
+ run,
1547
+ "failed",
1548
+ f"Agent failed: {run.snapshot.error}",
1549
+ )
1550
+ else:
1551
+ await self._emit_state(run, "completed", "Agent completed")
1552
+ except asyncio.CancelledError:
1553
+ # ``query_loop`` mutates its projection as it reaches safe
1554
+ # boundaries. Preserve that projection even when the stream
1555
+ # is interrupted before a TurnComplete event is emitted.
1556
+ self._capture_partial_output(run, params, baseline_message_ids)
1557
+ if not run.detached:
1558
+ run.snapshot.error = "stopped"
1559
+ run.snapshot.final_result = run.final_text.strip()
1560
+ self._persist_transcript(run)
1561
+ await self._emit_state(run, "stopped", "Agent stopped")
1562
+ except Exception as exc:
1563
+ self._capture_partial_output(run, params, baseline_message_ids)
1564
+ run.snapshot.error = str(exc)
1565
+ run.snapshot.final_result = run.final_text.strip()
1566
+ self._persist_transcript(run)
1567
+ await self._emit_state(run, "failed", f"Agent failed: {exc}")
1568
+ if not run.detached and run.snapshot.session_id == self._session_id:
1569
+ await self._safe_event_sink(
1570
+ ErrorEvent(
1571
+ message=str(exc),
1572
+ recoverable=True,
1573
+ error_type="agent",
1574
+ agent_id=run.snapshot.agent_id,
1575
+ ),
1576
+ run=run,
1577
+ )
1578
+ finally:
1579
+ try:
1580
+ if self._computer_use_release is not None:
1581
+ await self._computer_use_release(
1582
+ run.snapshot.session_id,
1583
+ run.snapshot.agent_id,
1584
+ )
1585
+ except Exception:
1586
+ logger.warning(
1587
+ "Failed to release Computer Use for agent %s",
1588
+ run.snapshot.agent_id,
1589
+ exc_info=True,
1590
+ )
1591
+ finally:
1592
+ run.done_event.set()
1593
+ await self._emit_completion(run)
1594
+
1595
+ async def _handle_agent_event(self, run: _AgentRun, event: CoreEvent) -> None:
1596
+ # Cancellation and session switches can race with a provider that is
1597
+ # slow to observe task cancellation. Never leak stale run events into
1598
+ # the replacement session's foreground/background stream.
1599
+ if not self._is_current_run(run):
1600
+ return
1601
+ agent_id = run.snapshot.agent_id
1602
+ if isinstance(event, StreamModeEvent):
1603
+ if event.mode == "thinking":
1604
+ await self._safe_event_sink(
1605
+ AgentOutputEvent(agent_id=agent_id, stream="thinking", text="thinking"),
1606
+ run=run,
1607
+ )
1608
+ return
1609
+ if isinstance(event, StreamRetryEvent):
1610
+ remaining = min(event.discarded_text_chars, len(run.final_text))
1611
+ if remaining:
1612
+ run.final_text = run.final_text[:-remaining]
1613
+ while remaining and run.output_chunks:
1614
+ chunk = run.output_chunks.pop()
1615
+ if len(chunk) <= remaining:
1616
+ remaining -= len(chunk)
1617
+ else:
1618
+ run.output_chunks.append(chunk[:-remaining])
1619
+ remaining = 0
1620
+ await self._safe_event_sink(
1621
+ StreamRetryEvent(
1622
+ message=event.message,
1623
+ error=event.error,
1624
+ retry_count=event.retry_count,
1625
+ max_retries=event.max_retries,
1626
+ delay_seconds=event.delay_seconds,
1627
+ unbounded=event.unbounded,
1628
+ transport_fallback=event.transport_fallback,
1629
+ discarded_text_chars=event.discarded_text_chars,
1630
+ agent_id=agent_id,
1631
+ ),
1632
+ run=run,
1633
+ )
1634
+ return
1635
+ if isinstance(event, StreamTextEvent):
1636
+ run.output_chunks.append(event.text)
1637
+ run.final_text += event.text
1638
+ await self._safe_event_sink(
1639
+ AgentOutputEvent(agent_id=agent_id, stream="text", text=event.text),
1640
+ run=run,
1641
+ )
1642
+ return
1643
+ if isinstance(event, ToolUseEvent):
1644
+ await self._safe_event_sink(
1645
+ ToolUseEvent(
1646
+ tool_name=event.tool_name,
1647
+ tool_input=event.tool_input,
1648
+ tool_use_id=event.tool_use_id,
1649
+ agent_id=agent_id,
1650
+ ),
1651
+ run=run,
1652
+ )
1653
+ await self._safe_event_sink(
1654
+ AgentOutputEvent(
1655
+ agent_id=agent_id,
1656
+ stream="tool_use",
1657
+ text=event.tool_name,
1658
+ tool_name=event.tool_name,
1659
+ ),
1660
+ run=run,
1661
+ )
1662
+ return
1663
+ if isinstance(event, ToolResultEvent):
1664
+ await self._safe_event_sink(
1665
+ ToolResultEvent(
1666
+ tool_use_id=event.tool_use_id,
1667
+ tool_name=event.tool_name,
1668
+ result=event.result,
1669
+ is_error=event.is_error,
1670
+ result_for_display=event.result_for_display,
1671
+ tool_input=event.tool_input,
1672
+ agent_id=agent_id,
1673
+ images=event.images,
1674
+ ),
1675
+ run=run,
1676
+ )
1677
+ return
1678
+ if isinstance(event, PermissionRequestEvent):
1679
+ await self._safe_event_sink(
1680
+ PermissionRequestEvent(
1681
+ tool_name=event.tool_name,
1682
+ tool_input=event.tool_input,
1683
+ tool_use_id=event.tool_use_id,
1684
+ reason=event.reason,
1685
+ permission_key=event.permission_key,
1686
+ agent_id=agent_id,
1687
+ ),
1688
+ run=run,
1689
+ )
1690
+ return
1691
+ if isinstance(event, ChoiceRequestEvent):
1692
+ await self._safe_event_sink(
1693
+ ChoiceRequestEvent(
1694
+ tool_use_id=event.tool_use_id,
1695
+ question=event.question,
1696
+ options=event.options,
1697
+ multiple=event.multiple,
1698
+ agent_id=agent_id,
1699
+ ),
1700
+ run=run,
1701
+ )
1702
+ return
1703
+ if isinstance(event, ErrorEvent):
1704
+ run.snapshot.error = event.message
1705
+ await self._safe_event_sink(
1706
+ ErrorEvent(
1707
+ message=event.message,
1708
+ recoverable=event.recoverable,
1709
+ error_type=event.error_type,
1710
+ agent_id=agent_id,
1711
+ ),
1712
+ run=run,
1713
+ )
1714
+ return
1715
+ if isinstance(event, CompactEvent):
1716
+ await self._safe_event_sink(
1717
+ CompactEvent(
1718
+ summary=event.summary,
1719
+ messages_before=event.messages_before,
1720
+ messages_after=event.messages_after,
1721
+ trigger=event.trigger,
1722
+ agent_id=agent_id,
1723
+ ),
1724
+ run=run,
1725
+ )
1726
+ return
1727
+ if isinstance(event, TurnCompleteEvent):
1728
+ # AgentStateEvent carries sub-agent completion. Forwarding this raw
1729
+ # event would be indistinguishable from the parent turn completing.
1730
+ return
1731
+ await self._safe_event_sink(event, run=run)