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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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)
|