agent-crossbar 0.2.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.
- agent_crossbar/__init__.py +1 -0
- agent_crossbar/acp_client.py +422 -0
- agent_crossbar/acp_lifecycle.py +240 -0
- agent_crossbar/acp_runtime.py +389 -0
- agent_crossbar/adapters/__init__.py +5 -0
- agent_crossbar/adapters/base.py +89 -0
- agent_crossbar/adapters/chatgpt_pro.py +20 -0
- agent_crossbar/adapters/claude.py +510 -0
- agent_crossbar/adapters/claude_model_probe.py +781 -0
- agent_crossbar/adapters/codex.py +145 -0
- agent_crossbar/adapters/opencode.py +253 -0
- agent_crossbar/adapters/reasonix.py +20 -0
- agent_crossbar/adapters/registry.py +21 -0
- agent_crossbar/agent_runner.py +435 -0
- agent_crossbar/cli.py +109 -0
- agent_crossbar/context.py +33 -0
- agent_crossbar/discovery.py +416 -0
- agent_crossbar/discovery_runner.py +324 -0
- agent_crossbar/env_compat.py +55 -0
- agent_crossbar/envelope.py +293 -0
- agent_crossbar/jobs.py +837 -0
- agent_crossbar/model_cache.py +204 -0
- agent_crossbar/models.py +67 -0
- agent_crossbar/profiles/__init__.py +88 -0
- agent_crossbar/profiles/chatgpt_pro.py +33 -0
- agent_crossbar/profiles/claude.py +41 -0
- agent_crossbar/profiles/codex.py +39 -0
- agent_crossbar/profiles/opencode.py +49 -0
- agent_crossbar/profiles/reasonix.py +35 -0
- agent_crossbar/providers.py +461 -0
- agent_crossbar/readiness.py +785 -0
- agent_crossbar/redaction.py +66 -0
- agent_crossbar/runner.py +2974 -0
- agent_crossbar/server.py +1156 -0
- agent_crossbar/shell_server.py +68 -0
- agent_crossbar/telemetry.py +266 -0
- agent_crossbar/tmux_output.py +241 -0
- agent_crossbar/validation.py +276 -0
- agent_crossbar-0.2.0.dist-info/METADATA +12 -0
- agent_crossbar-0.2.0.dist-info/RECORD +44 -0
- agent_crossbar-0.2.0.dist-info/WHEEL +4 -0
- agent_crossbar-0.2.0.dist-info/entry_points.txt +3 -0
- agent_crossbar-0.2.0.dist-info/licenses/LICENSE +21 -0
- agent_harness_mcp/__init__.py +87 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""ACP Client — async one-shot agent prompt via Agent Client Protocol SDK.
|
|
2
|
+
|
|
3
|
+
Launches a provider command through ``acp.spawn_agent_process``, initializes
|
|
4
|
+
protocol v1, creates a session for *cwd*, optionally sets a model via
|
|
5
|
+
``config_option``, sends one text prompt, accumulates assistant text from
|
|
6
|
+
``session/update`` notifications, and returns a typed :class:`AcpResult`.
|
|
7
|
+
|
|
8
|
+
Permission policy:
|
|
9
|
+
|
|
10
|
+
* ``read_only`` tools → **denied** (selects ``reject_once``).
|
|
11
|
+
* ``edit_local`` tools → **allowed** with ``allow_once`` only; ``allow_always``
|
|
12
|
+
is treated as escalating and skipped.
|
|
13
|
+
|
|
14
|
+
Timeouts and cancellation are supported via ``asyncio.wait_for`` with clean
|
|
15
|
+
child-process termination through the context-manager.
|
|
16
|
+
|
|
17
|
+
The module is a focused abstraction layer; it does NOT integrate with
|
|
18
|
+
``server.py``, ``jobs.py``, or the job store.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from acp import PROTOCOL_VERSION, spawn_agent_process, text_block
|
|
29
|
+
from acp.schema import (
|
|
30
|
+
AgentMessageChunk,
|
|
31
|
+
AllowedOutcome,
|
|
32
|
+
ClientCapabilities,
|
|
33
|
+
DeniedOutcome,
|
|
34
|
+
PermissionOption,
|
|
35
|
+
RequestPermissionResponse,
|
|
36
|
+
SessionConfigOptionSelect,
|
|
37
|
+
SessionConfigSelectGroup,
|
|
38
|
+
SessionConfigSelectOption,
|
|
39
|
+
ToolCallUpdate,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
from .models import Autonomy
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
# ── Public types ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class AcpResult:
|
|
51
|
+
"""Immutable result of a one-shot ACP prompt."""
|
|
52
|
+
|
|
53
|
+
output: str
|
|
54
|
+
stop_reason: str
|
|
55
|
+
session_id: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AcpError(Exception):
|
|
59
|
+
"""Base exception for all ACP client errors."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AcpTimeoutError(AcpError):
|
|
63
|
+
"""The ACP prompt exceeded the configured timeout.
|
|
64
|
+
|
|
65
|
+
``stage`` distinguishes a timeout that struck before the prompt was
|
|
66
|
+
ever dispatched to the agent (``"prompt_delivery"``) from one that
|
|
67
|
+
struck while awaiting the agent's response to an already-dispatched
|
|
68
|
+
prompt (``"execution"``, the default) — see ``run_acp_prompt``.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, message: str, *, stage: str = "execution") -> None:
|
|
72
|
+
super().__init__(message)
|
|
73
|
+
self.stage = stage
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AcpProtocolError(AcpError):
|
|
77
|
+
"""The ACP protocol sequence failed — e.g. session not created.
|
|
78
|
+
|
|
79
|
+
``stage`` distinguishes a failure that struck before the prompt was
|
|
80
|
+
ever dispatched to the agent (handshake, session creation, model
|
|
81
|
+
config — ``"prompt_delivery"``) from one that struck while the agent
|
|
82
|
+
was already processing an already-dispatched prompt (``"execution"``,
|
|
83
|
+
the default) — mirrors :class:`AcpTimeoutError`.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, message: str, *, stage: str = "execution") -> None:
|
|
87
|
+
super().__init__(message)
|
|
88
|
+
self.stage = stage
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AcpLaunchError(AcpError):
|
|
92
|
+
"""The ACP provider process could not be launched."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── Internal :class:`Client` implementation ─────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _OneShotClient:
|
|
99
|
+
"""Implements the ``acp.Client`` protocol for a single prompt.
|
|
100
|
+
|
|
101
|
+
Accumulates ``AgentMessageChunk`` text into a list and selects
|
|
102
|
+
permission options according to the configured autonomy level.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, autonomy: Autonomy) -> None:
|
|
106
|
+
self._autonomy = autonomy
|
|
107
|
+
self._session_id: str | None = None
|
|
108
|
+
self._output_parts: list[str] = []
|
|
109
|
+
self._stop_reason = "unknown"
|
|
110
|
+
self.prompt_sent = False
|
|
111
|
+
|
|
112
|
+
# -- Client protocol --------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def on_connect(self, conn: Any) -> None:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
async def request_permission(
|
|
118
|
+
self,
|
|
119
|
+
session_id: str,
|
|
120
|
+
tool_call: ToolCallUpdate,
|
|
121
|
+
options: list[PermissionOption],
|
|
122
|
+
**kwargs: Any,
|
|
123
|
+
) -> RequestPermissionResponse:
|
|
124
|
+
if self._autonomy is Autonomy.EDIT_LOCAL and getattr(tool_call, "kind", None) == "edit":
|
|
125
|
+
return _select_allow_once(options)
|
|
126
|
+
return _select_reject_once(options)
|
|
127
|
+
|
|
128
|
+
async def session_update(
|
|
129
|
+
self,
|
|
130
|
+
session_id: str,
|
|
131
|
+
update: Any,
|
|
132
|
+
**kwargs: Any,
|
|
133
|
+
) -> None:
|
|
134
|
+
if isinstance(update, AgentMessageChunk):
|
|
135
|
+
content = getattr(update, "content", None)
|
|
136
|
+
if content is not None and getattr(content, "type", None) == "text":
|
|
137
|
+
text = getattr(content, "text", "")
|
|
138
|
+
if text:
|
|
139
|
+
self._output_parts.append(str(text))
|
|
140
|
+
|
|
141
|
+
async def write_text_file(self, session_id: str, path: str, content: str, **kwargs: Any) -> Any:
|
|
142
|
+
return None # Not supported in one-shot mode
|
|
143
|
+
|
|
144
|
+
async def read_text_file(
|
|
145
|
+
self,
|
|
146
|
+
session_id: str,
|
|
147
|
+
path: str,
|
|
148
|
+
line: int | None = None,
|
|
149
|
+
limit: int | None = None,
|
|
150
|
+
**kwargs: Any,
|
|
151
|
+
) -> Any:
|
|
152
|
+
# Return an empty read — one-shot client doesn't serve files
|
|
153
|
+
from acp.schema import ReadTextFileResponse
|
|
154
|
+
|
|
155
|
+
return ReadTextFileResponse(content="")
|
|
156
|
+
|
|
157
|
+
async def create_terminal(
|
|
158
|
+
self,
|
|
159
|
+
session_id: str,
|
|
160
|
+
command: str,
|
|
161
|
+
args: list[str] | None = None,
|
|
162
|
+
env: list[Any] | None = None,
|
|
163
|
+
cwd: str | None = None,
|
|
164
|
+
output_byte_limit: int | None = None,
|
|
165
|
+
**kwargs: Any,
|
|
166
|
+
) -> Any:
|
|
167
|
+
raise AcpProtocolError("Terminal creation is not supported in one-shot mode")
|
|
168
|
+
|
|
169
|
+
async def terminal_output(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:
|
|
170
|
+
raise AcpProtocolError("Terminal output is not supported in one-shot mode")
|
|
171
|
+
|
|
172
|
+
async def release_terminal(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
async def wait_for_terminal_exit(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:
|
|
176
|
+
raise AcpProtocolError("Terminal wait is not supported in one-shot mode")
|
|
177
|
+
|
|
178
|
+
async def kill_terminal(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
async def create_elicitation(self, message: str, mode: Any, **kwargs: Any) -> Any:
|
|
182
|
+
raise AcpProtocolError("Elicitation is not supported in one-shot mode")
|
|
183
|
+
|
|
184
|
+
async def complete_elicitation(self, elicitation_id: str, **kwargs: Any) -> None:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
188
|
+
raise AcpProtocolError(f"Extension method {method!r} is not supported in one-shot mode")
|
|
189
|
+
|
|
190
|
+
async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── Permission helpers ──────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _select_reject_once(
|
|
198
|
+
options: list[PermissionOption],
|
|
199
|
+
) -> RequestPermissionResponse:
|
|
200
|
+
"""Select reject_once when offered, otherwise cancel."""
|
|
201
|
+
for opt in options:
|
|
202
|
+
if getattr(opt, "kind", None) == "reject_once":
|
|
203
|
+
return RequestPermissionResponse(
|
|
204
|
+
outcome=AllowedOutcome(option_id=opt.option_id, outcome="selected")
|
|
205
|
+
)
|
|
206
|
+
return RequestPermissionResponse(outcome=DeniedOutcome(outcome="cancelled"))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _select_allow_once(
|
|
210
|
+
options: list[PermissionOption],
|
|
211
|
+
) -> RequestPermissionResponse:
|
|
212
|
+
"""Select the ``allow_once`` (non-escalating) option.
|
|
213
|
+
|
|
214
|
+
Deliberately skips ``allow_always`` — that is an escalation.
|
|
215
|
+
Falls back to ``reject_once`` if no ``allow_once`` is present.
|
|
216
|
+
"""
|
|
217
|
+
for opt in options:
|
|
218
|
+
if getattr(opt, "kind", None) == "allow_once":
|
|
219
|
+
return RequestPermissionResponse(
|
|
220
|
+
outcome=AllowedOutcome(option_id=opt.option_id, outcome="selected")
|
|
221
|
+
)
|
|
222
|
+
return _select_reject_once(options)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ── Model config helpers ───────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _find_model_config_option(
|
|
229
|
+
config_options: list[Any] | None,
|
|
230
|
+
) -> SessionConfigOptionSelect | None:
|
|
231
|
+
"""Find the session config option for model selection.
|
|
232
|
+
|
|
233
|
+
Prefers `category=='model'` over `id=='model'` as a fallback.
|
|
234
|
+
Returns ``None`` when no matching select option is found.
|
|
235
|
+
"""
|
|
236
|
+
if not config_options:
|
|
237
|
+
return None
|
|
238
|
+
# Prefer category == "model"
|
|
239
|
+
for opt in config_options:
|
|
240
|
+
if isinstance(opt, SessionConfigOptionSelect) and getattr(opt, "category", None) == "model":
|
|
241
|
+
return opt
|
|
242
|
+
# Fallback: id == "model"
|
|
243
|
+
for opt in config_options:
|
|
244
|
+
if isinstance(opt, SessionConfigOptionSelect) and getattr(opt, "id", None) == "model":
|
|
245
|
+
return opt
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _model_value_available(option: SessionConfigOptionSelect, value: str) -> bool:
|
|
250
|
+
"""Check if *value* exists among *option*'s flat options or grouped options."""
|
|
251
|
+
for entry in option.options:
|
|
252
|
+
if isinstance(entry, SessionConfigSelectOption) and entry.value == value:
|
|
253
|
+
return True
|
|
254
|
+
if isinstance(entry, SessionConfigSelectGroup):
|
|
255
|
+
for sub in entry.options:
|
|
256
|
+
if sub.value == value:
|
|
257
|
+
return True
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── Public API ──────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def run_acp_prompt(
|
|
265
|
+
provider_command: list[str],
|
|
266
|
+
prompt_text: str,
|
|
267
|
+
cwd: str,
|
|
268
|
+
*,
|
|
269
|
+
timeout: float | None = None,
|
|
270
|
+
autonomy: str | Autonomy = Autonomy.READ_ONLY,
|
|
271
|
+
model: str | None = None,
|
|
272
|
+
) -> AcpResult:
|
|
273
|
+
"""Launch a provider, optionally set model, run one ACP prompt, and return the result.
|
|
274
|
+
|
|
275
|
+
Sequence: ``initialize`` → ``session/new`` → (optional ``set_config_option``
|
|
276
|
+
for model) → ``session/prompt``.
|
|
277
|
+
|
|
278
|
+
During ``session/prompt`` the agent may send ``session/update``
|
|
279
|
+
notifications carrying ``AgentMessageChunk`` — those are accumulated
|
|
280
|
+
into :attr:`AcpResult.output`. Permission requests are answered
|
|
281
|
+
automatically according to the configured autonomy level.
|
|
282
|
+
|
|
283
|
+
The prompt text is NEVER included in any exception message or log
|
|
284
|
+
record — only a byte-length hint is emitted.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
provider_command: ``argv`` list for the ACP agent process.
|
|
288
|
+
The first element is the executable; the rest are args.
|
|
289
|
+
prompt_text: Prompt content delivered via
|
|
290
|
+
``[text_block(prompt_text)]``.
|
|
291
|
+
cwd: Working directory passed to ``session/new``.
|
|
292
|
+
timeout: Optional seconds for the entire operation (including
|
|
293
|
+
launch). Exceeding this raises :class:`AcpTimeoutError`.
|
|
294
|
+
autonomy: Permission policy for ACP tool calls.
|
|
295
|
+
model: Optional model identifier. When provided, looks for a
|
|
296
|
+
``SessionConfigOptionSelect`` with ``category=="model"`` (or
|
|
297
|
+
``id=="model"`` as fallback) in the ``NewSessionResponse``
|
|
298
|
+
config options, verifies the model value is available, and
|
|
299
|
+
calls ``set_config_option`` before the prompt. When ``None``,
|
|
300
|
+
no config option is set.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
``AcpResult`` with ``output``, ``stop_reason``, and ``session_id``.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
AcpTimeoutError: The operation exceeded *timeout*.
|
|
307
|
+
AcpProtocolError: The protocol handshake failed, the requested
|
|
308
|
+
model is unavailable, or ``set_config_option`` failed.
|
|
309
|
+
AcpLaunchError: The provider process could not be started.
|
|
310
|
+
"""
|
|
311
|
+
try:
|
|
312
|
+
normalized_autonomy = Autonomy(autonomy)
|
|
313
|
+
except ValueError:
|
|
314
|
+
raise AcpProtocolError(f"Invalid autonomy: {autonomy}", stage="prompt_delivery") from None
|
|
315
|
+
|
|
316
|
+
client_impl = _OneShotClient(normalized_autonomy)
|
|
317
|
+
|
|
318
|
+
async def _run() -> AcpResult:
|
|
319
|
+
try:
|
|
320
|
+
async with spawn_agent_process(
|
|
321
|
+
client_impl,
|
|
322
|
+
provider_command[0],
|
|
323
|
+
*provider_command[1:],
|
|
324
|
+
cwd=cwd,
|
|
325
|
+
) as (conn, _process):
|
|
326
|
+
# 1. initialize
|
|
327
|
+
init_response = await conn.initialize(
|
|
328
|
+
protocol_version=PROTOCOL_VERSION,
|
|
329
|
+
client_capabilities=ClientCapabilities(),
|
|
330
|
+
)
|
|
331
|
+
logger.debug(
|
|
332
|
+
"ACP initialized: protocol_version=%s",
|
|
333
|
+
getattr(init_response, "protocol_version", None),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# 2. session/new
|
|
337
|
+
session_response = await conn.new_session(cwd=cwd)
|
|
338
|
+
session_id: str = session_response.session_id
|
|
339
|
+
client_impl._session_id = session_id
|
|
340
|
+
logger.debug("ACP session created: id=%s", session_id)
|
|
341
|
+
|
|
342
|
+
# 2b. optional model config
|
|
343
|
+
if model is not None:
|
|
344
|
+
config_options: list[Any] | None = getattr(
|
|
345
|
+
session_response, "config_options", None
|
|
346
|
+
)
|
|
347
|
+
model_option = _find_model_config_option(config_options)
|
|
348
|
+
if model_option is None:
|
|
349
|
+
raise AcpProtocolError(
|
|
350
|
+
"No model config option available from agent",
|
|
351
|
+
stage="prompt_delivery",
|
|
352
|
+
)
|
|
353
|
+
if not _model_value_available(model_option, model):
|
|
354
|
+
raise AcpProtocolError(
|
|
355
|
+
f"Requested model {model!r} not available from agent",
|
|
356
|
+
stage="prompt_delivery",
|
|
357
|
+
)
|
|
358
|
+
try:
|
|
359
|
+
set_response = await conn.set_config_option(
|
|
360
|
+
config_id=model_option.id,
|
|
361
|
+
session_id=session_id,
|
|
362
|
+
value=model,
|
|
363
|
+
)
|
|
364
|
+
except Exception as exc:
|
|
365
|
+
raise AcpProtocolError(
|
|
366
|
+
"Failed to set model config option", stage="prompt_delivery"
|
|
367
|
+
) from exc
|
|
368
|
+
|
|
369
|
+
# Validate that the agent accepted the model value
|
|
370
|
+
response_options: list[Any] | None = getattr(
|
|
371
|
+
set_response, "config_options", None
|
|
372
|
+
)
|
|
373
|
+
response_model_option = _find_model_config_option(response_options)
|
|
374
|
+
if (
|
|
375
|
+
response_model_option is None
|
|
376
|
+
or response_model_option.current_value != model
|
|
377
|
+
):
|
|
378
|
+
raise AcpProtocolError(
|
|
379
|
+
f"Agent rejected model {model!r}: the config option was not applied",
|
|
380
|
+
stage="prompt_delivery",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# 3. session/prompt
|
|
384
|
+
client_impl.prompt_sent = True
|
|
385
|
+
prompt_response = await conn.prompt(
|
|
386
|
+
session_id=session_id,
|
|
387
|
+
prompt=[text_block(prompt_text)],
|
|
388
|
+
)
|
|
389
|
+
stop_reason = getattr(prompt_response, "stop_reason", None) or "unknown"
|
|
390
|
+
client_impl._stop_reason = stop_reason
|
|
391
|
+
logger.debug(
|
|
392
|
+
"ACP prompt finished: stop_reason=%s prompt_bytes=%d",
|
|
393
|
+
stop_reason,
|
|
394
|
+
len(prompt_text.encode("utf-8")),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
output = (
|
|
398
|
+
"".join(client_impl._output_parts)
|
|
399
|
+
if client_impl._output_parts
|
|
400
|
+
else "(no output)"
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return AcpResult(
|
|
404
|
+
output=output,
|
|
405
|
+
stop_reason=stop_reason,
|
|
406
|
+
session_id=session_id,
|
|
407
|
+
)
|
|
408
|
+
except FileNotFoundError as exc:
|
|
409
|
+
raise AcpLaunchError(f"Provider binary not found: {provider_command[0]}") from exc
|
|
410
|
+
except AcpError:
|
|
411
|
+
raise
|
|
412
|
+
except Exception as exc:
|
|
413
|
+
stage = "execution" if client_impl.prompt_sent else "prompt_delivery"
|
|
414
|
+
raise AcpProtocolError(f"ACP protocol sequence failed: {exc}", stage=stage) from exc
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
if timeout is not None:
|
|
418
|
+
return await asyncio.wait_for(_run(), timeout=timeout)
|
|
419
|
+
return await _run()
|
|
420
|
+
except asyncio.TimeoutError:
|
|
421
|
+
stage = "execution" if client_impl.prompt_sent else "prompt_delivery"
|
|
422
|
+
raise AcpTimeoutError(f"ACP prompt timed out after {timeout:.1f}s", stage=stage) from None
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""ACP server readiness checks for Codex and OpenCode providers.
|
|
2
|
+
|
|
3
|
+
These are CLIENT-SIDE preflight checks — they probe the local binaries
|
|
4
|
+
before attempting to spawn an ACP agent process. Returns structured
|
|
5
|
+
results with actionable remediation so callers can surface clear errors.
|
|
6
|
+
|
|
7
|
+
- Codex: no native ``codex acp`` subcommand. Uses the standalone
|
|
8
|
+
``@agentclientprotocol/codex-acp`` package (>= 1.1.7), accessed via
|
|
9
|
+
``pnpm dlx @agentclientprotocol/codex-acp@1.1.7 --version``.
|
|
10
|
+
- OpenCode: uses the native ``opencode acp`` subcommand directly.
|
|
11
|
+
Readiness probes ``opencode --version`` followed by a nonblocking
|
|
12
|
+
``opencode acp --help`` to verify the subcommand is available.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Protocol
|
|
19
|
+
|
|
20
|
+
_CODEX_ACP_MIN_VERSION = (1, 1, 7)
|
|
21
|
+
_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SubprocessRunner(Protocol):
|
|
25
|
+
def run(self, args, *, timeout=None, cwd=None, env=None) -> Any: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_version(output: str):
|
|
29
|
+
match = _VERSION_RE.search(output)
|
|
30
|
+
if not match:
|
|
31
|
+
return None
|
|
32
|
+
return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_codex_acp_readiness(runner) -> dict:
|
|
36
|
+
# Step 1: pnpm --version
|
|
37
|
+
try:
|
|
38
|
+
pnpm_result = runner.run(["pnpm", "--version"], timeout=10)
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
return {
|
|
41
|
+
"ready": False,
|
|
42
|
+
"state": "missing_binary",
|
|
43
|
+
"error_code": "pnpm_missing",
|
|
44
|
+
"remediation": (
|
|
45
|
+
"pnpm is not installed or not on PATH. "
|
|
46
|
+
"Install pnpm (https://pnpm.io/installation). "
|
|
47
|
+
"The harness uses `pnpm dlx @agentclientprotocol/codex-acp` "
|
|
48
|
+
"(>= 1.1.7)."
|
|
49
|
+
),
|
|
50
|
+
"version": None,
|
|
51
|
+
}
|
|
52
|
+
except Exception as exc:
|
|
53
|
+
return {
|
|
54
|
+
"ready": False,
|
|
55
|
+
"state": "probe_failed",
|
|
56
|
+
"error_code": "pnpm_probe_failed",
|
|
57
|
+
"remediation": f"Failed to probe pnpm: {exc}",
|
|
58
|
+
"version": None,
|
|
59
|
+
}
|
|
60
|
+
if pnpm_result.returncode != 0:
|
|
61
|
+
return {
|
|
62
|
+
"ready": False,
|
|
63
|
+
"state": "missing_binary",
|
|
64
|
+
"error_code": "pnpm_broken",
|
|
65
|
+
"remediation": "pnpm is present but broken. Reinstall pnpm.",
|
|
66
|
+
"version": None,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Step 2: codex --version
|
|
70
|
+
try:
|
|
71
|
+
codex_result = runner.run(["codex", "--version"], timeout=10)
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
return {
|
|
74
|
+
"ready": False,
|
|
75
|
+
"state": "missing_binary",
|
|
76
|
+
"error_code": "codex_missing",
|
|
77
|
+
"remediation": (
|
|
78
|
+
"codex CLI is not installed or not on PATH. "
|
|
79
|
+
"Install the Codex CLI to use the codex provider."
|
|
80
|
+
),
|
|
81
|
+
"version": None,
|
|
82
|
+
}
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
return {
|
|
85
|
+
"ready": False,
|
|
86
|
+
"state": "probe_failed",
|
|
87
|
+
"error_code": "codex_probe_failed",
|
|
88
|
+
"remediation": f"Failed to probe codex: {exc}",
|
|
89
|
+
"version": None,
|
|
90
|
+
}
|
|
91
|
+
if codex_result.returncode != 0:
|
|
92
|
+
return {
|
|
93
|
+
"ready": False,
|
|
94
|
+
"state": "missing_binary",
|
|
95
|
+
"error_code": "codex_broken",
|
|
96
|
+
"remediation": "codex CLI is present but broken. Reinstall the Codex CLI.",
|
|
97
|
+
"version": None,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Step 3: pnpm dlx @agentclientprotocol/codex-acp@1.1.7 --version
|
|
101
|
+
try:
|
|
102
|
+
result = runner.run(
|
|
103
|
+
["pnpm", "dlx", "@agentclientprotocol/codex-acp@1.1.7", "--version"],
|
|
104
|
+
timeout=30,
|
|
105
|
+
)
|
|
106
|
+
except FileNotFoundError:
|
|
107
|
+
return {
|
|
108
|
+
"ready": False,
|
|
109
|
+
"state": "missing_binary",
|
|
110
|
+
"error_code": "pnpm_missing",
|
|
111
|
+
"remediation": (
|
|
112
|
+
"pnpm is not installed. Install pnpm and run: "
|
|
113
|
+
"pnpm dlx @agentclientprotocol/codex-acp@1.1.7 --version"
|
|
114
|
+
),
|
|
115
|
+
"version": None,
|
|
116
|
+
}
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
return {
|
|
119
|
+
"ready": False,
|
|
120
|
+
"state": "probe_failed",
|
|
121
|
+
"error_code": "codex_acp_probe_failed",
|
|
122
|
+
"remediation": (
|
|
123
|
+
f"Failed to probe @agentclientprotocol/codex-acp: {exc}. "
|
|
124
|
+
"Install with: pnpm dlx @agentclientprotocol/codex-acp@1.1.7"
|
|
125
|
+
),
|
|
126
|
+
"version": None,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if result.returncode != 0:
|
|
130
|
+
return {
|
|
131
|
+
"ready": False,
|
|
132
|
+
"state": "probe_failed",
|
|
133
|
+
"error_code": "codex_acp_version_failed",
|
|
134
|
+
"remediation": (
|
|
135
|
+
f"@agentclientprotocol/codex-acp --version exited with "
|
|
136
|
+
f"code {result.returncode}. "
|
|
137
|
+
f"stderr: {(result.stderr or '')[:200]}. "
|
|
138
|
+
"Install with: pnpm dlx @agentclientprotocol/codex-acp@1.1.7"
|
|
139
|
+
),
|
|
140
|
+
"version": None,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
version = _parse_version(result.stdout)
|
|
144
|
+
if version is None:
|
|
145
|
+
return {
|
|
146
|
+
"ready": True,
|
|
147
|
+
"state": "ready",
|
|
148
|
+
"error_code": None,
|
|
149
|
+
"remediation": None,
|
|
150
|
+
"version": None,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if version < _CODEX_ACP_MIN_VERSION:
|
|
154
|
+
current = ".".join(str(v) for v in version)
|
|
155
|
+
required = ".".join(str(v) for v in _CODEX_ACP_MIN_VERSION)
|
|
156
|
+
return {
|
|
157
|
+
"ready": False,
|
|
158
|
+
"state": "version_too_low",
|
|
159
|
+
"error_code": "codex_acp_version_too_low",
|
|
160
|
+
"remediation": (
|
|
161
|
+
f"@agentclientprotocol/codex-acp version {current} is too old. "
|
|
162
|
+
f"Upgrade to >= {required}. "
|
|
163
|
+
f"Run: pnpm dlx @agentclientprotocol/codex-acp@{required}"
|
|
164
|
+
),
|
|
165
|
+
"version": current,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"ready": True,
|
|
170
|
+
"state": "ready",
|
|
171
|
+
"error_code": None,
|
|
172
|
+
"remediation": None,
|
|
173
|
+
"version": ".".join(str(v) for v in version),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def check_opencode_acp_readiness(runner) -> dict:
|
|
178
|
+
# Step 1: opencode --version
|
|
179
|
+
try:
|
|
180
|
+
opencode_result = runner.run(["opencode", "--version"], timeout=10)
|
|
181
|
+
except FileNotFoundError:
|
|
182
|
+
return {
|
|
183
|
+
"ready": False,
|
|
184
|
+
"state": "missing_binary",
|
|
185
|
+
"error_code": "opencode_missing",
|
|
186
|
+
"remediation": (
|
|
187
|
+
"opencode CLI is not installed or not on PATH. "
|
|
188
|
+
"Install the OpenCode CLI to use the opencode provider."
|
|
189
|
+
),
|
|
190
|
+
}
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
return {
|
|
193
|
+
"ready": False,
|
|
194
|
+
"state": "probe_failed",
|
|
195
|
+
"error_code": "opencode_probe_failed",
|
|
196
|
+
"remediation": f"Failed to probe opencode: {exc}",
|
|
197
|
+
}
|
|
198
|
+
if opencode_result.returncode != 0:
|
|
199
|
+
return {
|
|
200
|
+
"ready": False,
|
|
201
|
+
"state": "missing_binary",
|
|
202
|
+
"error_code": "opencode_broken",
|
|
203
|
+
"remediation": "opencode CLI is present but broken. Reinstall the OpenCode CLI.",
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
# Step 2: opencode acp --help (nonblocking probe)
|
|
207
|
+
try:
|
|
208
|
+
acp_result = runner.run(["opencode", "acp", "--help"], timeout=30)
|
|
209
|
+
except Exception as exc:
|
|
210
|
+
stderr_snippet = str(exc)[:200]
|
|
211
|
+
return {
|
|
212
|
+
"ready": False,
|
|
213
|
+
"state": "probe_failed",
|
|
214
|
+
"error_code": "opencode_acp_probe_failed",
|
|
215
|
+
"remediation": (
|
|
216
|
+
f"Failed to probe `opencode acp --help`: {stderr_snippet}. "
|
|
217
|
+
"Ensure the opencode CLI is installed and the `acp` subcommand is available."
|
|
218
|
+
),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if acp_result.returncode != 0:
|
|
222
|
+
stderr_snippet = (acp_result.stderr or "")[:200]
|
|
223
|
+
return {
|
|
224
|
+
"ready": False,
|
|
225
|
+
"state": "unavailable",
|
|
226
|
+
"error_code": "opencode_acp_unavailable",
|
|
227
|
+
"remediation": (
|
|
228
|
+
f"`opencode acp --help` exited with code {acp_result.returncode}. "
|
|
229
|
+
f"stderr: {stderr_snippet}. "
|
|
230
|
+
"Ensure your opencode version supports the native `acp` subcommand. "
|
|
231
|
+
"Upgrade opencode if needed."
|
|
232
|
+
),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"ready": True,
|
|
237
|
+
"state": "ready",
|
|
238
|
+
"error_code": None,
|
|
239
|
+
"remediation": None,
|
|
240
|
+
}
|