forked from Jason-Vaughan/TangleBrain
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcli.py
More file actions
341 lines (276 loc) · 13.6 KB
/
Copy pathcli.py
File metadata and controls
341 lines (276 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
"""CLI adapter — the authenticated-CLI tier.
Runs a configured command-line tool (e.g. ``claude`` / ``codex`` / ``gemini``) as a subprocess and
returns its final text, behind the uniform :class:`~tanglebrain.adapters.base.Adapter` interface. The
routing layer never sees the per-CLI differences below — it hands over a prompt and gets text.
Two things vary per CLI and are config-driven from the roster, never hardcoded here:
- **How the prompt is passed.** A literal ``{prompt}`` token anywhere in the roster ``cmd`` is
replaced with the prompt (e.g. gemini's ``-p {prompt}``); with no token the prompt is appended
as the final argument (claude's ``-p ... <prompt>``, codex's ``exec <prompt>``). The prompt is
passed through ``argv`` with **no shell** (``shell=True`` is never used), so it cannot be
interpreted as shell syntax.
- **How the final text is extracted.** ``invoke.parse`` names a parser (:data:`PARSERS`):
``claude-json`` (single ``{"result": ...}`` object), ``gemini-json`` (``{"response": ...}``),
or ``plain`` (stripped stdout — codex prints the answer to stdout, metadata to stderr).
The safety-critical piece is **env-scrub**: ``invoke.scrub_env`` names env vars stripped from the
subprocess environment, so a CLI runs against its own authenticated session rather than an injected
API key (e.g. ``claude -p`` without ``ANTHROPIC_API_KEY``). Scrubbing operates on a **copy** of the
environment — the parent process's ``os.environ`` is never mutated.
Like the openai-compat adapter, failures (non-zero exit, timeout, missing binary, unparseable
output) surface as :class:`~tanglebrain.adapters.base.AdapterError`. This layer never retries or
falls back — the routing layer decides what to do next.
"""
from __future__ import annotations
import json
import os
import subprocess
from typing import Callable, Mapping
from tanglebrain.adapters.base import AdapterError
from tanglebrain.roster import RosterEntry
PROMPT_TOKEN = "{prompt}"
DEFAULT_TIMEOUT_SECONDS = 300.0
DEFAULT_PARSER = "plain"
def _parse_plain(stdout: str) -> str:
"""Return stripped stdout as the final text (codex ``exec`` and any plain-text CLI).
Args:
stdout: The subprocess's captured stdout.
Returns:
The stripped text.
Raises:
AdapterError: If stdout is empty/whitespace-only (no answer produced).
"""
text = stdout.strip()
if not text:
raise AdapterError("CLI produced no stdout to parse as text")
return text
def _parse_json_field(stdout: str, field: str, *, label: str) -> str:
"""Parse stdout as a single JSON object and return one string field.
Args:
stdout: The subprocess's captured stdout (expected to be one JSON object).
field: The key whose value is the final text.
label: Human label for the source CLI, used in error messages.
Returns:
The value at ``field``.
Raises:
AdapterError: If stdout is not valid JSON, is not an object, the field is missing, or
the field's value is not a (non-empty) string.
"""
try:
data = json.loads(stdout)
except json.JSONDecodeError as exc:
raise AdapterError(f"{label}: stdout is not valid JSON: {exc}; got {stdout!r}") from exc
if not isinstance(data, dict):
raise AdapterError(f"{label}: expected a JSON object, got {type(data).__name__}")
if field not in data:
raise AdapterError(f"{label}: response JSON missing {field!r} field: {data!r}")
value = data[field]
if not isinstance(value, str) or not value.strip():
raise AdapterError(f"{label}: {field!r} is not non-empty text: {value!r}")
return value
def _parse_claude_json(stdout: str) -> str:
"""Parse ``claude -p --output-format json`` output and return the result text.
Claude emits a single JSON object with an ``is_error`` flag and the answer in ``result``.
Args:
stdout: The subprocess's captured stdout.
Returns:
The ``result`` text.
Raises:
AdapterError: If the JSON is malformed/unexpected, ``is_error`` is true, or ``result``
is missing or not non-empty text.
"""
try:
data = json.loads(stdout)
except json.JSONDecodeError as exc:
raise AdapterError(f"claude: stdout is not valid JSON: {exc}; got {stdout!r}") from exc
if not isinstance(data, dict):
raise AdapterError(f"claude: expected a JSON object, got {type(data).__name__}")
if data.get("is_error"):
raise AdapterError(
f"claude reported an error (subtype={data.get('subtype')!r}): {data.get('result')!r}"
)
return _parse_json_field(stdout, "result", label="claude")
def _parse_gemini_json(stdout: str) -> str:
"""Parse ``gemini -p ... --output-format json`` output and return the response text.
Gemini emits a single JSON object with the answer in ``response`` (alongside a ``stats``
block that is intentionally ignored).
Args:
stdout: The subprocess's captured stdout.
Returns:
The ``response`` text.
Raises:
AdapterError: If the JSON is malformed/unexpected or ``response`` is missing/not text.
"""
return _parse_json_field(stdout, "response", label="gemini")
#: Output parsers keyed by ``invoke.parse`` name. Adding a CLI with a new output shape = a new
#: entry here plus the name in the roster — the routing layer is unaffected.
PARSERS: dict[str, Callable[[str], str]] = {
"plain": _parse_plain,
"claude-json": _parse_claude_json,
"gemini-json": _parse_gemini_json,
}
def scrubbed_env(scrub_env: list[str]) -> dict[str, str]:
"""Return a copy of the current environment with ``scrub_env`` names removed.
This is the session-vs-key safety boundary: removing ``ANTHROPIC_API_KEY`` makes ``claude -p``
run against its own authenticated session rather than an injected API key. The parent's
``os.environ`` is **not** mutated — only the returned copy (handed to the subprocess) is.
Args:
scrub_env: Env var names to remove. Names absent from the environment are ignored.
Returns:
A fresh ``dict`` of the environment minus the scrubbed names.
"""
env = dict(os.environ)
for name in scrub_env:
env.pop(name, None)
return env
def build_argv(cmd: list[str], prompt: str) -> list[str]:
"""Build the subprocess argv, injecting ``prompt`` into ``cmd``.
If any ``cmd`` element contains the literal ``{prompt}`` token, the token is replaced (in
place, in every element that contains it). Otherwise the prompt is appended as the final
argument. No shell is involved, so the prompt is never interpreted as shell syntax.
Args:
cmd: The roster ``invoke.cmd`` argv template.
prompt: The prompt to inject.
Returns:
The concrete argv to execute.
"""
if any(PROMPT_TOKEN in part for part in cmd):
return [part.replace(PROMPT_TOKEN, prompt) for part in cmd]
return [*cmd, prompt]
class CliAdapter:
"""Adapter that runs prompts through a subscription CLI subprocess.
Implements the uniform :class:`~tanglebrain.adapters.base.Adapter` interface
(``run(prompt, opts) -> text``).
"""
def __init__(
self,
cmd: list[str],
parse: str | None = None,
scrub_env: list[str] | None = None,
delegate_args: list[str] | None = None,
inject_delegate: bool = False,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
) -> None:
"""Configure the adapter.
Args:
cmd: The argv template (see :func:`build_argv` for ``{prompt}`` handling).
parse: Name of the output parser (a key of :data:`PARSERS`). ``None`` uses
:data:`DEFAULT_PARSER` (``plain``).
scrub_env: Env var names to strip from the subprocess environment.
delegate_args: Per-CLI flags that make the local-delegate tool available to this CLI
as an orchestrator. A ``{delegate_mcp_json}`` token is substituted with the
delegate's MCP-server JSON. Only applied when ``inject_delegate`` is true.
inject_delegate: When true, append the (substituted) ``delegate_args`` to the command
so the orchestrator can offload sub-tasks to the free local backend.
timeout: Per-call subprocess timeout in seconds.
Raises:
AdapterError: If ``cmd`` is empty, or ``parse`` names an unknown parser.
"""
if not cmd:
raise AdapterError("CliAdapter requires a non-empty cmd")
parser_name = parse or DEFAULT_PARSER
if parser_name not in PARSERS:
raise AdapterError(
f"unknown parser {parser_name!r}; expected one of {sorted(PARSERS)}"
)
self.cmd = list(cmd)
self.parser_name = parser_name
self.scrub_env = list(scrub_env or [])
self.delegate_args = list(delegate_args or [])
self.inject_delegate = inject_delegate
self.timeout = timeout
@classmethod
def from_entry(
cls, entry: RosterEntry, inject_delegate: bool = False, **overrides: object
) -> "CliAdapter":
"""Build an adapter from a ``cli`` roster entry.
Args:
entry: A roster entry whose ``invoke.kind`` is ``cli``.
inject_delegate: Make the local-delegate tool available to this CLI; honors the
entry's ``invoke.delegate_args``.
**overrides: Optional constructor overrides (``timeout``).
Returns:
A configured :class:`CliAdapter`.
Raises:
AdapterError: If the entry's invoke kind is not ``cli``, or ``cmd`` is missing.
"""
if entry.invoke.kind != "cli":
raise AdapterError(
f"entry {entry.id!r} has invoke.kind {entry.invoke.kind!r}, not 'cli'"
)
if not entry.invoke.cmd:
raise AdapterError(f"entry {entry.id!r}: cli invoke requires a non-empty cmd")
return cls(
cmd=entry.invoke.cmd,
parse=entry.invoke.parse,
scrub_env=entry.invoke.scrub_env,
delegate_args=entry.invoke.delegate_args,
inject_delegate=inject_delegate,
**overrides, # type: ignore[arg-type]
)
def _effective_cmd(self) -> list[str]:
"""Return the base ``cmd``, with substituted ``delegate_args`` appended when injecting.
The delegate tokens (``{delegate_mcp_json}``, ``{delegate_mcp_command}``) in
``delegate_args`` are replaced via ``delegate_substitutions()`` (imported lazily so the
mcp-free import graph is unaffected). Delegate flags land after the base command and before
the prompt (added by :func:`build_argv`).
Returns:
The command with delegate flags applied, or just ``self.cmd`` when not injecting.
"""
if not self.inject_delegate or not self.delegate_args:
return self.cmd
from tanglebrain.delegate import delegate_substitutions
subs = delegate_substitutions()
injected = []
for arg in self.delegate_args:
for token, value in subs.items():
arg = arg.replace(token, value)
injected.append(arg)
return [*self.cmd, *injected]
def run(self, prompt: str, opts: Mapping[str, object] | None = None) -> str:
"""Run the prompt through the CLI subprocess and return the final text.
Args:
prompt: The prompt to send.
opts: Optional per-call options. Recognized key: ``timeout`` (float, seconds).
Other keys are ignored (per the adapter contract).
Returns:
The CLI's final response text, per this adapter's parser.
Raises:
AdapterError: On a missing binary, non-zero exit, timeout, or unparseable output.
"""
opts = opts or {}
timeout = float(opts.get("timeout", self.timeout))
argv = build_argv(self._effective_cmd(), prompt)
env = scrubbed_env(self.scrub_env)
# When this CLI is an orchestrator carrying the delegate tool, propagate the top-level task
# id into its environment so the MCP delegate child it spawns can stamp each sub-call's
# parent_task_id (linking the delegation tree across the process boundary). Gated on
# inject_delegate: a leaf CLI call spawns no delegate child, so there is nothing to link.
# Lazy import keeps the constant's home module (measurement) out of this adapter's import
# graph, mirroring the lazy delegate_substitutions import in _effective_cmd.
task_id = opts.get("task_id")
if self.inject_delegate and task_id is not None:
from tanglebrain.measurement import PARENT_TASK_ID_ENV
env[PARENT_TASK_ID_ENV] = str(task_id)
try:
completed = subprocess.run(
argv,
# The prompt travels via argv (see build_argv); close stdin with EOF so a CLI
# that probes stdin for "additional input" (e.g. codex) does not block.
input="",
capture_output=True,
text=True,
timeout=timeout,
env=env,
check=False,
)
except FileNotFoundError as exc:
raise AdapterError(f"CLI binary not found: {argv[0]!r} ({exc})") from exc
except subprocess.TimeoutExpired as exc:
raise AdapterError(
f"CLI {argv[0]!r} timed out after {timeout}s"
) from exc
if completed.returncode != 0:
stderr = (completed.stderr or "").strip()
raise AdapterError(
f"CLI {argv[0]!r} exited {completed.returncode}: {stderr or '(no stderr)'}"
)
return PARSERS[self.parser_name](completed.stdout)