agentlink-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentlink_cli-0.1.0.dist-info/METADATA +136 -0
- agentlink_cli-0.1.0.dist-info/RECORD +55 -0
- agentlink_cli-0.1.0.dist-info/WHEEL +4 -0
- agentlink_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/acp/__init__.py +6 -0
- connector/acp/adapter.py +1221 -0
- connector/acp/config_options.py +175 -0
- connector/acp/discovery.py +385 -0
- connector/acp/manifest.py +110 -0
- connector/acp/manifests/__init__.py +1 -0
- connector/acp/manifests/codebuddy.json +37 -0
- connector/acp/manifests/cursor.json +39 -0
- connector/acp/manifests/gemini.json +33 -0
- connector/acp/manifests/grok_build.json +31 -0
- connector/acp/reducer.py +615 -0
- connector/acp/rpc.py +308 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +603 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1376 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +280 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +1150 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1309 -0
- connector/codex/rpc.py +261 -0
- connector/control.py +298 -0
- connector/json_rpc.py +143 -0
- connector/launch.py +310 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +144 -0
- connector/local/ops.py +92 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +658 -0
- connector/local_ops.py +5 -0
- connector/local_runtime.py +139 -0
- connector/logging.py +50 -0
- connector/perf.py +89 -0
- connector/protocol.py +26 -0
- connector/registry.py +49 -0
- connector/runtime.py +1309 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
- connector/version.py +13 -0
connector/acp/adapter.py
ADDED
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import secrets
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from connector.acp.config_options import (
|
|
12
|
+
extract_mode_options,
|
|
13
|
+
extract_model_options,
|
|
14
|
+
find_config_option,
|
|
15
|
+
is_interactive_auth_method,
|
|
16
|
+
order_headless_auth_method_ids,
|
|
17
|
+
order_interactive_auth_method_ids,
|
|
18
|
+
summarize_auth_methods,
|
|
19
|
+
)
|
|
20
|
+
from connector.acp.manifest import AgentManifest
|
|
21
|
+
from connector.acp.reducer import AcpTimelineReducer, map_approval_status_to_option
|
|
22
|
+
from connector.acp.rpc import AcpJsonRpcClient, AcpJsonRpcError
|
|
23
|
+
from connector.launch import LaunchTarget, launch_target
|
|
24
|
+
from connector.logging import logger
|
|
25
|
+
from connector.perf import StageTimer, elapsed_ms, log_stage
|
|
26
|
+
from connector.time import utc_now
|
|
27
|
+
from connector.version import connector_version
|
|
28
|
+
|
|
29
|
+
NotificationSink = Callable[[str, dict[str, Any]], Awaitable[None]] | None
|
|
30
|
+
AttachmentDownloader = Callable[[str, str], Awaitable[tuple[bytes, str, str]]]
|
|
31
|
+
|
|
32
|
+
# Default ceiling for a single prompt turn (can be overridden via manifest.quirks).
|
|
33
|
+
_DEFAULT_MAX_TURN_SECONDS = 60 * 60
|
|
34
|
+
# User-triggered interactive OAuth (browser) — allow several minutes to complete.
|
|
35
|
+
_INTERACTIVE_AUTH_TIMEOUT_S = 5 * 60
|
|
36
|
+
# Bound session/new: fail fast instead of hanging near UI/RPC ceilings.
|
|
37
|
+
_SESSION_NEW_TIMEOUT_S = 20.0
|
|
38
|
+
_AUTH_ERROR_TOKENS = (
|
|
39
|
+
"auth",
|
|
40
|
+
"login",
|
|
41
|
+
"unauthor",
|
|
42
|
+
"api key",
|
|
43
|
+
"credential",
|
|
44
|
+
"not configured",
|
|
45
|
+
"token",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class _PendingApproval:
|
|
51
|
+
request_id: str | int
|
|
52
|
+
future: asyncio.Future[str]
|
|
53
|
+
options: list[dict[str, Any]]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class _SessionRuntime:
|
|
58
|
+
session_id: str
|
|
59
|
+
cwd: str | None = None
|
|
60
|
+
external_session_id: str | None = None
|
|
61
|
+
active_turn_id: str | None = None
|
|
62
|
+
active_task: asyncio.Task[None] | None = None
|
|
63
|
+
interrupted: bool = False
|
|
64
|
+
client_message_id: str | None = None
|
|
65
|
+
reducer: AcpTimelineReducer | None = None
|
|
66
|
+
pending_approvals: dict[str, _PendingApproval] = field(default_factory=dict)
|
|
67
|
+
# ACP config options from session/new (model/mode selectors).
|
|
68
|
+
config_options: list[dict[str, Any]] = field(default_factory=list)
|
|
69
|
+
# Short lock only for claiming/releasing a turn (not held for the whole turn).
|
|
70
|
+
claim_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
71
|
+
turn_timer: StageTimer | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(slots=True)
|
|
75
|
+
class AcpAdapter:
|
|
76
|
+
"""Generic Adapter backed by an ACP agent subprocess."""
|
|
77
|
+
|
|
78
|
+
manifest: AgentManifest
|
|
79
|
+
notification_sink: NotificationSink = None
|
|
80
|
+
attachment_downloader: AttachmentDownloader | None = None
|
|
81
|
+
launch: LaunchTarget | None = None
|
|
82
|
+
client_factory: Callable[[list[str], dict[str, str] | None, str | None], AcpJsonRpcClient] | None = None
|
|
83
|
+
_client: AcpJsonRpcClient | None = field(default=None, init=False, repr=False)
|
|
84
|
+
_initialized: bool = field(default=False, init=False, repr=False)
|
|
85
|
+
_needs_restart: bool = field(default=False, init=False, repr=False)
|
|
86
|
+
_agent_capabilities: dict[str, Any] = field(default_factory=dict, init=False, repr=False)
|
|
87
|
+
_auth_methods: list[dict[str, Any]] = field(default_factory=list, init=False, repr=False)
|
|
88
|
+
_sessions: dict[str, _SessionRuntime] = field(default_factory=dict, init=False, repr=False)
|
|
89
|
+
_start_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
|
|
90
|
+
_auth_status: str = field(default="unknown", init=False, repr=False)
|
|
91
|
+
_auth_hint: str | None = field(default=None, init=False, repr=False)
|
|
92
|
+
# external_session_id -> last known ACP configOptions from session/new|set_config
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def runtime(self) -> str:
|
|
96
|
+
return self.manifest.id
|
|
97
|
+
|
|
98
|
+
def rewire(self, target: LaunchTarget | None) -> None:
|
|
99
|
+
"""Point at a new binary. Old process is closed on next ensure_client."""
|
|
100
|
+
self.launch = target
|
|
101
|
+
self._initialized = False
|
|
102
|
+
self._needs_restart = True
|
|
103
|
+
|
|
104
|
+
def forget_sync_state(self) -> None:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def forget_persisted_sync_state(self, connector_id: str) -> None:
|
|
108
|
+
self.forget_sync_state()
|
|
109
|
+
|
|
110
|
+
async def create_session(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
111
|
+
session_id = _optional_string(params.get("sessionId")) or f"sess_{self.runtime}_{secrets.token_urlsafe(10)}"
|
|
112
|
+
cwd = _require_cwd(params)
|
|
113
|
+
runtime = self._runtime_for(session_id, params)
|
|
114
|
+
timer = StageTimer()
|
|
115
|
+
# Skip spawn/session/new when a prior probe already proved auth is missing.
|
|
116
|
+
self._raise_if_auth_blocked()
|
|
117
|
+
client = await self._ensure_client()
|
|
118
|
+
try:
|
|
119
|
+
# Bound cold session/new (Cursor/Grok historically hung well past UI timeouts).
|
|
120
|
+
result = await client.request(
|
|
121
|
+
"session/new",
|
|
122
|
+
{
|
|
123
|
+
"cwd": cwd,
|
|
124
|
+
"mcpServers": params.get("mcpServers") if isinstance(params.get("mcpServers"), list) else [],
|
|
125
|
+
},
|
|
126
|
+
timeout=_SESSION_NEW_TIMEOUT_S,
|
|
127
|
+
)
|
|
128
|
+
except (AcpJsonRpcError, TimeoutError, asyncio.TimeoutError) as exc:
|
|
129
|
+
stderr = client.stderr_excerpt if client else ""
|
|
130
|
+
detail = str(exc)
|
|
131
|
+
if _is_authish_error(detail):
|
|
132
|
+
detail = await self._mark_auth_required(detail)
|
|
133
|
+
if stderr:
|
|
134
|
+
detail = f"{detail}; stderr={stderr[:800]}"
|
|
135
|
+
if isinstance(exc, (TimeoutError, asyncio.TimeoutError)):
|
|
136
|
+
detail = (
|
|
137
|
+
f"{self.runtime} session/new timed out after {_SESSION_NEW_TIMEOUT_S:.0f}s: {detail}"
|
|
138
|
+
)
|
|
139
|
+
raise AcpJsonRpcError(detail) from exc
|
|
140
|
+
external = _optional_string(result.get("sessionId")) or _optional_string(result.get("session_id"))
|
|
141
|
+
if not external:
|
|
142
|
+
raise AcpJsonRpcError(f"{self.runtime} session/new did not return sessionId")
|
|
143
|
+
self._set_auth_status("ok")
|
|
144
|
+
runtime.external_session_id = external
|
|
145
|
+
runtime.cwd = cwd
|
|
146
|
+
runtime.reducer = AcpTimelineReducer(runtime=self.runtime)
|
|
147
|
+
config_options = result.get("configOptions") if isinstance(result.get("configOptions"), list) else []
|
|
148
|
+
runtime.config_options = [opt for opt in config_options if isinstance(opt, dict)]
|
|
149
|
+
# Apply preferred model/mode from AA settings if agent exposed config options.
|
|
150
|
+
await self._apply_session_settings(client, runtime, params)
|
|
151
|
+
model_options = extract_model_options(runtime.config_options)
|
|
152
|
+
mode_options = extract_mode_options(runtime.config_options)
|
|
153
|
+
elapsed = timer.elapsed_ms()
|
|
154
|
+
log_stage(
|
|
155
|
+
"adapter.create_session",
|
|
156
|
+
elapsed,
|
|
157
|
+
runtime=self.runtime,
|
|
158
|
+
session_id=session_id,
|
|
159
|
+
)
|
|
160
|
+
logger.info(
|
|
161
|
+
"{} session created session_id={} external={} elapsed_ms={}",
|
|
162
|
+
self.runtime,
|
|
163
|
+
session_id,
|
|
164
|
+
external,
|
|
165
|
+
elapsed,
|
|
166
|
+
)
|
|
167
|
+
await self._emit(
|
|
168
|
+
"session.updated",
|
|
169
|
+
{
|
|
170
|
+
"sessionId": session_id,
|
|
171
|
+
"runtime": self.runtime,
|
|
172
|
+
"externalSessionId": external,
|
|
173
|
+
"status": "idle",
|
|
174
|
+
"cwd": cwd,
|
|
175
|
+
"sourceObservedAt": utc_now(),
|
|
176
|
+
"configOptions": runtime.config_options,
|
|
177
|
+
"modelOptions": model_options,
|
|
178
|
+
"modeOptions": mode_options,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
# Also refresh device-level observed options for schema merge.
|
|
182
|
+
if model_options or mode_options or runtime.config_options:
|
|
183
|
+
await self._emit(
|
|
184
|
+
"runtime.optionsUpdated",
|
|
185
|
+
{
|
|
186
|
+
"runtime": self.runtime,
|
|
187
|
+
"configOptions": runtime.config_options,
|
|
188
|
+
"modelOptions": model_options,
|
|
189
|
+
"modeOptions": mode_options,
|
|
190
|
+
"authStatus": "ok",
|
|
191
|
+
"sourceObservedAt": utc_now(),
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
return {
|
|
195
|
+
"sessionId": session_id,
|
|
196
|
+
"externalSessionId": external,
|
|
197
|
+
"configOptions": runtime.config_options,
|
|
198
|
+
"modelOptions": model_options,
|
|
199
|
+
"modeOptions": mode_options,
|
|
200
|
+
"backendNotifications": [],
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
204
|
+
session_id = _required(params, "sessionId")
|
|
205
|
+
runtime = self._sessions.get(session_id)
|
|
206
|
+
external = _optional_string(params.get("externalSessionId")) or (
|
|
207
|
+
runtime.external_session_id if runtime else None
|
|
208
|
+
)
|
|
209
|
+
if external is None:
|
|
210
|
+
return {"backendNotifications": []}
|
|
211
|
+
caps = self._agent_capabilities
|
|
212
|
+
session_caps = caps.get("sessionCapabilities") if isinstance(caps.get("sessionCapabilities"), dict) else {}
|
|
213
|
+
load_supported = bool(caps.get("loadSession")) or "load" in session_caps or "resume" in session_caps
|
|
214
|
+
if not load_supported:
|
|
215
|
+
return {"backendNotifications": []}
|
|
216
|
+
cwd = (
|
|
217
|
+
_optional_string(params.get("cwd"))
|
|
218
|
+
or (runtime.cwd if runtime else None)
|
|
219
|
+
)
|
|
220
|
+
if not cwd:
|
|
221
|
+
raise ValueError("missing cwd")
|
|
222
|
+
client = await self._ensure_client()
|
|
223
|
+
method = "session/load" if bool(caps.get("loadSession")) or "load" in session_caps else "session/resume"
|
|
224
|
+
try:
|
|
225
|
+
await client.request(
|
|
226
|
+
method,
|
|
227
|
+
{
|
|
228
|
+
"sessionId": external,
|
|
229
|
+
"cwd": cwd,
|
|
230
|
+
"mcpServers": [],
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
except AcpJsonRpcError as exc:
|
|
234
|
+
logger.warning("{} {} failed: {}", self.runtime, method, exc)
|
|
235
|
+
return {"backendNotifications": []}
|
|
236
|
+
|
|
237
|
+
async def sync_existing_sessions(
|
|
238
|
+
self,
|
|
239
|
+
connector_id: str,
|
|
240
|
+
*,
|
|
241
|
+
limit: int = 100,
|
|
242
|
+
force: bool = False,
|
|
243
|
+
notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
del connector_id, force, notification_sink
|
|
246
|
+
caps = self._agent_capabilities
|
|
247
|
+
session_caps = caps.get("sessionCapabilities") if isinstance(caps.get("sessionCapabilities"), dict) else {}
|
|
248
|
+
if "list" not in session_caps and not caps.get("sessionList"):
|
|
249
|
+
return {"threads": [], "skippedThreads": [], "backendNotifications": []}
|
|
250
|
+
try:
|
|
251
|
+
client = await self._ensure_client()
|
|
252
|
+
result = await client.request("session/list", {"limit": limit})
|
|
253
|
+
sessions = result.get("sessions") if isinstance(result.get("sessions"), list) else []
|
|
254
|
+
threads = [
|
|
255
|
+
str(item.get("sessionId"))
|
|
256
|
+
for item in sessions
|
|
257
|
+
if isinstance(item, dict) and item.get("sessionId")
|
|
258
|
+
]
|
|
259
|
+
return {"threads": threads[:limit], "skippedThreads": [], "backendNotifications": []}
|
|
260
|
+
except Exception:
|
|
261
|
+
logger.exception("{} session/list failed", self.runtime)
|
|
262
|
+
return {"threads": [], "skippedThreads": [], "backendNotifications": []}
|
|
263
|
+
|
|
264
|
+
async def start_turn(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
265
|
+
sync_timer = StageTimer()
|
|
266
|
+
session_id = _required(params, "sessionId")
|
|
267
|
+
content = _required(params, "content")
|
|
268
|
+
runtime = self._runtime_for(session_id, params)
|
|
269
|
+
if runtime.external_session_id is None:
|
|
270
|
+
created = await self.create_session(params)
|
|
271
|
+
runtime.external_session_id = created.get("externalSessionId")
|
|
272
|
+
|
|
273
|
+
async with runtime.claim_lock:
|
|
274
|
+
if runtime.active_task is not None and not runtime.active_task.done():
|
|
275
|
+
raise AcpJsonRpcError(f"{self.runtime} turn already running for this session")
|
|
276
|
+
runtime.interrupted = False
|
|
277
|
+
turn_id = _optional_string(params.get("turnId")) or f"turn_{self.runtime}_{secrets.token_urlsafe(8)}"
|
|
278
|
+
runtime.active_turn_id = turn_id
|
|
279
|
+
runtime.client_message_id = _optional_string(params.get("clientMessageId"))
|
|
280
|
+
if runtime.reducer is None:
|
|
281
|
+
runtime.reducer = AcpTimelineReducer(runtime=self.runtime)
|
|
282
|
+
runtime.turn_timer = StageTimer()
|
|
283
|
+
runtime.active_task = asyncio.create_task(
|
|
284
|
+
self._drive_turn(runtime=runtime, params=params, content=content, turn_id=turn_id)
|
|
285
|
+
)
|
|
286
|
+
log_stage(
|
|
287
|
+
"adapter.start_turn_sync",
|
|
288
|
+
sync_timer.elapsed_ms(),
|
|
289
|
+
runtime=self.runtime,
|
|
290
|
+
session_id=session_id,
|
|
291
|
+
turn_id=turn_id,
|
|
292
|
+
)
|
|
293
|
+
return {"turnId": turn_id}
|
|
294
|
+
|
|
295
|
+
async def interrupt_turn(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
296
|
+
session_id = _required(params, "sessionId")
|
|
297
|
+
runtime = self._sessions.get(session_id)
|
|
298
|
+
if runtime is None or runtime.external_session_id is None:
|
|
299
|
+
return {"interrupted": False, "reason": "session not registered"}
|
|
300
|
+
runtime.interrupted = True
|
|
301
|
+
for pending in list(runtime.pending_approvals.values()):
|
|
302
|
+
if not pending.future.done():
|
|
303
|
+
pending.future.set_result("cancelled")
|
|
304
|
+
try:
|
|
305
|
+
client = await self._ensure_client()
|
|
306
|
+
await client.notify("session/cancel", {"sessionId": runtime.external_session_id})
|
|
307
|
+
return {"interrupted": True}
|
|
308
|
+
except Exception as exc:
|
|
309
|
+
logger.exception("{} session/cancel failed", self.runtime)
|
|
310
|
+
return {"interrupted": False, "reason": str(exc)}
|
|
311
|
+
|
|
312
|
+
async def resolve_approval(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
313
|
+
session_id = _required(params, "sessionId")
|
|
314
|
+
approval_id = _required(params, "approvalId")
|
|
315
|
+
status = _required(params, "status")
|
|
316
|
+
runtime = self._sessions.get(session_id)
|
|
317
|
+
if runtime is None:
|
|
318
|
+
return {"resolved": False, "reason": "session not registered"}
|
|
319
|
+
pending = runtime.pending_approvals.get(approval_id)
|
|
320
|
+
if pending is None:
|
|
321
|
+
for key, value in runtime.pending_approvals.items():
|
|
322
|
+
if str(value.request_id) == str(approval_id):
|
|
323
|
+
pending = value
|
|
324
|
+
approval_id = key
|
|
325
|
+
break
|
|
326
|
+
if pending is None:
|
|
327
|
+
return {"resolved": False, "reason": "approval not pending"}
|
|
328
|
+
if not pending.future.done():
|
|
329
|
+
pending.future.set_result(status)
|
|
330
|
+
return {"resolved": True}
|
|
331
|
+
|
|
332
|
+
async def close(self) -> None:
|
|
333
|
+
await self._shutdown_client()
|
|
334
|
+
|
|
335
|
+
def _runtime_for(self, session_id: str, params: dict[str, Any]) -> _SessionRuntime:
|
|
336
|
+
runtime = self._sessions.get(session_id)
|
|
337
|
+
if runtime is None:
|
|
338
|
+
runtime = _SessionRuntime(
|
|
339
|
+
session_id=session_id,
|
|
340
|
+
cwd=_optional_string(params.get("cwd")),
|
|
341
|
+
external_session_id=_optional_string(params.get("externalSessionId")),
|
|
342
|
+
reducer=AcpTimelineReducer(runtime=self.runtime),
|
|
343
|
+
)
|
|
344
|
+
self._sessions[session_id] = runtime
|
|
345
|
+
if params.get("cwd"):
|
|
346
|
+
runtime.cwd = _optional_string(params.get("cwd"))
|
|
347
|
+
if params.get("externalSessionId"):
|
|
348
|
+
runtime.external_session_id = _optional_string(params.get("externalSessionId"))
|
|
349
|
+
return runtime
|
|
350
|
+
|
|
351
|
+
async def _drive_turn(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
runtime: _SessionRuntime,
|
|
355
|
+
params: dict[str, Any],
|
|
356
|
+
content: str,
|
|
357
|
+
turn_id: str,
|
|
358
|
+
) -> None:
|
|
359
|
+
assert runtime.reducer is not None
|
|
360
|
+
max_turn_s = _max_turn_seconds(self.manifest)
|
|
361
|
+
outcome = "done"
|
|
362
|
+
try:
|
|
363
|
+
reduced = runtime.reducer.turn_start(
|
|
364
|
+
session_id=runtime.session_id,
|
|
365
|
+
turn_id=turn_id,
|
|
366
|
+
external_session_id=runtime.external_session_id,
|
|
367
|
+
content=content,
|
|
368
|
+
client_message_id=runtime.client_message_id,
|
|
369
|
+
attachments=_attachments_metadata(params),
|
|
370
|
+
)
|
|
371
|
+
await self._emit_reduction(reduced)
|
|
372
|
+
client = await self._ensure_client()
|
|
373
|
+
# Re-apply model/settings before each turn (composer can change mid-session).
|
|
374
|
+
await self._apply_session_settings(client, runtime, params)
|
|
375
|
+
prompt = await self._build_prompt(content=content, params=params, runtime=runtime)
|
|
376
|
+
try:
|
|
377
|
+
result = await client.request(
|
|
378
|
+
"session/prompt",
|
|
379
|
+
{
|
|
380
|
+
"sessionId": runtime.external_session_id,
|
|
381
|
+
"prompt": prompt,
|
|
382
|
+
},
|
|
383
|
+
timeout=max_turn_s,
|
|
384
|
+
)
|
|
385
|
+
except AcpJsonRpcError as exc:
|
|
386
|
+
if runtime.interrupted:
|
|
387
|
+
outcome = "cancelled"
|
|
388
|
+
end = runtime.reducer.turn_end(
|
|
389
|
+
session_id=runtime.session_id,
|
|
390
|
+
turn_id=turn_id,
|
|
391
|
+
external_session_id=runtime.external_session_id,
|
|
392
|
+
stop_reason="cancelled",
|
|
393
|
+
interrupted=True,
|
|
394
|
+
)
|
|
395
|
+
await self._emit_reduction(end)
|
|
396
|
+
return
|
|
397
|
+
raise exc
|
|
398
|
+
stop_reason = _optional_string(result.get("stopReason") or result.get("stop_reason"))
|
|
399
|
+
if runtime.interrupted:
|
|
400
|
+
outcome = "cancelled"
|
|
401
|
+
end = runtime.reducer.turn_end(
|
|
402
|
+
session_id=runtime.session_id,
|
|
403
|
+
turn_id=turn_id,
|
|
404
|
+
external_session_id=runtime.external_session_id,
|
|
405
|
+
stop_reason=stop_reason,
|
|
406
|
+
interrupted=runtime.interrupted,
|
|
407
|
+
)
|
|
408
|
+
await self._emit_reduction(end)
|
|
409
|
+
except asyncio.CancelledError:
|
|
410
|
+
outcome = "cancelled"
|
|
411
|
+
raise
|
|
412
|
+
except Exception as exc:
|
|
413
|
+
outcome = "cancelled" if runtime.interrupted else "failed"
|
|
414
|
+
logger.exception(
|
|
415
|
+
"ACP turn failed runtime={} session_id={} turn_id={}",
|
|
416
|
+
self.runtime,
|
|
417
|
+
runtime.session_id,
|
|
418
|
+
turn_id,
|
|
419
|
+
)
|
|
420
|
+
stderr = self._client.stderr_excerpt if self._client else ""
|
|
421
|
+
if runtime.reducer is not None:
|
|
422
|
+
# Always go through reducer so contentHash/revision stay consistent.
|
|
423
|
+
end = runtime.reducer.turn_end(
|
|
424
|
+
session_id=runtime.session_id,
|
|
425
|
+
turn_id=turn_id,
|
|
426
|
+
external_session_id=runtime.external_session_id,
|
|
427
|
+
stop_reason=(str(exc)[:500] or "failed"),
|
|
428
|
+
interrupted=runtime.interrupted,
|
|
429
|
+
)
|
|
430
|
+
await self._emit_reduction(end)
|
|
431
|
+
await self._emit(
|
|
432
|
+
"runtime.error",
|
|
433
|
+
{
|
|
434
|
+
"sessionId": runtime.session_id,
|
|
435
|
+
"runtime": self.runtime,
|
|
436
|
+
"message": str(exc),
|
|
437
|
+
"stderr": stderr or None,
|
|
438
|
+
},
|
|
439
|
+
)
|
|
440
|
+
finally:
|
|
441
|
+
timer = runtime.turn_timer
|
|
442
|
+
if timer is not None:
|
|
443
|
+
timer.mark_turn_complete(
|
|
444
|
+
outcome=outcome,
|
|
445
|
+
runtime=self.runtime,
|
|
446
|
+
session_id=runtime.session_id,
|
|
447
|
+
turn_id=turn_id,
|
|
448
|
+
)
|
|
449
|
+
runtime.turn_timer = None
|
|
450
|
+
async with runtime.claim_lock:
|
|
451
|
+
if runtime.active_turn_id == turn_id:
|
|
452
|
+
runtime.active_turn_id = None
|
|
453
|
+
runtime.active_task = None
|
|
454
|
+
runtime.client_message_id = None
|
|
455
|
+
runtime.pending_approvals.clear()
|
|
456
|
+
|
|
457
|
+
async def _build_prompt(
|
|
458
|
+
self,
|
|
459
|
+
*,
|
|
460
|
+
content: str,
|
|
461
|
+
params: dict[str, Any],
|
|
462
|
+
runtime: _SessionRuntime,
|
|
463
|
+
) -> list[dict[str, Any]]:
|
|
464
|
+
blocks: list[dict[str, Any]] = [{"type": "text", "text": content}]
|
|
465
|
+
attachments = params.get("attachments")
|
|
466
|
+
if not isinstance(attachments, list) or self.attachment_downloader is None:
|
|
467
|
+
return blocks
|
|
468
|
+
for entry in attachments:
|
|
469
|
+
if not isinstance(entry, dict):
|
|
470
|
+
continue
|
|
471
|
+
file_id = entry.get("fileId") or entry.get("id")
|
|
472
|
+
if not isinstance(file_id, str) or not file_id:
|
|
473
|
+
continue
|
|
474
|
+
media_type = str(entry.get("mediaType") or entry.get("media_type") or "")
|
|
475
|
+
name = str(entry.get("name") or file_id)
|
|
476
|
+
try:
|
|
477
|
+
data, original_name, detected_type = await self.attachment_downloader(
|
|
478
|
+
runtime.session_id, file_id
|
|
479
|
+
)
|
|
480
|
+
except Exception:
|
|
481
|
+
logger.exception("attachment download failed file_id={}", file_id)
|
|
482
|
+
blocks.append({"type": "text", "text": f"\n\nAttached file: {name}"})
|
|
483
|
+
continue
|
|
484
|
+
media = media_type or detected_type or "application/octet-stream"
|
|
485
|
+
if media.startswith("image/"):
|
|
486
|
+
import base64
|
|
487
|
+
|
|
488
|
+
blocks.append(
|
|
489
|
+
{
|
|
490
|
+
"type": "image",
|
|
491
|
+
"mimeType": media,
|
|
492
|
+
"data": base64.b64encode(data).decode("ascii"),
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
else:
|
|
496
|
+
blocks.append(
|
|
497
|
+
{
|
|
498
|
+
"type": "text",
|
|
499
|
+
"text": f"\n\nAttached file: {original_name or name} ({media})",
|
|
500
|
+
}
|
|
501
|
+
)
|
|
502
|
+
return blocks
|
|
503
|
+
|
|
504
|
+
async def _ensure_client(self, *, skip_auto_auth: bool = False) -> AcpJsonRpcClient:
|
|
505
|
+
async with self._start_lock:
|
|
506
|
+
if (
|
|
507
|
+
not self._needs_restart
|
|
508
|
+
and self._client is not None
|
|
509
|
+
and self._initialized
|
|
510
|
+
and self._client.alive
|
|
511
|
+
):
|
|
512
|
+
return self._client
|
|
513
|
+
await self._shutdown_client_unlocked()
|
|
514
|
+
command = self._command()
|
|
515
|
+
started = time.perf_counter()
|
|
516
|
+
# Process cwd is launch context only (None → inherit connector cwd).
|
|
517
|
+
# Per-session workspace is always passed via session/new|load cwd.
|
|
518
|
+
factory = self.client_factory or (
|
|
519
|
+
lambda cmd, env, workdir: AcpJsonRpcClient(cmd, env=env, cwd=workdir)
|
|
520
|
+
)
|
|
521
|
+
client = factory(command, dict(self.manifest.env) or None, None)
|
|
522
|
+
await client.start(
|
|
523
|
+
notification_handler=self._on_notification,
|
|
524
|
+
server_request_handler=self._on_server_request,
|
|
525
|
+
exit_handler=self._on_client_exit,
|
|
526
|
+
)
|
|
527
|
+
try:
|
|
528
|
+
# Cap initialize: cold agent spawn (esp. Cursor via shell shims)
|
|
529
|
+
# previously hung for the default 120s and blocked UI.
|
|
530
|
+
init = await client.request(
|
|
531
|
+
"initialize",
|
|
532
|
+
{
|
|
533
|
+
"protocolVersion": 1,
|
|
534
|
+
"clientCapabilities": {
|
|
535
|
+
**self.manifest.client_capabilities(),
|
|
536
|
+
"session": {"configOptions": {"boolean": {}}},
|
|
537
|
+
},
|
|
538
|
+
"clientInfo": {
|
|
539
|
+
"name": "agent-link-connector",
|
|
540
|
+
"version": _connector_version(),
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
timeout=45.0,
|
|
544
|
+
)
|
|
545
|
+
except (AcpJsonRpcError, TimeoutError, asyncio.TimeoutError) as exc:
|
|
546
|
+
stderr = client.stderr_excerpt
|
|
547
|
+
await self._shutdown_client_unlocked()
|
|
548
|
+
detail = str(exc)
|
|
549
|
+
if stderr:
|
|
550
|
+
detail = f"{detail}; agent stderr: {stderr[:800]}"
|
|
551
|
+
raise AcpJsonRpcError(
|
|
552
|
+
f"failed to initialize {self.runtime} ACP agent: {detail}"
|
|
553
|
+
) from exc
|
|
554
|
+
self._agent_capabilities = (
|
|
555
|
+
init.get("agentCapabilities")
|
|
556
|
+
if isinstance(init.get("agentCapabilities"), dict)
|
|
557
|
+
else {}
|
|
558
|
+
)
|
|
559
|
+
auth_methods = init.get("authMethods") if isinstance(init.get("authMethods"), list) else []
|
|
560
|
+
self._auth_methods = [m for m in auth_methods if isinstance(m, dict)]
|
|
561
|
+
if not skip_auto_auth:
|
|
562
|
+
await self._maybe_authenticate(client)
|
|
563
|
+
self._client = client
|
|
564
|
+
self._initialized = True
|
|
565
|
+
self._needs_restart = False
|
|
566
|
+
log_stage(
|
|
567
|
+
"adapter.ensure_client",
|
|
568
|
+
elapsed_ms(started),
|
|
569
|
+
runtime=self.runtime,
|
|
570
|
+
command=" ".join(str(part) for part in command[:6]),
|
|
571
|
+
)
|
|
572
|
+
return client
|
|
573
|
+
|
|
574
|
+
async def warm_start(self) -> None:
|
|
575
|
+
"""Best-effort pre-spawn of the ACP process so first session is faster."""
|
|
576
|
+
try:
|
|
577
|
+
await self._ensure_client()
|
|
578
|
+
except Exception as exc:
|
|
579
|
+
logger.info("{} warm_start skipped: {}", self.runtime, exc)
|
|
580
|
+
|
|
581
|
+
async def _shutdown_client(self) -> None:
|
|
582
|
+
async with self._start_lock:
|
|
583
|
+
await self._shutdown_client_unlocked()
|
|
584
|
+
|
|
585
|
+
async def _shutdown_client_unlocked(self) -> None:
|
|
586
|
+
client = self._client
|
|
587
|
+
self._client = None
|
|
588
|
+
self._initialized = False
|
|
589
|
+
if client is not None:
|
|
590
|
+
try:
|
|
591
|
+
await client.close()
|
|
592
|
+
except Exception:
|
|
593
|
+
logger.exception("{} failed to close ACP client", self.runtime)
|
|
594
|
+
|
|
595
|
+
async def _on_client_exit(self) -> None:
|
|
596
|
+
"""Called when the agent process stdout closes unexpectedly."""
|
|
597
|
+
logger.warning("{} ACP process exited", self.runtime)
|
|
598
|
+
self._needs_restart = True
|
|
599
|
+
self._initialized = False
|
|
600
|
+
# Fail any in-flight turns so UI does not stick on running.
|
|
601
|
+
for runtime in list(self._sessions.values()):
|
|
602
|
+
for pending in list(runtime.pending_approvals.values()):
|
|
603
|
+
if not pending.future.done():
|
|
604
|
+
pending.future.set_result("cancelled")
|
|
605
|
+
if runtime.active_turn_id and runtime.reducer is not None:
|
|
606
|
+
turn_id = runtime.active_turn_id
|
|
607
|
+
try:
|
|
608
|
+
end = runtime.reducer.turn_end(
|
|
609
|
+
session_id=runtime.session_id,
|
|
610
|
+
turn_id=turn_id,
|
|
611
|
+
external_session_id=runtime.external_session_id,
|
|
612
|
+
stop_reason="agent_process_exited",
|
|
613
|
+
interrupted=True,
|
|
614
|
+
)
|
|
615
|
+
await self._emit_reduction(end)
|
|
616
|
+
except Exception:
|
|
617
|
+
logger.exception("failed to finalize turn after process exit")
|
|
618
|
+
await self._emit(
|
|
619
|
+
"runtime.error",
|
|
620
|
+
{
|
|
621
|
+
"sessionId": runtime.session_id,
|
|
622
|
+
"runtime": self.runtime,
|
|
623
|
+
"message": "ACP agent process exited",
|
|
624
|
+
},
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
async def _apply_session_settings(
|
|
628
|
+
self,
|
|
629
|
+
client: AcpJsonRpcClient,
|
|
630
|
+
runtime: _SessionRuntime,
|
|
631
|
+
params: dict[str, Any],
|
|
632
|
+
) -> None:
|
|
633
|
+
"""Map AA model/permissionMode/effort onto ACP session/set_config_option when available."""
|
|
634
|
+
if not runtime.external_session_id:
|
|
635
|
+
return
|
|
636
|
+
model = _optional_string(params.get("model"))
|
|
637
|
+
permission_mode = _optional_string(params.get("permissionMode"))
|
|
638
|
+
effort = _optional_string(params.get("effort"))
|
|
639
|
+
if not model and not permission_mode and not effort:
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
options = runtime.config_options
|
|
643
|
+
model_opt = find_config_option(options, category="model", preferred_ids=("model", "llm", "models"))
|
|
644
|
+
mode_opt = find_config_option(
|
|
645
|
+
options, category="mode", preferred_ids=("mode", "permission", "permissionMode")
|
|
646
|
+
)
|
|
647
|
+
effort_opt = find_config_option(
|
|
648
|
+
options,
|
|
649
|
+
category="thought_level",
|
|
650
|
+
preferred_ids=("effort", "reasoning", "thought_level", "thinking"),
|
|
651
|
+
)
|
|
652
|
+
model_id = _optional_string(model_opt.get("id")) if model_opt else None
|
|
653
|
+
mode_id = _optional_string(mode_opt.get("id")) if mode_opt else None
|
|
654
|
+
effort_id = _optional_string(effort_opt.get("id")) if effort_opt else None
|
|
655
|
+
quirk_model = self.manifest.quirks.get("modelConfigId") if isinstance(self.manifest.quirks, dict) else None
|
|
656
|
+
if isinstance(quirk_model, str) and quirk_model:
|
|
657
|
+
model_id = model_id or quirk_model
|
|
658
|
+
|
|
659
|
+
async def _set(config_id: str | None, value: str | None) -> None:
|
|
660
|
+
if not config_id or not value:
|
|
661
|
+
return
|
|
662
|
+
try:
|
|
663
|
+
result = await client.request(
|
|
664
|
+
"session/set_config_option",
|
|
665
|
+
{
|
|
666
|
+
"sessionId": runtime.external_session_id,
|
|
667
|
+
"configId": config_id,
|
|
668
|
+
"value": value,
|
|
669
|
+
},
|
|
670
|
+
timeout=30.0,
|
|
671
|
+
)
|
|
672
|
+
updated = result.get("configOptions") if isinstance(result.get("configOptions"), list) else None
|
|
673
|
+
if updated is not None:
|
|
674
|
+
runtime.config_options = [opt for opt in updated if isinstance(opt, dict)]
|
|
675
|
+
except AcpJsonRpcError as exc:
|
|
676
|
+
# Fallback for Gemini-style unstable model API
|
|
677
|
+
if config_id == model_id:
|
|
678
|
+
try:
|
|
679
|
+
await client.request(
|
|
680
|
+
"session/set_model",
|
|
681
|
+
{"sessionId": runtime.external_session_id, "modelId": value},
|
|
682
|
+
timeout=30.0,
|
|
683
|
+
)
|
|
684
|
+
return
|
|
685
|
+
except AcpJsonRpcError:
|
|
686
|
+
pass
|
|
687
|
+
logger.warning(
|
|
688
|
+
"{} session/set_config_option id={} value={} failed: {}",
|
|
689
|
+
self.runtime,
|
|
690
|
+
config_id,
|
|
691
|
+
value,
|
|
692
|
+
exc,
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
await _set(model_id, model)
|
|
696
|
+
await _set(mode_id, permission_mode)
|
|
697
|
+
await _set(effort_id, effort)
|
|
698
|
+
|
|
699
|
+
# If agent never advertised configOptions, still try common methods once.
|
|
700
|
+
if not options and model:
|
|
701
|
+
for method, body in (
|
|
702
|
+
(
|
|
703
|
+
"session/set_config_option",
|
|
704
|
+
{
|
|
705
|
+
"sessionId": runtime.external_session_id,
|
|
706
|
+
"configId": "model",
|
|
707
|
+
"value": model,
|
|
708
|
+
},
|
|
709
|
+
),
|
|
710
|
+
(
|
|
711
|
+
"session/set_model",
|
|
712
|
+
{"sessionId": runtime.external_session_id, "modelId": model},
|
|
713
|
+
),
|
|
714
|
+
(
|
|
715
|
+
"unstable_setSessionModel",
|
|
716
|
+
{"sessionId": runtime.external_session_id, "modelId": model},
|
|
717
|
+
),
|
|
718
|
+
):
|
|
719
|
+
try:
|
|
720
|
+
await client.request(method, body, timeout=15.0)
|
|
721
|
+
break
|
|
722
|
+
except AcpJsonRpcError:
|
|
723
|
+
continue
|
|
724
|
+
|
|
725
|
+
async def authenticate_interactive(self, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
726
|
+
"""User-triggered ACP login (browser OAuth or headless re-check).
|
|
727
|
+
|
|
728
|
+
Never called from discovery/reconnect. First reuses local CLI credentials
|
|
729
|
+
via session/new; only then calls authenticate for the chosen method.
|
|
730
|
+
"""
|
|
731
|
+
params = params if isinstance(params, dict) else {}
|
|
732
|
+
requested = _optional_string(params.get("methodId") or params.get("method_id"))
|
|
733
|
+
# Skip auto headless auth on ensure — we re-probe and auth intentionally below.
|
|
734
|
+
client = await self._ensure_client(skip_auto_auth=True)
|
|
735
|
+
method_ids = [
|
|
736
|
+
str(item.get("id") or item.get("methodId") or "")
|
|
737
|
+
for item in self._auth_methods
|
|
738
|
+
if (item.get("id") or item.get("methodId"))
|
|
739
|
+
]
|
|
740
|
+
method_ids = [mid for mid in method_ids if mid]
|
|
741
|
+
|
|
742
|
+
# Many agents (Cursor/Gemini) already have disk credentials — session/new
|
|
743
|
+
# succeeds without calling authenticate (which often times out).
|
|
744
|
+
probe = await self._probe_auth_and_options(client)
|
|
745
|
+
if probe.get("authStatus") == "ok":
|
|
746
|
+
probe["methodId"] = requested or "local_session"
|
|
747
|
+
probe["reusedLocalCredentials"] = True
|
|
748
|
+
await self._emit_auth_status(probe)
|
|
749
|
+
return probe
|
|
750
|
+
|
|
751
|
+
if requested:
|
|
752
|
+
if requested not in method_ids:
|
|
753
|
+
raise AcpJsonRpcError(
|
|
754
|
+
f"{self.manifest.display_name} does not advertise auth method {requested!r}. "
|
|
755
|
+
f"Available: {', '.join(method_ids) or '(none)'}"
|
|
756
|
+
)
|
|
757
|
+
ordered = [requested]
|
|
758
|
+
else:
|
|
759
|
+
ordered = order_interactive_auth_method_ids(
|
|
760
|
+
method_ids,
|
|
761
|
+
preferred=list(self.manifest.preferred_auth_method_ids),
|
|
762
|
+
)
|
|
763
|
+
if not ordered:
|
|
764
|
+
ordered = order_headless_auth_method_ids(
|
|
765
|
+
method_ids,
|
|
766
|
+
preferred=list(self.manifest.preferred_auth_method_ids),
|
|
767
|
+
)
|
|
768
|
+
if not ordered:
|
|
769
|
+
await self._emit_auth_status(probe)
|
|
770
|
+
return probe
|
|
771
|
+
|
|
772
|
+
last_error: str | None = None
|
|
773
|
+
used_method: str | None = None
|
|
774
|
+
for mid in ordered:
|
|
775
|
+
# cursor_login / gemini-api-key can hang when re-prompting; use a longer
|
|
776
|
+
# budget only for explicit user-triggered sign-in.
|
|
777
|
+
timeout = (
|
|
778
|
+
_INTERACTIVE_AUTH_TIMEOUT_S
|
|
779
|
+
if is_interactive_auth_method(mid)
|
|
780
|
+
else 45.0
|
|
781
|
+
)
|
|
782
|
+
try:
|
|
783
|
+
logger.info(
|
|
784
|
+
"{} starting user-triggered authenticate method={} interactive={}",
|
|
785
|
+
self.runtime,
|
|
786
|
+
mid,
|
|
787
|
+
is_interactive_auth_method(mid),
|
|
788
|
+
)
|
|
789
|
+
await client.request(
|
|
790
|
+
"authenticate",
|
|
791
|
+
{"methodId": mid},
|
|
792
|
+
timeout=timeout,
|
|
793
|
+
)
|
|
794
|
+
used_method = mid
|
|
795
|
+
break
|
|
796
|
+
except Exception as exc:
|
|
797
|
+
last_error = str(exc)
|
|
798
|
+
logger.warning(
|
|
799
|
+
"{} interactive authenticate method={} failed: {}",
|
|
800
|
+
self.runtime,
|
|
801
|
+
mid,
|
|
802
|
+
exc,
|
|
803
|
+
)
|
|
804
|
+
continue
|
|
805
|
+
|
|
806
|
+
if used_method is None:
|
|
807
|
+
# Last chance: local credentials may still work without authenticate RPC.
|
|
808
|
+
probe = await self._probe_auth_and_options(client)
|
|
809
|
+
if probe.get("authStatus") == "ok":
|
|
810
|
+
probe["methodId"] = "local_session"
|
|
811
|
+
probe["reusedLocalCredentials"] = True
|
|
812
|
+
await self._emit_auth_status(probe)
|
|
813
|
+
return probe
|
|
814
|
+
methods = summarize_auth_methods(self._auth_methods)
|
|
815
|
+
names = ", ".join(m["name"] for m in methods) or "interactive login"
|
|
816
|
+
detail = (
|
|
817
|
+
f"Authentication failed for {self.manifest.display_name}. "
|
|
818
|
+
f"Tried: {', '.join(ordered)}. Methods: {names}."
|
|
819
|
+
)
|
|
820
|
+
if last_error:
|
|
821
|
+
detail = f"{detail} Last error: {last_error}"
|
|
822
|
+
if self.manifest.pre_auth_hint:
|
|
823
|
+
detail = f"{detail} Hint: {self.manifest.pre_auth_hint}"
|
|
824
|
+
raise AcpJsonRpcError(detail)
|
|
825
|
+
|
|
826
|
+
probe = await self._probe_auth_and_options(client)
|
|
827
|
+
probe["methodId"] = used_method
|
|
828
|
+
if probe.get("authStatus") != "ok":
|
|
829
|
+
logger.info(
|
|
830
|
+
"{} auth method={} returned but session still requires auth; restarting agent process",
|
|
831
|
+
self.runtime,
|
|
832
|
+
used_method,
|
|
833
|
+
)
|
|
834
|
+
self._needs_restart = True
|
|
835
|
+
client = await self._ensure_client(skip_auto_auth=True)
|
|
836
|
+
await self._maybe_authenticate(client)
|
|
837
|
+
probe = await self._probe_auth_and_options(client)
|
|
838
|
+
probe["methodId"] = used_method
|
|
839
|
+
probe["restarted"] = True
|
|
840
|
+
|
|
841
|
+
await self._emit_auth_status(probe)
|
|
842
|
+
return probe
|
|
843
|
+
|
|
844
|
+
def _raise_if_auth_blocked(self) -> None:
|
|
845
|
+
if self._auth_status != "required":
|
|
846
|
+
return
|
|
847
|
+
methods = summarize_auth_methods(self._auth_methods)
|
|
848
|
+
names = ", ".join(m["name"] for m in methods) or "interactive login"
|
|
849
|
+
detail = self._auth_hint or (
|
|
850
|
+
f"Authentication required for {self.manifest.display_name}. "
|
|
851
|
+
f"ACP auth methods: {names}. "
|
|
852
|
+
"Use Sign in in AgentLink to complete browser OAuth on this device "
|
|
853
|
+
"(interactive TUI login does not always satisfy headless ACP)."
|
|
854
|
+
)
|
|
855
|
+
raise AcpJsonRpcError(detail)
|
|
856
|
+
|
|
857
|
+
def _set_auth_status(self, status: str, hint: str | None = None) -> None:
|
|
858
|
+
self._auth_status = status
|
|
859
|
+
if status == "required":
|
|
860
|
+
self._auth_hint = hint or self._auth_hint
|
|
861
|
+
elif status == "ok":
|
|
862
|
+
self._auth_hint = None
|
|
863
|
+
|
|
864
|
+
async def _mark_auth_required(self, raw_detail: str) -> str:
|
|
865
|
+
methods = summarize_auth_methods(self._auth_methods)
|
|
866
|
+
names = ", ".join(m["name"] for m in methods) or "interactive login"
|
|
867
|
+
detail = (
|
|
868
|
+
f"Authentication required for {self.manifest.display_name}. "
|
|
869
|
+
f"ACP auth methods: {names}. "
|
|
870
|
+
"Use Sign in in AgentLink to complete browser OAuth on this device "
|
|
871
|
+
"(interactive TUI login does not always satisfy headless ACP)."
|
|
872
|
+
)
|
|
873
|
+
if self.manifest.pre_auth_hint:
|
|
874
|
+
detail = f"{detail} Hint: {self.manifest.pre_auth_hint}"
|
|
875
|
+
elif raw_detail and not _is_authish_error(raw_detail):
|
|
876
|
+
detail = f"{detail} ({raw_detail[:200]})"
|
|
877
|
+
self._set_auth_status("required", detail)
|
|
878
|
+
try:
|
|
879
|
+
await self._emit(
|
|
880
|
+
"runtime.optionsUpdated",
|
|
881
|
+
{
|
|
882
|
+
"runtime": self.runtime,
|
|
883
|
+
"authStatus": "required",
|
|
884
|
+
"authMethods": methods,
|
|
885
|
+
"authHint": detail,
|
|
886
|
+
"sourceObservedAt": utc_now(),
|
|
887
|
+
},
|
|
888
|
+
)
|
|
889
|
+
except Exception:
|
|
890
|
+
pass
|
|
891
|
+
return detail
|
|
892
|
+
|
|
893
|
+
async def _probe_auth_and_options(self, client: AcpJsonRpcClient) -> dict[str, Any]:
|
|
894
|
+
"""session/new probe: authStatus + live model/mode options."""
|
|
895
|
+
methods = summarize_auth_methods(self._auth_methods)
|
|
896
|
+
probe: dict[str, Any] = {
|
|
897
|
+
"runtime": self.runtime,
|
|
898
|
+
"authStatus": "unknown",
|
|
899
|
+
"authMethods": methods,
|
|
900
|
+
"modelOptions": [],
|
|
901
|
+
"modeOptions": [],
|
|
902
|
+
"configOptions": [],
|
|
903
|
+
}
|
|
904
|
+
with tempfile.TemporaryDirectory(prefix="agent-link-acp-auth-") as tmp:
|
|
905
|
+
try:
|
|
906
|
+
session = await client.request(
|
|
907
|
+
"session/new",
|
|
908
|
+
{"cwd": tmp, "mcpServers": []},
|
|
909
|
+
timeout=_SESSION_NEW_TIMEOUT_S,
|
|
910
|
+
)
|
|
911
|
+
except AcpJsonRpcError as exc:
|
|
912
|
+
message = str(exc)
|
|
913
|
+
if _is_authish_error(message):
|
|
914
|
+
names = ", ".join(m["name"] for m in methods) or "interactive login"
|
|
915
|
+
probe["authStatus"] = "required"
|
|
916
|
+
probe["authHint"] = self.manifest.pre_auth_hint or (
|
|
917
|
+
f"{self.manifest.display_name} still requires authentication "
|
|
918
|
+
f"({names}). Complete login on the device running the connector, "
|
|
919
|
+
"then try again."
|
|
920
|
+
)
|
|
921
|
+
self._set_auth_status("required", probe["authHint"])
|
|
922
|
+
return probe
|
|
923
|
+
probe["authStatus"] = "unknown"
|
|
924
|
+
probe["error"] = message
|
|
925
|
+
self._set_auth_status("unknown")
|
|
926
|
+
return probe
|
|
927
|
+
|
|
928
|
+
probe["authStatus"] = "ok"
|
|
929
|
+
self._set_auth_status("ok")
|
|
930
|
+
config_options = session.get("configOptions") if isinstance(session.get("configOptions"), list) else []
|
|
931
|
+
config_options = [opt for opt in config_options if isinstance(opt, dict)]
|
|
932
|
+
if config_options:
|
|
933
|
+
probe["configOptions"] = config_options
|
|
934
|
+
models = extract_model_options(config_options)
|
|
935
|
+
modes = extract_mode_options(config_options)
|
|
936
|
+
if models:
|
|
937
|
+
probe["modelOptions"] = models
|
|
938
|
+
if modes:
|
|
939
|
+
probe["modeOptions"] = modes
|
|
940
|
+
session_id = session.get("sessionId") or session.get("session_id")
|
|
941
|
+
if isinstance(session_id, str) and session_id:
|
|
942
|
+
for method in ("session/close", "session/cancel"):
|
|
943
|
+
try:
|
|
944
|
+
if method == "session/cancel":
|
|
945
|
+
await client.notify(method, {"sessionId": session_id})
|
|
946
|
+
else:
|
|
947
|
+
await client.request(method, {"sessionId": session_id}, timeout=3.0)
|
|
948
|
+
break
|
|
949
|
+
except Exception:
|
|
950
|
+
continue
|
|
951
|
+
return probe
|
|
952
|
+
|
|
953
|
+
async def _emit_auth_status(self, probe: dict[str, Any]) -> None:
|
|
954
|
+
status = str(probe.get("authStatus") or "unknown")
|
|
955
|
+
hint = probe.get("authHint") if isinstance(probe.get("authHint"), str) else None
|
|
956
|
+
self._set_auth_status(status, hint)
|
|
957
|
+
payload: dict[str, Any] = {
|
|
958
|
+
"runtime": self.runtime,
|
|
959
|
+
"authStatus": status,
|
|
960
|
+
"authMethods": probe.get("authMethods") or summarize_auth_methods(self._auth_methods),
|
|
961
|
+
"sourceObservedAt": utc_now(),
|
|
962
|
+
}
|
|
963
|
+
if hint:
|
|
964
|
+
payload["authHint"] = hint
|
|
965
|
+
for key in ("configOptions", "modelOptions", "modeOptions"):
|
|
966
|
+
if probe.get(key) is not None:
|
|
967
|
+
payload[key] = probe[key]
|
|
968
|
+
await self._emit("runtime.optionsUpdated", payload)
|
|
969
|
+
|
|
970
|
+
async def _maybe_authenticate(self, client: AcpJsonRpcClient) -> bool:
|
|
971
|
+
"""Best-effort headless auth only. Never open browser OAuth.
|
|
972
|
+
|
|
973
|
+
Avoids expensive session/new probes on every ensure_client (was adding
|
|
974
|
+
10–20s before the first user turn). Real auth is verified on session/new.
|
|
975
|
+
"""
|
|
976
|
+
if not self._auth_methods:
|
|
977
|
+
return True
|
|
978
|
+
method_ids = [
|
|
979
|
+
str(item.get("id") or item.get("methodId") or "")
|
|
980
|
+
for item in self._auth_methods
|
|
981
|
+
if (item.get("id") or item.get("methodId"))
|
|
982
|
+
]
|
|
983
|
+
method_ids = [mid for mid in method_ids if mid]
|
|
984
|
+
ordered = order_headless_auth_method_ids(
|
|
985
|
+
method_ids,
|
|
986
|
+
preferred=list(self.manifest.preferred_auth_method_ids),
|
|
987
|
+
)
|
|
988
|
+
if not ordered:
|
|
989
|
+
# Interactive-only agents (CodeBuddy): skip; session/new will surface auth.
|
|
990
|
+
return True
|
|
991
|
+
for mid in ordered:
|
|
992
|
+
try:
|
|
993
|
+
await client.request(
|
|
994
|
+
"authenticate",
|
|
995
|
+
{"methodId": mid, "_meta": {"headless": True}},
|
|
996
|
+
timeout=4.0,
|
|
997
|
+
)
|
|
998
|
+
logger.info("{} authenticated via method={}", self.runtime, mid)
|
|
999
|
+
return True
|
|
1000
|
+
except Exception as exc:
|
|
1001
|
+
logger.debug(
|
|
1002
|
+
"{} authenticate method={} failed: {}",
|
|
1003
|
+
self.runtime,
|
|
1004
|
+
mid,
|
|
1005
|
+
exc,
|
|
1006
|
+
)
|
|
1007
|
+
continue
|
|
1008
|
+
# Do not fail ensure_client — create_session will report auth errors.
|
|
1009
|
+
logger.info(
|
|
1010
|
+
"{} headless auth not confirmed; will verify on session/new methods={}",
|
|
1011
|
+
self.runtime,
|
|
1012
|
+
method_ids,
|
|
1013
|
+
)
|
|
1014
|
+
return True
|
|
1015
|
+
|
|
1016
|
+
def _command(self) -> list[str]:
|
|
1017
|
+
if self.launch is not None:
|
|
1018
|
+
return self.launch.command(self.manifest.launch_args())
|
|
1019
|
+
binary = self.manifest.command[0]
|
|
1020
|
+
target = launch_target("cli", binary)
|
|
1021
|
+
if len(self.manifest.command) == 1:
|
|
1022
|
+
return target.command(self.manifest.launch_args())
|
|
1023
|
+
return list(self.manifest.command) + self.manifest.launch_args()
|
|
1024
|
+
|
|
1025
|
+
async def _on_notification(self, payload: dict[str, Any]) -> None:
|
|
1026
|
+
method = payload.get("method")
|
|
1027
|
+
if method != "session/update":
|
|
1028
|
+
return
|
|
1029
|
+
params = payload.get("params") if isinstance(payload.get("params"), dict) else {}
|
|
1030
|
+
external = _optional_string(params.get("sessionId") or params.get("session_id"))
|
|
1031
|
+
update = params.get("update") if isinstance(params.get("update"), dict) else {}
|
|
1032
|
+
runtime = self._session_by_external(external)
|
|
1033
|
+
if runtime is None or runtime.reducer is None or runtime.active_turn_id is None:
|
|
1034
|
+
return
|
|
1035
|
+
reduced = runtime.reducer.reduce_session_update(
|
|
1036
|
+
session_id=runtime.session_id,
|
|
1037
|
+
turn_id=runtime.active_turn_id,
|
|
1038
|
+
external_session_id=runtime.external_session_id,
|
|
1039
|
+
update=update,
|
|
1040
|
+
)
|
|
1041
|
+
await self._emit_reduction(reduced)
|
|
1042
|
+
|
|
1043
|
+
async def _on_server_request(
|
|
1044
|
+
self,
|
|
1045
|
+
request_id: str | int,
|
|
1046
|
+
method: str,
|
|
1047
|
+
params: dict[str, Any],
|
|
1048
|
+
) -> dict[str, Any] | None:
|
|
1049
|
+
if method == "session/request_permission":
|
|
1050
|
+
return await self._handle_permission(request_id, params)
|
|
1051
|
+
if method.startswith("cursor/"):
|
|
1052
|
+
return self._handle_cursor_extension(method, params)
|
|
1053
|
+
if method.startswith("fs/"):
|
|
1054
|
+
if method == "fs/read_text_file":
|
|
1055
|
+
raise AcpJsonRpcError("fs/read_text_file not implemented in connector v1")
|
|
1056
|
+
raise AcpJsonRpcError(f"Unsupported fs method: {method}")
|
|
1057
|
+
logger.warning("ACP unsupported server request method={}", method)
|
|
1058
|
+
return {}
|
|
1059
|
+
|
|
1060
|
+
async def _handle_permission(
|
|
1061
|
+
self,
|
|
1062
|
+
request_id: str | int,
|
|
1063
|
+
params: dict[str, Any],
|
|
1064
|
+
) -> dict[str, Any]:
|
|
1065
|
+
external = _optional_string(params.get("sessionId") or params.get("session_id"))
|
|
1066
|
+
runtime = self._session_by_external(external)
|
|
1067
|
+
if runtime is None or runtime.reducer is None:
|
|
1068
|
+
return {"outcome": {"outcome": "cancelled"}}
|
|
1069
|
+
turn_id = runtime.active_turn_id or f"turn_{self.runtime}_perm"
|
|
1070
|
+
reduced = runtime.reducer.reduce_permission_request(
|
|
1071
|
+
session_id=runtime.session_id,
|
|
1072
|
+
turn_id=turn_id,
|
|
1073
|
+
external_session_id=runtime.external_session_id,
|
|
1074
|
+
request_id=request_id,
|
|
1075
|
+
params=params,
|
|
1076
|
+
)
|
|
1077
|
+
await self._emit_reduction(reduced)
|
|
1078
|
+
approval = reduced.approval or {}
|
|
1079
|
+
approval_id = str(approval.get("id") or request_id)
|
|
1080
|
+
loop = asyncio.get_running_loop()
|
|
1081
|
+
future: asyncio.Future[str] = loop.create_future()
|
|
1082
|
+
options = params.get("options") if isinstance(params.get("options"), list) else []
|
|
1083
|
+
runtime.pending_approvals[approval_id] = _PendingApproval(
|
|
1084
|
+
request_id=request_id,
|
|
1085
|
+
future=future,
|
|
1086
|
+
options=[opt for opt in options if isinstance(opt, dict)],
|
|
1087
|
+
)
|
|
1088
|
+
try:
|
|
1089
|
+
status = await asyncio.wait_for(future, timeout=60 * 60)
|
|
1090
|
+
except TimeoutError:
|
|
1091
|
+
status = "cancelled"
|
|
1092
|
+
finally:
|
|
1093
|
+
runtime.pending_approvals.pop(approval_id, None)
|
|
1094
|
+
if status == "cancelled":
|
|
1095
|
+
return {"outcome": {"outcome": "cancelled"}}
|
|
1096
|
+
option_id = map_approval_status_to_option(status, options if isinstance(options, list) else [])
|
|
1097
|
+
if option_id is None:
|
|
1098
|
+
if status in {"approved", "approved_for_session"} and options:
|
|
1099
|
+
first = options[0] if isinstance(options[0], dict) else {}
|
|
1100
|
+
option_id = str(first.get("optionId") or first.get("option_id") or "allow-once")
|
|
1101
|
+
else:
|
|
1102
|
+
return {"outcome": {"outcome": "cancelled"}}
|
|
1103
|
+
return {"outcome": {"outcome": "selected", "optionId": option_id}}
|
|
1104
|
+
|
|
1105
|
+
def _handle_cursor_extension(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
1106
|
+
del params
|
|
1107
|
+
quirks = self.manifest.quirks.get("extensionMethods") if isinstance(self.manifest.quirks, dict) else {}
|
|
1108
|
+
policy = (quirks or {}).get(method, "ignore")
|
|
1109
|
+
if method == "cursor/ask_question":
|
|
1110
|
+
return {"outcome": {"outcome": "skipped", "reason": "not supported by AgentLink v1"}}
|
|
1111
|
+
if method == "cursor/create_plan":
|
|
1112
|
+
if policy in {"accept", "ignore"}:
|
|
1113
|
+
return {"outcome": {"outcome": "accepted"}}
|
|
1114
|
+
return {"outcome": {"outcome": "rejected", "reason": "not supported"}}
|
|
1115
|
+
return {"outcome": {"outcome": "accepted"}}
|
|
1116
|
+
|
|
1117
|
+
def _session_by_external(self, external: str | None) -> _SessionRuntime | None:
|
|
1118
|
+
if not external:
|
|
1119
|
+
return None
|
|
1120
|
+
for runtime in self._sessions.values():
|
|
1121
|
+
if runtime.external_session_id == external:
|
|
1122
|
+
return runtime
|
|
1123
|
+
return None
|
|
1124
|
+
|
|
1125
|
+
async def _emit_reduction(self, reduced: Any) -> None:
|
|
1126
|
+
if reduced.session_update:
|
|
1127
|
+
await self._emit("session.updated", reduced.session_update)
|
|
1128
|
+
for item in reduced.timeline_items:
|
|
1129
|
+
item_type = item.get("type") if isinstance(item, dict) else None
|
|
1130
|
+
timer = None
|
|
1131
|
+
session_id = item.get("sessionId") if isinstance(item, dict) else None
|
|
1132
|
+
if isinstance(session_id, str):
|
|
1133
|
+
session_runtime = self._sessions.get(session_id)
|
|
1134
|
+
timer = session_runtime.turn_timer if session_runtime is not None else None
|
|
1135
|
+
if timer is not None and item_type not in {"turn.start", "turn.end"}:
|
|
1136
|
+
if (
|
|
1137
|
+
item_type == "message"
|
|
1138
|
+
and isinstance(item, dict)
|
|
1139
|
+
and item.get("role") == "assistant"
|
|
1140
|
+
and isinstance(item.get("content"), dict)
|
|
1141
|
+
and (
|
|
1142
|
+
(isinstance(item["content"].get("text"), str) and item["content"]["text"].strip())
|
|
1143
|
+
or (isinstance(item["content"].get("rawText"), str) and item["content"]["rawText"].strip())
|
|
1144
|
+
)
|
|
1145
|
+
):
|
|
1146
|
+
timer.mark_first_timeline(
|
|
1147
|
+
runtime=self.runtime,
|
|
1148
|
+
session_id=session_id,
|
|
1149
|
+
turn_id=item.get("turnId") if isinstance(item, dict) else None,
|
|
1150
|
+
stage_alias="adapter.first_assistant_token",
|
|
1151
|
+
)
|
|
1152
|
+
await self._emit("timeline.itemUpsert", {"sessionId": item["sessionId"], "item": item})
|
|
1153
|
+
if reduced.approval:
|
|
1154
|
+
await self._emit("approval.requested", reduced.approval)
|
|
1155
|
+
|
|
1156
|
+
async def _emit(self, method: str, params: dict[str, Any]) -> None:
|
|
1157
|
+
if self.notification_sink is None:
|
|
1158
|
+
return
|
|
1159
|
+
await self.notification_sink(method, params)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _required(params: dict[str, Any], key: str) -> str:
|
|
1163
|
+
value = params.get(key)
|
|
1164
|
+
if not isinstance(value, str) or not value:
|
|
1165
|
+
raise ValueError(f"missing {key}")
|
|
1166
|
+
return value
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _optional_string(value: Any) -> str | None:
|
|
1170
|
+
return value if isinstance(value, str) and value else None
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _require_cwd(params: dict[str, Any]) -> str:
|
|
1174
|
+
cwd = _optional_string(params.get("cwd"))
|
|
1175
|
+
if not cwd:
|
|
1176
|
+
raise ValueError("missing cwd: ACP sessions require an absolute workspace path")
|
|
1177
|
+
return cwd
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _is_authish_error(message: str) -> bool:
|
|
1181
|
+
lowered = message.lower()
|
|
1182
|
+
return any(token in lowered for token in _AUTH_ERROR_TOKENS)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _attachments_metadata(params: dict[str, Any]) -> list[dict[str, Any]] | None:
|
|
1186
|
+
raw = params.get("timelineAttachments") or params.get("attachments")
|
|
1187
|
+
if not isinstance(raw, list):
|
|
1188
|
+
return None
|
|
1189
|
+
out: list[dict[str, Any]] = []
|
|
1190
|
+
for entry in raw:
|
|
1191
|
+
if not isinstance(entry, dict):
|
|
1192
|
+
continue
|
|
1193
|
+
file_id = entry.get("fileId") or entry.get("id")
|
|
1194
|
+
if not isinstance(file_id, str):
|
|
1195
|
+
continue
|
|
1196
|
+
item: dict[str, Any] = {"fileId": file_id}
|
|
1197
|
+
for key in ("name", "mediaType", "size", "sha256"):
|
|
1198
|
+
if entry.get(key) is not None:
|
|
1199
|
+
item[key] = entry[key]
|
|
1200
|
+
out.append(item)
|
|
1201
|
+
return out or None
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _max_turn_seconds(manifest: AgentManifest) -> float | None:
|
|
1205
|
+
quirks = manifest.quirks if isinstance(manifest.quirks, dict) else {}
|
|
1206
|
+
raw = quirks.get("maxTurnSeconds", _DEFAULT_MAX_TURN_SECONDS)
|
|
1207
|
+
if raw is None:
|
|
1208
|
+
return None
|
|
1209
|
+
try:
|
|
1210
|
+
value = float(raw)
|
|
1211
|
+
except (TypeError, ValueError):
|
|
1212
|
+
return float(_DEFAULT_MAX_TURN_SECONDS)
|
|
1213
|
+
if value <= 0:
|
|
1214
|
+
return None
|
|
1215
|
+
return value
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def _connector_version() -> str:
|
|
1219
|
+
return connector_version()
|
|
1220
|
+
|
|
1221
|
+
|