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,1581 @@
|
|
|
1
|
+
"""CrabCode ACP Agent implementation.
|
|
2
|
+
|
|
3
|
+
Implements the ACP Agent protocol by translating between ACP JSON-RPC
|
|
4
|
+
and CrabCode's internal Gateway REST API + EventBus.
|
|
5
|
+
|
|
6
|
+
Architecture mirrors OpenCode's agent.ts:
|
|
7
|
+
- ACP client ↔ AgentSideConnection ↔ CrabCodeACPAgent ↔ httpx ↔ Gateway REST API
|
|
8
|
+
- EventBus SSE → _event_listener → session_update() → ACP client
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import acp
|
|
19
|
+
import acp.schema as S
|
|
20
|
+
from acp.exceptions import RequestError
|
|
21
|
+
from acp.schema import (
|
|
22
|
+
AgentCapabilities,
|
|
23
|
+
AllowedOutcome,
|
|
24
|
+
AuthenticateResponse,
|
|
25
|
+
ContentToolCallContent,
|
|
26
|
+
FileEditToolCallContent,
|
|
27
|
+
ForkSessionResponse,
|
|
28
|
+
HttpMcpServer,
|
|
29
|
+
Implementation,
|
|
30
|
+
InitializeResponse,
|
|
31
|
+
ListSessionsResponse,
|
|
32
|
+
LoadSessionResponse,
|
|
33
|
+
McpCapabilities,
|
|
34
|
+
McpServerStdio,
|
|
35
|
+
ModelInfo,
|
|
36
|
+
NewSessionResponse,
|
|
37
|
+
PermissionOption,
|
|
38
|
+
PromptCapabilities,
|
|
39
|
+
PromptResponse,
|
|
40
|
+
ResumeSessionResponse,
|
|
41
|
+
SseMcpServer,
|
|
42
|
+
SessionCapabilities,
|
|
43
|
+
SessionConfigOptionSelect,
|
|
44
|
+
SessionConfigSelectOption,
|
|
45
|
+
SessionInfo,
|
|
46
|
+
SessionListCapabilities,
|
|
47
|
+
SessionMode,
|
|
48
|
+
SessionModeState,
|
|
49
|
+
SessionModelState,
|
|
50
|
+
SessionResumeCapabilities,
|
|
51
|
+
SessionForkCapabilities,
|
|
52
|
+
SetSessionConfigOptionResponse,
|
|
53
|
+
SetSessionModeResponse,
|
|
54
|
+
SetSessionModelResponse,
|
|
55
|
+
TextContent,
|
|
56
|
+
ToolCallProgress,
|
|
57
|
+
ToolCallStart,
|
|
58
|
+
Usage,
|
|
59
|
+
UsageUpdate,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
from crabcode_core import VERSION
|
|
63
|
+
from crabcode_core.logging_utils import get_logger
|
|
64
|
+
from crabcode_gateway.acp.session import ACPSessionManager
|
|
65
|
+
from crabcode_gateway.acp.types import ACPConfig, ACPSessionState, ModelSelection, to_locations, to_tool_kind
|
|
66
|
+
|
|
67
|
+
logger = get_logger(__name__)
|
|
68
|
+
|
|
69
|
+
# ── Permission options ─────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
_PERMISSION_OPTIONS = [
|
|
72
|
+
PermissionOption(option_id="once", kind="allow_once", name="Allow once"),
|
|
73
|
+
PermissionOption(option_id="always", kind="allow_always", name="Always allow"),
|
|
74
|
+
PermissionOption(option_id="reject", kind="reject_once", name="Reject"),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
_LISTENER_READY_TIMEOUT = 10.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CrabCodeACPAgent:
|
|
81
|
+
"""ACP Agent that bridges ACP protocol to CrabCode's Gateway API."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, config: ACPConfig) -> None:
|
|
84
|
+
self._config = config
|
|
85
|
+
self._session_mgr = ACPSessionManager(config)
|
|
86
|
+
self._connection: Any = None # set by on_connect
|
|
87
|
+
self._event_task: asyncio.Task | None = None
|
|
88
|
+
self._listener_generation = 0
|
|
89
|
+
self._listener_ready = asyncio.Event()
|
|
90
|
+
self._listener_error: str | None = None
|
|
91
|
+
self._interaction_tasks: dict[
|
|
92
|
+
asyncio.Task[Any],
|
|
93
|
+
tuple[int, str, str],
|
|
94
|
+
] = {}
|
|
95
|
+
self._closed = False
|
|
96
|
+
# Tool-use ids are only unique within a conversation in some ACP
|
|
97
|
+
# clients. Include the session id so two sessions cannot suppress
|
|
98
|
+
# each other's notifications.
|
|
99
|
+
self._tool_starts: set[tuple[str, str]] = set()
|
|
100
|
+
self._turn_events: dict[str, asyncio.Event] = {}
|
|
101
|
+
self._turn_operations: dict[str, str] = {}
|
|
102
|
+
self._turn_releases: dict[tuple[str, str], asyncio.Event] = {}
|
|
103
|
+
self._turn_send_settled: dict[str, tuple[str, asyncio.Event]] = {}
|
|
104
|
+
self._turn_send_accepted: dict[str, str] = {}
|
|
105
|
+
self._turn_cancel_requests: dict[str, str] = {}
|
|
106
|
+
self._turn_cancellations: dict[str, str] = {}
|
|
107
|
+
self._turn_usage: dict[str, Usage | None] = {}
|
|
108
|
+
self._turn_errors: dict[str, str] = {}
|
|
109
|
+
self._turn_reasons: dict[str, str] = {}
|
|
110
|
+
|
|
111
|
+
# ── Connection lifecycle ────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def on_connect(self, connection: Any) -> None:
|
|
114
|
+
"""Called by AgentSideConnection after wiring."""
|
|
115
|
+
self._fail_active_prompts("ACP event connection replaced")
|
|
116
|
+
self._closed = False
|
|
117
|
+
self._connection = connection
|
|
118
|
+
self._start_event_listener()
|
|
119
|
+
|
|
120
|
+
def _start_event_listener(self) -> None:
|
|
121
|
+
"""Start a new listener generation and fence its predecessor."""
|
|
122
|
+
previous = self._event_task
|
|
123
|
+
previous_ready = self._listener_ready
|
|
124
|
+
previous_ready.set()
|
|
125
|
+
self._listener_generation += 1
|
|
126
|
+
generation = self._listener_generation
|
|
127
|
+
ready = asyncio.Event()
|
|
128
|
+
self._listener_ready = ready
|
|
129
|
+
self._listener_error = None
|
|
130
|
+
if previous is not None and not previous.done():
|
|
131
|
+
previous.cancel()
|
|
132
|
+
self._event_task = asyncio.create_task(
|
|
133
|
+
self._event_listener(generation, ready)
|
|
134
|
+
)
|
|
135
|
+
self._event_task.add_done_callback(self._consume_task_result)
|
|
136
|
+
|
|
137
|
+
def _fail_active_prompts(self, message: str) -> None:
|
|
138
|
+
for session_id, waiter in list(self._turn_events.items()):
|
|
139
|
+
if session_id not in self._turn_operations or waiter.is_set():
|
|
140
|
+
continue
|
|
141
|
+
self._turn_errors.setdefault(session_id, message)
|
|
142
|
+
waiter.set()
|
|
143
|
+
|
|
144
|
+
async def _ensure_event_listener_ready(self) -> None:
|
|
145
|
+
"""Join a ready listener, restarting a failed/ended generation."""
|
|
146
|
+
while True:
|
|
147
|
+
if self._closed:
|
|
148
|
+
raise RequestError(code=-32603, message="ACP agent is closed")
|
|
149
|
+
task = self._event_task
|
|
150
|
+
if task is None or task.done() or self._listener_error is not None:
|
|
151
|
+
self._start_event_listener()
|
|
152
|
+
generation = self._listener_generation
|
|
153
|
+
ready = self._listener_ready
|
|
154
|
+
try:
|
|
155
|
+
await asyncio.wait_for(
|
|
156
|
+
ready.wait(),
|
|
157
|
+
timeout=_LISTENER_READY_TIMEOUT,
|
|
158
|
+
)
|
|
159
|
+
except asyncio.TimeoutError as exc:
|
|
160
|
+
if generation == self._listener_generation:
|
|
161
|
+
self._listener_error = "Timed out waiting for Gateway event stream"
|
|
162
|
+
if self._event_task is not None:
|
|
163
|
+
self._event_task.cancel()
|
|
164
|
+
raise RequestError(
|
|
165
|
+
code=-32001,
|
|
166
|
+
message="Timed out waiting for Gateway event stream",
|
|
167
|
+
) from exc
|
|
168
|
+
if generation != self._listener_generation:
|
|
169
|
+
continue
|
|
170
|
+
if self._listener_error:
|
|
171
|
+
raise RequestError(code=-32603, message=self._listener_error)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
async def _wait_for_listener_or_turn_stop(
|
|
175
|
+
self,
|
|
176
|
+
session_id: str,
|
|
177
|
+
operation_id: str,
|
|
178
|
+
turn_event: asyncio.Event,
|
|
179
|
+
) -> bool:
|
|
180
|
+
"""Wait for SSE readiness, returning false for a pre-send cancel."""
|
|
181
|
+
listener_ready = asyncio.create_task(self._ensure_event_listener_ready())
|
|
182
|
+
turn_stopped = asyncio.create_task(turn_event.wait())
|
|
183
|
+
try:
|
|
184
|
+
await asyncio.wait(
|
|
185
|
+
{listener_ready, turn_stopped},
|
|
186
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
187
|
+
)
|
|
188
|
+
if self._turn_cancellations.get(session_id) == operation_id:
|
|
189
|
+
return False
|
|
190
|
+
if turn_event.is_set():
|
|
191
|
+
message = self._turn_errors.get(
|
|
192
|
+
session_id,
|
|
193
|
+
"ACP operation stopped before Gateway admission",
|
|
194
|
+
)
|
|
195
|
+
raise RequestError(code=-32603, message=message)
|
|
196
|
+
await listener_ready
|
|
197
|
+
return True
|
|
198
|
+
finally:
|
|
199
|
+
for task in (listener_ready, turn_stopped):
|
|
200
|
+
if not task.done():
|
|
201
|
+
task.cancel()
|
|
202
|
+
for task in (listener_ready, turn_stopped):
|
|
203
|
+
try:
|
|
204
|
+
await task
|
|
205
|
+
except asyncio.CancelledError:
|
|
206
|
+
pass
|
|
207
|
+
except Exception:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _consume_task_result(task: asyncio.Task[Any]) -> None:
|
|
212
|
+
try:
|
|
213
|
+
task.result()
|
|
214
|
+
except asyncio.CancelledError:
|
|
215
|
+
pass
|
|
216
|
+
except Exception:
|
|
217
|
+
logger.debug("ACP event listener task failed", exc_info=True)
|
|
218
|
+
|
|
219
|
+
def _start_interaction_task(
|
|
220
|
+
self,
|
|
221
|
+
generation: int,
|
|
222
|
+
session_id: str,
|
|
223
|
+
operation_id: str,
|
|
224
|
+
payload: dict[str, Any],
|
|
225
|
+
) -> None:
|
|
226
|
+
task = asyncio.create_task(
|
|
227
|
+
self._run_interaction(
|
|
228
|
+
generation,
|
|
229
|
+
session_id,
|
|
230
|
+
operation_id,
|
|
231
|
+
payload,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
self._interaction_tasks[task] = (generation, session_id, operation_id)
|
|
235
|
+
task.add_done_callback(self._interaction_task_done)
|
|
236
|
+
|
|
237
|
+
def _interaction_task_done(self, task: asyncio.Task[Any]) -> None:
|
|
238
|
+
self._interaction_tasks.pop(task, None)
|
|
239
|
+
self._consume_task_result(task)
|
|
240
|
+
|
|
241
|
+
async def _cancel_interaction_tasks(self, generation: int | None = None) -> None:
|
|
242
|
+
tasks = [
|
|
243
|
+
task
|
|
244
|
+
for task, (task_generation, _session_id, _operation_id)
|
|
245
|
+
in self._interaction_tasks.items()
|
|
246
|
+
if generation is None or task_generation == generation
|
|
247
|
+
]
|
|
248
|
+
for task in tasks:
|
|
249
|
+
task.cancel()
|
|
250
|
+
if tasks:
|
|
251
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
252
|
+
|
|
253
|
+
async def _cancel_operation_interactions(
|
|
254
|
+
self,
|
|
255
|
+
session_id: str,
|
|
256
|
+
operation_id: str,
|
|
257
|
+
) -> None:
|
|
258
|
+
tasks = [
|
|
259
|
+
task
|
|
260
|
+
for task, (_generation, task_session, task_operation) in list(
|
|
261
|
+
self._interaction_tasks.items()
|
|
262
|
+
)
|
|
263
|
+
if task_session == session_id and task_operation == operation_id
|
|
264
|
+
]
|
|
265
|
+
for task in tasks:
|
|
266
|
+
task.cancel()
|
|
267
|
+
if tasks:
|
|
268
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
269
|
+
|
|
270
|
+
def _interaction_is_current(
|
|
271
|
+
self,
|
|
272
|
+
generation: int,
|
|
273
|
+
session_id: str,
|
|
274
|
+
operation_id: str,
|
|
275
|
+
) -> bool:
|
|
276
|
+
return (
|
|
277
|
+
not self._closed
|
|
278
|
+
and generation == self._listener_generation
|
|
279
|
+
and self._turn_operations.get(session_id) == operation_id
|
|
280
|
+
and (session_id, operation_id) not in self._turn_releases
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def _run_interaction(
|
|
284
|
+
self,
|
|
285
|
+
generation: int,
|
|
286
|
+
session_id: str,
|
|
287
|
+
operation_id: str,
|
|
288
|
+
payload: dict[str, Any],
|
|
289
|
+
) -> None:
|
|
290
|
+
if not self._interaction_is_current(generation, session_id, operation_id):
|
|
291
|
+
return
|
|
292
|
+
event_type = payload.get("type")
|
|
293
|
+
try:
|
|
294
|
+
if event_type == "permission_request":
|
|
295
|
+
await self._handle_permission_request(
|
|
296
|
+
session_id,
|
|
297
|
+
payload,
|
|
298
|
+
generation=generation,
|
|
299
|
+
operation_id=operation_id,
|
|
300
|
+
)
|
|
301
|
+
elif event_type == "choice_request":
|
|
302
|
+
await self._handle_choice_request(
|
|
303
|
+
session_id,
|
|
304
|
+
payload,
|
|
305
|
+
generation=generation,
|
|
306
|
+
operation_id=operation_id,
|
|
307
|
+
)
|
|
308
|
+
except asyncio.CancelledError:
|
|
309
|
+
raise
|
|
310
|
+
except Exception:
|
|
311
|
+
logger.exception("acp_interaction_failed")
|
|
312
|
+
if not self._interaction_is_current(
|
|
313
|
+
generation,
|
|
314
|
+
session_id,
|
|
315
|
+
operation_id,
|
|
316
|
+
):
|
|
317
|
+
return
|
|
318
|
+
if event_type == "permission_request":
|
|
319
|
+
delivered = await self._reply_permission(
|
|
320
|
+
str(payload.get("tool_use_id") or ""),
|
|
321
|
+
session_id,
|
|
322
|
+
"reject",
|
|
323
|
+
agent_id=payload.get("agent_id"),
|
|
324
|
+
)
|
|
325
|
+
if not delivered:
|
|
326
|
+
await self._fail_interaction_reply(
|
|
327
|
+
session_id,
|
|
328
|
+
operation_id,
|
|
329
|
+
"Failed to deliver permission response to Gateway",
|
|
330
|
+
)
|
|
331
|
+
elif event_type == "choice_request":
|
|
332
|
+
delivered = await self._reply_choice(
|
|
333
|
+
str(payload.get("tool_use_id") or ""),
|
|
334
|
+
session_id,
|
|
335
|
+
selected=[],
|
|
336
|
+
cancelled=True,
|
|
337
|
+
agent_id=payload.get("agent_id"),
|
|
338
|
+
)
|
|
339
|
+
if not delivered:
|
|
340
|
+
await self._fail_interaction_reply(
|
|
341
|
+
session_id,
|
|
342
|
+
operation_id,
|
|
343
|
+
"Failed to deliver choice response to Gateway",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
async def _event_listener(
|
|
347
|
+
self,
|
|
348
|
+
generation: int,
|
|
349
|
+
ready: asyncio.Event,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Subscribe to Gateway EventBus SSE and translate CoreEvents to ACP session_updates."""
|
|
352
|
+
client = self._session_mgr.client
|
|
353
|
+
failure: str | None = None
|
|
354
|
+
try:
|
|
355
|
+
async with client.stream("GET", "/event") as resp:
|
|
356
|
+
resp.raise_for_status()
|
|
357
|
+
async for line in resp.aiter_lines():
|
|
358
|
+
if not line or not line.startswith("data:"):
|
|
359
|
+
continue
|
|
360
|
+
payload_str = line[len("data:"):].strip()
|
|
361
|
+
if not payload_str:
|
|
362
|
+
continue
|
|
363
|
+
try:
|
|
364
|
+
import json
|
|
365
|
+
payload = json.loads(payload_str)
|
|
366
|
+
except json.JSONDecodeError:
|
|
367
|
+
continue
|
|
368
|
+
if payload.get("type") == "server.connected":
|
|
369
|
+
if generation == self._listener_generation:
|
|
370
|
+
ready.set()
|
|
371
|
+
continue
|
|
372
|
+
if generation != self._listener_generation:
|
|
373
|
+
return
|
|
374
|
+
try:
|
|
375
|
+
await self._handle_event_payload(
|
|
376
|
+
payload,
|
|
377
|
+
generation=generation,
|
|
378
|
+
)
|
|
379
|
+
except Exception:
|
|
380
|
+
logger.exception("acp_event_handler_error")
|
|
381
|
+
failure = "Gateway event stream ended"
|
|
382
|
+
except asyncio.CancelledError:
|
|
383
|
+
return
|
|
384
|
+
except Exception as exc:
|
|
385
|
+
failure = f"Gateway event stream failed: {exc}"
|
|
386
|
+
logger.exception("acp_event_listener_stopped")
|
|
387
|
+
finally:
|
|
388
|
+
if failure and generation == self._listener_generation and not self._closed:
|
|
389
|
+
self._listener_error = failure
|
|
390
|
+
ready.set()
|
|
391
|
+
self._fail_active_prompts(failure)
|
|
392
|
+
await self._cancel_interaction_tasks(generation)
|
|
393
|
+
|
|
394
|
+
async def _handle_event_payload(
|
|
395
|
+
self,
|
|
396
|
+
payload: dict[str, Any],
|
|
397
|
+
*,
|
|
398
|
+
generation: int | None = None,
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Translate a Gateway EventBus payload into ACP session_update(s)."""
|
|
401
|
+
event_type = payload.get("type", "")
|
|
402
|
+
session_id = payload.get("session_id", "")
|
|
403
|
+
state = self._session_mgr.try_get(session_id)
|
|
404
|
+
if not state:
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
active_operation = self._turn_operations.get(session_id)
|
|
408
|
+
if (
|
|
409
|
+
active_operation is None
|
|
410
|
+
or payload.get("operation_scope") != "foreground"
|
|
411
|
+
or payload.get("operation_id") != active_operation
|
|
412
|
+
or (session_id, active_operation) in self._turn_releases
|
|
413
|
+
):
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
if event_type in {"permission_request", "choice_request"}:
|
|
417
|
+
if generation is None:
|
|
418
|
+
if event_type == "permission_request":
|
|
419
|
+
await self._handle_permission_request(session_id, payload)
|
|
420
|
+
else:
|
|
421
|
+
await self._handle_choice_request(session_id, payload)
|
|
422
|
+
else:
|
|
423
|
+
self._start_interaction_task(
|
|
424
|
+
generation,
|
|
425
|
+
session_id,
|
|
426
|
+
active_operation,
|
|
427
|
+
payload,
|
|
428
|
+
)
|
|
429
|
+
elif event_type == "tool_use":
|
|
430
|
+
await self._handle_tool_use(session_id, payload)
|
|
431
|
+
elif event_type == "tool_result":
|
|
432
|
+
await self._handle_tool_result(session_id, payload)
|
|
433
|
+
elif event_type == "stream_text":
|
|
434
|
+
await self._push_agent_message_chunk(session_id, payload.get("text", ""))
|
|
435
|
+
elif event_type == "thinking":
|
|
436
|
+
await self._push_agent_thought_chunk(session_id, payload.get("text", ""))
|
|
437
|
+
elif event_type == "turn_complete":
|
|
438
|
+
usage = _usage_from_payload(payload.get("usage"))
|
|
439
|
+
self._turn_usage[session_id] = usage
|
|
440
|
+
self._turn_reasons.setdefault(
|
|
441
|
+
session_id,
|
|
442
|
+
str(payload.get("reason") or "end_turn"),
|
|
443
|
+
)
|
|
444
|
+
waiter = self._turn_events.get(session_id)
|
|
445
|
+
if waiter is not None:
|
|
446
|
+
waiter.set()
|
|
447
|
+
elif event_type == "error":
|
|
448
|
+
if self._turn_cancellations.get(session_id) == active_operation:
|
|
449
|
+
return
|
|
450
|
+
# Managed-agent failures are lifecycle events for that background
|
|
451
|
+
# run, not failures of the active top-level ACP prompt. The agent
|
|
452
|
+
# state/callback channels report them separately. Callback and
|
|
453
|
+
# monitor continuation failures predate agent attribution but are
|
|
454
|
+
# likewise emitted outside the active foreground turn.
|
|
455
|
+
if (
|
|
456
|
+
payload.get("agent_id") is not None
|
|
457
|
+
or payload.get("error_type") in {"agent_callback", "monitor_callback"}
|
|
458
|
+
):
|
|
459
|
+
return
|
|
460
|
+
# A failed query may not emit turn_complete. Wake the ACP
|
|
461
|
+
# prompt waiter and surface the error instead of hanging for the
|
|
462
|
+
# full timeout.
|
|
463
|
+
self._turn_errors[session_id] = str(payload.get("message") or "Query failed")
|
|
464
|
+
waiter = self._turn_events.get(session_id)
|
|
465
|
+
if waiter is not None:
|
|
466
|
+
waiter.set()
|
|
467
|
+
|
|
468
|
+
async def _handle_permission_request(
|
|
469
|
+
self,
|
|
470
|
+
session_id: str,
|
|
471
|
+
payload: dict[str, Any],
|
|
472
|
+
*,
|
|
473
|
+
generation: int | None = None,
|
|
474
|
+
operation_id: str | None = None,
|
|
475
|
+
) -> None:
|
|
476
|
+
"""Forward a CrabCode permission request to the ACP client."""
|
|
477
|
+
tool_name = payload.get("tool_name", "")
|
|
478
|
+
tool_use_id = payload.get("tool_use_id", "")
|
|
479
|
+
tool_input = payload.get("tool_input", {})
|
|
480
|
+
agent_id = payload.get("agent_id")
|
|
481
|
+
|
|
482
|
+
tool_kind = to_tool_kind(tool_name)
|
|
483
|
+
locations = to_locations(tool_name, tool_input)
|
|
484
|
+
|
|
485
|
+
tool_call = S.ToolCallUpdate(
|
|
486
|
+
tool_call_id=tool_use_id,
|
|
487
|
+
status="pending",
|
|
488
|
+
title=tool_name,
|
|
489
|
+
kind=tool_kind,
|
|
490
|
+
raw_input=tool_input,
|
|
491
|
+
locations=[S.ToolCallLocation(**loc) for loc in locations],
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
result = await self._connection.request_permission(
|
|
496
|
+
options=_PERMISSION_OPTIONS,
|
|
497
|
+
session_id=session_id,
|
|
498
|
+
tool_call=tool_call,
|
|
499
|
+
)
|
|
500
|
+
except Exception:
|
|
501
|
+
logger.exception("acp_request_permission_failed")
|
|
502
|
+
if self._interaction_reply_allowed(
|
|
503
|
+
generation,
|
|
504
|
+
session_id,
|
|
505
|
+
operation_id,
|
|
506
|
+
):
|
|
507
|
+
delivered = await self._reply_permission(
|
|
508
|
+
tool_use_id,
|
|
509
|
+
session_id,
|
|
510
|
+
"reject",
|
|
511
|
+
agent_id=agent_id,
|
|
512
|
+
)
|
|
513
|
+
if not delivered:
|
|
514
|
+
await self._fail_interaction_reply(
|
|
515
|
+
session_id,
|
|
516
|
+
operation_id,
|
|
517
|
+
"Failed to deliver permission response to Gateway",
|
|
518
|
+
)
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
if not self._interaction_reply_allowed(generation, session_id, operation_id):
|
|
522
|
+
return
|
|
523
|
+
outcome = result.outcome if result else None
|
|
524
|
+
if not outcome or not isinstance(outcome, AllowedOutcome) or outcome.outcome != "selected":
|
|
525
|
+
delivered = await self._reply_permission(
|
|
526
|
+
tool_use_id,
|
|
527
|
+
session_id,
|
|
528
|
+
"reject",
|
|
529
|
+
agent_id=agent_id,
|
|
530
|
+
)
|
|
531
|
+
if not delivered:
|
|
532
|
+
await self._fail_interaction_reply(
|
|
533
|
+
session_id,
|
|
534
|
+
operation_id,
|
|
535
|
+
"Failed to deliver permission response to Gateway",
|
|
536
|
+
)
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
option_id = outcome.option_id if hasattr(outcome, "option_id") else "reject"
|
|
540
|
+
delivered = await self._reply_permission(
|
|
541
|
+
tool_use_id,
|
|
542
|
+
session_id,
|
|
543
|
+
option_id,
|
|
544
|
+
agent_id=agent_id,
|
|
545
|
+
)
|
|
546
|
+
if not delivered:
|
|
547
|
+
await self._fail_interaction_reply(
|
|
548
|
+
session_id,
|
|
549
|
+
operation_id,
|
|
550
|
+
"Failed to deliver permission response to Gateway",
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
def _interaction_reply_allowed(
|
|
554
|
+
self,
|
|
555
|
+
generation: int | None,
|
|
556
|
+
session_id: str,
|
|
557
|
+
operation_id: str | None,
|
|
558
|
+
) -> bool:
|
|
559
|
+
if generation is None or operation_id is None:
|
|
560
|
+
return True
|
|
561
|
+
return self._interaction_is_current(generation, session_id, operation_id)
|
|
562
|
+
|
|
563
|
+
async def _reply_permission(
|
|
564
|
+
self,
|
|
565
|
+
tool_use_id: str,
|
|
566
|
+
session_id: str,
|
|
567
|
+
reply: str,
|
|
568
|
+
*,
|
|
569
|
+
agent_id: str | None = None,
|
|
570
|
+
) -> bool:
|
|
571
|
+
"""Send a permission response back to the Gateway."""
|
|
572
|
+
allowed = reply in ("once", "always")
|
|
573
|
+
always = reply == "always"
|
|
574
|
+
try:
|
|
575
|
+
response = await self._session_mgr.client.post(
|
|
576
|
+
"/permission/respond",
|
|
577
|
+
json={
|
|
578
|
+
"tool_use_id": tool_use_id,
|
|
579
|
+
"allowed": allowed,
|
|
580
|
+
"always_allow": always,
|
|
581
|
+
"session_id": session_id,
|
|
582
|
+
"agent_id": agent_id,
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
response.raise_for_status()
|
|
586
|
+
return True
|
|
587
|
+
except Exception:
|
|
588
|
+
logger.exception("acp_reply_permission_failed")
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
async def _handle_choice_request(
|
|
592
|
+
self,
|
|
593
|
+
session_id: str,
|
|
594
|
+
payload: dict[str, Any],
|
|
595
|
+
*,
|
|
596
|
+
generation: int | None = None,
|
|
597
|
+
operation_id: str | None = None,
|
|
598
|
+
) -> None:
|
|
599
|
+
tool_use_id = str(payload.get("tool_use_id") or "")
|
|
600
|
+
question = str(payload.get("question") or "Choose an option")
|
|
601
|
+
options = [str(option) for option in payload.get("options") or []]
|
|
602
|
+
agent_id = payload.get("agent_id")
|
|
603
|
+
|
|
604
|
+
if payload.get("multiple") or not options:
|
|
605
|
+
if self._interaction_reply_allowed(generation, session_id, operation_id):
|
|
606
|
+
delivered = await self._reply_choice(
|
|
607
|
+
tool_use_id,
|
|
608
|
+
session_id,
|
|
609
|
+
selected=[],
|
|
610
|
+
cancelled=True,
|
|
611
|
+
agent_id=agent_id,
|
|
612
|
+
)
|
|
613
|
+
if not delivered:
|
|
614
|
+
await self._fail_interaction_reply(
|
|
615
|
+
session_id,
|
|
616
|
+
operation_id,
|
|
617
|
+
"Failed to deliver choice response to Gateway",
|
|
618
|
+
)
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
option_ids = {f"choice-{index}": option for index, option in enumerate(options)}
|
|
622
|
+
permission_options = [
|
|
623
|
+
PermissionOption(
|
|
624
|
+
option_id=option_id,
|
|
625
|
+
kind="allow_once",
|
|
626
|
+
name=option,
|
|
627
|
+
)
|
|
628
|
+
for option_id, option in option_ids.items()
|
|
629
|
+
]
|
|
630
|
+
tool_call = S.ToolCallUpdate(
|
|
631
|
+
tool_call_id=tool_use_id,
|
|
632
|
+
status="pending",
|
|
633
|
+
title=question,
|
|
634
|
+
kind="other",
|
|
635
|
+
raw_input={"question": question, "options": options},
|
|
636
|
+
)
|
|
637
|
+
try:
|
|
638
|
+
result = await self._connection.request_permission(
|
|
639
|
+
options=permission_options,
|
|
640
|
+
session_id=session_id,
|
|
641
|
+
tool_call=tool_call,
|
|
642
|
+
)
|
|
643
|
+
except Exception:
|
|
644
|
+
logger.exception("acp_choice_request_failed")
|
|
645
|
+
result = None
|
|
646
|
+
|
|
647
|
+
if not self._interaction_reply_allowed(generation, session_id, operation_id):
|
|
648
|
+
return
|
|
649
|
+
outcome = result.outcome if result else None
|
|
650
|
+
selected = (
|
|
651
|
+
option_ids.get(outcome.option_id)
|
|
652
|
+
if isinstance(outcome, AllowedOutcome) and outcome.outcome == "selected"
|
|
653
|
+
else None
|
|
654
|
+
)
|
|
655
|
+
delivered = await self._reply_choice(
|
|
656
|
+
tool_use_id,
|
|
657
|
+
session_id,
|
|
658
|
+
selected=[selected] if selected is not None else [],
|
|
659
|
+
cancelled=selected is None,
|
|
660
|
+
agent_id=agent_id,
|
|
661
|
+
)
|
|
662
|
+
if not delivered:
|
|
663
|
+
await self._fail_interaction_reply(
|
|
664
|
+
session_id,
|
|
665
|
+
operation_id,
|
|
666
|
+
"Failed to deliver choice response to Gateway",
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
async def _reply_choice(
|
|
670
|
+
self,
|
|
671
|
+
tool_use_id: str,
|
|
672
|
+
session_id: str,
|
|
673
|
+
*,
|
|
674
|
+
selected: list[str],
|
|
675
|
+
cancelled: bool,
|
|
676
|
+
agent_id: str | None = None,
|
|
677
|
+
) -> bool:
|
|
678
|
+
try:
|
|
679
|
+
response = await self._session_mgr.client.post(
|
|
680
|
+
"/choice/respond",
|
|
681
|
+
json={
|
|
682
|
+
"tool_use_id": tool_use_id,
|
|
683
|
+
"selected": selected,
|
|
684
|
+
"cancelled": cancelled,
|
|
685
|
+
"session_id": session_id,
|
|
686
|
+
"agent_id": agent_id,
|
|
687
|
+
},
|
|
688
|
+
)
|
|
689
|
+
response.raise_for_status()
|
|
690
|
+
return True
|
|
691
|
+
except Exception:
|
|
692
|
+
logger.exception("acp_reply_choice_failed")
|
|
693
|
+
return False
|
|
694
|
+
|
|
695
|
+
async def _fail_interaction_reply(
|
|
696
|
+
self,
|
|
697
|
+
session_id: str,
|
|
698
|
+
operation_id: str | None,
|
|
699
|
+
message: str,
|
|
700
|
+
) -> None:
|
|
701
|
+
"""Stop an operation whose interactive response could not be routed."""
|
|
702
|
+
if operation_id is None or self._turn_operations.get(session_id) != operation_id:
|
|
703
|
+
return
|
|
704
|
+
if not await self._interrupt_operation(session_id, operation_id):
|
|
705
|
+
return
|
|
706
|
+
if self._turn_operations.get(session_id) != operation_id:
|
|
707
|
+
return
|
|
708
|
+
waiter = self._turn_events.get(session_id)
|
|
709
|
+
if waiter is None or waiter.is_set():
|
|
710
|
+
return
|
|
711
|
+
self._turn_errors[session_id] = message
|
|
712
|
+
waiter.set()
|
|
713
|
+
|
|
714
|
+
async def _handle_tool_use(self, session_id: str, payload: dict[str, Any]) -> None:
|
|
715
|
+
"""Tool call started — push tool_call start notification."""
|
|
716
|
+
tool_name = payload.get("tool_name", "")
|
|
717
|
+
tool_use_id = payload.get("tool_use_id", "")
|
|
718
|
+
tool_input = payload.get("tool_input", {})
|
|
719
|
+
|
|
720
|
+
tool_key = (session_id, tool_use_id)
|
|
721
|
+
if tool_key in self._tool_starts:
|
|
722
|
+
return
|
|
723
|
+
self._tool_starts.add(tool_key)
|
|
724
|
+
|
|
725
|
+
tool_kind = to_tool_kind(tool_name)
|
|
726
|
+
locations = to_locations(tool_name, tool_input)
|
|
727
|
+
|
|
728
|
+
update = ToolCallStart(
|
|
729
|
+
session_update="tool_call",
|
|
730
|
+
tool_call_id=tool_use_id,
|
|
731
|
+
title=tool_name,
|
|
732
|
+
kind=tool_kind,
|
|
733
|
+
status="pending",
|
|
734
|
+
locations=[S.ToolCallLocation(**loc) for loc in locations],
|
|
735
|
+
raw_input=tool_input,
|
|
736
|
+
)
|
|
737
|
+
await self._connection.session_update(session_id=session_id, update=update)
|
|
738
|
+
|
|
739
|
+
async def _handle_tool_result(self, session_id: str, payload: dict[str, Any]) -> None:
|
|
740
|
+
"""Tool call completed — push tool_call_update."""
|
|
741
|
+
tool_name = payload.get("tool_name", "")
|
|
742
|
+
tool_use_id = payload.get("tool_use_id", "")
|
|
743
|
+
result_text = payload.get("result", "")
|
|
744
|
+
is_error = payload.get("is_error", False)
|
|
745
|
+
tool_input = payload.get("tool_input", {})
|
|
746
|
+
|
|
747
|
+
self._tool_starts.discard((session_id, tool_use_id))
|
|
748
|
+
tool_kind = to_tool_kind(tool_name)
|
|
749
|
+
locations = to_locations(tool_name, tool_input)
|
|
750
|
+
|
|
751
|
+
content: list[ContentToolCallContent | FileEditToolCallContent] = [
|
|
752
|
+
ContentToolCallContent(type="content", content=TextContent(type="text", text=result_text)),
|
|
753
|
+
]
|
|
754
|
+
|
|
755
|
+
# Add diff for edit tools
|
|
756
|
+
if tool_kind == "edit":
|
|
757
|
+
file_path = tool_input.get("filePath") or tool_input.get("file_path") or ""
|
|
758
|
+
old_text = tool_input.get("oldString") or tool_input.get("old_string") or ""
|
|
759
|
+
new_text = tool_input.get("newString") or tool_input.get("new_string") or tool_input.get("content") or ""
|
|
760
|
+
content.append(
|
|
761
|
+
FileEditToolCallContent(type="diff", path=file_path, old_text=old_text, new_text=new_text)
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
status = "failed" if is_error else "completed"
|
|
765
|
+
|
|
766
|
+
update = ToolCallProgress(
|
|
767
|
+
session_update="tool_call_update",
|
|
768
|
+
tool_call_id=tool_use_id,
|
|
769
|
+
title=tool_name,
|
|
770
|
+
kind=tool_kind,
|
|
771
|
+
status=status,
|
|
772
|
+
content=content,
|
|
773
|
+
locations=[S.ToolCallLocation(**loc) for loc in locations],
|
|
774
|
+
raw_input=tool_input,
|
|
775
|
+
raw_output={"output": result_text} if not is_error else {"error": result_text},
|
|
776
|
+
)
|
|
777
|
+
await self._connection.session_update(session_id=session_id, update=update)
|
|
778
|
+
|
|
779
|
+
async def _push_agent_message_chunk(self, session_id: str, text: str) -> None:
|
|
780
|
+
update = acp.update_agent_message_text(text)
|
|
781
|
+
await self._connection.session_update(session_id=session_id, update=update)
|
|
782
|
+
|
|
783
|
+
async def _push_agent_thought_chunk(self, session_id: str, text: str) -> None:
|
|
784
|
+
update = acp.update_agent_thought_text(text)
|
|
785
|
+
await self._connection.session_update(session_id=session_id, update=update)
|
|
786
|
+
|
|
787
|
+
# ── ACP Agent interface ─────────────────────────────────────
|
|
788
|
+
|
|
789
|
+
async def initialize(
|
|
790
|
+
self,
|
|
791
|
+
protocol_version: int,
|
|
792
|
+
client_capabilities: S.ClientCapabilities | None = None,
|
|
793
|
+
client_info: S.Implementation | None = None,
|
|
794
|
+
**kwargs: Any,
|
|
795
|
+
) -> InitializeResponse:
|
|
796
|
+
logger.info("acp_initialize", extra={"protocol_version": protocol_version})
|
|
797
|
+
|
|
798
|
+
return InitializeResponse(
|
|
799
|
+
protocol_version=acp.PROTOCOL_VERSION,
|
|
800
|
+
agent_capabilities=AgentCapabilities(
|
|
801
|
+
load_session=True,
|
|
802
|
+
mcp_capabilities=McpCapabilities(http=True, sse=True),
|
|
803
|
+
prompt_capabilities=PromptCapabilities(embedded_context=True, image=True),
|
|
804
|
+
session_capabilities=SessionCapabilities(
|
|
805
|
+
fork=SessionForkCapabilities(),
|
|
806
|
+
list=SessionListCapabilities(),
|
|
807
|
+
resume=SessionResumeCapabilities(),
|
|
808
|
+
),
|
|
809
|
+
),
|
|
810
|
+
# Gateway authentication is handled by the transport (Basic Auth
|
|
811
|
+
# / bearer middleware). Do not advertise an ACP login method that
|
|
812
|
+
# the CLI does not implement.
|
|
813
|
+
auth_methods=[],
|
|
814
|
+
agent_info=Implementation(name="CrabCode", version=VERSION),
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
async def new_session(
|
|
818
|
+
self,
|
|
819
|
+
cwd: str,
|
|
820
|
+
mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] | None = None,
|
|
821
|
+
**kwargs: Any,
|
|
822
|
+
) -> NewSessionResponse:
|
|
823
|
+
if self._closed:
|
|
824
|
+
raise RequestError(code=-32603, message="ACP agent is closed")
|
|
825
|
+
model = self._config.default_model
|
|
826
|
+
try:
|
|
827
|
+
state = await self._session_mgr.create(cwd, mcp_servers or [], model)
|
|
828
|
+
except Exception as e:
|
|
829
|
+
raise RequestError(code=-32603, message=str(e))
|
|
830
|
+
|
|
831
|
+
models_state = await self._build_models_state(state)
|
|
832
|
+
modes_state = await self._build_modes_state(state)
|
|
833
|
+
config_options = _build_config_options(models_state, modes_state)
|
|
834
|
+
|
|
835
|
+
return NewSessionResponse(
|
|
836
|
+
session_id=state.id,
|
|
837
|
+
models=models_state,
|
|
838
|
+
modes=modes_state,
|
|
839
|
+
config_options=config_options,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
async def load_session(
|
|
843
|
+
self,
|
|
844
|
+
cwd: str,
|
|
845
|
+
session_id: str,
|
|
846
|
+
mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] | None = None,
|
|
847
|
+
**kwargs: Any,
|
|
848
|
+
) -> LoadSessionResponse | None:
|
|
849
|
+
if self._closed:
|
|
850
|
+
raise RequestError(code=-32603, message="ACP agent is closed")
|
|
851
|
+
model = self._config.default_model
|
|
852
|
+
try:
|
|
853
|
+
state = await self._session_mgr.load(session_id, cwd, mcp_servers or [], model)
|
|
854
|
+
except Exception as e:
|
|
855
|
+
raise RequestError(code=-32603, message=str(e))
|
|
856
|
+
|
|
857
|
+
models_state = await self._build_models_state(state)
|
|
858
|
+
modes_state = await self._build_modes_state(state)
|
|
859
|
+
config_options = _build_config_options(models_state, modes_state)
|
|
860
|
+
|
|
861
|
+
# Replay messages for loaded session
|
|
862
|
+
await self._replay_messages(state)
|
|
863
|
+
|
|
864
|
+
# Push usage update
|
|
865
|
+
await self._send_usage_update(state)
|
|
866
|
+
|
|
867
|
+
return LoadSessionResponse(
|
|
868
|
+
models=models_state,
|
|
869
|
+
modes=modes_state,
|
|
870
|
+
config_options=config_options,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
async def list_sessions(
|
|
874
|
+
self,
|
|
875
|
+
cursor: str | None = None,
|
|
876
|
+
cwd: str | None = None,
|
|
877
|
+
**kwargs: Any,
|
|
878
|
+
) -> ListSessionsResponse:
|
|
879
|
+
try:
|
|
880
|
+
params: dict[str, Any] = {}
|
|
881
|
+
if cwd:
|
|
882
|
+
params["cwd"] = cwd
|
|
883
|
+
resp = await self._session_mgr.client.get("/session/list", params=params)
|
|
884
|
+
resp.raise_for_status()
|
|
885
|
+
data = resp.json()
|
|
886
|
+
except Exception as e:
|
|
887
|
+
raise RequestError(code=-32603, message=str(e))
|
|
888
|
+
|
|
889
|
+
entries: list[SessionInfo] = []
|
|
890
|
+
for s in data:
|
|
891
|
+
entries.append(SessionInfo(
|
|
892
|
+
session_id=s.get("session_id", ""),
|
|
893
|
+
cwd=s.get("cwd", cwd or ""),
|
|
894
|
+
title=s.get("title"),
|
|
895
|
+
updated_at=s.get("updated_at"),
|
|
896
|
+
))
|
|
897
|
+
|
|
898
|
+
return ListSessionsResponse(sessions=entries)
|
|
899
|
+
|
|
900
|
+
async def fork_session(
|
|
901
|
+
self,
|
|
902
|
+
cwd: str,
|
|
903
|
+
session_id: str,
|
|
904
|
+
mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] | None = None,
|
|
905
|
+
**kwargs: Any,
|
|
906
|
+
) -> ForkSessionResponse:
|
|
907
|
+
if self._closed:
|
|
908
|
+
raise RequestError(code=-32603, message="ACP agent is closed")
|
|
909
|
+
try:
|
|
910
|
+
# ACP currently identifies the source session, not a message. Use
|
|
911
|
+
# its latest completed assistant reply; Desktop exposes the more
|
|
912
|
+
# precise message-level fork endpoint directly.
|
|
913
|
+
messages_resp = await self._session_mgr.client.get(
|
|
914
|
+
"/session/messages",
|
|
915
|
+
params={"session_id": session_id},
|
|
916
|
+
)
|
|
917
|
+
messages_resp.raise_for_status()
|
|
918
|
+
messages = messages_resp.json()
|
|
919
|
+
requested_uuid = kwargs.get("message_uuid") or kwargs.get("messageUuid")
|
|
920
|
+
assistant_uuid = str(requested_uuid) if requested_uuid else next(
|
|
921
|
+
(
|
|
922
|
+
str(message.get("uuid"))
|
|
923
|
+
for message in reversed(messages)
|
|
924
|
+
if isinstance(message, dict)
|
|
925
|
+
and message.get("role") == "assistant"
|
|
926
|
+
and message.get("uuid")
|
|
927
|
+
),
|
|
928
|
+
None,
|
|
929
|
+
)
|
|
930
|
+
if assistant_uuid is None:
|
|
931
|
+
raise RequestError(code=-32602, message="Session has no assistant reply to fork")
|
|
932
|
+
response = await self._session_mgr.client.post(
|
|
933
|
+
"/session/fork",
|
|
934
|
+
json={"session_id": session_id, "message_uuid": assistant_uuid},
|
|
935
|
+
)
|
|
936
|
+
response.raise_for_status()
|
|
937
|
+
data = response.json()
|
|
938
|
+
forked_id = str(data["session_id"])
|
|
939
|
+
state = ACPSessionState(
|
|
940
|
+
id=forked_id,
|
|
941
|
+
cwd=str(data.get("cwd") or cwd),
|
|
942
|
+
mcp_servers=mcp_servers or [],
|
|
943
|
+
created_at=time.time(),
|
|
944
|
+
model=self._config.default_model,
|
|
945
|
+
)
|
|
946
|
+
self._session_mgr._sessions[forked_id] = state
|
|
947
|
+
models_state = await self._build_models_state(state)
|
|
948
|
+
modes_state = await self._build_modes_state(state)
|
|
949
|
+
return ForkSessionResponse(
|
|
950
|
+
session_id=forked_id,
|
|
951
|
+
models=models_state,
|
|
952
|
+
modes=modes_state,
|
|
953
|
+
config_options=_build_config_options(models_state, modes_state),
|
|
954
|
+
)
|
|
955
|
+
except RequestError:
|
|
956
|
+
raise
|
|
957
|
+
except Exception as e:
|
|
958
|
+
raise RequestError(code=-32603, message=str(e))
|
|
959
|
+
|
|
960
|
+
async def resume_session(
|
|
961
|
+
self,
|
|
962
|
+
cwd: str,
|
|
963
|
+
session_id: str,
|
|
964
|
+
mcp_servers: list[McpServerStdio | HttpMcpServer | SseMcpServer] | None = None,
|
|
965
|
+
**kwargs: Any,
|
|
966
|
+
) -> ResumeSessionResponse:
|
|
967
|
+
if self._closed:
|
|
968
|
+
raise RequestError(code=-32603, message="ACP agent is closed")
|
|
969
|
+
model = self._config.default_model
|
|
970
|
+
try:
|
|
971
|
+
state = await self._session_mgr.load(session_id, cwd, mcp_servers or [], model)
|
|
972
|
+
except Exception as e:
|
|
973
|
+
raise RequestError(code=-32603, message=str(e))
|
|
974
|
+
|
|
975
|
+
models_state = await self._build_models_state(state)
|
|
976
|
+
modes_state = await self._build_modes_state(state)
|
|
977
|
+
config_options = _build_config_options(models_state, modes_state)
|
|
978
|
+
|
|
979
|
+
await self._send_usage_update(state)
|
|
980
|
+
await self._replay_messages(state)
|
|
981
|
+
|
|
982
|
+
return ResumeSessionResponse(
|
|
983
|
+
models=models_state,
|
|
984
|
+
modes=modes_state,
|
|
985
|
+
config_options=config_options,
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
async def prompt(
|
|
989
|
+
self,
|
|
990
|
+
prompt: list,
|
|
991
|
+
session_id: str,
|
|
992
|
+
message_id: str | None = None,
|
|
993
|
+
**kwargs: Any,
|
|
994
|
+
) -> PromptResponse:
|
|
995
|
+
if self._closed:
|
|
996
|
+
raise RequestError(code=-32603, message="ACP agent is closed")
|
|
997
|
+
self._session_mgr.get(session_id) # validate session exists
|
|
998
|
+
|
|
999
|
+
# Convert ACP prompt parts to simple text
|
|
1000
|
+
text_parts: list[str] = []
|
|
1001
|
+
for part in prompt:
|
|
1002
|
+
if hasattr(part, "text"):
|
|
1003
|
+
text_parts.append(part.text)
|
|
1004
|
+
elif hasattr(part, "uri"):
|
|
1005
|
+
text_parts.append(f"[file: {part.uri}]")
|
|
1006
|
+
text = "\n".join(text_parts)
|
|
1007
|
+
|
|
1008
|
+
# Install the waiter before sending. The gateway can complete a very
|
|
1009
|
+
# short turn before the HTTP request returns, and that event must not
|
|
1010
|
+
# be lost between the response and waiter setup.
|
|
1011
|
+
if session_id in self._turn_operations:
|
|
1012
|
+
raise RequestError(code=-32000, message=f"Session {session_id} already has a prompt in progress")
|
|
1013
|
+
operation_id = uuid.uuid4().hex
|
|
1014
|
+
turn_event = asyncio.Event()
|
|
1015
|
+
self._turn_events[session_id] = turn_event
|
|
1016
|
+
self._turn_operations[session_id] = operation_id
|
|
1017
|
+
self._turn_send_settled.pop(session_id, None)
|
|
1018
|
+
self._turn_send_accepted.pop(session_id, None)
|
|
1019
|
+
self._turn_cancel_requests.pop(session_id, None)
|
|
1020
|
+
self._turn_cancellations.pop(session_id, None)
|
|
1021
|
+
self._turn_usage.pop(session_id, None)
|
|
1022
|
+
self._turn_errors.pop(session_id, None)
|
|
1023
|
+
self._turn_reasons.pop(session_id, None)
|
|
1024
|
+
|
|
1025
|
+
# Do not launch the foreground operation until the global SSE listener
|
|
1026
|
+
# has consumed its connected handshake. Otherwise a fast operation can
|
|
1027
|
+
# publish its terminal event before this ACP process is subscribed.
|
|
1028
|
+
send_attempted = False
|
|
1029
|
+
try:
|
|
1030
|
+
listener_ready = await self._wait_for_listener_or_turn_stop(
|
|
1031
|
+
session_id,
|
|
1032
|
+
operation_id,
|
|
1033
|
+
turn_event,
|
|
1034
|
+
)
|
|
1035
|
+
if not listener_ready:
|
|
1036
|
+
await self._release_turn_ownership(
|
|
1037
|
+
session_id,
|
|
1038
|
+
operation_id,
|
|
1039
|
+
turn_event,
|
|
1040
|
+
)
|
|
1041
|
+
self._discard_turn_result(session_id)
|
|
1042
|
+
return PromptResponse(
|
|
1043
|
+
stop_reason="cancelled",
|
|
1044
|
+
usage=None,
|
|
1045
|
+
user_message_id=message_id,
|
|
1046
|
+
)
|
|
1047
|
+
send_attempted = True
|
|
1048
|
+
send_settled = asyncio.Event()
|
|
1049
|
+
self._turn_send_settled[session_id] = (operation_id, send_settled)
|
|
1050
|
+
try:
|
|
1051
|
+
resp = await self._session_mgr.client.post(
|
|
1052
|
+
"/session/send",
|
|
1053
|
+
json={
|
|
1054
|
+
"text": text,
|
|
1055
|
+
"session_id": session_id,
|
|
1056
|
+
"operation_id": operation_id,
|
|
1057
|
+
},
|
|
1058
|
+
)
|
|
1059
|
+
resp.raise_for_status()
|
|
1060
|
+
finally:
|
|
1061
|
+
send_settled.set()
|
|
1062
|
+
self._turn_send_accepted[session_id] = operation_id
|
|
1063
|
+
if self._turn_cancel_requests.get(session_id) == operation_id:
|
|
1064
|
+
# cancel() may have raced the POST while the operation did not
|
|
1065
|
+
# yet exist server-side. Re-issue after successful admission.
|
|
1066
|
+
interrupted = await self._interrupt_operation(
|
|
1067
|
+
session_id,
|
|
1068
|
+
operation_id,
|
|
1069
|
+
)
|
|
1070
|
+
if interrupted:
|
|
1071
|
+
self._confirm_turn_cancel(session_id, operation_id)
|
|
1072
|
+
elif self._turn_cancel_requests.get(session_id) == operation_id:
|
|
1073
|
+
self._turn_cancel_requests.pop(session_id, None)
|
|
1074
|
+
except asyncio.CancelledError:
|
|
1075
|
+
await self._release_turn_ownership(
|
|
1076
|
+
session_id,
|
|
1077
|
+
operation_id,
|
|
1078
|
+
turn_event,
|
|
1079
|
+
)
|
|
1080
|
+
self._discard_turn_result(session_id)
|
|
1081
|
+
if send_attempted:
|
|
1082
|
+
await self._interrupt_after_task_cancellation(
|
|
1083
|
+
session_id,
|
|
1084
|
+
operation_id,
|
|
1085
|
+
)
|
|
1086
|
+
raise
|
|
1087
|
+
except Exception as e:
|
|
1088
|
+
await self._release_turn_ownership(
|
|
1089
|
+
session_id,
|
|
1090
|
+
operation_id,
|
|
1091
|
+
turn_event,
|
|
1092
|
+
)
|
|
1093
|
+
self._discard_turn_result(session_id)
|
|
1094
|
+
if send_attempted:
|
|
1095
|
+
await self._interrupt_operation(session_id, operation_id)
|
|
1096
|
+
if isinstance(e, RequestError):
|
|
1097
|
+
raise
|
|
1098
|
+
raise RequestError(code=-32603, message=str(e))
|
|
1099
|
+
|
|
1100
|
+
try:
|
|
1101
|
+
usage = await self._wait_turn_complete(
|
|
1102
|
+
session_id,
|
|
1103
|
+
operation_id=operation_id,
|
|
1104
|
+
event=turn_event,
|
|
1105
|
+
)
|
|
1106
|
+
except asyncio.CancelledError:
|
|
1107
|
+
self._discard_turn_result(session_id)
|
|
1108
|
+
await self._interrupt_after_task_cancellation(session_id, operation_id)
|
|
1109
|
+
raise
|
|
1110
|
+
except RequestError:
|
|
1111
|
+
# A timeout must not leave the Gateway query running indefinitely.
|
|
1112
|
+
self._discard_turn_result(session_id)
|
|
1113
|
+
if not self._closed:
|
|
1114
|
+
await self._interrupt_operation(session_id, operation_id)
|
|
1115
|
+
raise
|
|
1116
|
+
|
|
1117
|
+
reason = self._turn_reasons.pop(session_id, "end_turn")
|
|
1118
|
+
stop_reason = {
|
|
1119
|
+
"interrupted": "cancelled",
|
|
1120
|
+
"cancelled": "cancelled",
|
|
1121
|
+
"max_tokens": "max_tokens",
|
|
1122
|
+
"max_turns": "max_turn_requests",
|
|
1123
|
+
}.get(reason, "end_turn")
|
|
1124
|
+
|
|
1125
|
+
return PromptResponse(
|
|
1126
|
+
stop_reason=stop_reason,
|
|
1127
|
+
usage=usage,
|
|
1128
|
+
user_message_id=message_id,
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
async def cancel(self, session_id: str, **kwargs: Any) -> None:
|
|
1132
|
+
self._session_mgr.get(session_id) # validate session exists
|
|
1133
|
+
operation_id = self._turn_operations.get(session_id)
|
|
1134
|
+
if operation_id is None:
|
|
1135
|
+
return
|
|
1136
|
+
send_state = self._turn_send_settled.get(session_id)
|
|
1137
|
+
send_started = send_state is not None and send_state[0] == operation_id
|
|
1138
|
+
if not send_started:
|
|
1139
|
+
self._confirm_turn_cancel(session_id, operation_id)
|
|
1140
|
+
return
|
|
1141
|
+
|
|
1142
|
+
self._turn_cancel_requests[session_id] = operation_id
|
|
1143
|
+
if self._turn_send_accepted.get(session_id) != operation_id:
|
|
1144
|
+
return
|
|
1145
|
+
interrupted = await self._interrupt_operation(session_id, operation_id)
|
|
1146
|
+
if self._turn_operations.get(session_id) != operation_id:
|
|
1147
|
+
return
|
|
1148
|
+
if interrupted:
|
|
1149
|
+
self._confirm_turn_cancel(session_id, operation_id)
|
|
1150
|
+
elif self._turn_cancel_requests.get(session_id) == operation_id:
|
|
1151
|
+
self._turn_cancel_requests.pop(session_id, None)
|
|
1152
|
+
|
|
1153
|
+
def _confirm_turn_cancel(self, session_id: str, operation_id: str) -> None:
|
|
1154
|
+
if self._turn_operations.get(session_id) != operation_id:
|
|
1155
|
+
return
|
|
1156
|
+
waiter = self._turn_events.get(session_id)
|
|
1157
|
+
if waiter is None or waiter.is_set():
|
|
1158
|
+
self._turn_cancel_requests.pop(session_id, None)
|
|
1159
|
+
return
|
|
1160
|
+
self._turn_cancel_requests.pop(session_id, None)
|
|
1161
|
+
self._turn_cancellations[session_id] = operation_id
|
|
1162
|
+
self._turn_reasons[session_id] = "cancelled"
|
|
1163
|
+
waiter.set()
|
|
1164
|
+
|
|
1165
|
+
async def _interrupt_operation(
|
|
1166
|
+
self,
|
|
1167
|
+
session_id: str,
|
|
1168
|
+
operation_id: str,
|
|
1169
|
+
) -> bool:
|
|
1170
|
+
"""Best-effort interrupt for one owned Gateway operation."""
|
|
1171
|
+
try:
|
|
1172
|
+
response = await self._session_mgr.client.post(
|
|
1173
|
+
"/session/interrupt",
|
|
1174
|
+
json={
|
|
1175
|
+
"session_id": session_id,
|
|
1176
|
+
"operation_id": operation_id,
|
|
1177
|
+
},
|
|
1178
|
+
)
|
|
1179
|
+
response.raise_for_status()
|
|
1180
|
+
return True
|
|
1181
|
+
except Exception:
|
|
1182
|
+
logger.debug("acp_operation_interrupt_failed", exc_info=True)
|
|
1183
|
+
return False
|
|
1184
|
+
|
|
1185
|
+
async def _interrupt_after_task_cancellation(
|
|
1186
|
+
self,
|
|
1187
|
+
session_id: str,
|
|
1188
|
+
operation_id: str,
|
|
1189
|
+
) -> None:
|
|
1190
|
+
"""Finish a targeted interrupt despite repeated caller cancellation."""
|
|
1191
|
+
cleanup = asyncio.create_task(
|
|
1192
|
+
self._interrupt_operation(session_id, operation_id)
|
|
1193
|
+
)
|
|
1194
|
+
while True:
|
|
1195
|
+
try:
|
|
1196
|
+
await asyncio.shield(cleanup)
|
|
1197
|
+
return
|
|
1198
|
+
except asyncio.CancelledError:
|
|
1199
|
+
if cleanup.done():
|
|
1200
|
+
try:
|
|
1201
|
+
cleanup.result()
|
|
1202
|
+
except asyncio.CancelledError:
|
|
1203
|
+
pass
|
|
1204
|
+
return
|
|
1205
|
+
|
|
1206
|
+
async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> SetSessionModelResponse | None:
|
|
1207
|
+
self._session_mgr.get(session_id) # validate session exists
|
|
1208
|
+
try:
|
|
1209
|
+
resp = await self._session_mgr.client.post(
|
|
1210
|
+
"/config/switch-model",
|
|
1211
|
+
json={"name": model_id, "session_id": session_id},
|
|
1212
|
+
)
|
|
1213
|
+
resp.raise_for_status()
|
|
1214
|
+
except Exception as exc:
|
|
1215
|
+
raise RequestError(code=-32603, message=str(exc)) from exc
|
|
1216
|
+
parts = model_id.split("/", 1)
|
|
1217
|
+
self._session_mgr.set_model(
|
|
1218
|
+
session_id,
|
|
1219
|
+
ModelSelection(
|
|
1220
|
+
provider_id=parts[0] if len(parts) == 2 else "",
|
|
1221
|
+
model_id=parts[1] if len(parts) == 2 else model_id,
|
|
1222
|
+
),
|
|
1223
|
+
)
|
|
1224
|
+
return SetSessionModelResponse()
|
|
1225
|
+
|
|
1226
|
+
async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> SetSessionModeResponse | None:
|
|
1227
|
+
self._session_mgr.get(session_id)
|
|
1228
|
+
try:
|
|
1229
|
+
resp = await self._session_mgr.client.post(
|
|
1230
|
+
"/config/switch-mode",
|
|
1231
|
+
json={"mode": mode_id, "session_id": session_id},
|
|
1232
|
+
)
|
|
1233
|
+
resp.raise_for_status()
|
|
1234
|
+
except Exception as exc:
|
|
1235
|
+
raise RequestError(code=-32603, message=str(exc)) from exc
|
|
1236
|
+
self._session_mgr.set_mode(session_id, mode_id)
|
|
1237
|
+
return SetSessionModeResponse()
|
|
1238
|
+
|
|
1239
|
+
async def set_config_option(
|
|
1240
|
+
self, config_id: str, session_id: str, value: str | bool, **kwargs: Any
|
|
1241
|
+
) -> SetSessionConfigOptionResponse | None:
|
|
1242
|
+
state = self._session_mgr.get(session_id)
|
|
1243
|
+
if config_id == "model" and isinstance(value, str):
|
|
1244
|
+
await self.set_session_model(value, session_id)
|
|
1245
|
+
elif config_id == "mode" and isinstance(value, str):
|
|
1246
|
+
await self.set_session_mode(value, session_id)
|
|
1247
|
+
else:
|
|
1248
|
+
raise RequestError(code=-32602, message=f"Unknown config option: {config_id}")
|
|
1249
|
+
|
|
1250
|
+
models_state = await self._build_models_state(state)
|
|
1251
|
+
modes_state = await self._build_modes_state(state)
|
|
1252
|
+
return SetSessionConfigOptionResponse(
|
|
1253
|
+
config_options=_build_config_options(models_state, modes_state),
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None:
|
|
1257
|
+
raise RequestError(code=-32601, message="Authentication not implemented")
|
|
1258
|
+
|
|
1259
|
+
# ── Helpers ─────────────────────────────────────────────────
|
|
1260
|
+
|
|
1261
|
+
async def _build_models_state(self, state: ACPSessionState) -> SessionModelState | None:
|
|
1262
|
+
"""Build ACP SessionModelState from available Gateway config."""
|
|
1263
|
+
try:
|
|
1264
|
+
resp = await self._session_mgr.client.get(
|
|
1265
|
+
"/config/models",
|
|
1266
|
+
params={"session_id": state.id},
|
|
1267
|
+
)
|
|
1268
|
+
resp.raise_for_status()
|
|
1269
|
+
data = resp.json()
|
|
1270
|
+
if isinstance(data, list):
|
|
1271
|
+
models = data
|
|
1272
|
+
elif isinstance(data, dict):
|
|
1273
|
+
models = data.get("models", data.get("items", []))
|
|
1274
|
+
else:
|
|
1275
|
+
models = []
|
|
1276
|
+
if not isinstance(models, list):
|
|
1277
|
+
models = []
|
|
1278
|
+
available = [
|
|
1279
|
+
ModelInfo(
|
|
1280
|
+
model_id=str(
|
|
1281
|
+
m.get("modelId")
|
|
1282
|
+
or m.get("model_id")
|
|
1283
|
+
or m.get("id")
|
|
1284
|
+
or m.get("name")
|
|
1285
|
+
or ""
|
|
1286
|
+
),
|
|
1287
|
+
name=str(m.get("name") or m.get("id") or m.get("modelId") or ""),
|
|
1288
|
+
description=m.get("description"),
|
|
1289
|
+
)
|
|
1290
|
+
for m in models
|
|
1291
|
+
if isinstance(m, dict)
|
|
1292
|
+
]
|
|
1293
|
+
current = ""
|
|
1294
|
+
if state.model:
|
|
1295
|
+
current = f"{state.model.provider_id}/{state.model.model_id}"
|
|
1296
|
+
elif available:
|
|
1297
|
+
current = available[0].model_id
|
|
1298
|
+
return SessionModelState(current_model_id=current, available_models=available)
|
|
1299
|
+
except Exception:
|
|
1300
|
+
logger.exception("acp_build_models_failed")
|
|
1301
|
+
return None
|
|
1302
|
+
|
|
1303
|
+
async def _build_modes_state(self, state: ACPSessionState) -> SessionModeState | None:
|
|
1304
|
+
"""Build ACP SessionModeState."""
|
|
1305
|
+
modes = [
|
|
1306
|
+
SessionMode(id="agent", name="Agent", description="Autonomous coding agent"),
|
|
1307
|
+
SessionMode(id="plan", name="Plan", description="Plan before executing"),
|
|
1308
|
+
]
|
|
1309
|
+
current = state.mode_id or "agent"
|
|
1310
|
+
return SessionModeState(current_mode_id=current, available_modes=modes)
|
|
1311
|
+
|
|
1312
|
+
async def _replay_messages(self, state: ACPSessionState) -> None:
|
|
1313
|
+
"""Replay session history as ACP updates (for load_session)."""
|
|
1314
|
+
try:
|
|
1315
|
+
resp = await self._session_mgr.client.get(
|
|
1316
|
+
"/session/messages",
|
|
1317
|
+
params={"session_id": state.id},
|
|
1318
|
+
)
|
|
1319
|
+
if resp.status_code != 200:
|
|
1320
|
+
return
|
|
1321
|
+
data = resp.json()
|
|
1322
|
+
if not isinstance(data, list):
|
|
1323
|
+
return
|
|
1324
|
+
for msg in data:
|
|
1325
|
+
role = msg.get("role", "")
|
|
1326
|
+
content = msg.get("content", "")
|
|
1327
|
+
if isinstance(content, list):
|
|
1328
|
+
content = "\n".join(
|
|
1329
|
+
str(item.get("text", ""))
|
|
1330
|
+
for item in content
|
|
1331
|
+
if isinstance(item, dict) and item.get("text")
|
|
1332
|
+
)
|
|
1333
|
+
if isinstance(content, str) and content:
|
|
1334
|
+
if role == "assistant":
|
|
1335
|
+
await self._push_agent_message_chunk(state.id, content)
|
|
1336
|
+
elif role == "user":
|
|
1337
|
+
update = acp.update_user_message_text(content)
|
|
1338
|
+
await self._connection.session_update(session_id=state.id, update=update)
|
|
1339
|
+
except Exception:
|
|
1340
|
+
logger.exception("acp_replay_failed")
|
|
1341
|
+
|
|
1342
|
+
async def _send_usage_update(self, state: ACPSessionState) -> None:
|
|
1343
|
+
"""Push a usage_update notification to the ACP client."""
|
|
1344
|
+
try:
|
|
1345
|
+
resp = await self._session_mgr.client.get(
|
|
1346
|
+
"/session/status",
|
|
1347
|
+
params={"session_id": state.id},
|
|
1348
|
+
)
|
|
1349
|
+
if resp.status_code != 200:
|
|
1350
|
+
return
|
|
1351
|
+
payload = resp.json()
|
|
1352
|
+
if not isinstance(payload, dict):
|
|
1353
|
+
return
|
|
1354
|
+
size = max(0, int(payload.get("context_window_tokens") or 0))
|
|
1355
|
+
used = max(0, int(payload.get("context_used_tokens") or 0))
|
|
1356
|
+
if not size and not used:
|
|
1357
|
+
return
|
|
1358
|
+
update = UsageUpdate(session_update="usage_update", size=size, used=used)
|
|
1359
|
+
if self._connection is not None:
|
|
1360
|
+
await self._connection.session_update(session_id=state.id, update=update)
|
|
1361
|
+
except Exception:
|
|
1362
|
+
logger.debug("acp_usage_update_failed", exc_info=True)
|
|
1363
|
+
|
|
1364
|
+
async def _wait_turn_complete(
|
|
1365
|
+
self,
|
|
1366
|
+
session_id: str,
|
|
1367
|
+
timeout: float = 120.0,
|
|
1368
|
+
*,
|
|
1369
|
+
operation_id: str | None = None,
|
|
1370
|
+
event: asyncio.Event | None = None,
|
|
1371
|
+
) -> Usage | None:
|
|
1372
|
+
"""Wait for the agent turn to complete via EventBus SSE.
|
|
1373
|
+
|
|
1374
|
+
The event listener resolves a per-session event when the gateway
|
|
1375
|
+
publishes ``turn_complete`` (or an error). A timeout is reported to
|
|
1376
|
+
the ACP client rather than falsely acknowledging an unfinished turn.
|
|
1377
|
+
"""
|
|
1378
|
+
waiter = event or self._turn_events.get(session_id)
|
|
1379
|
+
if waiter is None:
|
|
1380
|
+
raise RequestError(code=-32602, message=f"Session not found: {session_id}")
|
|
1381
|
+
try:
|
|
1382
|
+
await asyncio.wait_for(waiter.wait(), timeout=timeout)
|
|
1383
|
+
except asyncio.TimeoutError as exc:
|
|
1384
|
+
raise RequestError(
|
|
1385
|
+
code=-32001,
|
|
1386
|
+
message=f"Timed out waiting for session {session_id} to complete",
|
|
1387
|
+
) from exc
|
|
1388
|
+
finally:
|
|
1389
|
+
if operation_id is not None:
|
|
1390
|
+
await self._release_turn_ownership(
|
|
1391
|
+
session_id,
|
|
1392
|
+
operation_id,
|
|
1393
|
+
waiter,
|
|
1394
|
+
)
|
|
1395
|
+
elif self._turn_events.get(session_id) is waiter:
|
|
1396
|
+
self._turn_events.pop(session_id, None)
|
|
1397
|
+
|
|
1398
|
+
error = self._turn_errors.pop(session_id, None)
|
|
1399
|
+
if error:
|
|
1400
|
+
raise RequestError(code=-32603, message=error)
|
|
1401
|
+
return self._turn_usage.pop(session_id, None)
|
|
1402
|
+
|
|
1403
|
+
async def _release_turn_ownership(
|
|
1404
|
+
self,
|
|
1405
|
+
session_id: str,
|
|
1406
|
+
operation_id: str,
|
|
1407
|
+
waiter: asyncio.Event,
|
|
1408
|
+
) -> None:
|
|
1409
|
+
if (
|
|
1410
|
+
self._turn_operations.get(session_id) != operation_id
|
|
1411
|
+
or self._turn_events.get(session_id) is not waiter
|
|
1412
|
+
):
|
|
1413
|
+
return
|
|
1414
|
+
|
|
1415
|
+
key = (session_id, operation_id)
|
|
1416
|
+
release_done = self._turn_releases.get(key)
|
|
1417
|
+
if release_done is not None:
|
|
1418
|
+
await release_done.wait()
|
|
1419
|
+
return
|
|
1420
|
+
|
|
1421
|
+
release_done = asyncio.Event()
|
|
1422
|
+
self._turn_releases[key] = release_done
|
|
1423
|
+
try:
|
|
1424
|
+
await self._cancel_operation_interactions(session_id, operation_id)
|
|
1425
|
+
finally:
|
|
1426
|
+
if (
|
|
1427
|
+
self._turn_operations.get(session_id) == operation_id
|
|
1428
|
+
and self._turn_events.get(session_id) is waiter
|
|
1429
|
+
):
|
|
1430
|
+
self._turn_operations.pop(session_id, None)
|
|
1431
|
+
self._turn_events.pop(session_id, None)
|
|
1432
|
+
send_state = self._turn_send_settled.get(session_id)
|
|
1433
|
+
if send_state is not None and send_state[0] == operation_id:
|
|
1434
|
+
self._turn_send_settled.pop(session_id, None)
|
|
1435
|
+
if self._turn_send_accepted.get(session_id) == operation_id:
|
|
1436
|
+
self._turn_send_accepted.pop(session_id, None)
|
|
1437
|
+
if self._turn_cancel_requests.get(session_id) == operation_id:
|
|
1438
|
+
self._turn_cancel_requests.pop(session_id, None)
|
|
1439
|
+
if self._turn_cancellations.get(session_id) == operation_id:
|
|
1440
|
+
self._turn_cancellations.pop(session_id, None)
|
|
1441
|
+
if self._turn_releases.get(key) is release_done:
|
|
1442
|
+
self._turn_releases.pop(key, None)
|
|
1443
|
+
release_done.set()
|
|
1444
|
+
|
|
1445
|
+
def _discard_turn_result(self, session_id: str) -> None:
|
|
1446
|
+
self._turn_usage.pop(session_id, None)
|
|
1447
|
+
self._turn_errors.pop(session_id, None)
|
|
1448
|
+
self._turn_reasons.pop(session_id, None)
|
|
1449
|
+
|
|
1450
|
+
async def close(self) -> None:
|
|
1451
|
+
"""Clean up resources."""
|
|
1452
|
+
admitted_operations: list[
|
|
1453
|
+
tuple[str, str, asyncio.Event | None, asyncio.Event | None]
|
|
1454
|
+
] = []
|
|
1455
|
+
for session_id, operation_id in self._turn_operations.items():
|
|
1456
|
+
send_state = self._turn_send_settled.get(session_id)
|
|
1457
|
+
send_settled = (
|
|
1458
|
+
send_state[1]
|
|
1459
|
+
if send_state is not None and send_state[0] == operation_id
|
|
1460
|
+
else None
|
|
1461
|
+
)
|
|
1462
|
+
if (
|
|
1463
|
+
self._turn_send_accepted.get(session_id) == operation_id
|
|
1464
|
+
or send_settled is not None
|
|
1465
|
+
):
|
|
1466
|
+
admitted_operations.append(
|
|
1467
|
+
(
|
|
1468
|
+
session_id,
|
|
1469
|
+
operation_id,
|
|
1470
|
+
self._turn_events.get(session_id),
|
|
1471
|
+
send_settled,
|
|
1472
|
+
)
|
|
1473
|
+
)
|
|
1474
|
+
self._closed = True
|
|
1475
|
+
self._listener_generation += 1
|
|
1476
|
+
self._listener_ready.set()
|
|
1477
|
+
self._listener_error = "ACP agent closed"
|
|
1478
|
+
self._fail_active_prompts("ACP agent closed")
|
|
1479
|
+
for session_id, operation_id, waiter, send_settled in admitted_operations:
|
|
1480
|
+
if send_settled is not None:
|
|
1481
|
+
await send_settled.wait()
|
|
1482
|
+
await self._interrupt_operation(session_id, operation_id)
|
|
1483
|
+
if waiter is not None:
|
|
1484
|
+
await self._release_turn_ownership(
|
|
1485
|
+
session_id,
|
|
1486
|
+
operation_id,
|
|
1487
|
+
waiter,
|
|
1488
|
+
)
|
|
1489
|
+
if self._event_task:
|
|
1490
|
+
self._event_task.cancel()
|
|
1491
|
+
try:
|
|
1492
|
+
await self._event_task
|
|
1493
|
+
except asyncio.CancelledError:
|
|
1494
|
+
pass
|
|
1495
|
+
self._event_task = None
|
|
1496
|
+
await self._cancel_interaction_tasks()
|
|
1497
|
+
self._turn_usage.clear()
|
|
1498
|
+
self._turn_reasons.clear()
|
|
1499
|
+
await self._session_mgr.aclose()
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
# ── Module-level helpers ───────────────────────────────────────
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _build_config_options(
|
|
1506
|
+
models_state: SessionModelState | None,
|
|
1507
|
+
modes_state: SessionModeState | None,
|
|
1508
|
+
) -> list[SessionConfigOptionSelect]:
|
|
1509
|
+
"""Build ACP config option descriptors for the client."""
|
|
1510
|
+
options: list[SessionConfigOptionSelect] = []
|
|
1511
|
+
|
|
1512
|
+
if models_state:
|
|
1513
|
+
options.append(SessionConfigOptionSelect(
|
|
1514
|
+
id="model",
|
|
1515
|
+
name="Model",
|
|
1516
|
+
category="model",
|
|
1517
|
+
type="select",
|
|
1518
|
+
current_value=models_state.current_model_id,
|
|
1519
|
+
options=[
|
|
1520
|
+
SessionConfigSelectOption(value=m.model_id, name=m.name)
|
|
1521
|
+
for m in models_state.available_models
|
|
1522
|
+
],
|
|
1523
|
+
))
|
|
1524
|
+
|
|
1525
|
+
if modes_state:
|
|
1526
|
+
options.append(SessionConfigOptionSelect(
|
|
1527
|
+
id="mode",
|
|
1528
|
+
name="Session Mode",
|
|
1529
|
+
category="mode",
|
|
1530
|
+
type="select",
|
|
1531
|
+
current_value=modes_state.current_mode_id,
|
|
1532
|
+
options=[
|
|
1533
|
+
SessionConfigSelectOption(value=m.id, name=m.name, description=m.description)
|
|
1534
|
+
for m in modes_state.available_modes
|
|
1535
|
+
],
|
|
1536
|
+
))
|
|
1537
|
+
|
|
1538
|
+
return options
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _usage_from_payload(raw: Any) -> Usage | None:
|
|
1542
|
+
"""Translate a gateway usage mapping to ACP's required token fields."""
|
|
1543
|
+
if not isinstance(raw, dict):
|
|
1544
|
+
return None
|
|
1545
|
+
|
|
1546
|
+
def _number(*keys: str) -> int:
|
|
1547
|
+
for key in keys:
|
|
1548
|
+
value = raw.get(key)
|
|
1549
|
+
if isinstance(value, (int, float)) and value >= 0:
|
|
1550
|
+
return int(value)
|
|
1551
|
+
return 0
|
|
1552
|
+
|
|
1553
|
+
input_tokens = _number(
|
|
1554
|
+
"total_input_tokens",
|
|
1555
|
+
"input_tokens",
|
|
1556
|
+
"inputTokens",
|
|
1557
|
+
"prompt_tokens",
|
|
1558
|
+
)
|
|
1559
|
+
output_tokens = _number("output_tokens", "outputTokens", "completion_tokens")
|
|
1560
|
+
thought_tokens = _number("thought_tokens", "thoughtTokens", "reasoning_tokens")
|
|
1561
|
+
cached_read_tokens = _number("cache_read_tokens", "cachedReadTokens")
|
|
1562
|
+
cached_write_tokens = _number("cache_write_tokens", "cachedWriteTokens")
|
|
1563
|
+
total_tokens = _number("total_tokens", "totalTokens")
|
|
1564
|
+
if total_tokens == 0:
|
|
1565
|
+
total_tokens = input_tokens + output_tokens + thought_tokens
|
|
1566
|
+
if not (
|
|
1567
|
+
input_tokens
|
|
1568
|
+
or output_tokens
|
|
1569
|
+
or total_tokens
|
|
1570
|
+
or cached_read_tokens
|
|
1571
|
+
or cached_write_tokens
|
|
1572
|
+
):
|
|
1573
|
+
return None
|
|
1574
|
+
return Usage(
|
|
1575
|
+
input_tokens=input_tokens,
|
|
1576
|
+
output_tokens=output_tokens,
|
|
1577
|
+
thought_tokens=thought_tokens or None,
|
|
1578
|
+
cached_read_tokens=cached_read_tokens or None,
|
|
1579
|
+
cached_write_tokens=cached_write_tokens or None,
|
|
1580
|
+
total_tokens=total_tokens,
|
|
1581
|
+
)
|