coderouter-cli 2.8.1__py3-none-any.whl → 2.9.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.
- coderouter/adapters/registry.py +31 -48
- coderouter/config/schemas.py +32 -7
- coderouter/doctor.py +58 -0
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.0.dist-info}/RECORD +8 -9
- coderouter/adapters/agent_cli.py +0 -1168
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.8.1.dist-info → coderouter_cli-2.9.0.dist-info}/licenses/LICENSE +0 -0
coderouter/adapters/agent_cli.py
DELETED
|
@@ -1,1168 +0,0 @@
|
|
|
1
|
-
"""External coding-agent CLI adapter (``kind="agent_cli"``).
|
|
2
|
-
|
|
3
|
-
This adapter invokes an external coding-agent CLI (Claude Code / Codex /
|
|
4
|
-
Antigravity / Grok) as a single one-shot ``exec`` and returns the agent's
|
|
5
|
-
final answer as one ``prompt in → text out`` transformation. It is the
|
|
6
|
-
in-core implementation of the external-agents-adapter design
|
|
7
|
-
(``docs/designs/external-agents-adapter.md``).
|
|
8
|
-
|
|
9
|
-
Design in one paragraph
|
|
10
|
-
=======================
|
|
11
|
-
|
|
12
|
-
A coding-agent CLI is normally a stateful, multi-turn, filesystem-editing
|
|
13
|
-
control loop — at odds with CodeRouter's "one request = one stateless
|
|
14
|
-
transformation" ethos. Restricting it to a single non-interactive
|
|
15
|
-
one-shot ``exec`` collapses it back into a single transformation, which
|
|
16
|
-
keeps the ethos intact: orchestration stays on the *client* side, and
|
|
17
|
-
CodeRouter merely performs the one conversion. Following the
|
|
18
|
-
``openai_compat`` precedent, a *single* adapter class fronts *multiple*
|
|
19
|
-
target agents, dispatched on the ``agent`` field.
|
|
20
|
-
|
|
21
|
-
Implemented agents (Phase 1 complete: 1a + 1b + 1c + 1d)
|
|
22
|
-
==========================================================
|
|
23
|
-
|
|
24
|
-
Four targets are implemented here:
|
|
25
|
-
|
|
26
|
-
* ``claude`` (Claude Code CLI, Phase 1a) — the most stable CLI, the most
|
|
27
|
-
fine-grained safety controls, and the only one that emits
|
|
28
|
-
``total_cost_usd`` directly (making it the reference implementation for
|
|
29
|
-
the parser / cost path).
|
|
30
|
-
* ``codex`` (codex CLI, Phase 1b) — headless one-shot via ``codex exec
|
|
31
|
-
--json``, prompt on stdin (like claude). The CLI is pre-1.0 and emits
|
|
32
|
-
defensively-parsed JSONL (one event per line); usage comes from
|
|
33
|
-
``turn.completed`` events, normalized per design §5.1.6 with
|
|
34
|
-
``cached_input_tokens`` kept as a *subset* of ``input_tokens`` (unlike
|
|
35
|
-
claude, which folds its cache buckets into ``prompt_tokens``).
|
|
36
|
-
``--skip-git-repo-check`` and ``--ephemeral`` are always passed — the
|
|
37
|
-
isolated workdir is not a git repo, and the adapter never wants
|
|
38
|
-
session persistence.
|
|
39
|
-
* ``grok`` (grok CLI, Phase 1d) — headless one-shot via ``--prompt-file`` +
|
|
40
|
-
``--output-format json``. The CLI emits no token/cost figures, so usage
|
|
41
|
-
is reported as zeros (cost stays 0 unless the operator sets
|
|
42
|
-
``ProviderConfig.cost``, design §5.1.6). ``--no-memory`` is always passed
|
|
43
|
-
so a user-level cross-session memory setting cannot leak state between
|
|
44
|
-
requests.
|
|
45
|
-
* ``antigravity`` (Antigravity CLI, command ``agy``, Phase 1c, in lieu of
|
|
46
|
-
``gemini``) — headless one-shot via ``agy -p <prompt> --mode ...``.
|
|
47
|
-
Google discontinued the legacy Gemini CLI's OAuth for individual
|
|
48
|
-
accounts in June 2026 (field-verified ``IneligibleTierError`` /
|
|
49
|
-
``UNSUPPORTED_CLIENT``); its successor, the Antigravity CLI, is a
|
|
50
|
-
separate Go implementation (not a gemini-cli fork) fulfilling the design's
|
|
51
|
-
"gemini" slot. It has no stdin or ``--prompt-file`` channel — piped stdin
|
|
52
|
-
hangs the CLI (field-verified on agy 1.1.1) — so the prompt rides argv
|
|
53
|
-
(see the security note below). Output is plain text with no
|
|
54
|
-
``--output-format`` flag, no token/cost figures, and no session id, so
|
|
55
|
-
usage is reported as zeros and meta is empty, mirroring grok's rationale.
|
|
56
|
-
It is also the only agent with a CLI-side self-termination flag
|
|
57
|
-
(``--print-timeout``), layered *underneath* the adapter's own
|
|
58
|
-
``asyncio.wait_for`` + PGID SIGKILL rather than replacing it.
|
|
59
|
-
|
|
60
|
-
``gemini`` itself is declared in the config schema for backward-compatible
|
|
61
|
-
config parsing, but constructing an adapter for it raises a clear
|
|
62
|
-
``AdapterError`` with a migration pointer to ``agent="antigravity"``.
|
|
63
|
-
|
|
64
|
-
Security (design §6, non-negotiable)
|
|
65
|
-
====================================
|
|
66
|
-
|
|
67
|
-
* **allowlist argv only** — the child is launched with
|
|
68
|
-
:func:`asyncio.create_subprocess_exec` and a list argv. ``shell=True`` is
|
|
69
|
-
never used. Prompt delivery is one of three mechanisms depending on the
|
|
70
|
-
agent: claude and codex read it from stdin (codex's argv carries a
|
|
71
|
-
trailing ``-`` sentinel making that explicit); grok reads it from a
|
|
72
|
-
private ``0600`` prompt file inside the resolved workdir (its ``-p``
|
|
73
|
-
requires the prompt as an argv value, and argv would both hit Linux's
|
|
74
|
-
~128KiB ``MAX_ARG_STRLEN`` on huge prompts and leak the text into ``ps``
|
|
75
|
-
output); antigravity has neither a stdin nor a ``--prompt-file`` channel
|
|
76
|
-
(piped stdin hangs the CLI, field-verified), so its prompt is carried on
|
|
77
|
-
argv — accepting the same ``MAX_ARG_STRLEN`` cap and local ``ps``
|
|
78
|
-
visibility that grok's file delivery was specifically designed to avoid.
|
|
79
|
-
No shell is involved in any case (list argv, never shell text), and the
|
|
80
|
-
documented threat model (an isolated, single-operator workstation) treats
|
|
81
|
-
local ``ps`` visibility as an accepted, documented limitation for
|
|
82
|
-
antigravity rather than a defect.
|
|
83
|
-
* **default read-only** — ``allow_file_writes=False`` /
|
|
84
|
-
``sandbox_mode="read_only"`` are the defaults, mapped to claude's
|
|
85
|
-
``--permission-mode plan``. Writes require explicit opt-in and the sandbox
|
|
86
|
-
mapping is clamped to read-only whenever ``allow_file_writes`` is False.
|
|
87
|
-
* **workdir boundary** — the working directory is expanded, resolved to an
|
|
88
|
-
absolute path and created; a literal ``..`` escape is rejected.
|
|
89
|
-
* **timeout with process-group kill** — ``exec_timeout_s`` is enforced with
|
|
90
|
-
:func:`asyncio.wait_for`; on expiry the whole process *group* is
|
|
91
|
-
``SIGKILL``ed (the CLI hangs a real LLM call off a child, so killing only
|
|
92
|
-
the parent would orphan it).
|
|
93
|
-
* **env allowlist** — the child does NOT inherit the parent environment. A
|
|
94
|
-
minimal env is built explicitly; ``ANTHROPIC_API_KEY`` is never forwarded
|
|
95
|
-
unless the operator lists it in ``passthrough_env`` (this prevents a
|
|
96
|
-
stray key from silently overriding subscription OAuth).
|
|
97
|
-
* **recursion cap** — ``CODEROUTER_AGENT_DEPTH`` is propagated (incremented)
|
|
98
|
-
into the child and refused at or above ``agent_depth_limit``.
|
|
99
|
-
"""
|
|
100
|
-
|
|
101
|
-
from __future__ import annotations
|
|
102
|
-
|
|
103
|
-
import asyncio
|
|
104
|
-
import contextlib
|
|
105
|
-
import json
|
|
106
|
-
import os
|
|
107
|
-
import re
|
|
108
|
-
import shutil
|
|
109
|
-
import signal
|
|
110
|
-
import time
|
|
111
|
-
import uuid
|
|
112
|
-
from collections.abc import AsyncIterator, Iterator
|
|
113
|
-
from pathlib import Path
|
|
114
|
-
from typing import Any
|
|
115
|
-
|
|
116
|
-
from coderouter.adapters.base import (
|
|
117
|
-
AdapterError,
|
|
118
|
-
BaseAdapter,
|
|
119
|
-
ChatRequest,
|
|
120
|
-
ChatResponse,
|
|
121
|
-
ProviderCallOverrides,
|
|
122
|
-
StreamChunk,
|
|
123
|
-
)
|
|
124
|
-
from coderouter.config.schemas import AgentCliConfig, ProviderConfig
|
|
125
|
-
from coderouter.logging import get_logger
|
|
126
|
-
|
|
127
|
-
logger = get_logger(__name__)
|
|
128
|
-
|
|
129
|
-
# Fixed, minimal PATH injected into the child (design §5.3.1). The adapter
|
|
130
|
-
# resolves the CLI executable to an absolute path itself (see ``generate``),
|
|
131
|
-
# so this PATH only governs any helper binaries the CLI spawns.
|
|
132
|
-
_SAFE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
|
133
|
-
|
|
134
|
-
# Environment variable that carries the recursion depth across nested agent
|
|
135
|
-
# invocations (design §5.5).
|
|
136
|
-
_DEPTH_ENV = "CODEROUTER_AGENT_DEPTH"
|
|
137
|
-
|
|
138
|
-
# How many trailing stderr bytes to attach to a non-zero-exit error message.
|
|
139
|
-
_MAX_STDERR_TAIL = 2000
|
|
140
|
-
|
|
141
|
-
# Coarse chunk size (characters) for the pseudo-stream splitter (design §5.1.4).
|
|
142
|
-
_STREAM_CHUNK_CHARS = 512
|
|
143
|
-
|
|
144
|
-
# sandbox_mode → claude ``--permission-mode`` value (design §5.4). ``edit``
|
|
145
|
-
# and ``full_auto`` both map to ``acceptEdits`` in Phase 1a.
|
|
146
|
-
_CLAUDE_PERMISSION_MODE = {
|
|
147
|
-
"read_only": "plan",
|
|
148
|
-
"edit": "acceptEdits",
|
|
149
|
-
"full_auto": "acceptEdits",
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
# sandbox_mode → grok sandbox/approval flags (design §5.4, grok CLI v0.2.93).
|
|
153
|
-
# grok's ``--sandbox`` takes a built-in profile VALUE (off|workspace|
|
|
154
|
-
# read-only|strict) and its ``--permission-mode`` shares Claude Code's value
|
|
155
|
-
# set; ``full_auto`` swaps the permission mode for ``--always-approve``
|
|
156
|
-
# (auto-approve all tool executions).
|
|
157
|
-
_GROK_SANDBOX_ARGS = {
|
|
158
|
-
"read_only": ["--sandbox", "read-only", "--permission-mode", "plan"],
|
|
159
|
-
"edit": ["--sandbox", "workspace", "--permission-mode", "acceptEdits"],
|
|
160
|
-
"full_auto": ["--sandbox", "workspace", "--always-approve"],
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
# sandbox_mode → codex ``-s/--sandbox`` value (design §5.4, codex-cli
|
|
164
|
-
# 0.144.1, verified via facts-codex.md). ``codex exec`` has NO approval
|
|
165
|
-
# flag at all (non-interactive, so there is no prompt to approve/skip), so
|
|
166
|
-
# ``full_auto`` collapses onto the same ``workspace-write`` value as
|
|
167
|
-
# ``edit`` — there is nothing further to "auto" beyond granting writes.
|
|
168
|
-
_CODEX_SANDBOX_ARGS = {
|
|
169
|
-
"read_only": ["-s", "read-only"],
|
|
170
|
-
"edit": ["-s", "workspace-write"],
|
|
171
|
-
"full_auto": ["-s", "workspace-write"],
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
# sandbox_mode → antigravity ``--mode`` flags (design §5.4, Antigravity CLI
|
|
175
|
-
# 1.1.1, verified via facts-antigravity.md). ``agy --help`` only enumerates
|
|
176
|
-
# two ``--mode`` values (``plan`` / ``accept-edits``), so ``full_auto`` maps
|
|
177
|
-
# onto ``accept-edits`` plus the separate ``--dangerously-skip-permissions``
|
|
178
|
-
# flag (auto-approves all tool executions) rather than a third ``--mode``
|
|
179
|
-
# value. ``--sandbox`` is deliberately never used here: it has a known bypass
|
|
180
|
-
# bug when combined with ``--dangerously-skip-permissions`` (agy issue #36).
|
|
181
|
-
_ANTIGRAVITY_MODE_ARGS = {
|
|
182
|
-
"read_only": ["--mode", "plan"],
|
|
183
|
-
"edit": ["--mode", "accept-edits"],
|
|
184
|
-
"full_auto": ["--mode", "accept-edits", "--dangerously-skip-permissions"],
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
# Compiled ANSI escape sequence stripper for antigravity's plain-text output
|
|
188
|
-
# (design §5.1.6). Covers CSI sequences (``ESC [ ... final-byte``) plus bare
|
|
189
|
-
# OSC-style ``ESC ]`` sequences terminated by BEL or ``ESC \`` (kept simple —
|
|
190
|
-
# antigravity's TUI chrome is not fully specified, so this is a defensive
|
|
191
|
-
# best-effort strip, not a full ANSI parser).
|
|
192
|
-
_ANSI_RE = re.compile(
|
|
193
|
-
r"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI ... final byte
|
|
194
|
-
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC ... BEL or ST
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def _chunk_text(text: str, size: int = _STREAM_CHUNK_CHARS) -> Iterator[str]:
|
|
199
|
-
"""Split ``text`` into ``size``-char pieces for the pseudo-stream."""
|
|
200
|
-
for start in range(0, len(text), size):
|
|
201
|
-
yield text[start : start + size]
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
class AgentCliAdapter(BaseAdapter):
|
|
205
|
-
"""Invoke an external coding-agent CLI one-shot (claude + codex + grok +
|
|
206
|
-
antigravity).
|
|
207
|
-
|
|
208
|
-
The ``agent`` field selects the argv builder / output parser via the
|
|
209
|
-
dispatch tables built in :meth:`__init__`, mirroring how
|
|
210
|
-
``openai_compat`` fronts many HTTP backends from one class.
|
|
211
|
-
"""
|
|
212
|
-
|
|
213
|
-
_IMPLEMENTED_AGENTS = ("claude", "codex", "grok", "antigravity")
|
|
214
|
-
|
|
215
|
-
def __init__(self, config: ProviderConfig) -> None:
|
|
216
|
-
"""Bind to a ``ProviderConfig`` and reject unsupported agents.
|
|
217
|
-
|
|
218
|
-
``gemini`` gets its own rejection message (non-retryable): Google
|
|
219
|
-
discontinued the Gemini CLI's OAuth for individual accounts in June
|
|
220
|
-
2026, so this adapter points the operator at ``antigravity``
|
|
221
|
-
instead of a generic "not implemented" message. Any other
|
|
222
|
-
unimplemented value (future-proofing; currently none, since the
|
|
223
|
-
schema's ``Literal`` only allows the five known agents) falls back
|
|
224
|
-
to a generic message listing what IS implemented.
|
|
225
|
-
"""
|
|
226
|
-
super().__init__(config)
|
|
227
|
-
if config.agent_cli is None: # pragma: no cover - schema enforces this
|
|
228
|
-
raise AdapterError(
|
|
229
|
-
"agent_cli provider is missing its agent_cli sub-config",
|
|
230
|
-
provider=config.name,
|
|
231
|
-
retryable=False,
|
|
232
|
-
)
|
|
233
|
-
self.acfg: AgentCliConfig = config.agent_cli
|
|
234
|
-
if self.acfg.agent not in self._IMPLEMENTED_AGENTS:
|
|
235
|
-
if self.acfg.agent == "gemini":
|
|
236
|
-
raise AdapterError(
|
|
237
|
-
"agent 'gemini' is not supported: Google discontinued "
|
|
238
|
-
"the Gemini CLI for individual accounts (June 2026; "
|
|
239
|
-
"IneligibleTierError). Use agent='antigravity' "
|
|
240
|
-
"(Antigravity CLI, command 'agy') instead.",
|
|
241
|
-
provider=config.name,
|
|
242
|
-
retryable=False,
|
|
243
|
-
)
|
|
244
|
-
raise AdapterError(
|
|
245
|
-
f"agent {self.acfg.agent!r} is not implemented "
|
|
246
|
-
f"(implemented: {', '.join(self._IMPLEMENTED_AGENTS)}).",
|
|
247
|
-
provider=config.name,
|
|
248
|
-
retryable=False,
|
|
249
|
-
)
|
|
250
|
-
# agent → argv builder / output parser dispatch tables. claude landed
|
|
251
|
-
# in Phase 1a, codex in Phase 1b, grok in Phase 1d, antigravity in
|
|
252
|
-
# Phase 1c — all four (design §9) are now implemented.
|
|
253
|
-
self._builders = {
|
|
254
|
-
"claude": self._build_claude_argv,
|
|
255
|
-
"codex": self._build_codex_argv,
|
|
256
|
-
"grok": self._build_grok_argv,
|
|
257
|
-
"antigravity": self._build_antigravity_argv,
|
|
258
|
-
}
|
|
259
|
-
self._parsers = {
|
|
260
|
-
"claude": self._parse_claude,
|
|
261
|
-
"codex": self._parse_codex,
|
|
262
|
-
"grok": self._parse_grok,
|
|
263
|
-
"antigravity": self._parse_antigravity,
|
|
264
|
-
}
|
|
265
|
-
# Prompt delivery is per-agent, one of three mechanisms: claude and
|
|
266
|
-
# codex read their prompt from stdin (10MB cap; codex's argv carries
|
|
267
|
-
# a trailing "-" sentinel making that explicit), which keeps argv
|
|
268
|
-
# free of the (potentially huge) prompt text. grok's ``-p``
|
|
269
|
-
# REQUIRES the prompt as its argv value (piped stdin is only
|
|
270
|
-
# appended as extra context, verified on v0.2.93), so grok gets the
|
|
271
|
-
# prompt via ``--prompt-file`` instead — see ``_write_prompt_file``
|
|
272
|
-
# for the rationale. antigravity has NEITHER a stdin nor a
|
|
273
|
-
# ``--prompt-file`` channel — piped stdin hangs the CLI outright
|
|
274
|
-
# (field-verified on agy 1.1.1) — so its prompt rides argv instead;
|
|
275
|
-
# see ``_build_antigravity_argv`` for the tradeoff this accepts.
|
|
276
|
-
self._uses_stdin = self.acfg.agent in ("claude", "codex")
|
|
277
|
-
self._uses_argv = self.acfg.agent == "antigravity"
|
|
278
|
-
|
|
279
|
-
# ------------------------------------------------------------------
|
|
280
|
-
# BaseAdapter contract
|
|
281
|
-
# ------------------------------------------------------------------
|
|
282
|
-
|
|
283
|
-
async def healthcheck(self) -> bool:
|
|
284
|
-
"""Lightweight check: the CLI binary exists on PATH (design §5.1.2)."""
|
|
285
|
-
return shutil.which(self.acfg.command) is not None
|
|
286
|
-
|
|
287
|
-
async def generate(
|
|
288
|
-
self,
|
|
289
|
-
request: ChatRequest,
|
|
290
|
-
*,
|
|
291
|
-
overrides: ProviderCallOverrides | None = None,
|
|
292
|
-
) -> ChatResponse:
|
|
293
|
-
"""Run the CLI once and shape the final answer into a ChatResponse.
|
|
294
|
-
|
|
295
|
-
Raises :class:`AdapterError` on every failure path, with
|
|
296
|
-
``retryable`` set so the fallback engine can decide whether to try
|
|
297
|
-
the next provider (transient failures) or stop (config / recursion
|
|
298
|
-
errors).
|
|
299
|
-
"""
|
|
300
|
-
# Recursion guard (design §5.5): refuse when we are already nested at
|
|
301
|
-
# or beyond the configured depth. Non-retryable — retrying the same
|
|
302
|
-
# chain would recurse again.
|
|
303
|
-
depth = self._current_depth()
|
|
304
|
-
if depth >= self.acfg.agent_depth_limit:
|
|
305
|
-
raise AdapterError(
|
|
306
|
-
f"agent recursion depth {depth} >= limit {self.acfg.agent_depth_limit}",
|
|
307
|
-
provider=self.name,
|
|
308
|
-
retryable=False,
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
# exec_timeout_s is the base; a profile-level override wins when set.
|
|
312
|
-
# NOTE: the inherited ``effective_timeout`` falls back to
|
|
313
|
-
# ``ProviderConfig.timeout_s``, which is the wrong knob here, so we
|
|
314
|
-
# resolve against ``exec_timeout_s`` explicitly (design §5.1.3).
|
|
315
|
-
timeout = (
|
|
316
|
-
overrides.timeout_s
|
|
317
|
-
if overrides is not None and overrides.timeout_s is not None
|
|
318
|
-
else self.acfg.exec_timeout_s
|
|
319
|
-
)
|
|
320
|
-
|
|
321
|
-
prompt = self._render_prompt(request, overrides)
|
|
322
|
-
workdir = self._resolve_workdir()
|
|
323
|
-
|
|
324
|
-
# Agents that cannot take the prompt on stdin (grok) get it through a
|
|
325
|
-
# private temp file inside the workdir; it is ALWAYS removed in the
|
|
326
|
-
# ``finally`` below, including on timeout / exception paths. Argv
|
|
327
|
-
# agents (antigravity) get neither a file nor stdin bytes — the
|
|
328
|
-
# prompt text is handed straight to the builder instead.
|
|
329
|
-
prompt_file = (
|
|
330
|
-
None
|
|
331
|
-
if self._uses_stdin or self._uses_argv
|
|
332
|
-
else self._write_prompt_file(prompt, workdir)
|
|
333
|
-
)
|
|
334
|
-
try:
|
|
335
|
-
argv = self._builders[self.acfg.agent](workdir, prompt_file, prompt)
|
|
336
|
-
# Resolve the executable to an absolute path so argv[0] is a
|
|
337
|
-
# concrete binary independent of the child's minimal PATH
|
|
338
|
-
# (design §6 allowlist).
|
|
339
|
-
resolved = shutil.which(argv[0])
|
|
340
|
-
if resolved is not None:
|
|
341
|
-
argv = [resolved, *argv[1:]]
|
|
342
|
-
env = self._build_child_env()
|
|
343
|
-
|
|
344
|
-
logger.info(
|
|
345
|
-
"agent-cli-exec",
|
|
346
|
-
extra={
|
|
347
|
-
"provider": self.name,
|
|
348
|
-
"agent": self.acfg.agent,
|
|
349
|
-
"argv0": argv[0],
|
|
350
|
-
"timeout_s": timeout,
|
|
351
|
-
},
|
|
352
|
-
)
|
|
353
|
-
|
|
354
|
-
try:
|
|
355
|
-
proc = await asyncio.create_subprocess_exec(
|
|
356
|
-
*argv,
|
|
357
|
-
stdin=asyncio.subprocess.PIPE,
|
|
358
|
-
stdout=asyncio.subprocess.PIPE,
|
|
359
|
-
stderr=asyncio.subprocess.PIPE,
|
|
360
|
-
cwd=workdir,
|
|
361
|
-
env=env,
|
|
362
|
-
# New session/process group so a timeout can SIGKILL the
|
|
363
|
-
# whole group (the CLI hangs its real LLM call off a
|
|
364
|
-
# child).
|
|
365
|
-
start_new_session=True,
|
|
366
|
-
)
|
|
367
|
-
except (FileNotFoundError, OSError) as exc:
|
|
368
|
-
raise AdapterError(
|
|
369
|
-
f"failed to launch {self.acfg.command!r}: {exc}",
|
|
370
|
-
provider=self.name,
|
|
371
|
-
retryable=False,
|
|
372
|
-
) from exc
|
|
373
|
-
|
|
374
|
-
# Non-stdin agents (grok's file delivery, antigravity's argv
|
|
375
|
-
# delivery) get None here, so ``communicate()`` closes stdin
|
|
376
|
-
# immediately without writing to it — verified required for
|
|
377
|
-
# antigravity, whose CLI hangs if anything is piped to stdin.
|
|
378
|
-
stdin_bytes = prompt.encode("utf-8") if self._uses_stdin else None
|
|
379
|
-
try:
|
|
380
|
-
stdout, stderr = await asyncio.wait_for(
|
|
381
|
-
proc.communicate(input=stdin_bytes), timeout=timeout
|
|
382
|
-
)
|
|
383
|
-
except TimeoutError as exc:
|
|
384
|
-
self._kill_process_group(proc)
|
|
385
|
-
with contextlib.suppress(Exception):
|
|
386
|
-
await proc.wait()
|
|
387
|
-
raise AdapterError(
|
|
388
|
-
f"{self.acfg.agent} exec timed out after {timeout}s",
|
|
389
|
-
provider=self.name,
|
|
390
|
-
retryable=True,
|
|
391
|
-
) from exc
|
|
392
|
-
|
|
393
|
-
if proc.returncode != 0:
|
|
394
|
-
detail = self._error_detail(stdout, stderr)
|
|
395
|
-
raise AdapterError(
|
|
396
|
-
f"{self.acfg.agent} exited {proc.returncode}: {detail}",
|
|
397
|
-
provider=self.name,
|
|
398
|
-
status_code=None,
|
|
399
|
-
retryable=self._is_retryable_exit(proc.returncode),
|
|
400
|
-
)
|
|
401
|
-
|
|
402
|
-
final_text, usage, meta = self._parsers[self.acfg.agent](stdout, stderr)
|
|
403
|
-
finally:
|
|
404
|
-
if prompt_file is not None:
|
|
405
|
-
# Best-effort cleanup — the child may already have exited and
|
|
406
|
-
# a vanished file is not an error worth surfacing.
|
|
407
|
-
with contextlib.suppress(OSError):
|
|
408
|
-
os.unlink(prompt_file)
|
|
409
|
-
return self._to_chat_response(final_text, usage, meta)
|
|
410
|
-
|
|
411
|
-
async def stream(
|
|
412
|
-
self,
|
|
413
|
-
request: ChatRequest,
|
|
414
|
-
*,
|
|
415
|
-
overrides: ProviderCallOverrides | None = None,
|
|
416
|
-
) -> AsyncIterator[StreamChunk]:
|
|
417
|
-
"""Pseudo-stream (design §5.1.4): run once, then chunk the answer.
|
|
418
|
-
|
|
419
|
-
No CLI in Phase 1a exposes a stable token stream, so the final text
|
|
420
|
-
from :meth:`generate` is split into content chunks followed by a
|
|
421
|
-
terminal ``finish_reason="stop"`` chunk carrying usage.
|
|
422
|
-
"""
|
|
423
|
-
resp = await self.generate(request, overrides=overrides)
|
|
424
|
-
try:
|
|
425
|
-
content = resp.choices[0]["message"]["content"] or ""
|
|
426
|
-
except (IndexError, KeyError, TypeError): # pragma: no cover - defensive
|
|
427
|
-
content = ""
|
|
428
|
-
for piece in _chunk_text(content):
|
|
429
|
-
yield StreamChunk(
|
|
430
|
-
id=resp.id,
|
|
431
|
-
created=resp.created,
|
|
432
|
-
model=resp.model,
|
|
433
|
-
choices=[{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
|
|
434
|
-
)
|
|
435
|
-
yield StreamChunk(
|
|
436
|
-
id=resp.id,
|
|
437
|
-
created=resp.created,
|
|
438
|
-
model=resp.model,
|
|
439
|
-
choices=[{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
|
440
|
-
usage=resp.usage,
|
|
441
|
-
)
|
|
442
|
-
|
|
443
|
-
# ------------------------------------------------------------------
|
|
444
|
-
# claude argv builder + output parser
|
|
445
|
-
# ------------------------------------------------------------------
|
|
446
|
-
|
|
447
|
-
def _build_claude_argv(
|
|
448
|
-
self, workdir: str, prompt_file: str | None = None, prompt: str | None = None
|
|
449
|
-
) -> list[str]:
|
|
450
|
-
"""Assemble the ``claude -p`` argv (design §5.1.5 / §5.4).
|
|
451
|
-
|
|
452
|
-
Shape::
|
|
453
|
-
|
|
454
|
-
claude -p --output-format json --model <m> --max-turns <n>
|
|
455
|
-
--permission-mode <plan|acceptEdits> --add-dir <workdir>
|
|
456
|
-
|
|
457
|
-
The prompt is fed on stdin (not argv), so it never appears here and
|
|
458
|
-
both ``prompt_file`` and ``prompt`` are ignored (they exist only to
|
|
459
|
-
keep the builder signature uniform across agents — see
|
|
460
|
-
``_build_antigravity_argv`` for the one agent that needs ``prompt``).
|
|
461
|
-
``--bare`` is deliberately NOT added — it would skip OAuth/keychain
|
|
462
|
-
reads and break subscription auth (design §5.3.4).
|
|
463
|
-
"""
|
|
464
|
-
del prompt_file, prompt # claude takes the prompt on stdin.
|
|
465
|
-
model = self.acfg.model or self.config.model
|
|
466
|
-
argv = [self.acfg.command, "-p", "--output-format", "json", "--model", model]
|
|
467
|
-
if self.acfg.max_turns is not None:
|
|
468
|
-
argv += ["--max-turns", str(self.acfg.max_turns)]
|
|
469
|
-
argv += ["--permission-mode", self._claude_permission_mode()]
|
|
470
|
-
argv += ["--add-dir", workdir]
|
|
471
|
-
return argv
|
|
472
|
-
|
|
473
|
-
def _claude_permission_mode(self) -> str:
|
|
474
|
-
"""Map ``sandbox_mode`` → claude ``--permission-mode``, clamped.
|
|
475
|
-
|
|
476
|
-
When ``allow_file_writes`` is False the effective mode is clamped to
|
|
477
|
-
``read_only`` regardless of ``sandbox_mode`` — defense in depth so an
|
|
478
|
-
``edit`` / ``full_auto`` request cannot grant writes without the
|
|
479
|
-
explicit ``allow_file_writes`` opt-in (design §5.4).
|
|
480
|
-
"""
|
|
481
|
-
mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
|
|
482
|
-
return _CLAUDE_PERMISSION_MODE[mode]
|
|
483
|
-
|
|
484
|
-
def _parse_claude(
|
|
485
|
-
self, stdout: bytes, stderr: bytes
|
|
486
|
-
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
|
487
|
-
"""Parse claude ``--output-format json`` output (design §5.1.6).
|
|
488
|
-
|
|
489
|
-
Returns ``(final_text, usage, meta)``. Parsing is deliberately
|
|
490
|
-
defensive: any missing/blank field or a reported ``is_error`` raises a
|
|
491
|
-
retryable :class:`AdapterError` so the chain can fall through.
|
|
492
|
-
"""
|
|
493
|
-
text = stdout.decode("utf-8", "replace").strip()
|
|
494
|
-
if not text:
|
|
495
|
-
raise AdapterError(
|
|
496
|
-
"claude produced no stdout to parse",
|
|
497
|
-
provider=self.name,
|
|
498
|
-
retryable=True,
|
|
499
|
-
)
|
|
500
|
-
try:
|
|
501
|
-
data = json.loads(text)
|
|
502
|
-
except json.JSONDecodeError as exc:
|
|
503
|
-
raise AdapterError(
|
|
504
|
-
f"claude emitted non-JSON output: {exc}",
|
|
505
|
-
provider=self.name,
|
|
506
|
-
retryable=True,
|
|
507
|
-
) from exc
|
|
508
|
-
if not isinstance(data, dict):
|
|
509
|
-
raise AdapterError(
|
|
510
|
-
"claude JSON output was not an object",
|
|
511
|
-
provider=self.name,
|
|
512
|
-
retryable=True,
|
|
513
|
-
)
|
|
514
|
-
if data.get("is_error"):
|
|
515
|
-
raise AdapterError(
|
|
516
|
-
f"claude reported is_error=true: {str(data.get('result'))[:500]!r}",
|
|
517
|
-
provider=self.name,
|
|
518
|
-
retryable=True,
|
|
519
|
-
)
|
|
520
|
-
result = data.get("result")
|
|
521
|
-
if not isinstance(result, str):
|
|
522
|
-
raise AdapterError(
|
|
523
|
-
"claude JSON output missing string 'result' field",
|
|
524
|
-
provider=self.name,
|
|
525
|
-
retryable=True,
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
usage = self._claude_usage(data)
|
|
529
|
-
meta: dict[str, Any] = {}
|
|
530
|
-
cost = data.get("total_cost_usd")
|
|
531
|
-
if isinstance(cost, (int, float)):
|
|
532
|
-
# claude is the only CLI that emits a dollar figure directly;
|
|
533
|
-
# surface it as response metadata for the cost dashboard.
|
|
534
|
-
meta["coderouter_cost_usd"] = float(cost)
|
|
535
|
-
session_id = data.get("session_id")
|
|
536
|
-
if isinstance(session_id, str):
|
|
537
|
-
meta["coderouter_session_id"] = session_id
|
|
538
|
-
return result, usage, meta
|
|
539
|
-
|
|
540
|
-
def _claude_usage(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
541
|
-
"""Normalize claude token usage into the OpenAI usage shape.
|
|
542
|
-
|
|
543
|
-
``input_tokens`` plus the two cache buckets fold into
|
|
544
|
-
``prompt_tokens``; ``cache_read_input_tokens`` is preserved under
|
|
545
|
-
``prompt_tokens_details.cached_tokens``. ``num_turns`` / ``duration_ms``
|
|
546
|
-
ride along as extra keys (design §5.1.6).
|
|
547
|
-
"""
|
|
548
|
-
raw = data.get("usage")
|
|
549
|
-
raw = raw if isinstance(raw, dict) else {}
|
|
550
|
-
|
|
551
|
-
def _int(key: str) -> int:
|
|
552
|
-
value = raw.get(key)
|
|
553
|
-
return int(value) if isinstance(value, (int, float)) else 0
|
|
554
|
-
|
|
555
|
-
input_tokens = _int("input_tokens")
|
|
556
|
-
output_tokens = _int("output_tokens")
|
|
557
|
-
cache_read = _int("cache_read_input_tokens")
|
|
558
|
-
cache_creation = _int("cache_creation_input_tokens")
|
|
559
|
-
prompt_tokens = input_tokens + cache_read + cache_creation
|
|
560
|
-
|
|
561
|
-
usage: dict[str, Any] = {
|
|
562
|
-
"prompt_tokens": prompt_tokens,
|
|
563
|
-
"completion_tokens": output_tokens,
|
|
564
|
-
"total_tokens": prompt_tokens + output_tokens,
|
|
565
|
-
}
|
|
566
|
-
if cache_read:
|
|
567
|
-
usage["prompt_tokens_details"] = {"cached_tokens": cache_read}
|
|
568
|
-
if isinstance(data.get("num_turns"), int):
|
|
569
|
-
usage["num_turns"] = data["num_turns"]
|
|
570
|
-
if isinstance(data.get("duration_ms"), (int, float)):
|
|
571
|
-
usage["duration_ms"] = data["duration_ms"]
|
|
572
|
-
return usage
|
|
573
|
-
|
|
574
|
-
# ------------------------------------------------------------------
|
|
575
|
-
# codex argv builder + output parser (Phase 1b)
|
|
576
|
-
# ------------------------------------------------------------------
|
|
577
|
-
|
|
578
|
-
def _build_codex_argv(
|
|
579
|
-
self, workdir: str, prompt_file: str | None = None, prompt: str | None = None
|
|
580
|
-
) -> list[str]:
|
|
581
|
-
"""Assemble the ``codex exec`` argv (design §5.1.5 / §5.4, verified
|
|
582
|
-
against codex-cli 0.144.1 — see ``_codex/facts-codex.md``).
|
|
583
|
-
|
|
584
|
-
Shape::
|
|
585
|
-
|
|
586
|
-
codex exec --json --skip-git-repo-check --ephemeral
|
|
587
|
-
-m <model> -C <workdir> -s <read-only|workspace-write> -
|
|
588
|
-
|
|
589
|
-
The prompt is fed on stdin (not argv), so it never appears here and
|
|
590
|
-
both ``prompt_file`` and ``prompt`` are ignored (they exist only to
|
|
591
|
-
keep the builder signature uniform across agents) — the trailing
|
|
592
|
-
``-`` makes the stdin intent explicit to ``codex exec``, which
|
|
593
|
-
otherwise treats a bare invocation with no PROMPT arg the same way
|
|
594
|
-
but reads more ambiguously in a fixed argv list. ``--skip-git-repo-
|
|
595
|
-
check`` is ALWAYS passed because the isolated workdir is not a git
|
|
596
|
-
repository (without it the CLI exits 1). ``--ephemeral`` is ALWAYS
|
|
597
|
-
passed so no session state persists to disk, matching the adapter's
|
|
598
|
-
stateless one-shot ethos (the same rationale as grok's
|
|
599
|
-
``--no-memory``). codex has no ``--max-turns`` equivalent, so
|
|
600
|
-
``AgentCliConfig.max_turns`` is silently ignored here (documented in
|
|
601
|
-
the schema).
|
|
602
|
-
"""
|
|
603
|
-
del prompt_file, prompt # codex takes the prompt on stdin.
|
|
604
|
-
model = self.acfg.model or self.config.model
|
|
605
|
-
argv = [
|
|
606
|
-
self.acfg.command,
|
|
607
|
-
"exec",
|
|
608
|
-
"--json",
|
|
609
|
-
"--skip-git-repo-check",
|
|
610
|
-
"--ephemeral",
|
|
611
|
-
"-m",
|
|
612
|
-
model,
|
|
613
|
-
"-C",
|
|
614
|
-
workdir,
|
|
615
|
-
]
|
|
616
|
-
argv += self._codex_sandbox_args()
|
|
617
|
-
argv += ["-"]
|
|
618
|
-
return argv
|
|
619
|
-
|
|
620
|
-
def _codex_sandbox_args(self) -> list[str]:
|
|
621
|
-
"""Map ``sandbox_mode`` → codex ``-s/--sandbox`` flags, clamped.
|
|
622
|
-
|
|
623
|
-
Same clamp as claude/grok (design §5.4): when ``allow_file_writes``
|
|
624
|
-
is False the effective mode is forced to ``read_only`` regardless of
|
|
625
|
-
``sandbox_mode``, so writes always require the explicit opt-in.
|
|
626
|
-
``codex exec`` has no approval flag in 0.144.1 (non-interactive, so
|
|
627
|
-
there is nothing to approve), so ``full_auto`` maps onto the same
|
|
628
|
-
``workspace-write`` value as ``edit`` —
|
|
629
|
-
``--dangerously-bypass-approvals-and-sandbox`` is never used.
|
|
630
|
-
"""
|
|
631
|
-
mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
|
|
632
|
-
return list(_CODEX_SANDBOX_ARGS[mode])
|
|
633
|
-
|
|
634
|
-
def _parse_codex(
|
|
635
|
-
self, stdout: bytes, stderr: bytes
|
|
636
|
-
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
|
637
|
-
"""Parse codex ``exec --json`` JSONL output (verified codex-cli
|
|
638
|
-
0.144.1, ``_codex/facts-codex.md``).
|
|
639
|
-
|
|
640
|
-
Real-run shape (one JSON object per line, newline-delimited)::
|
|
641
|
-
|
|
642
|
-
{"type":"thread.started","thread_id":"<uuid>"}
|
|
643
|
-
{"type":"turn.started"}
|
|
644
|
-
{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"2"}}
|
|
645
|
-
{"type":"turn.completed","usage":{"input_tokens":13810,"cached_input_tokens":9984,"output_tokens":5,"reasoning_output_tokens":0}}
|
|
646
|
-
|
|
647
|
-
The CLI is pre-1.0 and its JSON schema is not frozen, so parsing is
|
|
648
|
-
deliberately defensive at every level: individual lines that fail to
|
|
649
|
-
parse (or parse to something other than a JSON object) are SKIPPED
|
|
650
|
-
rather than aborting the whole parse — stray non-JSON noise on
|
|
651
|
-
stdout should not sink an otherwise-valid answer. The final answer
|
|
652
|
-
is the LAST ``item.completed`` event whose ``item`` is an
|
|
653
|
-
``agent_message`` with a string ``text``. If a completed answer was
|
|
654
|
-
found, it is returned even when a later ``error`` / ``turn.failed``
|
|
655
|
-
event also appears (a completed answer beats a trailing error); if
|
|
656
|
-
no answer was found, an ``error`` / ``turn.failed`` event (or the
|
|
657
|
-
total absence of any agent_message) raises a retryable
|
|
658
|
-
:class:`AdapterError`.
|
|
659
|
-
"""
|
|
660
|
-
text = stdout.decode("utf-8", "replace")
|
|
661
|
-
if not text.strip():
|
|
662
|
-
raise AdapterError(
|
|
663
|
-
"codex produced no stdout to parse",
|
|
664
|
-
provider=self.name,
|
|
665
|
-
retryable=True,
|
|
666
|
-
)
|
|
667
|
-
|
|
668
|
-
final_text: str | None = None
|
|
669
|
-
thread_id: str | None = None
|
|
670
|
-
failure_event: dict[str, Any] | None = None
|
|
671
|
-
prompt_tokens = 0
|
|
672
|
-
completion_tokens = 0
|
|
673
|
-
cached_tokens = 0
|
|
674
|
-
reasoning_tokens = 0
|
|
675
|
-
|
|
676
|
-
for line in text.splitlines():
|
|
677
|
-
line = line.strip()
|
|
678
|
-
if not line:
|
|
679
|
-
continue
|
|
680
|
-
try:
|
|
681
|
-
event = json.loads(line)
|
|
682
|
-
except json.JSONDecodeError:
|
|
683
|
-
# Defensive against stray non-JSON noise (progress text that
|
|
684
|
-
# leaked onto stdout, partial writes, etc.) — skip the line.
|
|
685
|
-
continue
|
|
686
|
-
if not isinstance(event, dict):
|
|
687
|
-
continue
|
|
688
|
-
|
|
689
|
-
etype = event.get("type")
|
|
690
|
-
if etype == "thread.started":
|
|
691
|
-
tid = event.get("thread_id")
|
|
692
|
-
if isinstance(tid, str):
|
|
693
|
-
thread_id = tid
|
|
694
|
-
elif etype == "item.completed":
|
|
695
|
-
item = event.get("item")
|
|
696
|
-
if (
|
|
697
|
-
isinstance(item, dict)
|
|
698
|
-
and item.get("type") == "agent_message"
|
|
699
|
-
and isinstance(item.get("text"), str)
|
|
700
|
-
):
|
|
701
|
-
final_text = item["text"]
|
|
702
|
-
elif etype == "turn.completed":
|
|
703
|
-
usage = event.get("usage")
|
|
704
|
-
usage = usage if isinstance(usage, dict) else {}
|
|
705
|
-
|
|
706
|
-
def _int(key: str, _usage: dict[str, Any] = usage) -> int:
|
|
707
|
-
value = _usage.get(key)
|
|
708
|
-
return int(value) if isinstance(value, (int, float)) else 0
|
|
709
|
-
|
|
710
|
-
prompt_tokens += _int("input_tokens")
|
|
711
|
-
completion_tokens += _int("output_tokens")
|
|
712
|
-
cached_tokens += _int("cached_input_tokens")
|
|
713
|
-
reasoning_tokens += _int("reasoning_output_tokens")
|
|
714
|
-
elif etype in ("error", "turn.failed"):
|
|
715
|
-
failure_event = event
|
|
716
|
-
|
|
717
|
-
if final_text is None:
|
|
718
|
-
if failure_event is not None:
|
|
719
|
-
raise AdapterError(
|
|
720
|
-
f"codex reported {failure_event.get('type')}: {failure_event!r}"[:500],
|
|
721
|
-
provider=self.name,
|
|
722
|
-
retryable=True,
|
|
723
|
-
)
|
|
724
|
-
raise AdapterError(
|
|
725
|
-
"codex JSONL output contained no agent_message",
|
|
726
|
-
provider=self.name,
|
|
727
|
-
retryable=True,
|
|
728
|
-
)
|
|
729
|
-
|
|
730
|
-
# cached_input_tokens is a SUBSET of input_tokens (not additive) —
|
|
731
|
-
# verified sample: input 13810 ⊇ cached 9984 — so it is preserved
|
|
732
|
-
# under prompt_tokens_details rather than folded into prompt_tokens
|
|
733
|
-
# (this differs from claude's normalization, design §5.1.6).
|
|
734
|
-
usage_out: dict[str, Any] = {
|
|
735
|
-
"prompt_tokens": prompt_tokens,
|
|
736
|
-
"completion_tokens": completion_tokens,
|
|
737
|
-
"total_tokens": prompt_tokens + completion_tokens,
|
|
738
|
-
}
|
|
739
|
-
if cached_tokens > 0:
|
|
740
|
-
usage_out["prompt_tokens_details"] = {"cached_tokens": cached_tokens}
|
|
741
|
-
if reasoning_tokens > 0:
|
|
742
|
-
usage_out["completion_tokens_details"] = {"reasoning_tokens": reasoning_tokens}
|
|
743
|
-
|
|
744
|
-
meta: dict[str, Any] = {}
|
|
745
|
-
if thread_id is not None:
|
|
746
|
-
meta["coderouter_session_id"] = thread_id
|
|
747
|
-
return final_text, usage_out, meta
|
|
748
|
-
|
|
749
|
-
# ------------------------------------------------------------------
|
|
750
|
-
# grok argv builder + output parser (Phase 1d)
|
|
751
|
-
# ------------------------------------------------------------------
|
|
752
|
-
|
|
753
|
-
def _build_grok_argv(
|
|
754
|
-
self, workdir: str, prompt_file: str | None = None, prompt: str | None = None
|
|
755
|
-
) -> list[str]:
|
|
756
|
-
"""Assemble the grok headless argv (design §5, grok CLI v0.2.93).
|
|
757
|
-
|
|
758
|
-
Shape::
|
|
759
|
-
|
|
760
|
-
grok --prompt-file <f> --output-format json -m <m> --cwd <w>
|
|
761
|
-
--max-turns <n> --no-memory --sandbox <profile>
|
|
762
|
-
[--permission-mode <mode> | --always-approve]
|
|
763
|
-
|
|
764
|
-
The prompt travels via ``--prompt-file`` (never argv / stdin): grok's
|
|
765
|
-
``-p`` requires the prompt as its argv value, and putting it there
|
|
766
|
-
would hit Linux's ~128KiB ``MAX_ARG_STRLEN`` on large prompts and
|
|
767
|
-
leak the text into ``ps`` output. ``--no-memory`` is deliberate: it
|
|
768
|
-
enforces the one-request-one-transformation statelessness even if
|
|
769
|
-
the user's grok config enables cross-session memory. ``prompt`` is
|
|
770
|
-
ignored (it exists only to keep the builder signature uniform across
|
|
771
|
-
agents — grok never takes it on argv).
|
|
772
|
-
"""
|
|
773
|
-
del prompt
|
|
774
|
-
if prompt_file is None: # pragma: no cover - generate() always supplies it
|
|
775
|
-
raise AdapterError(
|
|
776
|
-
"grok argv requires a prompt file",
|
|
777
|
-
provider=self.name,
|
|
778
|
-
retryable=False,
|
|
779
|
-
)
|
|
780
|
-
model = self.acfg.model or self.config.model
|
|
781
|
-
argv = [
|
|
782
|
-
self.acfg.command,
|
|
783
|
-
"--prompt-file",
|
|
784
|
-
prompt_file,
|
|
785
|
-
"--output-format",
|
|
786
|
-
"json",
|
|
787
|
-
"-m",
|
|
788
|
-
model,
|
|
789
|
-
"--cwd",
|
|
790
|
-
workdir,
|
|
791
|
-
]
|
|
792
|
-
if self.acfg.max_turns is not None:
|
|
793
|
-
argv += ["--max-turns", str(self.acfg.max_turns)]
|
|
794
|
-
argv += ["--no-memory"]
|
|
795
|
-
argv += self._grok_sandbox_args()
|
|
796
|
-
return argv
|
|
797
|
-
|
|
798
|
-
def _grok_sandbox_args(self) -> list[str]:
|
|
799
|
-
"""Map ``sandbox_mode`` → grok sandbox/approval flags, clamped.
|
|
800
|
-
|
|
801
|
-
Same clamp as claude (design §5.4): when ``allow_file_writes`` is
|
|
802
|
-
False the effective mode is forced to ``read_only`` regardless of
|
|
803
|
-
``sandbox_mode``, so writes always require the explicit opt-in.
|
|
804
|
-
"""
|
|
805
|
-
mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
|
|
806
|
-
return list(_GROK_SANDBOX_ARGS[mode])
|
|
807
|
-
|
|
808
|
-
def _parse_grok(
|
|
809
|
-
self, stdout: bytes, stderr: bytes
|
|
810
|
-
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
|
811
|
-
"""Parse grok ``--output-format json`` output (verified v0.2.93).
|
|
812
|
-
|
|
813
|
-
Real-run shape::
|
|
814
|
-
|
|
815
|
-
{"text": "...", "stopReason": "EndTurn", "sessionId": "<uuid>",
|
|
816
|
-
"requestId": "<uuid>", "thought": "..."}
|
|
817
|
-
|
|
818
|
-
The CLI is early beta, so parsing is deliberately defensive: empty /
|
|
819
|
-
non-JSON / non-object stdout and a missing ``text`` field all raise
|
|
820
|
-
a retryable :class:`AdapterError` so the chain can fall through.
|
|
821
|
-
``thought`` is ignored — only the final ``text`` is the answer.
|
|
822
|
-
"""
|
|
823
|
-
text = stdout.decode("utf-8", "replace").strip()
|
|
824
|
-
if not text:
|
|
825
|
-
raise AdapterError(
|
|
826
|
-
"grok produced no stdout to parse",
|
|
827
|
-
provider=self.name,
|
|
828
|
-
retryable=True,
|
|
829
|
-
)
|
|
830
|
-
try:
|
|
831
|
-
data = json.loads(text)
|
|
832
|
-
except json.JSONDecodeError as exc:
|
|
833
|
-
raise AdapterError(
|
|
834
|
-
f"grok emitted non-JSON output: {exc}",
|
|
835
|
-
provider=self.name,
|
|
836
|
-
retryable=True,
|
|
837
|
-
) from exc
|
|
838
|
-
if not isinstance(data, dict):
|
|
839
|
-
raise AdapterError(
|
|
840
|
-
"grok JSON output was not an object",
|
|
841
|
-
provider=self.name,
|
|
842
|
-
retryable=True,
|
|
843
|
-
)
|
|
844
|
-
result = data.get("text")
|
|
845
|
-
if not isinstance(result, str):
|
|
846
|
-
raise AdapterError(
|
|
847
|
-
"grok JSON output missing string 'text' field",
|
|
848
|
-
provider=self.name,
|
|
849
|
-
retryable=True,
|
|
850
|
-
)
|
|
851
|
-
|
|
852
|
-
# grok emits NO token usage / cost fields (verified), so usage is
|
|
853
|
-
# all-zeros; the cost dashboard shows 0 for this provider unless the
|
|
854
|
-
# operator sets ``ProviderConfig.cost`` rates (design §5.1.6).
|
|
855
|
-
usage: dict[str, Any] = {
|
|
856
|
-
"prompt_tokens": 0,
|
|
857
|
-
"completion_tokens": 0,
|
|
858
|
-
"total_tokens": 0,
|
|
859
|
-
}
|
|
860
|
-
meta: dict[str, Any] = {}
|
|
861
|
-
session_id = data.get("sessionId")
|
|
862
|
-
if isinstance(session_id, str):
|
|
863
|
-
meta["coderouter_session_id"] = session_id
|
|
864
|
-
return result, usage, meta
|
|
865
|
-
|
|
866
|
-
# ------------------------------------------------------------------
|
|
867
|
-
# antigravity argv builder + output parser (Phase 1c, in lieu of gemini)
|
|
868
|
-
# ------------------------------------------------------------------
|
|
869
|
-
|
|
870
|
-
def _build_antigravity_argv(
|
|
871
|
-
self, workdir: str, prompt_file: str | None = None, prompt: str | None = None
|
|
872
|
-
) -> list[str]:
|
|
873
|
-
"""Assemble the ``agy -p`` argv (design §5.1.5 / §5.4, verified
|
|
874
|
-
against Antigravity CLI 1.1.1 — see ``_codex/facts-antigravity.md``).
|
|
875
|
-
|
|
876
|
-
Shape::
|
|
877
|
-
|
|
878
|
-
agy -p <prompt> --model <m> --mode <plan|accept-edits>
|
|
879
|
-
[--dangerously-skip-permissions] --print-timeout <n>s
|
|
880
|
-
|
|
881
|
-
``workdir`` is unused here — antigravity picks up its working
|
|
882
|
-
directory from the child process's ``cwd`` (set by ``generate()``),
|
|
883
|
-
and this adapter deliberately never passes ``--add-dir`` (design
|
|
884
|
-
keeps the argv minimal; only claude's multi-root model needs it).
|
|
885
|
-
``prompt_file`` is unused (antigravity has no such flag).
|
|
886
|
-
|
|
887
|
-
Prompt-delivery tradeoff (read this before touching the ``-p``
|
|
888
|
-
line): agy has no stdin channel — piping content to stdin makes the
|
|
889
|
-
real CLI hang waiting for a response that never comes (field-
|
|
890
|
-
verified on 1.1.1) — and no ``--prompt-file`` equivalent either. The
|
|
891
|
-
prompt therefore rides argv as the ``-p`` value, which is the *only*
|
|
892
|
-
delivery mechanism the CLI offers. This caps practical prompt size
|
|
893
|
-
at Linux's ~128KiB ``MAX_ARG_STRLEN`` and exposes the prompt text to
|
|
894
|
-
``ps`` on the local host — exactly the two costs grok's
|
|
895
|
-
``--prompt-file`` delivery was built to avoid (see the module
|
|
896
|
-
docstring's security section). No shell is involved (list argv, not
|
|
897
|
-
shell text), and the adapter's threat model — an isolated,
|
|
898
|
-
single-operator workstation — accepts local ``ps`` visibility as a
|
|
899
|
-
documented limitation rather than a defect; there is no safer
|
|
900
|
-
channel to fall back to.
|
|
901
|
-
|
|
902
|
-
``--print-timeout`` is antigravity's own self-termination clock,
|
|
903
|
-
derived from ``exec_timeout_s`` — it is the CLI's *first* wall
|
|
904
|
-
against a hung call; the adapter's outer ``asyncio.wait_for`` +
|
|
905
|
-
process-group ``SIGKILL`` remains the second, unconditional wall
|
|
906
|
-
(design §6). ``max_turns`` is never emitted: agy has no ``--max-
|
|
907
|
-
turns``-equivalent flag (like codex), so ``AgentCliConfig.max_turns``
|
|
908
|
-
is silently ignored here (documented in the schema). No ``--sandbox``
|
|
909
|
-
(known bypass bug alongside ``--dangerously-skip-permissions``,
|
|
910
|
-
agy issue #36) and no ``--add-dir`` are ever passed.
|
|
911
|
-
"""
|
|
912
|
-
del prompt_file # antigravity has no prompt-file flag.
|
|
913
|
-
if prompt is None: # pragma: no cover - generate() always supplies it
|
|
914
|
-
raise AdapterError(
|
|
915
|
-
"antigravity argv requires the prompt text",
|
|
916
|
-
provider=self.name,
|
|
917
|
-
retryable=False,
|
|
918
|
-
)
|
|
919
|
-
model = self.acfg.model or self.config.model
|
|
920
|
-
argv = [self.acfg.command, "-p", prompt, "--model", model]
|
|
921
|
-
argv += self._antigravity_mode_args()
|
|
922
|
-
argv += ["--print-timeout", f"{int(self.acfg.exec_timeout_s)}s"]
|
|
923
|
-
return argv
|
|
924
|
-
|
|
925
|
-
def _antigravity_mode_args(self) -> list[str]:
|
|
926
|
-
"""Map ``sandbox_mode`` → antigravity ``--mode`` flags, clamped.
|
|
927
|
-
|
|
928
|
-
Same clamp as claude/codex/grok (design §5.4): when
|
|
929
|
-
``allow_file_writes`` is False the effective mode is forced to
|
|
930
|
-
``read_only`` regardless of ``sandbox_mode``, so writes always
|
|
931
|
-
require the explicit opt-in.
|
|
932
|
-
"""
|
|
933
|
-
mode = self.acfg.sandbox_mode if self.acfg.allow_file_writes else "read_only"
|
|
934
|
-
return list(_ANTIGRAVITY_MODE_ARGS[mode])
|
|
935
|
-
|
|
936
|
-
def _parse_antigravity(
|
|
937
|
-
self, stdout: bytes, stderr: bytes
|
|
938
|
-
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
|
939
|
-
"""Parse antigravity's plain-text ``-p`` output (verified agy 1.1.1).
|
|
940
|
-
|
|
941
|
-
There is no ``--output-format`` flag at all (agy's ``--help`` does
|
|
942
|
-
not list one) — output is whatever the model printed, decorated
|
|
943
|
-
with whatever terminal styling the CLI applied even in non-TTY runs.
|
|
944
|
-
Parsing is therefore: UTF-8 decode (defensively, replacing invalid
|
|
945
|
-
bytes) → strip ANSI escape sequences (``_ANSI_RE``, best-effort, see
|
|
946
|
-
its definition) → ``.strip()`` surrounding whitespace. Empty output
|
|
947
|
-
raises a retryable :class:`AdapterError` so the chain can fall
|
|
948
|
-
through. There is no token/cost figure and no session id anywhere
|
|
949
|
-
in agy's output, so ``usage`` is all-zeros (cost stays 0 unless the
|
|
950
|
-
operator sets ``ProviderConfig.cost``, same rationale as grok,
|
|
951
|
-
design §5.1.6) and ``meta`` is empty.
|
|
952
|
-
"""
|
|
953
|
-
text = _ANSI_RE.sub("", stdout.decode("utf-8", "replace")).strip()
|
|
954
|
-
if not text:
|
|
955
|
-
raise AdapterError(
|
|
956
|
-
"antigravity produced no stdout",
|
|
957
|
-
provider=self.name,
|
|
958
|
-
retryable=True,
|
|
959
|
-
)
|
|
960
|
-
usage: dict[str, Any] = {
|
|
961
|
-
"prompt_tokens": 0,
|
|
962
|
-
"completion_tokens": 0,
|
|
963
|
-
"total_tokens": 0,
|
|
964
|
-
}
|
|
965
|
-
meta: dict[str, Any] = {}
|
|
966
|
-
return text, usage, meta
|
|
967
|
-
|
|
968
|
-
# ------------------------------------------------------------------
|
|
969
|
-
# helpers: prompt rendering, response shaping, env, workdir, kill
|
|
970
|
-
# ------------------------------------------------------------------
|
|
971
|
-
|
|
972
|
-
def _write_prompt_file(self, prompt: str, workdir: str) -> str:
|
|
973
|
-
"""Write the prompt to a private ``0600`` temp file in ``workdir``.
|
|
974
|
-
|
|
975
|
-
Rationale: grok's ``-p`` requires the prompt as an argv value, but a
|
|
976
|
-
huge prompt would hit Linux's ~128KiB ``MAX_ARG_STRLEN`` and argv
|
|
977
|
-
leaks into ``ps`` output; file delivery keeps argv small and private,
|
|
978
|
-
and ``0600`` + the isolated workdir bounds exposure. ``O_EXCL`` with
|
|
979
|
-
a uuid4 name makes creation race-free; the caller (``generate``)
|
|
980
|
-
deletes the file in a ``finally`` block on every path.
|
|
981
|
-
"""
|
|
982
|
-
path = os.path.join(workdir, f".coderouter-prompt-{uuid.uuid4().hex}.txt")
|
|
983
|
-
try:
|
|
984
|
-
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
985
|
-
except OSError as exc:
|
|
986
|
-
raise AdapterError(
|
|
987
|
-
f"failed to create prompt file in {workdir}: {exc}",
|
|
988
|
-
provider=self.name,
|
|
989
|
-
retryable=False,
|
|
990
|
-
) from exc
|
|
991
|
-
try:
|
|
992
|
-
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
993
|
-
handle.write(prompt)
|
|
994
|
-
except OSError as exc:
|
|
995
|
-
with contextlib.suppress(OSError):
|
|
996
|
-
os.unlink(path)
|
|
997
|
-
raise AdapterError(
|
|
998
|
-
f"failed to write prompt file {path}: {exc}",
|
|
999
|
-
provider=self.name,
|
|
1000
|
-
retryable=False,
|
|
1001
|
-
) from exc
|
|
1002
|
-
return path
|
|
1003
|
-
|
|
1004
|
-
def _to_chat_response(
|
|
1005
|
-
self, final_text: str, usage: dict[str, Any], meta: dict[str, Any]
|
|
1006
|
-
) -> ChatResponse:
|
|
1007
|
-
"""Wrap the agent's final text in an OpenAI ChatResponse."""
|
|
1008
|
-
return ChatResponse(
|
|
1009
|
-
id=f"chatcmpl-{uuid.uuid4().hex}",
|
|
1010
|
-
created=int(time.time()),
|
|
1011
|
-
model=self.config.model,
|
|
1012
|
-
choices=[
|
|
1013
|
-
{
|
|
1014
|
-
"index": 0,
|
|
1015
|
-
"message": {"role": "assistant", "content": final_text},
|
|
1016
|
-
"finish_reason": "stop",
|
|
1017
|
-
}
|
|
1018
|
-
],
|
|
1019
|
-
usage=usage,
|
|
1020
|
-
coderouter_provider=self.name,
|
|
1021
|
-
**meta,
|
|
1022
|
-
)
|
|
1023
|
-
|
|
1024
|
-
def _render_prompt(self, request: ChatRequest, overrides: ProviderCallOverrides | None) -> str:
|
|
1025
|
-
"""Flatten the chat messages into a single role-tagged prompt string.
|
|
1026
|
-
|
|
1027
|
-
The profile-level ``append_system_prompt`` (if any) is prepended as a
|
|
1028
|
-
leading system block, matching the openai_compat directive semantics.
|
|
1029
|
-
"""
|
|
1030
|
-
parts: list[str] = []
|
|
1031
|
-
directive = self.effective_append_system_prompt(overrides)
|
|
1032
|
-
if directive:
|
|
1033
|
-
parts.append(f"[system]\n{directive}")
|
|
1034
|
-
for message in request.messages:
|
|
1035
|
-
text = self._message_text(message.content)
|
|
1036
|
-
if text:
|
|
1037
|
-
parts.append(f"[{message.role}]\n{text}")
|
|
1038
|
-
return "\n\n".join(parts).strip()
|
|
1039
|
-
|
|
1040
|
-
@staticmethod
|
|
1041
|
-
def _message_text(content: str | list[dict[str, Any]] | None) -> str:
|
|
1042
|
-
"""Extract plain text from a message's ``content`` (str or blocks)."""
|
|
1043
|
-
if content is None:
|
|
1044
|
-
return ""
|
|
1045
|
-
if isinstance(content, str):
|
|
1046
|
-
return content
|
|
1047
|
-
chunks: list[str] = []
|
|
1048
|
-
for part in content:
|
|
1049
|
-
if isinstance(part, dict) and part.get("type") == "text":
|
|
1050
|
-
text = part.get("text")
|
|
1051
|
-
if isinstance(text, str):
|
|
1052
|
-
chunks.append(text)
|
|
1053
|
-
return "\n".join(chunks)
|
|
1054
|
-
|
|
1055
|
-
def _current_depth(self) -> int:
|
|
1056
|
-
"""Read the current recursion depth from the environment (0 default)."""
|
|
1057
|
-
raw = os.environ.get(_DEPTH_ENV, "0")
|
|
1058
|
-
try:
|
|
1059
|
-
return int(raw)
|
|
1060
|
-
except ValueError:
|
|
1061
|
-
return 0
|
|
1062
|
-
|
|
1063
|
-
def _build_child_env(self) -> dict[str, str]:
|
|
1064
|
-
"""Build the minimal child environment (design §5.3).
|
|
1065
|
-
|
|
1066
|
-
The child does NOT inherit the parent environment. Only a fixed base
|
|
1067
|
-
(PATH / NO_COLOR / TERM), the inherited HOME / USER / LOGNAME (for
|
|
1068
|
-
credential discovery — on macOS the Claude Code CLI resolves its
|
|
1069
|
-
Keychain entry via ``USER``; without it headless runs fail with
|
|
1070
|
-
"Not logged in"), the incremented recursion depth, and the operator's
|
|
1071
|
-
``passthrough_env`` allowlist are injected. ``ANTHROPIC_API_KEY`` is
|
|
1072
|
-
therefore excluded unless explicitly allowlisted.
|
|
1073
|
-
"""
|
|
1074
|
-
parent = os.environ
|
|
1075
|
-
env: dict[str, str] = {
|
|
1076
|
-
"PATH": _SAFE_PATH,
|
|
1077
|
-
"NO_COLOR": "1",
|
|
1078
|
-
"TERM": "dumb",
|
|
1079
|
-
_DEPTH_ENV: str(self._current_depth() + 1),
|
|
1080
|
-
}
|
|
1081
|
-
for name in ("HOME", "USER", "LOGNAME"):
|
|
1082
|
-
value = parent.get(name)
|
|
1083
|
-
if value:
|
|
1084
|
-
env[name] = value
|
|
1085
|
-
for name in self.acfg.passthrough_env:
|
|
1086
|
-
value = parent.get(name)
|
|
1087
|
-
if value is not None:
|
|
1088
|
-
env[name] = value
|
|
1089
|
-
return env
|
|
1090
|
-
|
|
1091
|
-
@staticmethod
|
|
1092
|
-
def _error_detail(stdout: bytes, stderr: bytes) -> str:
|
|
1093
|
-
"""Extract the most useful error text from a failed CLI run.
|
|
1094
|
-
|
|
1095
|
-
The Claude Code CLI reports auth / API failures as an ``is_error:
|
|
1096
|
-
true`` result JSON on **stdout** with exit code 1 (stderr stays
|
|
1097
|
-
empty), so a stderr-only tail hides the actual cause (e.g. ``Not
|
|
1098
|
-
logged in · Please run /login``). Preference order: the ``result``
|
|
1099
|
-
field of an ``is_error`` stdout JSON → stderr tail → stdout tail.
|
|
1100
|
-
"""
|
|
1101
|
-
if stdout:
|
|
1102
|
-
with contextlib.suppress(ValueError, TypeError):
|
|
1103
|
-
doc = json.loads(stdout)
|
|
1104
|
-
if isinstance(doc, dict) and doc.get("is_error"):
|
|
1105
|
-
result = doc.get("result")
|
|
1106
|
-
if isinstance(result, str) and result.strip():
|
|
1107
|
-
return result.strip()[:_MAX_STDERR_TAIL]
|
|
1108
|
-
if stderr:
|
|
1109
|
-
return repr(stderr[-_MAX_STDERR_TAIL:].decode("utf-8", "replace"))
|
|
1110
|
-
if stdout:
|
|
1111
|
-
return repr(stdout[-_MAX_STDERR_TAIL:].decode("utf-8", "replace"))
|
|
1112
|
-
return "''"
|
|
1113
|
-
|
|
1114
|
-
def _resolve_workdir(self) -> str:
|
|
1115
|
-
"""Resolve + create the working directory, rejecting ``..`` escapes.
|
|
1116
|
-
|
|
1117
|
-
A configured ``workdir`` is ``~`` / env-var expanded and resolved to
|
|
1118
|
-
an absolute path; a literal ``..`` component is rejected as an escape
|
|
1119
|
-
attempt. When unset, a dedicated isolated directory
|
|
1120
|
-
(``~/.coderouter/agents/<name>``) is used (design §6).
|
|
1121
|
-
"""
|
|
1122
|
-
raw = self.acfg.workdir
|
|
1123
|
-
if raw:
|
|
1124
|
-
if ".." in Path(raw).parts:
|
|
1125
|
-
raise AdapterError(
|
|
1126
|
-
f"workdir {raw!r} must not contain '..' (escape attempt)",
|
|
1127
|
-
provider=self.name,
|
|
1128
|
-
retryable=False,
|
|
1129
|
-
)
|
|
1130
|
-
expanded = os.path.expanduser(os.path.expandvars(raw))
|
|
1131
|
-
path = Path(expanded).resolve()
|
|
1132
|
-
else:
|
|
1133
|
-
path = (Path.home() / ".coderouter" / "agents" / self.name).resolve()
|
|
1134
|
-
try:
|
|
1135
|
-
path.mkdir(parents=True, exist_ok=True)
|
|
1136
|
-
except OSError as exc:
|
|
1137
|
-
raise AdapterError(
|
|
1138
|
-
f"failed to prepare workdir {path}: {exc}",
|
|
1139
|
-
provider=self.name,
|
|
1140
|
-
retryable=False,
|
|
1141
|
-
) from exc
|
|
1142
|
-
return str(path)
|
|
1143
|
-
|
|
1144
|
-
def _kill_process_group(self, proc: asyncio.subprocess.Process) -> None:
|
|
1145
|
-
"""SIGKILL the child's whole process group (best effort)."""
|
|
1146
|
-
pid = proc.pid
|
|
1147
|
-
try:
|
|
1148
|
-
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
|
1149
|
-
except (ProcessLookupError, PermissionError, OSError):
|
|
1150
|
-
# Group already gone / no permission — fall back to a direct kill.
|
|
1151
|
-
with contextlib.suppress(ProcessLookupError, OSError):
|
|
1152
|
-
proc.kill()
|
|
1153
|
-
|
|
1154
|
-
def _is_retryable_exit(self, returncode: int | None) -> bool:
|
|
1155
|
-
"""Whether a non-zero exit should let the chain fall through.
|
|
1156
|
-
|
|
1157
|
-
Phase 1a treats any non-zero exit as transient (rate-limit / OAuth
|
|
1158
|
-
expiry / network), so the fallback engine advances to the next
|
|
1159
|
-
provider rather than surfacing a terminal failure.
|
|
1160
|
-
"""
|
|
1161
|
-
return True
|
|
1162
|
-
|
|
1163
|
-
# ``BaseAdapter`` (HTTP-oriented) lazily builds an httpx client; the agent
|
|
1164
|
-
# adapter never touches it, but ``aclose`` on the inherited base is a safe
|
|
1165
|
-
# no-op, so nothing extra is needed here.
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
__all__ = ["AgentCliAdapter"]
|