autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/providers.py
ADDED
|
@@ -0,0 +1,2893 @@
|
|
|
1
|
+
"""Agent provider adapters: the only place that knows real CLI flags.
|
|
2
|
+
|
|
3
|
+
The engine speaks :class:`AgentRequest` / :class:`AgentExecutionResult`;
|
|
4
|
+
each provider translates a logical execution profile (provider, model,
|
|
5
|
+
effort, timeout, options) into a concrete non-interactive CLI invocation.
|
|
6
|
+
|
|
7
|
+
Verified against locally installed CLIs (2026-09):
|
|
8
|
+
|
|
9
|
+
Claude Code (``claude 2.1.x``; the stream run against 2.1.291 to 2.1.293)::
|
|
10
|
+
|
|
11
|
+
claude -p --output-format stream-json --verbose --model <alias|id>
|
|
12
|
+
--effort <low|medium|high|xhigh|max> --permission-mode <mode>
|
|
13
|
+
--no-session-persistence --safe-mode --strict-mcp-config
|
|
14
|
+
[extra_args] -- <prompt>
|
|
15
|
+
|
|
16
|
+
* ``-p/--print`` = non-interactive. With ``stream-json`` (the default,
|
|
17
|
+
#192) stdout is one JSON record per line while the agent runs
|
|
18
|
+
(``system`` init / thinking_tokens / api_retry, ``assistant`` content
|
|
19
|
+
blocks, ``user`` tool results, ``rate_limit_event``) and a ``result``
|
|
20
|
+
record that ends the turn, whose ``result`` field is its final
|
|
21
|
+
assistant text; a background task or a scheduled wakeup still pending
|
|
22
|
+
then keeps the CLI alive for another turn with its own ``result``
|
|
23
|
+
(held back to the end, in turn order, while a background agent runs).
|
|
24
|
+
The CLI refuses ``stream-json`` under ``--print`` without ``--verbose``.
|
|
25
|
+
The child runs under the duplex handle and the stream is reduced in
|
|
26
|
+
claude_stream.py: ``stdout`` of the result is the last ``result``
|
|
27
|
+
text verbatim (so the CONTROL_RESULT block survives untouched), the
|
|
28
|
+
other records become live progress (:mod:`autoforge.progress`), and an
|
|
29
|
+
error result in any turn, a result outside a turn, a malformed line or
|
|
30
|
+
a turn without its result is a
|
|
31
|
+
:attr:`AgentExecutionResult.provider_failure`.
|
|
32
|
+
* ``options.output_format: text`` is the opt-out: the final text is
|
|
33
|
+
printed once, at exit, and the run reports no progress and no activity
|
|
34
|
+
while it runs.
|
|
35
|
+
* ``--permission-mode bypassPermissions`` is required for unattended
|
|
36
|
+
edits and local git; ``--permission-prompts none`` would silently
|
|
37
|
+
deny anything that prompts. Configure via profile ``options``.
|
|
38
|
+
* nothing asks a person (#258; run on 2.1.312 against a loopback
|
|
39
|
+
stand-in of the Messages API, so no model was called): a tool call the
|
|
40
|
+
profile's mode does not allow, or that a deny rule of the user settings
|
|
41
|
+
names, is refused at once and the agent goes on without it (exit 0),
|
|
42
|
+
and ``AskUserQuestion`` is not offered under ``-p``. The stream says
|
|
43
|
+
which (claude_stream.py); the adapter names them in a run that fails
|
|
44
|
+
anyway and in the run log (:func:`_name_refused`). ``text`` output
|
|
45
|
+
carries none of it.
|
|
46
|
+
* ``--safe-mode`` and ``--strict-mcp-config`` (#165): no hook, installed
|
|
47
|
+
plugin or MCP server of the user's, the project's or a plugin's
|
|
48
|
+
configuration runs, so none can hand the agent a GitHub token of its
|
|
49
|
+
own; skills, custom agents and commands and CLAUDE.md auto-discovery
|
|
50
|
+
are off too (every phase prompt tells the agent to read ``AGENTS.md`` /
|
|
51
|
+
``CLAUDE.md``). Checked against 2.1.296 with marker hooks and MCP
|
|
52
|
+
servers and no login (they run before the model is asked): user and
|
|
53
|
+
project settings, ``.mcp.json``, an installed marketplace plugin,
|
|
54
|
+
``--settings``, ``--plugin-dir`` and ``--mcp-config`` all ran without
|
|
55
|
+
the flag and none ran with it, ``CLAUDE_CODE_SAFE_MODE=0`` set or not;
|
|
56
|
+
a real ``stream-json`` run is unchanged by it (the ``init`` record still
|
|
57
|
+
names the enabled plugins). Auth, model, tools and permission mode work
|
|
58
|
+
as before; managed (administrator) settings still apply.
|
|
59
|
+
``--setting-sources=user``: no project or local settings, whose ``env``
|
|
60
|
+
the repository could use to undo the Bash tool's isolation (below).
|
|
61
|
+
:data:`CLAUDE_INTEGRATION_FLAGS` in ``extra_args`` are refused, and
|
|
62
|
+
``autoforge doctor`` requires all three in ``claude --help``.
|
|
63
|
+
* the Bash tool runs no function or alias of a shell startup file (#165):
|
|
64
|
+
every launch sets ``CLAUDE_ENV_FILE`` to a file of its own holding
|
|
65
|
+
:data:`CLAUDE_SHELL_CLEANER`, ``CLAUDE_CODE_SHELL`` to a bash or zsh
|
|
66
|
+
and ``CLAUDE_CODE_SHELL_PREFIX`` to empty
|
|
67
|
+
(:meth:`ClaudeCodeProvider.integration_isolation`), and is refused while
|
|
68
|
+
the ``env`` of the user's settings or of the global configuration would
|
|
69
|
+
set a variable over them or over the controller's
|
|
70
|
+
(:func:`claude_configuration_problems`).
|
|
71
|
+
* exit code 0 on success; 1 on model/auth errors (message on stdout).
|
|
72
|
+
|
|
73
|
+
OpenCode (``opencode 2.0.x``, #186; run against 2.0.23)::
|
|
74
|
+
|
|
75
|
+
opencode run --standalone -m <provider/model>[#<effort>] --format default [--auto]
|
|
76
|
+
[extra_args] (message on stdin)
|
|
77
|
+
|
|
78
|
+
* the message travels on stdin, written whole and then closed: in argv,
|
|
79
|
+
v2 duplicates a message given after ``--`` and wraps one given without
|
|
80
|
+
it in quotes with ``"`` and backslashes escaped, and takes one that starts
|
|
81
|
+
with ``-`` for a flag (it prints the help and exits 0). stdin arrives
|
|
82
|
+
verbatim. A CLI that exits 0 without having read all of it is a
|
|
83
|
+
:attr:`AgentExecutionResult.provider_failure`.
|
|
84
|
+
* ``--standalone`` runs the agent on a private server that is the
|
|
85
|
+
client's own child; without it the run goes to a shared background
|
|
86
|
+
service (``opencode serve --service``) whose tools run outside this
|
|
87
|
+
invocation's process group, cwd and environment, and outlive it.
|
|
88
|
+
* model ids are ``provider/model`` (``openai/gpt-5.6-luna``); the
|
|
89
|
+
reasoning effort is the ``#<variant>`` suffix (v2 removed
|
|
90
|
+
``--variant``), so a configured model carries no ``#`` of its own. An
|
|
91
|
+
unknown model or variant exits 1 with nothing on stdout.
|
|
92
|
+
* ``--format default`` prints only the final assistant text to stdout
|
|
93
|
+
(tool traces go to stderr). ``--format json`` is NOT used: on 2.0.23 it
|
|
94
|
+
emits ``step_start``, ``text``, ``tool_use`` and ``step_finish``
|
|
95
|
+
records, but a ``tool_use`` record only once the tool has completed (no
|
|
96
|
+
record when it starts) and no terminal record carrying the final text
|
|
97
|
+
or the outcome, so it could neither show a tool as it starts nor be
|
|
98
|
+
reduced to the CONTROL_RESULT text the way Claude's stream is (#192).
|
|
99
|
+
Live progress is therefore liveness only: each chunk on stdout or
|
|
100
|
+
stderr is an ``activity`` event, which prints nothing but keeps the
|
|
101
|
+
heartbeat's "last activity" current; the trace itself reaches the
|
|
102
|
+
operator only in ``stderr.log``.
|
|
103
|
+
* a run-level error prints ``ESC[91mESC[1mError: ESC[0m<message>`` on
|
|
104
|
+
stderr (whether or not it is a terminal) and sets exit 1, even when the
|
|
105
|
+
session goes on after it and finishes its turn; a tool's error
|
|
106
|
+
is the same line right after the tool's ``ESC[0m✗ ESC[0m<title> failed``
|
|
107
|
+
line and fails nothing (read from the 2.0.26 build). An exit 1 of an
|
|
108
|
+
``openai/*`` model whose run-level errors are all on a short list, each
|
|
109
|
+
compared as a whole message (:data:`_OPENCODE_OPENAI_TRANSIENT_ERRORS`),
|
|
110
|
+
is reported as a :attr:`AgentExecutionResult.transient_failure`:
|
|
111
|
+
``WebSocket closed with code 1000`` (the socket to the model closed
|
|
112
|
+
under a running turn, #164), ``WebSocket closed with code 1008`` and
|
|
113
|
+
OpenAI's generic ``server_error: An error occurred while processing your
|
|
114
|
+
request. You can retry your request, ...`` (#226). OpenAI documents no
|
|
115
|
+
close code for its Responses WebSocket (its 60-minute connection limit
|
|
116
|
+
is an error event, not a code), and 1008 is RFC 6455's generic policy
|
|
117
|
+
code; 1008 is listed because OpenCode's own retry rule (read from the
|
|
118
|
+
2.0.32 build) retries a socket closed with any code but 1009 (message
|
|
119
|
+
too big) as it retries a ``server_error``, and each of the two runs of
|
|
120
|
+
#226 reconnected after one and finished its turn. The ``server_error``
|
|
121
|
+
message is compared whole, not by its ``server_error: `` prefix: another
|
|
122
|
+
text under that code (one naming a request ID, say) is not the one that
|
|
123
|
+
says to retry, and stays a plain failed exit. A transient exit stays a
|
|
124
|
+
failed exit too (a CONTROL_RESULT it printed is not read), and whether
|
|
125
|
+
to relaunch is the engine's policy. Text anywhere else in the trace (a
|
|
126
|
+
diff, a file, a command's output) is not an error line, and a truncated
|
|
127
|
+
``stderr`` or a timeout is never classified. Not visible to this check:
|
|
128
|
+
an error the CLI does not print (it prints one execution failure only
|
|
129
|
+
when no step failed before it) and an exit 1 without any error line (a
|
|
130
|
+
cancelled permission prompt, below); both are failed exits all the same.
|
|
131
|
+
* bash/edit tools run without ``--auto`` under the default agent;
|
|
132
|
+
``--auto`` is opt-in via ``options.auto_approve: true``.
|
|
133
|
+
* a request of the agent's is refused, never put to a person (#258; run
|
|
134
|
+
on 2.1.4 against a loopback stand-in of the Messages API, so no model
|
|
135
|
+
was called, and read from its build). Without ``--auto``, a permission
|
|
136
|
+
a rule asks for (with the empty configuration below, a read of ``.env``
|
|
137
|
+
asked; a read outside the worktree did not) prints
|
|
138
|
+
``ESC[93mESC[1m! ESC[0mpermission requested: <permission> (<patterns>);
|
|
139
|
+
auto-rejecting`` and is rejected. The ``question`` tool is offered, and
|
|
140
|
+
a question is cancelled: its failed line, then the error line
|
|
141
|
+
:data:`_OPENCODE_QUESTION_CANCELLED`, with no question text. Either way
|
|
142
|
+
the model is told to go on without it, and the run goes on and exits 0.
|
|
143
|
+
2.0.26 was read, not run, to exit 1 with no error line after a cancelled
|
|
144
|
+
permission prompt; whether it printed the line above is not recorded.
|
|
145
|
+
So an exit 1 with no run-level error after such a line is that refusal,
|
|
146
|
+
reported as ADR 0007 D7.6's failure; any other failed run only has the
|
|
147
|
+
refusals added to its text (:func:`_name_refused`).
|
|
148
|
+
* ``opencode --version`` prints ``opencode v2.0.23``; 1.x printed the
|
|
149
|
+
bare version and rejects this argv, so ``autoforge doctor`` requires
|
|
150
|
+
:data:`OPENCODE_MIN_VERSION`.
|
|
151
|
+
* no flag disables the MCP servers or plugins of the user's or the
|
|
152
|
+
project's ``opencode.json`` (2.0.26 has neither ``--pure`` nor an MCP
|
|
153
|
+
switch), so every launch sets :func:`opencode_isolation_env` instead
|
|
154
|
+
(#165): an empty global configuration directory of its own, no project
|
|
155
|
+
configuration, no explicit file and no inline document. Read from the
|
|
156
|
+
2.0.26 build and checked against it with ``opencode api --standalone
|
|
157
|
+
GET /api/config`` (the configuration a launch resolves): without these
|
|
158
|
+
variables the global document and ``plugin/`` directory, the project's,
|
|
159
|
+
and an ``opencode.json`` two directories above the worktree were all
|
|
160
|
+
loaded; with them, only the empty directory, the operator's
|
|
161
|
+
``OPENCODE_CONFIG``, ``OPENCODE_CONFIG_CONTENT`` and
|
|
162
|
+
``OPENCODE_CONFIG_PROJECT_DISABLE=0`` notwithstanding. The settings in
|
|
163
|
+
those files (custom providers, permissions, agents, instructions) are
|
|
164
|
+
therefore not read either; the model credentials are.
|
|
165
|
+
|
|
166
|
+
Pi (``pi 1.0.x``, ADR 0003; read from the v1.0.0 source and docs, not yet
|
|
167
|
+
run against an installed Pi)::
|
|
168
|
+
|
|
169
|
+
pi --mode rpc --no-session --model <pi-provider>/<model-id> --thinking <effort>
|
|
170
|
+
|
|
171
|
+
* the prompt never travels in argv: it is one JSON ``prompt`` record on
|
|
172
|
+
stdin. The child runs under the duplex handle (executor_duplex.py) and
|
|
173
|
+
the conversation (``get_state``, ``get_available_models``, ``prompt``,
|
|
174
|
+
events until ``agent_settled``, ``get_last_assistant_text``) is the
|
|
175
|
+
reducer in pi_rpc.py; ``stdout`` of the result is the final assistant
|
|
176
|
+
text, never a protocol record, and an in-protocol failure is reported
|
|
177
|
+
in :attr:`AgentExecutionResult.provider_failure`.
|
|
178
|
+
* ``--thinking`` takes ``off|minimal|low|medium|high|xhigh|max``; the model
|
|
179
|
+
is ``provider/id`` with no ``:<thinking>`` suffix (ADR 0003 §2.2, §2.3).
|
|
180
|
+
* no ``extra_args``: every flag that matters is the adapter's or #132's
|
|
181
|
+
policy's, and a pass-through could undo it (``--approve``,
|
|
182
|
+
``--api-key``, ``--session-id``, ``--system-prompt``, ``--mode``, ...).
|
|
183
|
+
* ``pi --version`` prints the bare version; ``pi auth check --model <m>
|
|
184
|
+
--json --no-refresh`` reports, read-only, whether Pi holds a credential
|
|
185
|
+
for the model's provider (``autoforge doctor``).
|
|
186
|
+
* nothing asks (#258; read from the 1.3.0 build and docs): Pi has no
|
|
187
|
+
permission rule and no question tool, and under ``--no-extensions`` and
|
|
188
|
+
``--no-approve`` no dialog opens. An extension dialog would be
|
|
189
|
+
cancelled and fail the run, naming its method (pi_rpc.py).
|
|
190
|
+
|
|
191
|
+
The CLIs are launched with cwd = the directory the engine chose (a
|
|
192
|
+
per-issue worktree for a REMOTE run, the contract's repository root for a
|
|
193
|
+
LOCAL one), stdin = /dev/null or the adapter's payload
|
|
194
|
+
(:meth:`AgentProvider.stdin_payload`), an idle limit and an optional
|
|
195
|
+
wall-clock ceiling (:class:`AgentRequest`; #193), and an allow-listed
|
|
196
|
+
environment (see executor.py): the engine passes the configured allow-list
|
|
197
|
+
in :attr:`AgentRequest.env_allowlist` and each adapter adds the variables
|
|
198
|
+
its own CLI reads (:attr:`AgentProvider.environment_names`), so the key of
|
|
199
|
+
one provider is never handed to another. Neither list may name a GitHub or
|
|
200
|
+
git credential route (:func:`autoforge.child_env.refuse_forbidden_env`),
|
|
201
|
+
and the variables the controller sets for the launch
|
|
202
|
+
(:attr:`AgentRequest.env`, #165) reach every process the adapter starts,
|
|
203
|
+
with the adapter's own (:meth:`AgentProvider.integration_isolation`) over
|
|
204
|
+
them where its CLI has no flag to keep an integration, or a shell startup
|
|
205
|
+
file's functions and aliases, from loading. An integration or a setting
|
|
206
|
+
neither can keep off refuses the launch before it starts
|
|
207
|
+
(:meth:`AgentProvider.integration_preflight`).
|
|
208
|
+
|
|
209
|
+
The SDK providers ``claude-sdk`` and ``opencode-sdk`` (ADR 0007) have their
|
|
210
|
+
static surface here: names, option keys and validation, the launch
|
|
211
|
+
allow-list and the sandbox declaration. Their policy (tools, permissions,
|
|
212
|
+
setting sources, configuration and session roots, authentication, the
|
|
213
|
+
environment) is :mod:`autoforge.sdk_policy`'s (#149, docs/sdk-policy.md);
|
|
214
|
+
their helpers and transports are #151's and #152's, and until those land
|
|
215
|
+
every launch of one is refused before anything starts.
|
|
216
|
+
|
|
217
|
+
Every adapter runs a :class:`~autoforge.loop_detect.LoopMonitor` under
|
|
218
|
+
:attr:`AgentRequest.loop_detection` (#194): Claude's stream and Pi's events
|
|
219
|
+
report each completed tool call as an action, and a one-shot CLI's output
|
|
220
|
+
(OpenCode's stderr trace, Claude's ``text`` output) is digested line by
|
|
221
|
+
line as it arrives. A verdict in ``kill`` mode ends the invocation through
|
|
222
|
+
the same group kill as a timeout, and the result is a timeout whose limit is
|
|
223
|
+
``loop``; a warning is a ``loop_suspected`` progress event. The monitor's
|
|
224
|
+
calibration figures join ``provider_summary`` in every mode.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
from __future__ import annotations
|
|
228
|
+
|
|
229
|
+
import dataclasses
|
|
230
|
+
import json
|
|
231
|
+
import os
|
|
232
|
+
import pwd
|
|
233
|
+
import re
|
|
234
|
+
import shutil
|
|
235
|
+
import sqlite3
|
|
236
|
+
import tempfile
|
|
237
|
+
import threading
|
|
238
|
+
import time
|
|
239
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
240
|
+
from contextlib import closing, contextmanager
|
|
241
|
+
from dataclasses import dataclass, field
|
|
242
|
+
from pathlib import Path
|
|
243
|
+
|
|
244
|
+
from .child_env import child_shell, refuse_forbidden_env, undoes_isolation
|
|
245
|
+
from .claude_stream import ClaudeStream
|
|
246
|
+
from .config import LoopDetectionConfig, ProfileConfig
|
|
247
|
+
from .errors import (
|
|
248
|
+
ChildStdinClosedError,
|
|
249
|
+
ConfigurationError,
|
|
250
|
+
ExecutionError,
|
|
251
|
+
ExecutionTimeoutError,
|
|
252
|
+
)
|
|
253
|
+
from .executor import (
|
|
254
|
+
DEFAULT_MAX_OUTPUT_BYTES,
|
|
255
|
+
MAX_DEADLINE_SECONDS,
|
|
256
|
+
ExecutionRequest,
|
|
257
|
+
ExecutionResult,
|
|
258
|
+
describe_leftovers,
|
|
259
|
+
execute,
|
|
260
|
+
select_environment,
|
|
261
|
+
)
|
|
262
|
+
from .executor_duplex import (
|
|
263
|
+
DuplexChild,
|
|
264
|
+
DuplexRequest,
|
|
265
|
+
Eof,
|
|
266
|
+
Fragment,
|
|
267
|
+
Overflow,
|
|
268
|
+
Oversize,
|
|
269
|
+
Record,
|
|
270
|
+
Timeout,
|
|
271
|
+
start_duplex,
|
|
272
|
+
)
|
|
273
|
+
from .interaction import (
|
|
274
|
+
NO_CAPABILITIES,
|
|
275
|
+
Capabilities,
|
|
276
|
+
InteractionBroker,
|
|
277
|
+
InteractionOutcome,
|
|
278
|
+
Lifecycle,
|
|
279
|
+
RequestKind,
|
|
280
|
+
)
|
|
281
|
+
from .loop_detect import LIMIT_LOOP, LoopMonitor, LoopReport
|
|
282
|
+
from .pi_rpc import PiConversation, encodable
|
|
283
|
+
from .progress import ProgressEvent, ProgressKind, ProgressSink, clean, guarded
|
|
284
|
+
from .redaction import redact
|
|
285
|
+
from .sandbox import LaunchDescription, SandboxFacts
|
|
286
|
+
from .sandbox_policy import (
|
|
287
|
+
AuthItem,
|
|
288
|
+
AuthMode,
|
|
289
|
+
ProviderRequirements,
|
|
290
|
+
SandboxPlan,
|
|
291
|
+
declared_writable,
|
|
292
|
+
executable_requirements,
|
|
293
|
+
)
|
|
294
|
+
from .sdk_policy import (
|
|
295
|
+
CLAUDE_SDK,
|
|
296
|
+
CLAUDE_SDK_CREDENTIAL,
|
|
297
|
+
CLAUDE_SDK_OPTION_KEYS,
|
|
298
|
+
CLAUDE_SDK_PASSTHROUGH,
|
|
299
|
+
OPENCODE_SDK,
|
|
300
|
+
OPENCODE_SDK_OPTION_KEYS,
|
|
301
|
+
ClaudeSdkPolicy,
|
|
302
|
+
InteractionSettings,
|
|
303
|
+
OpenCodeSdkPolicy,
|
|
304
|
+
claude_sdk_policy,
|
|
305
|
+
interaction_settings,
|
|
306
|
+
opencode_model_problem,
|
|
307
|
+
opencode_sdk_policy,
|
|
308
|
+
sdk_launch_allowlist,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
Runner = Callable[[ExecutionRequest], ExecutionResult]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@dataclass
|
|
315
|
+
class AgentRequest:
|
|
316
|
+
phase: str
|
|
317
|
+
prompt: str
|
|
318
|
+
cwd: str
|
|
319
|
+
profile: ProfileConfig
|
|
320
|
+
# The limits the invocation runs under (#193; the engine resolves them
|
|
321
|
+
# with ``AutoForgeConfig.agent_limits``): the CLI is killed once it has
|
|
322
|
+
# written nothing on stdout or stderr for ``idle_timeout_seconds``
|
|
323
|
+
# (``None``: no idle limit, for a CLI that reports no activity while it
|
|
324
|
+
# works), or once it has run ``max_runtime_seconds`` (``None``: only the
|
|
325
|
+
# executor's backstop, ``MAX_DEADLINE_SECONDS``).
|
|
326
|
+
idle_timeout_seconds: int | None
|
|
327
|
+
max_runtime_seconds: int | None
|
|
328
|
+
attempt: int = 1
|
|
329
|
+
correction: bool = False
|
|
330
|
+
# The controller's environment allow-list (``execution.env_allowlist``
|
|
331
|
+
# plus ``env_allowlist_extra``); the provider adds its own names. ``None``
|
|
332
|
+
# inherits the whole environment and is never what the engine sends.
|
|
333
|
+
env_allowlist: tuple[str, ...] | None = None
|
|
334
|
+
# What the controller sets over the allow-listed environment for this
|
|
335
|
+
# launch (#165, :func:`autoforge.child_env.isolated_overrides`): an empty
|
|
336
|
+
# gh configuration, a placeholder token, the git configuration that
|
|
337
|
+
# disables every credential route, the author identity. Every adapter
|
|
338
|
+
# passes it to every process it starts for the launch, a preflight
|
|
339
|
+
# included; ``None`` sets nothing.
|
|
340
|
+
env: dict[str, str] | None = None
|
|
341
|
+
# Where the adapter reports live progress (#192): provider-neutral,
|
|
342
|
+
# already bounded and redacted events, never a raw provider record.
|
|
343
|
+
# ``None`` reports nothing. A sink that raises is dropped for the rest of
|
|
344
|
+
# the invocation (:func:`autoforge.progress.guarded`); progress never
|
|
345
|
+
# changes an outcome.
|
|
346
|
+
progress: ProgressSink | None = None
|
|
347
|
+
# How the adapter watches the invocation for a loop (#194): the engine
|
|
348
|
+
# passes ``execution.loop_detection``, and the request keeps it with the
|
|
349
|
+
# mode its own provider and phase run under (#199).
|
|
350
|
+
loop_detection: LoopDetectionConfig = field(default_factory=LoopDetectionConfig)
|
|
351
|
+
# The outer sandbox every process of the launch runs in (ADR 0005, #168):
|
|
352
|
+
# the engine's facts, to which the adapter adds its CLI's declaration
|
|
353
|
+
# (:meth:`AgentProvider.sandbox_requirements`). ``None`` launches as
|
|
354
|
+
# before the sandbox existed (``sandbox.mode: disabled``).
|
|
355
|
+
sandbox: SandboxPlan | None = None
|
|
356
|
+
# Where the adapter takes the agent's requests for a decision (ADR 0007
|
|
357
|
+
# D5.2, #134): the launch's broker, created by the engine for a provider
|
|
358
|
+
# whose ``interaction_settings`` names a policy, as it hands over
|
|
359
|
+
# ``progress``. The adapter mints each provider ask through it and does
|
|
360
|
+
# what the directive says; it never sees the policy. ``None`` (every
|
|
361
|
+
# provider through which nothing can ask) keeps today's behaviour.
|
|
362
|
+
interaction: InteractionBroker | None = None
|
|
363
|
+
|
|
364
|
+
def __post_init__(self) -> None:
|
|
365
|
+
self.loop_detection = self.loop_detection.for_invocation(self.profile.provider, self.phase)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@dataclass
|
|
369
|
+
class AgentExecutionResult:
|
|
370
|
+
command: list[str]
|
|
371
|
+
exit_code: int
|
|
372
|
+
stdout: str
|
|
373
|
+
stderr: str
|
|
374
|
+
started_at: str
|
|
375
|
+
finished_at: str
|
|
376
|
+
timed_out: bool = False
|
|
377
|
+
provider: str = ""
|
|
378
|
+
model: str = ""
|
|
379
|
+
effort: str = ""
|
|
380
|
+
# Capture facts from the executor (see :class:`ExecutionResult`): the
|
|
381
|
+
# engine logs the whole marked ``stdout`` but parses only ``stdout_tail``.
|
|
382
|
+
stdout_truncated: bool = False
|
|
383
|
+
stderr_truncated: bool = False
|
|
384
|
+
stdout_tail_offset: int = 0
|
|
385
|
+
# What the invocation left behind (see :class:`ExecutionResult`): the
|
|
386
|
+
# engine records these in the run log; none of them changes whether the
|
|
387
|
+
# child's own result is read.
|
|
388
|
+
descendants_killed: bool = False
|
|
389
|
+
group_survived_kill: bool = False
|
|
390
|
+
capture_abandoned: bool = False
|
|
391
|
+
orphans_killed: bool = False
|
|
392
|
+
orphan_survived_kill: bool = False
|
|
393
|
+
orphans_unchecked: bool = False
|
|
394
|
+
# A run that failed inside the provider's protocol although the process
|
|
395
|
+
# may have exited 0 (Pi shuts down in order whether its run failed or
|
|
396
|
+
# not): a short, bounded, already-redacted reason. The engine treats it
|
|
397
|
+
# like a non-zero exit (ADR 0003 §2.6). A one-shot CLI adapter sets it
|
|
398
|
+
# only when the CLI exited 0 without reading the whole prompt from stdin,
|
|
399
|
+
# or when a run that failed anyway had a request of the agent's refused
|
|
400
|
+
# by the CLI, which it names (#258, :func:`_name_refused`).
|
|
401
|
+
provider_failure: str | None = None
|
|
402
|
+
# A failed run the adapter recognised as a known transient failure of
|
|
403
|
+
# the provider (#164): a short, fixed reason naming it, never text taken
|
|
404
|
+
# from the output. It is a fact about this failure, not a decision: it
|
|
405
|
+
# never replaces the exit status or ``provider_failure``, and whether to
|
|
406
|
+
# relaunch is the engine's policy, which reads no provider name or error
|
|
407
|
+
# text of its own.
|
|
408
|
+
transient_failure: str | None = None
|
|
409
|
+
# A small flat mapping of scalars, bounded and redacted by the adapter,
|
|
410
|
+
# that the engine writes to ``execution.json`` under this key without
|
|
411
|
+
# interpreting it. Never raw protocol records or message contents.
|
|
412
|
+
provider_summary: dict[str, str | int | bool] = field(default_factory=dict)
|
|
413
|
+
# Which limit killed the CLI when ``timed_out`` (``"idle"``,
|
|
414
|
+
# ``"max_runtime"``, or ``"loop"`` when the loop detector did, #194), and
|
|
415
|
+
# when it last wrote anything (UTC, ISO 8601; ``None`` when it wrote
|
|
416
|
+
# nothing). See :class:`ExecutionResult`.
|
|
417
|
+
timeout_limit: str = ""
|
|
418
|
+
last_activity_at: str | None = None
|
|
419
|
+
# What the loop detector found (#194): the finding it killed for, or the
|
|
420
|
+
# strongest it warned about; ``None`` when neither. Names and counts only.
|
|
421
|
+
loop: LoopReport | None = None
|
|
422
|
+
# What the outer sandbox recorded for a sandboxed launch (ADR 0005
|
|
423
|
+
# D10.4), for the run log; ``None`` without one. Never read for a decision.
|
|
424
|
+
sandbox: SandboxFacts | None = None
|
|
425
|
+
# How an invocation through which the agent could ask ended (ADR 0007
|
|
426
|
+
# D10.5, #134): ``completed``, ``failed`` (the adapter also reports
|
|
427
|
+
# ``provider_failure``, and the engine raises InteractionRequiredError,
|
|
428
|
+
# never correcting or relaunching), ``interrupted``, or ``paused`` with
|
|
429
|
+
# the pending request (#126 persists it). ``None`` for a launch without
|
|
430
|
+
# a broker, with one exception: a CLI adapter, which has no broker and
|
|
431
|
+
# declares no capability, reports ``failed`` when its CLI's output shows
|
|
432
|
+
# that a request the CLI refused is what failed the run (#258, D7.6).
|
|
433
|
+
interaction: InteractionOutcome | None = None
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def stdout_tail(self) -> str:
|
|
437
|
+
"""The part of stdout captured contiguously up to EOF."""
|
|
438
|
+
return self.stdout[self.stdout_tail_offset :]
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def leftovers(self) -> str:
|
|
442
|
+
"""What the invocation left behind, for a log line; empty when nothing."""
|
|
443
|
+
return describe_leftovers(
|
|
444
|
+
descendants_killed=self.descendants_killed,
|
|
445
|
+
group_survived_kill=self.group_survived_kill,
|
|
446
|
+
capture_abandoned=self.capture_abandoned,
|
|
447
|
+
orphans_killed=self.orphans_killed,
|
|
448
|
+
orphan_survived_kill=self.orphan_survived_kill,
|
|
449
|
+
orphans_unchecked=self.orphans_unchecked,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def may_have_survivor(self) -> bool:
|
|
454
|
+
"""Whether a process the invocation started may still be running: a
|
|
455
|
+
member of its group outlived SIGKILL, a writer beyond the kill's reach
|
|
456
|
+
still held its pipes, or a re-parented process outlived its kill. Not
|
|
457
|
+
``orphans_unchecked``, which is a fact of the platform (no subreaper)
|
|
458
|
+
and true of every invocation there, not evidence of a survivor."""
|
|
459
|
+
return self.group_survived_kill or self.capture_abandoned or self.orphan_survived_kill
|
|
460
|
+
|
|
461
|
+
@property
|
|
462
|
+
def ok(self) -> bool:
|
|
463
|
+
"""Whether the invocation ran to a usable end.
|
|
464
|
+
|
|
465
|
+
Narrower than :attr:`ExecutionResult.ok` on purpose: for plumbing
|
|
466
|
+
(``gh --json``, ``git``) the whole output is the reply, so either
|
|
467
|
+
stream past the bound spoils it, whereas an agent's reply is the
|
|
468
|
+
CONTROL_RESULT on stdout and its stderr is diagnostics (an OpenCode
|
|
469
|
+
tool trace), which the engine logs but never parses; a stderr past
|
|
470
|
+
the bound is recorded, not a failure. The engine reads the underlying
|
|
471
|
+
facts (``timed_out``, ``exit_code``, ``stdout_tail``) rather than
|
|
472
|
+
this property.
|
|
473
|
+
"""
|
|
474
|
+
return not self.timed_out and self.exit_code == 0 and not self.stdout_truncated
|
|
475
|
+
|
|
476
|
+
@classmethod
|
|
477
|
+
def from_execution(cls, res: ExecutionResult, profile: ProfileConfig) -> AgentExecutionResult:
|
|
478
|
+
return cls(
|
|
479
|
+
command=list(res.command),
|
|
480
|
+
exit_code=res.exit_code,
|
|
481
|
+
stdout=res.stdout,
|
|
482
|
+
stderr=res.stderr,
|
|
483
|
+
started_at=res.started_at,
|
|
484
|
+
finished_at=res.finished_at,
|
|
485
|
+
timed_out=res.timed_out,
|
|
486
|
+
stdout_truncated=res.stdout_truncated,
|
|
487
|
+
stderr_truncated=res.stderr_truncated,
|
|
488
|
+
stdout_tail_offset=res.stdout_tail_offset,
|
|
489
|
+
descendants_killed=res.descendants_killed,
|
|
490
|
+
group_survived_kill=res.group_survived_kill,
|
|
491
|
+
capture_abandoned=res.capture_abandoned,
|
|
492
|
+
orphans_killed=res.orphans_killed,
|
|
493
|
+
orphan_survived_kill=res.orphan_survived_kill,
|
|
494
|
+
orphans_unchecked=res.orphans_unchecked,
|
|
495
|
+
provider=profile.provider,
|
|
496
|
+
model=profile.model,
|
|
497
|
+
effort=profile.effort,
|
|
498
|
+
timeout_limit=res.timeout_limit,
|
|
499
|
+
last_activity_at=res.last_activity_at,
|
|
500
|
+
sandbox=res.sandbox,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _activity_hook(progress: ProgressSink | None) -> Callable[[str], None] | None:
|
|
505
|
+
"""An executor output hook that reports each chunk as bare activity.
|
|
506
|
+
|
|
507
|
+
For a one-shot CLI whose output is not a protocol the adapter reads
|
|
508
|
+
while it runs (OpenCode's ``--format default``, Claude's ``text``): a
|
|
509
|
+
chunk on either stream is a sign of life and nothing more. Its bytes
|
|
510
|
+
are never looked at, so nothing the CLI printed reaches the progress
|
|
511
|
+
output.
|
|
512
|
+
"""
|
|
513
|
+
emit = guarded(progress)
|
|
514
|
+
if emit is None:
|
|
515
|
+
return None
|
|
516
|
+
|
|
517
|
+
def on_output(stream: str) -> None:
|
|
518
|
+
emit(ProgressEvent(ProgressKind.ACTIVITY))
|
|
519
|
+
|
|
520
|
+
return on_output
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _loop_monitor(req: AgentRequest, emit: ProgressSink | None) -> LoopMonitor:
|
|
524
|
+
"""The invocation's loop detector (#194), warning through ``emit``."""
|
|
525
|
+
|
|
526
|
+
def warn(text: str) -> None:
|
|
527
|
+
if emit is not None:
|
|
528
|
+
emit(ProgressEvent(ProgressKind.LOOP_SUSPECTED, detail=text))
|
|
529
|
+
|
|
530
|
+
return LoopMonitor(req.loop_detection, time.monotonic(), warn)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
class _ClockedLoop:
|
|
534
|
+
"""A :class:`LoopMonitor` as a clock-free reducer reports to it: each
|
|
535
|
+
event is timed as it is reduced, so the reducer reads no clock."""
|
|
536
|
+
|
|
537
|
+
def __init__(self, monitor: LoopMonitor) -> None:
|
|
538
|
+
self._monitor = monitor
|
|
539
|
+
|
|
540
|
+
def action(self, name: str, fingerprint: bytes) -> None:
|
|
541
|
+
self._monitor.action(name, fingerprint, time.monotonic())
|
|
542
|
+
|
|
543
|
+
def retry(self) -> None:
|
|
544
|
+
self._monitor.retry(time.monotonic())
|
|
545
|
+
|
|
546
|
+
def turn(self) -> None:
|
|
547
|
+
self._monitor.turn(time.monotonic())
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _finish_loop(
|
|
551
|
+
result: AgentExecutionResult, monitor: LoopMonitor, *, killed: bool
|
|
552
|
+
) -> AgentExecutionResult:
|
|
553
|
+
"""Record the loop detector's outcome on ``result`` (#194).
|
|
554
|
+
|
|
555
|
+
``killed``: the invocation was ended on the verdict. It is then a
|
|
556
|
+
timeout whose limit is ``loop``, which the engine and the run log treat
|
|
557
|
+
exactly as the other limits: whatever the protocol said is not read. The
|
|
558
|
+
calibration figures join ``provider_summary`` whatever the mode.
|
|
559
|
+
"""
|
|
560
|
+
result.provider_summary.update(monitor.calibration())
|
|
561
|
+
result.loop = monitor.report(killed=killed)
|
|
562
|
+
if killed:
|
|
563
|
+
result.timed_out = True
|
|
564
|
+
result.timeout_limit = LIMIT_LOOP
|
|
565
|
+
result.provider_failure = None
|
|
566
|
+
return result
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# Where an operator reads what to do about a request the CLI refused (#258).
|
|
570
|
+
REFUSED_REQUEST_GUIDE = "docs/usage.md, 'An agent asked for permission or input'"
|
|
571
|
+
MAX_REFUSED_SUBJECT_CHARS = 160
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _requests(refused: list[tuple[RequestKind, str]], count: int) -> str:
|
|
575
|
+
"""What the agent asked for, for a failure: the first request, and how many more."""
|
|
576
|
+
kind, subject = refused[0]
|
|
577
|
+
if kind is RequestKind.PERMISSION:
|
|
578
|
+
text = f"permission ({subject})" if subject else "permission"
|
|
579
|
+
else:
|
|
580
|
+
text = "an answer to a question"
|
|
581
|
+
if count > 1:
|
|
582
|
+
text += f" and {count - 1} more request{'s' if count > 2 else ''}"
|
|
583
|
+
return text
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _name_refused(
|
|
587
|
+
result: AgentExecutionResult,
|
|
588
|
+
refused: list[tuple[RequestKind, str]],
|
|
589
|
+
count: int,
|
|
590
|
+
name: str,
|
|
591
|
+
cli: str,
|
|
592
|
+
*,
|
|
593
|
+
ended_by_it: str = "",
|
|
594
|
+
) -> AgentExecutionResult:
|
|
595
|
+
"""Name on ``result`` the ``count`` requests of the agent's the CLI refused (#258).
|
|
596
|
+
|
|
597
|
+
``refused`` is the first of them, each its kind and its cleaned subject;
|
|
598
|
+
``name`` is the adapter's and ``cli`` the CLI's, as a reader knows it.
|
|
599
|
+
The run log always gets the count and the first one (``provider_summary``).
|
|
600
|
+
A run that ends usable is left as it is: the CLI told the agent it was
|
|
601
|
+
refused, and the agent went on without it. A timeout keeps its own text.
|
|
602
|
+
|
|
603
|
+
``ended_by_it``, the adapter's finding that the refusal is all that
|
|
604
|
+
failed the run (with what to do about it), makes the failure the named
|
|
605
|
+
one of ADR 0007 D7.6: ``provider_failure`` says what the agent asked for
|
|
606
|
+
and ``interaction`` is ``failed``, so the engine raises
|
|
607
|
+
InteractionRequiredError, never corrected and never relaunched: a
|
|
608
|
+
relaunch would ask again. The adapter declares no capability for it
|
|
609
|
+
(D5.3). Any other failure keeps its own reason, its exit status and a
|
|
610
|
+
transient finding, and only has the refusal added to its text: the
|
|
611
|
+
refusal may not be why it failed.
|
|
612
|
+
"""
|
|
613
|
+
if not count:
|
|
614
|
+
return result
|
|
615
|
+
kind, subject = refused[0]
|
|
616
|
+
result.provider_summary["refused_requests"] = count
|
|
617
|
+
result.provider_summary["refused_request"] = f"{kind} ({subject})" if subject else str(kind)
|
|
618
|
+
if result.timed_out:
|
|
619
|
+
return result
|
|
620
|
+
asked = _requests(refused, count)
|
|
621
|
+
if ended_by_it:
|
|
622
|
+
result.provider_failure = (
|
|
623
|
+
f"{name}: the agent asked for {asked}, which an unattended run "
|
|
624
|
+
f"cannot give; {ended_by_it}"
|
|
625
|
+
)
|
|
626
|
+
result.transient_failure = None
|
|
627
|
+
result.interaction = InteractionOutcome(Lifecycle.FAILED)
|
|
628
|
+
elif result.provider_failure or result.exit_code != 0:
|
|
629
|
+
failure = result.provider_failure or f"{name}: exited {result.exit_code}"
|
|
630
|
+
result.provider_failure = (
|
|
631
|
+
f"{failure}; during the run the agent asked for {asked}, which {cli} refused "
|
|
632
|
+
f"({REFUSED_REQUEST_GUIDE})"
|
|
633
|
+
)
|
|
634
|
+
return result
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _truthy(value: str | None, default: bool) -> bool:
|
|
638
|
+
if value is None or value == "":
|
|
639
|
+
return default
|
|
640
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class AgentProvider:
|
|
644
|
+
"""Base adapter. Subclasses implement ``build_command_for``, and
|
|
645
|
+
``stdin_payload`` when the prompt does not travel in argv."""
|
|
646
|
+
|
|
647
|
+
name = "base"
|
|
648
|
+
# The environment variables this provider's CLI reads for its own
|
|
649
|
+
# authentication and configuration, added to the request's allow-list.
|
|
650
|
+
# Names, never values: the executor selects them from the environment.
|
|
651
|
+
environment_names: tuple[str, ...] = ()
|
|
652
|
+
# The profile ``options`` keys this adapter reads. Any other key is a
|
|
653
|
+
# configuration error naming the profile and this set, so a misspelled
|
|
654
|
+
# knob cannot be a silent no-op. ``None`` leaves the keys unchecked; only
|
|
655
|
+
# the scripted test provider, which launches no real CLI, uses it.
|
|
656
|
+
option_keys: tuple[str, ...] | None = None
|
|
657
|
+
# The variables :meth:`integration_isolation` sets for this CLI, names
|
|
658
|
+
# only (the run log records them; the values vary by launch).
|
|
659
|
+
isolation_names: tuple[str, ...] = ()
|
|
660
|
+
# The variables this CLI reads that name a path (ADR 0005 D8.2): a
|
|
661
|
+
# sandboxed launch keeps one only when what it names is visible inside.
|
|
662
|
+
path_names: tuple[str, ...] = ()
|
|
663
|
+
|
|
664
|
+
def __init__(self, runner: Runner | None = None) -> None:
|
|
665
|
+
self._runner: Runner = runner or execute
|
|
666
|
+
|
|
667
|
+
# -- to override -----------------------------------------------------
|
|
668
|
+
def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
|
|
669
|
+
raise NotImplementedError
|
|
670
|
+
|
|
671
|
+
def stdin_payload(self, req: AgentRequest) -> bytes | None:
|
|
672
|
+
"""What :meth:`execute` writes to the CLI's stdin; ``None`` is ``/dev/null``."""
|
|
673
|
+
return None
|
|
674
|
+
|
|
675
|
+
@contextmanager
|
|
676
|
+
def integration_isolation(self) -> Iterator[dict[str, str]]:
|
|
677
|
+
"""The variables this CLI needs, for one launch, so that no MCP server,
|
|
678
|
+
plugin or hook of the operator's or the repository's configuration
|
|
679
|
+
loads and no function or alias of a shell startup file runs in its
|
|
680
|
+
shell tool (#165): one may hold a GitHub token of its own.
|
|
681
|
+
|
|
682
|
+
Set over the controller's (:attr:`AgentRequest.env`) by
|
|
683
|
+
:meth:`execute`, whatever the allow-list passed. An adapter whose CLI
|
|
684
|
+
has a flag for it says so in its argv instead and sets nothing here.
|
|
685
|
+
"""
|
|
686
|
+
yield {}
|
|
687
|
+
|
|
688
|
+
def integration_preflight(self, env_allowlist: tuple[str, ...] | None) -> None:
|
|
689
|
+
"""Refuse a launch whose CLI would still load an integration that
|
|
690
|
+
neither its flags nor :meth:`integration_isolation` keep off (#165).
|
|
691
|
+
|
|
692
|
+
``env_allowlist`` is the configured allow-list (this provider adds
|
|
693
|
+
its own names, as for the launch). Raises :class:`ConfigurationError`
|
|
694
|
+
before anything starts: the engine calls this with the credential
|
|
695
|
+
preflight, before a launch is charged, and :meth:`execute` calls it
|
|
696
|
+
again before it launches. The base adapter has nothing to refuse.
|
|
697
|
+
"""
|
|
698
|
+
|
|
699
|
+
def launch_environment(self, env_allowlist: tuple[str, ...] | None) -> dict[str, str]:
|
|
700
|
+
"""The variables of the controller's environment a launch with
|
|
701
|
+
``env_allowlist`` inherits, before any the controller sets."""
|
|
702
|
+
if env_allowlist is None:
|
|
703
|
+
return dict(os.environ)
|
|
704
|
+
return select_environment(self.launch_allowlist(env_allowlist))
|
|
705
|
+
|
|
706
|
+
def reports_activity(self, profile: ProfileConfig) -> bool:
|
|
707
|
+
"""Whether the CLI, launched for ``profile``, writes output while it works.
|
|
708
|
+
|
|
709
|
+
A fact about the CLI, not a policy: the controller decides what to do
|
|
710
|
+
with a CLI that is silent until it exits (it cannot run under an idle
|
|
711
|
+
limit, see ``AutoForgeConfig.agent_limits``).
|
|
712
|
+
"""
|
|
713
|
+
return True
|
|
714
|
+
|
|
715
|
+
def interaction_settings(self, profile: ProfileConfig) -> InteractionSettings | None:
|
|
716
|
+
"""``profile``'s interaction policy and limits (ADR 0007 D7.1, D7.3), or
|
|
717
|
+
``None`` for a provider through which nothing can ask. Validated:
|
|
718
|
+
:class:`ConfigurationError` for a value the policy refuses."""
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
def interaction_capabilities(self, profile: ProfileConfig) -> Capabilities:
|
|
722
|
+
"""What this provider can carry of a decision for ``profile`` (ADR 0007
|
|
723
|
+
D5.1, D5.3), for the launch's broker.
|
|
724
|
+
|
|
725
|
+
A capability is declared true only once every ADR 0007 §3 gate of its
|
|
726
|
+
row passed on the provider's pinned pair. None has yet, so every
|
|
727
|
+
adapter declares :data:`~autoforge.interaction.NO_CAPABILITIES`: an
|
|
728
|
+
ask fails the invocation under every policy.
|
|
729
|
+
"""
|
|
730
|
+
return NO_CAPABILITIES
|
|
731
|
+
|
|
732
|
+
def validate_profile(self, profile: ProfileConfig) -> None:
|
|
733
|
+
"""Raise ConfigurationError for values this CLI cannot accept.
|
|
734
|
+
|
|
735
|
+
An override calls this first: it refuses an environment name that
|
|
736
|
+
would hand the CLI a GitHub or git credential (#165) and checks the
|
|
737
|
+
``options`` keys.
|
|
738
|
+
"""
|
|
739
|
+
refuse_forbidden_env(self.environment_names, f"provider {self.name!r}")
|
|
740
|
+
if self.option_keys is None:
|
|
741
|
+
return
|
|
742
|
+
unknown = sorted(set(profile.options) - set(self.option_keys))
|
|
743
|
+
if unknown:
|
|
744
|
+
raise ConfigurationError(
|
|
745
|
+
f"profile {profile.name!r}: unknown {self.name} option(s) "
|
|
746
|
+
f"{', '.join(unknown)} (accepted: {', '.join(self.option_keys) or 'none'})"
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
def sandbox_requirements(
|
|
750
|
+
self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
|
|
751
|
+
) -> ProviderRequirements:
|
|
752
|
+
"""What a sandboxed launch of ``command`` needs from the host, as data (ADR 0005 D13.1).
|
|
753
|
+
|
|
754
|
+
``env`` is the launch's environment (the allow-listed variables, the
|
|
755
|
+
controller's overrides and :meth:`integration_isolation`'s) and
|
|
756
|
+
``home`` the operator's home. The base declaration is the executable,
|
|
757
|
+
resolved on the launch's ``PATH`` as the spawn would resolve it, its
|
|
758
|
+
install root and its interpreter's, an absolute ``#!`` path as
|
|
759
|
+
written (a spelled path, #225), and :attr:`path_names`; an
|
|
760
|
+
adapter adds its configuration, credential and state items. Names
|
|
761
|
+
and paths only: no file is read but an executable's first line.
|
|
762
|
+
"""
|
|
763
|
+
executable, roots, path_dirs, spelled = executable_requirements(
|
|
764
|
+
command[0], home=home, search_path=env.get("PATH")
|
|
765
|
+
)
|
|
766
|
+
return ProviderRequirements(
|
|
767
|
+
executable=executable,
|
|
768
|
+
install_roots=roots,
|
|
769
|
+
path_dirs=path_dirs,
|
|
770
|
+
spelled_paths=spelled,
|
|
771
|
+
path_names=self.path_names,
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
def written_directories(
|
|
775
|
+
self, profile: ProfileConfig, env_allowlist: tuple[str, ...] | None, home: str
|
|
776
|
+
) -> tuple[tuple[str, str], ...]:
|
|
777
|
+
"""The host directories a sandboxed launch of ``profile`` writes that outlive it (#280).
|
|
778
|
+
|
|
779
|
+
Each directory :meth:`sandbox_requirements` makes writable, with what
|
|
780
|
+
it is (:func:`~autoforge.sandbox_policy.declared_writable`), declared
|
|
781
|
+
over the environment a launch with ``env_allowlist`` inherits from
|
|
782
|
+
this process. The names :meth:`integration_isolation` sets are left
|
|
783
|
+
out of it: every launch sets them to a directory or file made for it
|
|
784
|
+
and removed afterwards, which nothing the operator keeps is in. What
|
|
785
|
+
a ``--config`` file may not lie in under an enforced mode, since an
|
|
786
|
+
agent could rewrite it there
|
|
787
|
+
(:func:`autoforge.engine.config_file_placement_problem`). Names and
|
|
788
|
+
paths only: nothing is created, and no file is read but an
|
|
789
|
+
executable's first line.
|
|
790
|
+
"""
|
|
791
|
+
isolated = set(self.isolation_names)
|
|
792
|
+
env = {
|
|
793
|
+
name: value
|
|
794
|
+
for name, value in self.launch_environment(env_allowlist).items()
|
|
795
|
+
if name not in isolated
|
|
796
|
+
}
|
|
797
|
+
needs = self.sandbox_requirements(profile, [profile.command or self.name], env, home)
|
|
798
|
+
return declared_writable(needs)
|
|
799
|
+
|
|
800
|
+
# -- shared behaviour -------------------------------------------------
|
|
801
|
+
def build_command(self, req: AgentRequest) -> list[str]:
|
|
802
|
+
return self.build_command_for(req.profile, req.prompt)
|
|
803
|
+
|
|
804
|
+
def sandboxed(
|
|
805
|
+
self, req: AgentRequest, command: list[str], env: Mapping[str, str] | None
|
|
806
|
+
) -> tuple[list[str], LaunchDescription | None]:
|
|
807
|
+
"""``command`` as it runs inside the request's sandbox, and its description.
|
|
808
|
+
|
|
809
|
+
Unchanged, and ``None``, without :attr:`AgentRequest.sandbox`. Inside,
|
|
810
|
+
the executable is named by its resolved host path, which the
|
|
811
|
+
description shows read-only (D13.1). ``env`` is what the adapter sets
|
|
812
|
+
over the allow-listed environment for this process. A launch the
|
|
813
|
+
policy refuses raises
|
|
814
|
+
:class:`~autoforge.errors.SandboxLaunchRefusedError` before anything
|
|
815
|
+
is spawned.
|
|
816
|
+
"""
|
|
817
|
+
if req.sandbox is None:
|
|
818
|
+
return command, None
|
|
819
|
+
launch_env = {**self.launch_environment(req.env_allowlist), **(env or {})}
|
|
820
|
+
needs = self.sandbox_requirements(req.profile, command, launch_env, req.sandbox.facts.home)
|
|
821
|
+
description = req.sandbox.describe(needs, launch_env)
|
|
822
|
+
if needs.executable:
|
|
823
|
+
command = [needs.executable, *command[1:]]
|
|
824
|
+
return command, description
|
|
825
|
+
|
|
826
|
+
def launch_allowlist(self, base: tuple[str, ...]) -> tuple[str, ...]:
|
|
827
|
+
"""``base`` (the configured allow-list) plus this provider's own names.
|
|
828
|
+
|
|
829
|
+
Neither may name a GitHub or git credential route (#165): config load
|
|
830
|
+
and :meth:`validate_profile` refuse one first, and this refuses it
|
|
831
|
+
again for an allow-list that did not come through either.
|
|
832
|
+
"""
|
|
833
|
+
names = tuple(dict.fromkeys([*base, *self.environment_names]))
|
|
834
|
+
refuse_forbidden_env(names, f"the {self.name} launch allow-list")
|
|
835
|
+
return names
|
|
836
|
+
|
|
837
|
+
def environment_allowlist(self, req: AgentRequest) -> tuple[str, ...] | None:
|
|
838
|
+
"""The allow-list the CLI is launched with: the request's plus this provider's."""
|
|
839
|
+
if req.env_allowlist is None:
|
|
840
|
+
return None
|
|
841
|
+
return self.launch_allowlist(req.env_allowlist)
|
|
842
|
+
|
|
843
|
+
def execute(self, req: AgentRequest) -> AgentExecutionResult:
|
|
844
|
+
command = self.build_command(req)
|
|
845
|
+
monitor = _loop_monitor(req, guarded(req.progress))
|
|
846
|
+
stop = threading.Event()
|
|
847
|
+
|
|
848
|
+
def on_chunk(stream: str, chunk: bytes) -> None:
|
|
849
|
+
# The output is not a protocol: its lines are digested as they
|
|
850
|
+
# arrive and dropped (#194); the capture keeps its own copy.
|
|
851
|
+
if monitor.output(stream, chunk, time.monotonic()) is not None:
|
|
852
|
+
stop.set()
|
|
853
|
+
|
|
854
|
+
self.integration_preflight(req.env_allowlist)
|
|
855
|
+
with self.integration_isolation() as isolation:
|
|
856
|
+
env = {**(req.env or {}), **isolation} if isolation else req.env
|
|
857
|
+
command, description = self.sandboxed(req, command, env)
|
|
858
|
+
res = self._runner(
|
|
859
|
+
ExecutionRequest(
|
|
860
|
+
command=command,
|
|
861
|
+
cwd=req.cwd,
|
|
862
|
+
timeout_seconds=req.max_runtime_seconds or MAX_DEADLINE_SECONDS,
|
|
863
|
+
idle_timeout_seconds=req.idle_timeout_seconds,
|
|
864
|
+
env_allowlist=self.environment_allowlist(req),
|
|
865
|
+
env=env,
|
|
866
|
+
# Nothing an agent starts outlives its invocation (ADR
|
|
867
|
+
# 0002), including what it detached from its process group.
|
|
868
|
+
contain_orphans=True,
|
|
869
|
+
stdin_data=self.stdin_payload(req),
|
|
870
|
+
on_output=_activity_hook(req.progress),
|
|
871
|
+
on_chunk=on_chunk,
|
|
872
|
+
stop=stop,
|
|
873
|
+
sandbox=description,
|
|
874
|
+
)
|
|
875
|
+
)
|
|
876
|
+
result = AgentExecutionResult.from_execution(res, req.profile)
|
|
877
|
+
if res.stdin_unread and res.exit_code == 0 and not res.timed_out and not res.stopped:
|
|
878
|
+
# A clean exit would otherwise be read as an answer to the whole
|
|
879
|
+
# prompt; a timeout or a failed exit already says more.
|
|
880
|
+
result.provider_failure = (
|
|
881
|
+
f"{self.name}: the prompt was not delivered: the CLI exited without "
|
|
882
|
+
f"reading the last {res.stdin_unread} bytes of it"
|
|
883
|
+
)
|
|
884
|
+
return _finish_loop(result, monitor, killed=res.stopped)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
CLAUDE_EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
888
|
+
CLAUDE_PERMISSION_MODES = ("acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan")
|
|
889
|
+
# `stream-json` (the default) reports progress while the agent runs and
|
|
890
|
+
# carries the final text in its last `result` line; `text` prints only the
|
|
891
|
+
# final text, at exit, and is kept as an explicit opt-out. `json` prints one
|
|
892
|
+
# object at exit: no progress, so nothing over `text`, and not accepted.
|
|
893
|
+
CLAUDE_OUTPUT_FORMATS = ("stream-json", "text")
|
|
894
|
+
CLAUDE_DEFAULT_OUTPUT_FORMAT = "stream-json"
|
|
895
|
+
# One stream-json line is bounded like a Pi record: the `result` line must
|
|
896
|
+
# arrive whole, and JSON escaping can double a final text that is itself
|
|
897
|
+
# up to the stdout bound. A longer line (a `tool_result` quoting a large
|
|
898
|
+
# file) is skipped and counted, and is never buffered whole.
|
|
899
|
+
CLAUDE_MAX_RECORD_BYTES = 2 * DEFAULT_MAX_OUTPUT_BYTES + 1024 * 1024
|
|
900
|
+
CLAUDE_MAX_PENDING_BYTES = 2 * CLAUDE_MAX_RECORD_BYTES
|
|
901
|
+
# How long the stream driver waits for a line before it checks whether the
|
|
902
|
+
# CLI has exited (it also checks after every line): a descendant holding
|
|
903
|
+
# stdout keeps EOF away, and this is how late its exit grace (ADR 0002) can
|
|
904
|
+
# start.
|
|
905
|
+
CLAUDE_EXIT_POLL_SECONDS = 0.25
|
|
906
|
+
# What every launch adds so that no integration of the operator's or the
|
|
907
|
+
# repository's configuration runs (#165): one may hold a GitHub token of its
|
|
908
|
+
# own. ``--safe-mode`` keeps hooks, installed plugins, MCP servers, skills,
|
|
909
|
+
# custom agents and commands, and CLAUDE.md from loading, those of
|
|
910
|
+
# ``--settings``, ``--plugin-dir`` and ``--mcp-config`` included, and wins
|
|
911
|
+
# over ``CLAUDE_CODE_SAFE_MODE=0``; ``--strict-mcp-config`` with no
|
|
912
|
+
# ``--mcp-config`` keeps MCP servers off on its own. ``--setting-sources=user``
|
|
913
|
+
# reads no project or local settings (``.claude/settings.json`` and
|
|
914
|
+
# ``settings.local.json`` of the worktree, which the repository controls):
|
|
915
|
+
# 2.1.296 applies their ``env`` over the launch's under ``--safe-mode`` too,
|
|
916
|
+
# so a ``CLAUDE_ENV_FILE`` there would undo :data:`CLAUDE_SHELL_CLEANER`
|
|
917
|
+
# (seen with a loopback API). The user's settings stay, checked instead
|
|
918
|
+
# (:func:`claude_configuration_problems`).
|
|
919
|
+
CLAUDE_ISOLATION_FLAGS = ("--safe-mode", "--strict-mcp-config", "--setting-sources=user")
|
|
920
|
+
# The flags that would bring an integration or the repository's settings
|
|
921
|
+
# back, refused in ``extra_args``: MCP servers, plugins, settings (hooks
|
|
922
|
+
# among them), setting sources and agent definitions, and the browser and
|
|
923
|
+
# IDE connections.
|
|
924
|
+
CLAUDE_INTEGRATION_FLAGS = (
|
|
925
|
+
"--mcp-config",
|
|
926
|
+
"--plugin-dir",
|
|
927
|
+
"--plugin-url",
|
|
928
|
+
"--settings",
|
|
929
|
+
"--setting-sources",
|
|
930
|
+
"--agents",
|
|
931
|
+
"--chrome",
|
|
932
|
+
"--ide",
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _integration_flag(arg: str) -> str | None:
|
|
937
|
+
"""The integration flag ``arg`` is (``--flag`` or ``--flag=value``); None if none."""
|
|
938
|
+
for flag in CLAUDE_INTEGRATION_FLAGS:
|
|
939
|
+
if arg == flag or arg.startswith(flag + "="):
|
|
940
|
+
return flag
|
|
941
|
+
return None
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
# The shell Claude Code's Bash tool runs a command in (#165). 2.1.296 (read
|
|
945
|
+
# from the build, checked against it with a loopback API) starts
|
|
946
|
+
# ``$CLAUDE_CODE_SHELL`` when that names a bash or a zsh, else picks one
|
|
947
|
+
# itself. Once per session it runs that shell as ``<shell> -c -l``, sources
|
|
948
|
+
# ``$HOME/.zshrc`` or ``$HOME/.bashrc`` there (by ``HOME``, whatever
|
|
949
|
+
# ``ZDOTDIR`` says) and writes the functions, aliases and options it then
|
|
950
|
+
# has to a snapshot; every command sources the snapshot first or, without
|
|
951
|
+
# one, runs in a login shell (``-c -l``, which reads ``~/.bash_profile``).
|
|
952
|
+
# A function or alias of the operator's there (``gh() { GH_TOKEN=... command
|
|
953
|
+
# gh "$@"; }``) would hand the agent's ordinary ``gh`` a credential, and no
|
|
954
|
+
# flag or variable turns the snapshot off. What 2.1.296 runs next in the
|
|
955
|
+
# same shell, before the command and in both cases, is the text of the file
|
|
956
|
+
# ``CLAUDE_ENV_FILE`` names, so every launch names a file of its own holding
|
|
957
|
+
# :data:`CLAUDE_SHELL_CLEANER`; it also empties ``CLAUDE_CODE_SHELL_PREFIX``,
|
|
958
|
+
# a program 2.1.296 would run every command through, and sets
|
|
959
|
+
# ``CLAUDE_CODE_SHELL`` itself, which could otherwise name a program of the
|
|
960
|
+
# operator's to start as the shell.
|
|
961
|
+
CLAUDE_SHELLS = ("bash", "zsh")
|
|
962
|
+
CLAUDE_ENV_FILE_NAME = "env.sh"
|
|
963
|
+
# Inlined by 2.1.296 after the snapshot is sourced and before the command
|
|
964
|
+
# (``<snapshot> && <this>\n: && ... && eval <command>``): it removes every
|
|
965
|
+
# function and alias, the operator's and Claude Code's own (its ``rg`` and
|
|
966
|
+
# ``pkill`` wrappers) alike, then checks that none is left and otherwise
|
|
967
|
+
# ends the shell, so the command does not run. The block is one line after
|
|
968
|
+
# the comment, joined to the snapshot line by ``&&``, so it is parsed before
|
|
969
|
+
# the snapshot defines an alias; every command word is ``\``-quoted, so an
|
|
970
|
+
# alias of the login files cannot expand either. In bash, assigning
|
|
971
|
+
# ``POSIXLY_CORRECT`` (no function shadows an assignment) puts the special
|
|
972
|
+
# builtin ``unset`` ahead of any function, which then removes whatever
|
|
973
|
+
# function shadows ``builtin`` or ``read``. zsh has no such mode: whatever
|
|
974
|
+
# ``builtin`` cannot remove, the count of ``$functions`` and the alias
|
|
975
|
+
# tables (parameters, which nothing shadows) refuses. ``${x:?}`` of an
|
|
976
|
+
# empty ``x``, an expansion and no command, ends a non-interactive shell.
|
|
977
|
+
_CLAUDE_CLEANER_LEFT = (
|
|
978
|
+
"__af_left=; __af_x=${__af_left:?AutoForge: a function or alias of a shell startup "
|
|
979
|
+
"file is still defined, so this command is not run (#165)}"
|
|
980
|
+
)
|
|
981
|
+
CLAUDE_SHELL_CLEANER = (
|
|
982
|
+
"# Written by AutoForge for one Claude Code launch (#165): no function or alias of a "
|
|
983
|
+
"shell startup file runs in a Bash tool command.\n"
|
|
984
|
+
"{ if [[ -n ${BASH_VERSION-} ]]; then "
|
|
985
|
+
"case :${SHELLOPTS-}: in (*:posix:*) __af_posix=1 ;; "
|
|
986
|
+
"(*) __af_posix= ; POSIXLY_CORRECT=y ;; esac; "
|
|
987
|
+
"\\unset -f builtin command declare read unset unalias alias; "
|
|
988
|
+
"[[ -n $__af_posix ]] || \\unset POSIXLY_CORRECT; "
|
|
989
|
+
'while \\builtin read -r __af_f; do \\builtin unset -f -- "${__af_f##* }" || __af_rc=1; '
|
|
990
|
+
"done < <(\\builtin declare -F); "
|
|
991
|
+
"\\builtin unalias -a 2>/dev/null || __af_rc=1; "
|
|
992
|
+
"[[ -z $(\\builtin declare -F) && -z $(\\builtin alias) ]] || { "
|
|
993
|
+
+ _CLAUDE_CLEANER_LEFT
|
|
994
|
+
+ "; }; "
|
|
995
|
+
"\\builtin unset __af_f __af_posix __af_rc __af_x __af_left; "
|
|
996
|
+
"elif [[ -n ${ZSH_VERSION-} ]]; then "
|
|
997
|
+
"\\builtin unfunction -m '*' 2>/dev/null || __af_rc=1; "
|
|
998
|
+
"\\builtin unalias -a 2>/dev/null || __af_rc=1; "
|
|
999
|
+
"\\builtin unalias -as 2>/dev/null || __af_rc=1; "
|
|
1000
|
+
"(( ${+functions} && ${+aliases} && ${+galiases} && ${+saliases} "
|
|
1001
|
+
"&& ${#functions} + ${#aliases} + ${#galiases} + ${#saliases} == 0 )) || { "
|
|
1002
|
+
+ _CLAUDE_CLEANER_LEFT
|
|
1003
|
+
+ "; }; "
|
|
1004
|
+
"\\builtin unset __af_rc __af_x __af_left; "
|
|
1005
|
+
"else __af_left=; __af_x=${__af_left:?AutoForge: this command would run in a shell "
|
|
1006
|
+
"AutoForge cannot clear of shell startup functions and aliases (#165)}; fi; }\n"
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def claude_shell(shell: str) -> str:
|
|
1011
|
+
"""The shell a Claude Code launch's Bash tool runs in (``CLAUDE_CODE_SHELL``):
|
|
1012
|
+
``shell`` (the launch's ``SHELL``, :func:`autoforge.child_env.child_shell`)
|
|
1013
|
+
when it is a bash or a zsh, else the ``bash`` or ``zsh`` on ``PATH``;
|
|
1014
|
+
empty when there is neither (2.1.296 then looks for one itself, and
|
|
1015
|
+
:data:`CLAUDE_SHELL_CLEANER` clears either)."""
|
|
1016
|
+
if os.path.basename(shell) in CLAUDE_SHELLS:
|
|
1017
|
+
return shell
|
|
1018
|
+
return shutil.which("bash") or shutil.which("zsh") or ""
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def claude_isolation_env(env_file: str, shell: str) -> dict[str, str]:
|
|
1022
|
+
"""What a Claude Code launch sets so that its Bash tool runs no function
|
|
1023
|
+
or alias of a shell startup file and no program of the operator's
|
|
1024
|
+
around a command: ``env_file`` holds :data:`CLAUDE_SHELL_CLEANER`."""
|
|
1025
|
+
return {
|
|
1026
|
+
"CLAUDE_ENV_FILE": env_file,
|
|
1027
|
+
"CLAUDE_CODE_SHELL": shell,
|
|
1028
|
+
"CLAUDE_CODE_SHELL_PREFIX": "",
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
# Read whole to find its ``env``, and refused past this size: Claude Code's
|
|
1033
|
+
# global configuration grows with its project history.
|
|
1034
|
+
CLAUDE_MAX_CONFIG_BYTES = 32 * 1024 * 1024
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def claude_config_files(env: Mapping[str, str]) -> list[Path]:
|
|
1038
|
+
"""The operator's files whose ``env`` a Claude Code launch with ``env``
|
|
1039
|
+
sets over its own environment, and so over every command its Bash tool
|
|
1040
|
+
runs, ``--safe-mode`` notwithstanding (2.1.296, read from the build and
|
|
1041
|
+
seen replacing the placeholder ``GH_TOKEN`` and ``CLAUDE_ENV_FILE``): the
|
|
1042
|
+
user settings, ``settings.json`` in the configuration directory
|
|
1043
|
+
(``CLAUDE_CONFIG_DIR``, else ``~/.claude``), and the global
|
|
1044
|
+
configuration, ``.config.json`` there when it exists, else
|
|
1045
|
+
``.claude.json`` (``.claude-custom-oauth.json`` with
|
|
1046
|
+
``CLAUDE_CODE_CUSTOM_OAUTH_URL``) in ``CLAUDE_CONFIG_DIR``, else in the
|
|
1047
|
+
home (``HOME``, else the password database's)."""
|
|
1048
|
+
home = env.get("HOME")
|
|
1049
|
+
if not home:
|
|
1050
|
+
try:
|
|
1051
|
+
home = pwd.getpwuid(os.getuid()).pw_dir
|
|
1052
|
+
except KeyError:
|
|
1053
|
+
home = "/"
|
|
1054
|
+
explicit = env.get("CLAUDE_CONFIG_DIR")
|
|
1055
|
+
config_dir = Path(explicit or Path(home, ".claude"))
|
|
1056
|
+
legacy = config_dir / ".config.json"
|
|
1057
|
+
suffix = "-custom-oauth" if env.get("CLAUDE_CODE_CUSTOM_OAUTH_URL") else ""
|
|
1058
|
+
global_config = legacy if legacy.exists() else Path(explicit or home, f".claude{suffix}.json")
|
|
1059
|
+
return [config_dir / "settings.json", global_config]
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _claude_config_env_problems(path: Path) -> list[str]:
|
|
1063
|
+
"""The names in the ``env`` of ``path`` that would undo a launch's
|
|
1064
|
+
isolation, or that it could not be read; names only, never a value."""
|
|
1065
|
+
if not path.exists():
|
|
1066
|
+
return []
|
|
1067
|
+
refused = _not_a_regular_file(path)
|
|
1068
|
+
if refused:
|
|
1069
|
+
return [refused]
|
|
1070
|
+
try:
|
|
1071
|
+
with path.open("rb") as handle:
|
|
1072
|
+
data = handle.read(CLAUDE_MAX_CONFIG_BYTES + 1)
|
|
1073
|
+
except FileNotFoundError:
|
|
1074
|
+
return []
|
|
1075
|
+
except OSError as exc:
|
|
1076
|
+
return [f"{path} could not be checked ({type(exc).__name__})"]
|
|
1077
|
+
if len(data) > CLAUDE_MAX_CONFIG_BYTES:
|
|
1078
|
+
return [f"{path} is larger than {CLAUDE_MAX_CONFIG_BYTES} bytes and was not checked"]
|
|
1079
|
+
try:
|
|
1080
|
+
document = json.loads(data)
|
|
1081
|
+
except ValueError:
|
|
1082
|
+
return [f"{path} could not be checked (not JSON)"]
|
|
1083
|
+
env = document.get("env") if isinstance(document, dict) else None
|
|
1084
|
+
if not isinstance(env, dict):
|
|
1085
|
+
return []
|
|
1086
|
+
isolation = {name.upper() for name in ClaudeCodeProvider.isolation_names}
|
|
1087
|
+
hits = sorted(
|
|
1088
|
+
name
|
|
1089
|
+
for name in env
|
|
1090
|
+
if isinstance(name, str) and (undoes_isolation(name) or name.upper() in isolation)
|
|
1091
|
+
)
|
|
1092
|
+
if not hits:
|
|
1093
|
+
return []
|
|
1094
|
+
return [f"{', '.join(redact(name) for name in hits)} in the env of {path}"]
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def claude_configuration_problems(env: Mapping[str, str]) -> list[str]:
|
|
1098
|
+
"""What, in the configuration a Claude Code launch with ``env`` reads,
|
|
1099
|
+
would set a variable over the launch's isolation (empty: nothing would).
|
|
1100
|
+
|
|
1101
|
+
``env`` is the environment the launch inherits (``HOME``,
|
|
1102
|
+
``CLAUDE_CONFIG_DIR`` and ``CLAUDE_CODE_CUSTOM_OAUTH_URL`` decide where
|
|
1103
|
+
2.1.296 looks, :func:`claude_config_files`). A file that exists but
|
|
1104
|
+
cannot be read is a problem too: a launch is refused rather than assumed
|
|
1105
|
+
clean. Managed (administrator) settings are not read: the operator can
|
|
1106
|
+
change them no more than a launch can.
|
|
1107
|
+
"""
|
|
1108
|
+
problems: list[str] = []
|
|
1109
|
+
for path in claude_config_files(env):
|
|
1110
|
+
problems += _claude_config_env_problems(path)
|
|
1111
|
+
return problems
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
class ClaudeCodeProvider(AgentProvider):
|
|
1115
|
+
name = "claude"
|
|
1116
|
+
# ``ANTHROPIC_API_KEY`` / ``ANTHROPIC_AUTH_TOKEN`` and the base URL and
|
|
1117
|
+
# model overrides the CLI reads; ``CLAUDE_CONFIG_DIR`` and the
|
|
1118
|
+
# ``CLAUDE_CODE_*`` feature switches.
|
|
1119
|
+
environment_names = ("ANTHROPIC_*", "CLAUDE_*")
|
|
1120
|
+
option_keys = ("permission_mode", "output_format", "session_persistence")
|
|
1121
|
+
isolation_names = tuple(sorted(claude_isolation_env("", "")))
|
|
1122
|
+
path_names = ("CLAUDE_ENV_FILE", "CLAUDE_CODE_SHELL", "CLAUDE_CONFIG_DIR")
|
|
1123
|
+
|
|
1124
|
+
def sandbox_requirements(
|
|
1125
|
+
self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
|
|
1126
|
+
) -> ProviderRequirements:
|
|
1127
|
+
"""Claude Code's declaration (ADR 0005 D7.1, D13.1).
|
|
1128
|
+
|
|
1129
|
+
The launch's ``CLAUDE_ENV_FILE`` read-only. With ``CLAUDE_CONFIG_DIR``
|
|
1130
|
+
set to an absolute path, that directory writable where it resolves
|
|
1131
|
+
(``dir-rw``: the CLI rewrites its login there; the variable names it
|
|
1132
|
+
there inside), refused when it is or lies in ``~/.claude`` (D7.2).
|
|
1133
|
+
Without it, the login file ``~/.claude/.credentials.json`` read-only
|
|
1134
|
+
(``file-ro``; a token the CLI must refresh cannot be written back)
|
|
1135
|
+
and the user settings ``~/.claude/settings.json`` read-only, both
|
|
1136
|
+
when present, each at that path in the private home, where the CLI
|
|
1137
|
+
looks for it, even when the host path is a link to a file elsewhere.
|
|
1138
|
+
A key in ``ANTHROPIC_*`` or ``CLAUDE_CODE_OAUTH_TOKEN`` is an ``env``
|
|
1139
|
+
item. ``~/.claude`` and ``~/.claude.json`` are the operator
|
|
1140
|
+
configuration roots.
|
|
1141
|
+
"""
|
|
1142
|
+
needs = super().sandbox_requirements(profile, command, env, home)
|
|
1143
|
+
root = os.path.join(home, ".claude")
|
|
1144
|
+
config: list[str] = []
|
|
1145
|
+
home_config: list[str] = []
|
|
1146
|
+
auth = [
|
|
1147
|
+
AuthItem(AuthMode.ENV, name)
|
|
1148
|
+
for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN")
|
|
1149
|
+
if env.get(name)
|
|
1150
|
+
]
|
|
1151
|
+
env_file = env.get("CLAUDE_ENV_FILE", "")
|
|
1152
|
+
if env_file.startswith("/"):
|
|
1153
|
+
config.append(env_file)
|
|
1154
|
+
explicit = env.get("CLAUDE_CONFIG_DIR", "")
|
|
1155
|
+
if explicit.startswith("/"):
|
|
1156
|
+
auth.append(
|
|
1157
|
+
AuthItem(
|
|
1158
|
+
AuthMode.DIR_RW,
|
|
1159
|
+
"the Claude Code configuration directory ($CLAUDE_CONFIG_DIR)",
|
|
1160
|
+
explicit,
|
|
1161
|
+
env_name="CLAUDE_CONFIG_DIR",
|
|
1162
|
+
)
|
|
1163
|
+
)
|
|
1164
|
+
else:
|
|
1165
|
+
login = os.path.join(root, ".credentials.json")
|
|
1166
|
+
if os.path.isfile(login):
|
|
1167
|
+
auth.append(
|
|
1168
|
+
AuthItem(AuthMode.FILE_RO, "the Claude Code login", login, target=login)
|
|
1169
|
+
)
|
|
1170
|
+
settings = os.path.join(root, "settings.json")
|
|
1171
|
+
if os.path.isfile(settings):
|
|
1172
|
+
home_config.append(settings)
|
|
1173
|
+
return dataclasses.replace(
|
|
1174
|
+
needs,
|
|
1175
|
+
config=(*needs.config, *config),
|
|
1176
|
+
home_config=(*needs.home_config, *home_config),
|
|
1177
|
+
auth=tuple(auth),
|
|
1178
|
+
operator_config_roots=(root, os.path.join(home, ".claude.json")),
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
@contextmanager
|
|
1182
|
+
def integration_isolation(self) -> Iterator[dict[str, str]]:
|
|
1183
|
+
"""A :data:`CLAUDE_SHELL_CLEANER` file for this launch's
|
|
1184
|
+
``CLAUDE_ENV_FILE``, a bash or zsh to run Bash tool commands in and no
|
|
1185
|
+
shell prefix (:func:`claude_isolation_env`): no function or alias of
|
|
1186
|
+
the operator's shell startup files runs in one (#165).
|
|
1187
|
+
|
|
1188
|
+
The directory (``mkdtemp``, mode 0700) is removed when the launch
|
|
1189
|
+
ends; one that cannot be made or written is an
|
|
1190
|
+
:class:`ExecutionError`, before anything is launched.
|
|
1191
|
+
"""
|
|
1192
|
+
try:
|
|
1193
|
+
env_dir = tempfile.mkdtemp(prefix="autoforge-claude-")
|
|
1194
|
+
except OSError as exc:
|
|
1195
|
+
raise ExecutionError(f"cannot prepare the claude shell environment: {exc}") from exc
|
|
1196
|
+
try:
|
|
1197
|
+
env_file = os.path.join(env_dir, CLAUDE_ENV_FILE_NAME)
|
|
1198
|
+
try:
|
|
1199
|
+
fd = os.open(env_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
1200
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
1201
|
+
handle.write(CLAUDE_SHELL_CLEANER)
|
|
1202
|
+
except OSError as exc:
|
|
1203
|
+
raise ExecutionError(f"cannot prepare the claude shell environment: {exc}") from exc
|
|
1204
|
+
yield claude_isolation_env(env_file, claude_shell(child_shell()))
|
|
1205
|
+
finally:
|
|
1206
|
+
shutil.rmtree(env_dir, ignore_errors=True)
|
|
1207
|
+
|
|
1208
|
+
def integration_preflight(self, env_allowlist: tuple[str, ...] | None) -> None:
|
|
1209
|
+
"""Refuse a launch while Claude Code's own configuration would set a
|
|
1210
|
+
variable over its isolation (:func:`claude_configuration_problems`):
|
|
1211
|
+
2.1.296 applies the ``env`` of the user settings and of the global
|
|
1212
|
+
configuration over the environment it is started with, under
|
|
1213
|
+
``--safe-mode`` too, so a ``GH_TOKEN`` there would replace the
|
|
1214
|
+
placeholder and a ``CLAUDE_ENV_FILE`` there the cleaner.
|
|
1215
|
+
"""
|
|
1216
|
+
problems = claude_configuration_problems(self.launch_environment(env_allowlist))
|
|
1217
|
+
if problems:
|
|
1218
|
+
raise ConfigurationError(
|
|
1219
|
+
"refusing to launch claude: its own configuration sets variables over the "
|
|
1220
|
+
"environment AutoForge gives a launch, which would hand its commands a GitHub "
|
|
1221
|
+
"or git credential or let a shell startup file's functions and aliases run in "
|
|
1222
|
+
f"them: {'; '.join(problems)}. Remove them from that env (Claude Code reads "
|
|
1223
|
+
"the variables of the environment it is started in; set them there and allow "
|
|
1224
|
+
"them through execution.env_allowlist_extra unless they name a credential), "
|
|
1225
|
+
"or route these profiles to another provider (docs/configuration.md, "
|
|
1226
|
+
"'Agent environment')"
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
def validate_profile(self, profile: ProfileConfig) -> None:
|
|
1230
|
+
super().validate_profile(profile)
|
|
1231
|
+
if profile.effort and profile.effort not in CLAUDE_EFFORTS:
|
|
1232
|
+
raise ConfigurationError(
|
|
1233
|
+
f"profile {profile.name!r}: claude --effort must be one of {CLAUDE_EFFORTS}, "
|
|
1234
|
+
f"got {profile.effort!r}"
|
|
1235
|
+
)
|
|
1236
|
+
output_format = self.output_format(profile)
|
|
1237
|
+
if output_format not in CLAUDE_OUTPUT_FORMATS:
|
|
1238
|
+
raise ConfigurationError(
|
|
1239
|
+
f"profile {profile.name!r}: claude output_format must be one of "
|
|
1240
|
+
f"{', '.join(CLAUDE_OUTPUT_FORMATS)} (got {output_format!r}): stream-json "
|
|
1241
|
+
"reports progress and is reduced to the final text, text is the final text "
|
|
1242
|
+
"alone with no progress, and json gives neither"
|
|
1243
|
+
)
|
|
1244
|
+
mode = profile.options.get("permission_mode", "bypassPermissions")
|
|
1245
|
+
if mode and mode not in CLAUDE_PERMISSION_MODES:
|
|
1246
|
+
raise ConfigurationError(
|
|
1247
|
+
f"profile {profile.name!r}: unknown claude permission_mode {mode!r}"
|
|
1248
|
+
)
|
|
1249
|
+
for arg in profile.extra_args:
|
|
1250
|
+
flag = _integration_flag(arg)
|
|
1251
|
+
if flag is not None:
|
|
1252
|
+
# The flag only, never its value: a settings string may hold a key.
|
|
1253
|
+
raise ConfigurationError(
|
|
1254
|
+
f"profile {profile.name!r}: claude extra_args cannot carry {flag}: it "
|
|
1255
|
+
"loads an integration (an MCP server, a plugin, hooks, an agent "
|
|
1256
|
+
"definition, a browser or IDE connection) or settings, which may hold "
|
|
1257
|
+
"a GitHub token of its own or set variables over the launch's, and "
|
|
1258
|
+
f"AutoForge launches Claude Code with {', '.join(CLAUDE_ISOLATION_FLAGS)} "
|
|
1259
|
+
"so that none applies (docs/configuration.md, 'Agent environment')"
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
@staticmethod
|
|
1263
|
+
def output_format(profile: ProfileConfig) -> str:
|
|
1264
|
+
return profile.options.get("output_format") or CLAUDE_DEFAULT_OUTPUT_FORMAT
|
|
1265
|
+
|
|
1266
|
+
def reports_activity(self, profile: ProfileConfig) -> bool:
|
|
1267
|
+
# `text` prints the final text once, at exit, and nothing before it.
|
|
1268
|
+
return self.output_format(profile) == "stream-json"
|
|
1269
|
+
|
|
1270
|
+
def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
|
|
1271
|
+
argv = [profile.command or "claude", "-p"]
|
|
1272
|
+
output_format = self.output_format(profile)
|
|
1273
|
+
argv += ["--output-format", output_format]
|
|
1274
|
+
if output_format == "stream-json":
|
|
1275
|
+
# The CLI refuses stream-json under --print without it.
|
|
1276
|
+
argv.append("--verbose")
|
|
1277
|
+
if profile.model:
|
|
1278
|
+
argv += ["--model", profile.model]
|
|
1279
|
+
if profile.effort:
|
|
1280
|
+
argv += ["--effort", profile.effort]
|
|
1281
|
+
mode = profile.options.get("permission_mode", "bypassPermissions")
|
|
1282
|
+
if mode:
|
|
1283
|
+
argv += ["--permission-mode", mode]
|
|
1284
|
+
if _truthy(profile.options.get("session_persistence"), default=False) is False:
|
|
1285
|
+
argv.append("--no-session-persistence")
|
|
1286
|
+
# No hook, plugin or MCP server of the operator's or the repository's
|
|
1287
|
+
# configuration runs (#165): one may hold a GitHub token of its own.
|
|
1288
|
+
argv += CLAUDE_ISOLATION_FLAGS
|
|
1289
|
+
argv += list(profile.extra_args)
|
|
1290
|
+
# ``--`` guarantees the prompt is never parsed as an option.
|
|
1291
|
+
argv += ["--", prompt]
|
|
1292
|
+
return argv
|
|
1293
|
+
|
|
1294
|
+
def execute(self, req: AgentRequest) -> AgentExecutionResult:
|
|
1295
|
+
"""Run the CLI; with stream-json, reduce its stream to the final text.
|
|
1296
|
+
|
|
1297
|
+
``output_format: text`` is the one-shot path every adapter shares.
|
|
1298
|
+
With ``stream-json`` the child runs under the duplex handle with its
|
|
1299
|
+
stdin on ``/dev/null``: the handle already frames LF-terminated
|
|
1300
|
+
records against a per-record bound and drops a longer one through
|
|
1301
|
+
its LF without buffering it, which is exactly what this stream needs
|
|
1302
|
+
(a ``tool_result`` line quotes whole files), so a line callback in
|
|
1303
|
+
the one-shot executor would have duplicated that framer. Every line
|
|
1304
|
+
goes to :class:`~autoforge.claude_stream.ClaudeStream`, which emits
|
|
1305
|
+
progress and decides the outcome. The CLI's exit, not EOF, ends the
|
|
1306
|
+
wait, so a leftover holding stdout costs ADR 0002's exit grace and
|
|
1307
|
+
kill, never the timeout. ``stdout`` of the result is the last
|
|
1308
|
+
``result`` text, tail-bounded as the executor's capture is; a failure
|
|
1309
|
+
inside the stream is ``provider_failure`` and a timeout wins over it.
|
|
1310
|
+
``exit_code`` is always the real process status.
|
|
1311
|
+
"""
|
|
1312
|
+
if self.output_format(req.profile) != "stream-json":
|
|
1313
|
+
return super().execute(req)
|
|
1314
|
+
emit = guarded(req.progress)
|
|
1315
|
+
monitor = _loop_monitor(req, emit)
|
|
1316
|
+
stream = ClaudeStream(cwd=req.cwd, emit=emit, loop=_ClockedLoop(monitor))
|
|
1317
|
+
self.integration_preflight(req.env_allowlist)
|
|
1318
|
+
with self.integration_isolation() as isolation:
|
|
1319
|
+
env = {**(req.env or {}), **isolation}
|
|
1320
|
+
command, description = self.sandboxed(req, self.build_command(req), env)
|
|
1321
|
+
duplex = DuplexRequest(
|
|
1322
|
+
command=command,
|
|
1323
|
+
cwd=req.cwd,
|
|
1324
|
+
env_allowlist=self.environment_allowlist(req),
|
|
1325
|
+
env=env,
|
|
1326
|
+
idle_timeout_seconds=req.idle_timeout_seconds,
|
|
1327
|
+
max_runtime_seconds=req.max_runtime_seconds,
|
|
1328
|
+
max_record_bytes=CLAUDE_MAX_RECORD_BYTES,
|
|
1329
|
+
max_pending_bytes=CLAUDE_MAX_PENDING_BYTES,
|
|
1330
|
+
# The prompt travels in argv; stdin stays /dev/null so the CLI
|
|
1331
|
+
# never waits on it.
|
|
1332
|
+
stdin_pipe=False,
|
|
1333
|
+
# Nothing an agent starts outlives its invocation (ADR 0002).
|
|
1334
|
+
contain_orphans=True,
|
|
1335
|
+
sandbox=description,
|
|
1336
|
+
)
|
|
1337
|
+
with start_duplex(duplex) as child:
|
|
1338
|
+
cut_short = _read_claude_stream(child, stream, monitor)
|
|
1339
|
+
res = child.finish()
|
|
1340
|
+
timed_out = cut_short or res.timed_out or res.stopped
|
|
1341
|
+
failure = stream.failure
|
|
1342
|
+
if stream.exited_early:
|
|
1343
|
+
failure = f"{failure} (exit {res.exit_code})"
|
|
1344
|
+
elif res.records_overflowed and failure is None:
|
|
1345
|
+
failure = "claude: stream-json lines arrived faster than they were read"
|
|
1346
|
+
summary = stream.summary()
|
|
1347
|
+
summary["failure"] = failure or ""
|
|
1348
|
+
text = stream.text if failure is None else None
|
|
1349
|
+
stdout, truncated = _keep_tail(encodable(text or ""), DEFAULT_MAX_OUTPUT_BYTES)
|
|
1350
|
+
result = AgentExecutionResult(
|
|
1351
|
+
command=list(res.command),
|
|
1352
|
+
exit_code=res.exit_code,
|
|
1353
|
+
stdout=stdout,
|
|
1354
|
+
stderr=res.stderr,
|
|
1355
|
+
started_at=res.started_at,
|
|
1356
|
+
finished_at=res.finished_at,
|
|
1357
|
+
timed_out=timed_out,
|
|
1358
|
+
provider=req.profile.provider,
|
|
1359
|
+
model=req.profile.model,
|
|
1360
|
+
effort=req.profile.effort,
|
|
1361
|
+
stdout_truncated=truncated,
|
|
1362
|
+
stderr_truncated=res.stderr_truncated,
|
|
1363
|
+
stdout_tail_offset=0,
|
|
1364
|
+
descendants_killed=res.descendants_killed,
|
|
1365
|
+
group_survived_kill=res.group_survived_kill,
|
|
1366
|
+
capture_abandoned=res.capture_abandoned,
|
|
1367
|
+
orphans_killed=res.orphans_killed,
|
|
1368
|
+
orphan_survived_kill=res.orphan_survived_kill,
|
|
1369
|
+
orphans_unchecked=res.orphans_unchecked,
|
|
1370
|
+
provider_failure=None if timed_out else failure,
|
|
1371
|
+
provider_summary=summary,
|
|
1372
|
+
timeout_limit=res.timeout_limit,
|
|
1373
|
+
last_activity_at=res.last_activity_at,
|
|
1374
|
+
sandbox=res.sandbox,
|
|
1375
|
+
)
|
|
1376
|
+
result = _finish_loop(result, monitor, killed=res.stopped)
|
|
1377
|
+
# A refusal never ends a -p run (claude_stream.py): the agent goes on
|
|
1378
|
+
# without what it asked for, so a failure is never the refusal's alone.
|
|
1379
|
+
return _name_refused(result, stream.refused, stream.refused_count, self.name, "Claude Code")
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _read_claude_stream(
|
|
1383
|
+
child: DuplexChild, stream: ClaudeStream, loop: LoopMonitor | None = None
|
|
1384
|
+
) -> bool:
|
|
1385
|
+
"""Feed every stdout outcome to ``stream`` through its end; True on the deadline.
|
|
1386
|
+
|
|
1387
|
+
The CLI's exit, not EOF, ends the wait: a descendant still holding its
|
|
1388
|
+
stdout, silent or writing, must not hold the invocation to the deadline
|
|
1389
|
+
(ADR 0002). After every read, a line included, the driver looks at the
|
|
1390
|
+
CLI; once it has exited, ``finish()`` gives what is left the exit grace
|
|
1391
|
+
and kills it, keeping the CLI's own status, and the rest of stdout,
|
|
1392
|
+
queued up to its end by then, is fed and validated like every line
|
|
1393
|
+
before it. A read that meets the deadline after the CLI's exit does the
|
|
1394
|
+
same (``read_line`` looks at the CLI first), so only a CLI still running
|
|
1395
|
+
at the deadline is a timeout. A verdict of ``loop`` (#194) on a CLI
|
|
1396
|
+
still running stops it, and the caller's ``finish()`` reports
|
|
1397
|
+
``stopped``.
|
|
1398
|
+
"""
|
|
1399
|
+
while True:
|
|
1400
|
+
item = child.read_line(timeout=CLAUDE_EXIT_POLL_SECONDS)
|
|
1401
|
+
if isinstance(item, Record):
|
|
1402
|
+
stream.feed(item.data)
|
|
1403
|
+
elif isinstance(item, Oversize):
|
|
1404
|
+
stream.oversize(item.limit)
|
|
1405
|
+
elif isinstance(item, Fragment):
|
|
1406
|
+
stream.framing_error("stdout ended inside an unterminated line")
|
|
1407
|
+
elif isinstance(item, Overflow):
|
|
1408
|
+
stream.framing_error("stdout lines arrived faster than they were read")
|
|
1409
|
+
return False
|
|
1410
|
+
elif isinstance(item, Eof):
|
|
1411
|
+
stream.stream_ended()
|
|
1412
|
+
return False
|
|
1413
|
+
elif item.deadline_exceeded:
|
|
1414
|
+
return True
|
|
1415
|
+
if loop is not None and loop.verdict is not None and child.stop():
|
|
1416
|
+
return False
|
|
1417
|
+
if child.exited():
|
|
1418
|
+
child.finish()
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def opencode_isolation_env(config_dir: str) -> dict[str, str]:
|
|
1422
|
+
"""What an OpenCode launch sets so that it reads no configuration of the
|
|
1423
|
+
operator's or the repository's (#165): no flag of 2.0.26 disables an MCP
|
|
1424
|
+
server or a plugin, and either may hold a GitHub token of its own.
|
|
1425
|
+
|
|
1426
|
+
- ``OPENCODE_CONFIG_DIR``: the global configuration directory, instead
|
|
1427
|
+
of ``$XDG_CONFIG_HOME/opencode``; ``config_dir`` is empty, so no
|
|
1428
|
+
``opencode.json(c)`` and no ``plugin(s)/`` directory is read from it.
|
|
1429
|
+
- ``OPENCODE_CONFIG_PROJECT_DISABLE`` and its older name
|
|
1430
|
+
``OPENCODE_DISABLE_PROJECT_CONFIG``: no ``opencode.json(c)``,
|
|
1431
|
+
``.opencode/``, ``.claude/`` or ``.agents/`` of the launch directory or
|
|
1432
|
+
of any directory above it (the search does not stop at the worktree, so
|
|
1433
|
+
it would reach the operator's checkout and home). OpenCode reads the
|
|
1434
|
+
first name and falls back to the second, so setting both leaves no
|
|
1435
|
+
value of the operator's for either in force.
|
|
1436
|
+
- ``OPENCODE_CONFIG`` and ``OPENCODE_CONFIG_CONTENT`` empty: no explicit
|
|
1437
|
+
file and no inline document, whatever the operator's shell passed
|
|
1438
|
+
through ``OPENCODE_*``.
|
|
1439
|
+
|
|
1440
|
+
The model credentials (``opencode auth login``, kept in the data
|
|
1441
|
+
directory) and the providers' API-key variables are untouched; so is
|
|
1442
|
+
the organization configuration kept with them, which no variable turns
|
|
1443
|
+
off and which refuses the launch instead
|
|
1444
|
+
(:func:`opencode_organization_problems`).
|
|
1445
|
+
"""
|
|
1446
|
+
return {
|
|
1447
|
+
"OPENCODE_CONFIG_DIR": config_dir,
|
|
1448
|
+
"OPENCODE_CONFIG_PROJECT_DISABLE": "1",
|
|
1449
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG": "1",
|
|
1450
|
+
"OPENCODE_CONFIG": "",
|
|
1451
|
+
"OPENCODE_CONFIG_CONTENT": "",
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
# Where OpenCode 2.0.26 keeps the organizations connected to it (a
|
|
1456
|
+
# ``.well-known/opencode`` integration, read from the build): the ``kv``
|
|
1457
|
+
# row ``wellknown:sources`` of its database lists their origins. Every
|
|
1458
|
+
# launch, ``Config.load`` fetches each one's manifest and configuration
|
|
1459
|
+
# again and, without a usable credential for it (``opencode auth logout``
|
|
1460
|
+
# removes only the credential), falls back to the copies cached in the same
|
|
1461
|
+
# table; either may name an MCP server or a plugin holding a GitHub
|
|
1462
|
+
# credential of its own, and no variable or flag keeps it from loading.
|
|
1463
|
+
# ``auth.json``, the credential file of earlier releases, is imported when
|
|
1464
|
+
# a database is created: an entry of ``"type": "wellknown"`` there becomes
|
|
1465
|
+
# a source.
|
|
1466
|
+
OPENCODE_WELLKNOWN_SOURCES = "wellknown:sources"
|
|
1467
|
+
OPENCODE_LEGACY_AUTH_FILE = "auth.json"
|
|
1468
|
+
OPENCODE_MAX_AUTH_BYTES = 1024 * 1024
|
|
1469
|
+
# How long reading the database may wait for an OpenCode writing to it.
|
|
1470
|
+
OPENCODE_DATABASE_TIMEOUT_SECONDS = 5
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def opencode_data_dir(env: Mapping[str, str]) -> Path:
|
|
1474
|
+
"""The data directory of an OpenCode launch with ``env``:
|
|
1475
|
+
``$XDG_DATA_HOME/opencode``, else ``~/.local/share/opencode`` (``HOME``,
|
|
1476
|
+
else the password database's home), where 2.0.26 keeps its database."""
|
|
1477
|
+
base = env.get("XDG_DATA_HOME")
|
|
1478
|
+
if not base:
|
|
1479
|
+
home = env.get("HOME")
|
|
1480
|
+
if not home:
|
|
1481
|
+
try:
|
|
1482
|
+
home = pwd.getpwuid(os.getuid()).pw_dir
|
|
1483
|
+
except KeyError:
|
|
1484
|
+
home = "/"
|
|
1485
|
+
base = os.path.join(home, ".local", "share")
|
|
1486
|
+
return Path(base, "opencode")
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def opencode_databases(env: Mapping[str, str], data_dir: Path) -> list[Path]:
|
|
1490
|
+
"""The databases an OpenCode launch with ``env`` may open: the file
|
|
1491
|
+
``OPENCODE_DB`` names (relative to ``data_dir``; ``:memory:`` is none),
|
|
1492
|
+
else each ``opencode*.db`` in ``data_dir`` (the name depends on the
|
|
1493
|
+
build's release channel)."""
|
|
1494
|
+
named = env.get("OPENCODE_DB")
|
|
1495
|
+
if named is not None:
|
|
1496
|
+
return [] if named == ":memory:" else [data_dir / named]
|
|
1497
|
+
return sorted(data_dir.glob("opencode*.db"))
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _not_a_regular_file(path: Path) -> str | None:
|
|
1501
|
+
"""Why ``path``, which exists, is not opened (a FIFO would block the
|
|
1502
|
+
launch forever, a directory has nothing to read); None for a file."""
|
|
1503
|
+
if path.is_file():
|
|
1504
|
+
return None
|
|
1505
|
+
return f"{path} could not be checked (not a regular file)"
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _opencode_database_problems(database: Path) -> list[str]:
|
|
1509
|
+
"""The organizations ``database`` lists, or that it could not be read.
|
|
1510
|
+
|
|
1511
|
+
Opened read-only (a URI with ``mode=ro``), never created or written;
|
|
1512
|
+
only the sources row is read, and only the number of origins is
|
|
1513
|
+
reported, never one.
|
|
1514
|
+
"""
|
|
1515
|
+
if not database.exists():
|
|
1516
|
+
return []
|
|
1517
|
+
refused = _not_a_regular_file(database)
|
|
1518
|
+
if refused:
|
|
1519
|
+
return [refused]
|
|
1520
|
+
try:
|
|
1521
|
+
uri = f"{database.resolve().as_uri()}?mode=ro"
|
|
1522
|
+
with closing(
|
|
1523
|
+
sqlite3.connect(uri, uri=True, timeout=OPENCODE_DATABASE_TIMEOUT_SECONDS)
|
|
1524
|
+
) as db:
|
|
1525
|
+
tables = db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'kv'")
|
|
1526
|
+
if tables.fetchone() is None:
|
|
1527
|
+
return []
|
|
1528
|
+
row = db.execute(
|
|
1529
|
+
"SELECT value FROM kv WHERE key = ?", (OPENCODE_WELLKNOWN_SOURCES,)
|
|
1530
|
+
).fetchone()
|
|
1531
|
+
except (sqlite3.Error, OSError, ValueError) as exc:
|
|
1532
|
+
return [f"{database} could not be checked ({type(exc).__name__})"]
|
|
1533
|
+
if row is None:
|
|
1534
|
+
return []
|
|
1535
|
+
try:
|
|
1536
|
+
sources = json.loads(row[0])
|
|
1537
|
+
except (TypeError, ValueError):
|
|
1538
|
+
sources = None
|
|
1539
|
+
if sources == []:
|
|
1540
|
+
return []
|
|
1541
|
+
if not isinstance(sources, list):
|
|
1542
|
+
return [f"{database} could not be checked (an unreadable list of organizations)"]
|
|
1543
|
+
return [f"{len(sources)} organization(s) connected to OpenCode in {database}"]
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _opencode_legacy_auth_problems(data_dir: Path) -> list[str]:
|
|
1547
|
+
"""An organization in the legacy ``auth.json``, or that it could not be read.
|
|
1548
|
+
|
|
1549
|
+
Parsed in memory only: no value of the file is ever reported.
|
|
1550
|
+
"""
|
|
1551
|
+
path = data_dir / OPENCODE_LEGACY_AUTH_FILE
|
|
1552
|
+
if not path.exists():
|
|
1553
|
+
return []
|
|
1554
|
+
refused = _not_a_regular_file(path)
|
|
1555
|
+
if refused:
|
|
1556
|
+
return [refused]
|
|
1557
|
+
try:
|
|
1558
|
+
with path.open("rb") as handle:
|
|
1559
|
+
data = handle.read(OPENCODE_MAX_AUTH_BYTES + 1)
|
|
1560
|
+
except FileNotFoundError:
|
|
1561
|
+
return []
|
|
1562
|
+
except OSError as exc:
|
|
1563
|
+
return [f"{path} could not be checked ({type(exc).__name__})"]
|
|
1564
|
+
if len(data) > OPENCODE_MAX_AUTH_BYTES:
|
|
1565
|
+
return [f"{path} is larger than {OPENCODE_MAX_AUTH_BYTES} bytes and was not checked"]
|
|
1566
|
+
try:
|
|
1567
|
+
entries = json.loads(data)
|
|
1568
|
+
except ValueError:
|
|
1569
|
+
return [f"{path} could not be checked (not JSON)"]
|
|
1570
|
+
if isinstance(entries, dict) and any(
|
|
1571
|
+
isinstance(entry, dict) and entry.get("type") == "wellknown" for entry in entries.values()
|
|
1572
|
+
):
|
|
1573
|
+
return [
|
|
1574
|
+
f'an organization (a "wellknown" entry) in {path}, which OpenCode imports '
|
|
1575
|
+
"into every database it creates"
|
|
1576
|
+
]
|
|
1577
|
+
return []
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def opencode_organization_problems(env: Mapping[str, str]) -> list[str]:
|
|
1581
|
+
"""What, in the data an OpenCode launch with ``env`` reads, would make it
|
|
1582
|
+
load an organization's configuration (empty: nothing would).
|
|
1583
|
+
|
|
1584
|
+
``env`` is the environment the launch inherits (``HOME``,
|
|
1585
|
+
``XDG_DATA_HOME`` and ``OPENCODE_DB`` decide where 2.0.26 looks). A
|
|
1586
|
+
database or file that exists but cannot be read is a problem too: a
|
|
1587
|
+
launch is refused rather than assumed clean.
|
|
1588
|
+
"""
|
|
1589
|
+
data_dir = opencode_data_dir(env)
|
|
1590
|
+
problems: list[str] = []
|
|
1591
|
+
for database in opencode_databases(env, data_dir):
|
|
1592
|
+
problems += _opencode_database_problems(database)
|
|
1593
|
+
problems += _opencode_legacy_auth_problems(data_dir)
|
|
1594
|
+
return problems
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
class OpenCodeProvider(AgentProvider):
|
|
1598
|
+
name = "opencode"
|
|
1599
|
+
# OpenCode's own settings plus the API keys of the model providers it
|
|
1600
|
+
# routes to; a provider not listed here is added through
|
|
1601
|
+
# ``execution.env_allowlist_extra``.
|
|
1602
|
+
environment_names = ("OPENCODE_*", "OPENAI_*", "ANTHROPIC_*", "GEMINI_*", "GOOGLE_*")
|
|
1603
|
+
option_keys = ("output_format", "auto_approve")
|
|
1604
|
+
isolation_names = tuple(sorted(opencode_isolation_env("")))
|
|
1605
|
+
path_names = ("OPENCODE_CONFIG_DIR", "OPENCODE_DB")
|
|
1606
|
+
|
|
1607
|
+
def sandbox_requirements(
|
|
1608
|
+
self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
|
|
1609
|
+
) -> ProviderRequirements:
|
|
1610
|
+
"""OpenCode's declaration (ADR 0005 D7.1, D13.1).
|
|
1611
|
+
|
|
1612
|
+
The launch's empty ``OPENCODE_CONFIG_DIR`` writable (it is the
|
|
1613
|
+
launch's own, removed afterwards). An absolute ``OPENCODE_DB``: its
|
|
1614
|
+
directory writable at its own path (``dir-rw``: the database holds
|
|
1615
|
+
the login and is rewritten), refused when it is or lies in the
|
|
1616
|
+
operator's data directory (D7.2). A relative or ``:memory:`` one
|
|
1617
|
+
names a database inside the private home. The providers' API keys
|
|
1618
|
+
are ``env`` items. The configuration, data and cache directories
|
|
1619
|
+
OpenCode loads settings, organizations and code from are the
|
|
1620
|
+
operator configuration roots.
|
|
1621
|
+
"""
|
|
1622
|
+
needs = super().sandbox_requirements(profile, command, env, home)
|
|
1623
|
+
state: list[str] = []
|
|
1624
|
+
auth = [AuthItem(AuthMode.ENV, name) for name in env if name.endswith("_API_KEY")]
|
|
1625
|
+
config_dir = env.get("OPENCODE_CONFIG_DIR", "")
|
|
1626
|
+
if config_dir.startswith("/"):
|
|
1627
|
+
state.append(config_dir)
|
|
1628
|
+
database = env.get("OPENCODE_DB", "")
|
|
1629
|
+
if database.startswith("/"):
|
|
1630
|
+
auth.append(
|
|
1631
|
+
AuthItem(
|
|
1632
|
+
AuthMode.DIR_RW,
|
|
1633
|
+
"the OpenCode database directory ($OPENCODE_DB)",
|
|
1634
|
+
os.path.dirname(database),
|
|
1635
|
+
)
|
|
1636
|
+
)
|
|
1637
|
+
config_home = env.get("XDG_CONFIG_HOME") or os.path.join(home, ".config")
|
|
1638
|
+
cache_home = env.get("XDG_CACHE_HOME") or os.path.join(home, ".cache")
|
|
1639
|
+
roots = (
|
|
1640
|
+
os.path.join(config_home, "opencode"),
|
|
1641
|
+
str(opencode_data_dir({**env, "HOME": home})),
|
|
1642
|
+
os.path.join(cache_home, "opencode"),
|
|
1643
|
+
)
|
|
1644
|
+
return dataclasses.replace(
|
|
1645
|
+
needs,
|
|
1646
|
+
state=(*needs.state, *state),
|
|
1647
|
+
auth=tuple(auth),
|
|
1648
|
+
operator_config_roots=roots,
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
@contextmanager
|
|
1652
|
+
def integration_isolation(self) -> Iterator[dict[str, str]]:
|
|
1653
|
+
"""An empty global configuration directory, for this launch only, and
|
|
1654
|
+
no project, explicit or inline configuration (:func:`opencode_isolation_env`).
|
|
1655
|
+
|
|
1656
|
+
The directory (``mkdtemp``, mode 0700) is removed when the launch
|
|
1657
|
+
ends, whatever OpenCode wrote into it; one that cannot be made is an
|
|
1658
|
+
:class:`ExecutionError`, before anything is launched.
|
|
1659
|
+
"""
|
|
1660
|
+
try:
|
|
1661
|
+
config_dir = tempfile.mkdtemp(prefix="autoforge-opencode-")
|
|
1662
|
+
except OSError as exc:
|
|
1663
|
+
raise ExecutionError(f"cannot prepare the opencode configuration: {exc}") from exc
|
|
1664
|
+
try:
|
|
1665
|
+
yield opencode_isolation_env(config_dir)
|
|
1666
|
+
finally:
|
|
1667
|
+
shutil.rmtree(config_dir, ignore_errors=True)
|
|
1668
|
+
|
|
1669
|
+
def data_dir(self, env_allowlist: tuple[str, ...] | None) -> Path:
|
|
1670
|
+
"""The data directory a launch with ``env_allowlist`` uses (:func:`opencode_data_dir`)."""
|
|
1671
|
+
return opencode_data_dir(self.launch_environment(env_allowlist))
|
|
1672
|
+
|
|
1673
|
+
def integration_preflight(self, env_allowlist: tuple[str, ...] | None) -> None:
|
|
1674
|
+
"""Refuse a launch while OpenCode would load an organization's
|
|
1675
|
+
configuration (:func:`opencode_organization_problems`): 2.0.26 loads
|
|
1676
|
+
it ahead of every configuration file, and no variable of
|
|
1677
|
+
:func:`opencode_isolation_env` reaches it.
|
|
1678
|
+
"""
|
|
1679
|
+
problems = opencode_organization_problems(self.launch_environment(env_allowlist))
|
|
1680
|
+
if problems:
|
|
1681
|
+
raise ConfigurationError(
|
|
1682
|
+
"refusing to launch opencode: it would load the configuration of an "
|
|
1683
|
+
"organization connected to it (a .well-known/opencode integration), which can "
|
|
1684
|
+
"name an MCP server or a plugin holding a GitHub credential of its own, and "
|
|
1685
|
+
f"nothing keeps it from loading: {'; '.join(problems)}. Give AutoForge's "
|
|
1686
|
+
"launches an OpenCode database without one (set OPENCODE_DB, which every "
|
|
1687
|
+
"launch inherits, to a file of their own and log in to the model provider "
|
|
1688
|
+
"with it set, or to ':memory:' with the provider's API-key variable), remove "
|
|
1689
|
+
'a "wellknown" entry from auth.json, or route these profiles to another '
|
|
1690
|
+
"provider (docs/configuration.md, 'Agent environment')"
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
def validate_profile(self, profile: ProfileConfig) -> None:
|
|
1694
|
+
super().validate_profile(profile)
|
|
1695
|
+
if "/" not in profile.model:
|
|
1696
|
+
raise ConfigurationError(
|
|
1697
|
+
f"profile {profile.name!r}: opencode model must be 'provider/model' "
|
|
1698
|
+
f"(e.g. openai/gpt-5.6-luna), got {profile.model!r}"
|
|
1699
|
+
)
|
|
1700
|
+
if "#" in profile.model:
|
|
1701
|
+
raise ConfigurationError(
|
|
1702
|
+
f"profile {profile.name!r}: opencode model must not carry a '#variant' "
|
|
1703
|
+
f"suffix (got {profile.model!r}); set the variant as the profile's effort"
|
|
1704
|
+
)
|
|
1705
|
+
fmt = profile.options.get("output_format", "default") or "default"
|
|
1706
|
+
if fmt != "default":
|
|
1707
|
+
raise ConfigurationError(
|
|
1708
|
+
f"profile {profile.name!r}: opencode output_format must be 'default' so the "
|
|
1709
|
+
"CONTROL_RESULT block reaches stdout verbatim (the json event stream escapes "
|
|
1710
|
+
"it and has no final result record)"
|
|
1711
|
+
)
|
|
1712
|
+
|
|
1713
|
+
def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
|
|
1714
|
+
"""The argv, without the prompt: it goes on stdin (:meth:`stdin_payload`)."""
|
|
1715
|
+
argv = [profile.command or "opencode", "run", "--standalone"]
|
|
1716
|
+
if profile.model:
|
|
1717
|
+
variant = f"#{profile.effort}" if profile.effort else ""
|
|
1718
|
+
argv += ["-m", profile.model + variant]
|
|
1719
|
+
argv += ["--format", profile.options.get("output_format", "default") or "default"]
|
|
1720
|
+
if _truthy(profile.options.get("auto_approve"), default=False):
|
|
1721
|
+
argv.append("--auto")
|
|
1722
|
+
argv += list(profile.extra_args)
|
|
1723
|
+
return argv
|
|
1724
|
+
|
|
1725
|
+
def stdin_payload(self, req: AgentRequest) -> bytes:
|
|
1726
|
+
# A lone surrogate (legal in the JSON GitHub returns, not in UTF-8)
|
|
1727
|
+
# becomes U+FFFD rather than failing the launch.
|
|
1728
|
+
return encodable(req.prompt).encode("utf-8")
|
|
1729
|
+
|
|
1730
|
+
def execute(self, req: AgentRequest) -> AgentExecutionResult:
|
|
1731
|
+
result = super().execute(req)
|
|
1732
|
+
if (
|
|
1733
|
+
result.exit_code == 1
|
|
1734
|
+
and not result.timed_out
|
|
1735
|
+
and not result.stderr_truncated
|
|
1736
|
+
and req.profile.model.partition("/")[0] == "openai"
|
|
1737
|
+
):
|
|
1738
|
+
errors = _opencode_run_errors(result.stderr)
|
|
1739
|
+
if errors and all(e in _OPENCODE_OPENAI_TRANSIENT_ERRORS for e in errors):
|
|
1740
|
+
reasons = dict.fromkeys(_OPENCODE_OPENAI_TRANSIENT_ERRORS[e] for e in errors)
|
|
1741
|
+
result.transient_failure = "opencode: " + ", ".join(reasons)
|
|
1742
|
+
refused = _opencode_refused_requests(result.stderr)
|
|
1743
|
+
ended_by_it = ""
|
|
1744
|
+
if (
|
|
1745
|
+
refused
|
|
1746
|
+
and result.exit_code == 1
|
|
1747
|
+
and result.provider_failure is None
|
|
1748
|
+
and not result.stderr_truncated
|
|
1749
|
+
and not _opencode_run_errors(result.stderr)
|
|
1750
|
+
):
|
|
1751
|
+
# An exit 1 with no run-level error of its own: what the 2.0.x
|
|
1752
|
+
# builds did once they had refused a permission (module docstring).
|
|
1753
|
+
remedy = (
|
|
1754
|
+
"Keep the issue's work inside the worktree, or change the issue so the agent "
|
|
1755
|
+
"need not ask; options.auto_approve: true approves every permission no rule "
|
|
1756
|
+
"denies, and is dangerous"
|
|
1757
|
+
if any(kind is RequestKind.PERMISSION for kind, _ in refused)
|
|
1758
|
+
else "Change the issue so the agent need not ask"
|
|
1759
|
+
)
|
|
1760
|
+
if any(kind is RequestKind.QUESTION for kind, _ in refused):
|
|
1761
|
+
remedy += "; a question cannot be answered until #126 lands"
|
|
1762
|
+
ended_by_it = (
|
|
1763
|
+
f"OpenCode refused {'them' if len(refused) > 1 else 'it'} and exited 1 with no "
|
|
1764
|
+
f"error of its own. {remedy}. 'resume' starts a fresh session, which may ask "
|
|
1765
|
+
f"again ({REFUSED_REQUEST_GUIDE})"
|
|
1766
|
+
)
|
|
1767
|
+
return _name_refused(
|
|
1768
|
+
result, refused, len(refused), self.name, "OpenCode", ended_by_it=ended_by_it
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
# How OpenCode 2.0.26 prints an error on stderr: `ESC[91mESC[1mError: ESC[0m`
|
|
1773
|
+
# and the message's first line (no terminal check), and a failed tool: its
|
|
1774
|
+
# `ESC[0m✗ ESC[0m<title> failed` line, then the tool's error in the same form.
|
|
1775
|
+
_OPENCODE_ERROR_LINE_RE = re.compile(r"\x1b\[91m\x1b\[1mError: \x1b\[0m(.*)")
|
|
1776
|
+
_OPENCODE_TOOL_FAILED_LINE_RE = re.compile(r"\x1b\[0m✗ \x1b\[0m.* failed")
|
|
1777
|
+
# The run-level errors that make an `openai/*` exit 1 transient, each the
|
|
1778
|
+
# whole message OpenCode prints, mapped to the fixed reason reported for it:
|
|
1779
|
+
# the socket to OpenAI closed under a running turn, normally (1000, #164) or
|
|
1780
|
+
# with the policy code (1008, #226), and OpenAI's generic server error, whose
|
|
1781
|
+
# own text says to retry (#226). A message that only starts the same way
|
|
1782
|
+
# (another `server_error` text, one naming a request ID) is not listed.
|
|
1783
|
+
_OPENCODE_OPENAI_TRANSIENT_ERRORS = {
|
|
1784
|
+
"WebSocket closed with code 1000": "OpenAI WebSocket closed with code 1000",
|
|
1785
|
+
"WebSocket closed with code 1008": "OpenAI WebSocket closed with code 1008",
|
|
1786
|
+
(
|
|
1787
|
+
"server_error: An error occurred while processing your request. You can retry "
|
|
1788
|
+
"your request, or contact us through our help center at help.openai.com if the "
|
|
1789
|
+
"error persists."
|
|
1790
|
+
): "OpenAI server_error",
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
# How OpenCode prints a request it refuses (#258; 2.1.4, run against a
|
|
1795
|
+
# loopback stand-in, and read from its build). A permission an agent's rule
|
|
1796
|
+
# asks for, without `--auto`: `ESC[93mESC[1m! ESC[0mpermission requested:
|
|
1797
|
+
# <permission> (<patterns, comma-separated>); auto-rejecting`. A question,
|
|
1798
|
+
# cancelled: the question tool's failed line, then this as its error line.
|
|
1799
|
+
_OPENCODE_PERMISSION_LINE_RE = re.compile(
|
|
1800
|
+
r"\x1b\[93m\x1b\[1m! \x1b\[0mpermission requested: (\S+) \((.*)\); auto-rejecting"
|
|
1801
|
+
)
|
|
1802
|
+
_OPENCODE_QUESTION_CANCELLED = (
|
|
1803
|
+
"This non-interactive run cannot ask the user questions, so the question was cancelled. "
|
|
1804
|
+
"Continue without an answer; make reasonable assumptions and state them."
|
|
1805
|
+
)
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def _opencode_refused_requests(stderr: str) -> list[tuple[RequestKind, str]]:
|
|
1809
|
+
"""The requests OpenCode printed on ``stderr`` that it refused, in order:
|
|
1810
|
+
each permission line, with its permission and patterns as the subject,
|
|
1811
|
+
and each failed tool whose error is the cancelled question's (no subject:
|
|
1812
|
+
OpenCode prints no question text). Lines as in :func:`_opencode_run_errors`;
|
|
1813
|
+
text that is not such a line on its own (a diff, a file, a command's
|
|
1814
|
+
output that quotes one) is no request."""
|
|
1815
|
+
refused: list[tuple[RequestKind, str]] = []
|
|
1816
|
+
previous = ""
|
|
1817
|
+
for line in stderr.split("\n"):
|
|
1818
|
+
line = line.removesuffix("\r")
|
|
1819
|
+
permission = _OPENCODE_PERMISSION_LINE_RE.fullmatch(line)
|
|
1820
|
+
error = _OPENCODE_ERROR_LINE_RE.fullmatch(line)
|
|
1821
|
+
if permission:
|
|
1822
|
+
subject = clean(f"{permission[1]}: {permission[2]}", MAX_REFUSED_SUBJECT_CHARS)
|
|
1823
|
+
refused.append((RequestKind.PERMISSION, subject))
|
|
1824
|
+
elif (
|
|
1825
|
+
error
|
|
1826
|
+
and error[1] == _OPENCODE_QUESTION_CANCELLED
|
|
1827
|
+
and _OPENCODE_TOOL_FAILED_LINE_RE.fullmatch(previous)
|
|
1828
|
+
):
|
|
1829
|
+
refused.append((RequestKind.QUESTION, ""))
|
|
1830
|
+
previous = line
|
|
1831
|
+
return refused
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def _opencode_run_errors(stderr: str) -> list[str]:
|
|
1835
|
+
"""The run-level errors OpenCode printed on ``stderr``, in order: each
|
|
1836
|
+
error line's message, except a failed tool's (the line right after its
|
|
1837
|
+
``✗ ... failed`` line), which does not fail the run. Text that is not
|
|
1838
|
+
such a line, wherever it says ``Error:``, is not an error. Lines end
|
|
1839
|
+
where OpenCode ends them (``os.EOL``), not at every character
|
|
1840
|
+
:meth:`str.splitlines` breaks on."""
|
|
1841
|
+
errors: list[str] = []
|
|
1842
|
+
previous = ""
|
|
1843
|
+
for line in stderr.split("\n"):
|
|
1844
|
+
line = line.removesuffix("\r")
|
|
1845
|
+
error = _OPENCODE_ERROR_LINE_RE.fullmatch(line)
|
|
1846
|
+
if error and not _OPENCODE_TOOL_FAILED_LINE_RE.fullmatch(previous):
|
|
1847
|
+
errors.append(error.group(1))
|
|
1848
|
+
previous = line
|
|
1849
|
+
return errors
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
# #186: the CLI whose argv and stdin delivery the adapter speaks. No upper bound.
|
|
1853
|
+
OPENCODE_MIN_VERSION = (2, 0, 0)
|
|
1854
|
+
# What `opencode --version` prints: `opencode v2.0.23`; 1.x printed `1.18.34`.
|
|
1855
|
+
_OPENCODE_VERSION_RE = re.compile(r"(?:opencode )?v?(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?")
|
|
1856
|
+
|
|
1857
|
+
|
|
1858
|
+
def parse_opencode_version(text: str) -> tuple[int, int, int] | None:
|
|
1859
|
+
"""``(major, minor, patch)`` from ``opencode --version`` output, or None
|
|
1860
|
+
when unreadable; a pre-release sorts below its release, as for Pi."""
|
|
1861
|
+
return _parse_version(text, _OPENCODE_VERSION_RE)
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
PI_EFFORTS = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
1865
|
+
# ADR 0003 §2.8: the release its RPC, model and auth facts were read from.
|
|
1866
|
+
# No upper bound.
|
|
1867
|
+
PI_MIN_VERSION = (1, 0, 0)
|
|
1868
|
+
PI_MODEL_PROVIDER_RE = re.compile(r"[a-z0-9-]+")
|
|
1869
|
+
# What `pi --version` prints: the bare version, optionally a pre-release.
|
|
1870
|
+
_PI_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?")
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
def parse_pi_version(text: str) -> tuple[int, int, int] | None:
|
|
1874
|
+
"""``(major, minor, patch)`` from ``pi --version`` output, or None when unreadable.
|
|
1875
|
+
|
|
1876
|
+
A pre-release sorts below its release: ``1.0.0-rc.1`` reads as
|
|
1877
|
+
``(1, 0, -1)``, which is below the 1.0.0 minimum, and ``1.1.0-rc.1`` as
|
|
1878
|
+
``(1, 1, -1)``, which is above it.
|
|
1879
|
+
"""
|
|
1880
|
+
return _parse_version(text, _PI_VERSION_RE)
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def _parse_version(text: str, pattern: re.Pattern[str]) -> tuple[int, int, int] | None:
|
|
1884
|
+
lines = text.strip().splitlines()
|
|
1885
|
+
match = pattern.fullmatch(lines[0].strip()) if len(lines) == 1 else None
|
|
1886
|
+
if match is None:
|
|
1887
|
+
return None
|
|
1888
|
+
major, minor, patch = (int(part) for part in match.group(1, 2, 3))
|
|
1889
|
+
if match.group(4):
|
|
1890
|
+
patch -= 1
|
|
1891
|
+
return major, minor, patch
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
# ADR 0003 §3: each stdio round trip around the prompt (`get_state`,
|
|
1895
|
+
# `get_available_models`, `get_last_assistant_text`) is bounded inside the
|
|
1896
|
+
# invocation's limits. The first two also cover Pi's start-up (Node, its
|
|
1897
|
+
# settings and its model catalog), hence more than a bare round trip needs.
|
|
1898
|
+
PI_ROUND_TRIP_SECONDS = 15.0
|
|
1899
|
+
# How long an `abort` is given to settle a running prompt before stdin is
|
|
1900
|
+
# closed. It follows a limit falling due and never shortens it (#193): the
|
|
1901
|
+
# group is killed this long after the limit, Pi's own exit included.
|
|
1902
|
+
PI_ABORT_SECONDS = 5.0
|
|
1903
|
+
# The per-record bound is sized for one message: a final text past the
|
|
1904
|
+
# stdout bound must still arrive whole in one `get_last_assistant_text`
|
|
1905
|
+
# record so that its tail can be kept, and JSON escaping can double a text.
|
|
1906
|
+
# It is not Pi's worst case, which has no bound: `agent_end` repeats every
|
|
1907
|
+
# message of the run (207 KB for a 200 KB prompt and one `read` on Pi
|
|
1908
|
+
# 1.0.1, and compaction does not shrink it), and `turn_end` a turn's
|
|
1909
|
+
# assistant message and all its tool results. Those two are dropped past
|
|
1910
|
+
# the bound and tolerated from their leading bytes, since the reducer needs
|
|
1911
|
+
# neither's content; any other record past it is a protocol violation
|
|
1912
|
+
# (`PiConversation.oversize`, #144). Memory stays at the bound either way.
|
|
1913
|
+
PI_MAX_RECORD_BYTES = 2 * DEFAULT_MAX_OUTPUT_BYTES + 1024 * 1024
|
|
1914
|
+
PI_MAX_PENDING_BYTES = 2 * PI_MAX_RECORD_BYTES
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
# Pi's built-in tools (dist/core/tools/index.js at 1.0.1). Pi drops a name
|
|
1918
|
+
# it does not know without a word, so a profile's `tools` is checked here.
|
|
1919
|
+
PI_TOOLS = ("read", "bash", "edit", "write", "grep", "find", "ls", "powershell")
|
|
1920
|
+
# The tool set a profile gets when it names none: the implementation phases
|
|
1921
|
+
# write the tree, review and EPIC maintenance read it. `bash` stays in both
|
|
1922
|
+
# (an agent needs `git` and `gh`), so the read set is defence in depth, not a
|
|
1923
|
+
# write barrier (docs/pi-policy.md §6).
|
|
1924
|
+
PI_WRITE_PROFILES = ("analyze_execute", "fix", "replan_reexecute")
|
|
1925
|
+
PI_WRITE_TOOLS = "read,bash,edit,write"
|
|
1926
|
+
PI_READ_TOOLS = "read,bash"
|
|
1927
|
+
_PI_TOOLS_RE = re.compile(r"[a-z]+(,[a-z]+)*")
|
|
1928
|
+
# Provider API keys Pi would use (or hand to its tools) in place of a
|
|
1929
|
+
# stored ChatGPT sign-in. A profile with `require_oauth` refuses to launch
|
|
1930
|
+
# when one of them would reach Pi (docs/pi-policy.md §8).
|
|
1931
|
+
PI_API_KEY_NAMES = ("OPENAI_API_KEY",)
|
|
1932
|
+
# What Pi loads from its agent directory and from every directory between
|
|
1933
|
+
# `/` and the cwd whatever the resource flags say (docs/pi-policy.md §4-5):
|
|
1934
|
+
# the first context file name found in each directory (none with
|
|
1935
|
+
# `--no-context-files`), and the agent directory's system prompt files
|
|
1936
|
+
# (always; no flag turns them off). `doctor` reports their presence only.
|
|
1937
|
+
PI_CONTEXT_FILES = ("AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD")
|
|
1938
|
+
PI_SYSTEM_PROMPT_FILES = ("SYSTEM.md", "APPEND_SYSTEM.md")
|
|
1939
|
+
PI_DEFAULT_AGENT_DIR = "~/.pi/agent"
|
|
1940
|
+
# The per-launch `pi auth check` reads one file and prints one line.
|
|
1941
|
+
PI_AUTH_CHECK_SECONDS = 60
|
|
1942
|
+
PI_AUTH_CHECK_MAX_OUTPUT_BYTES = 64 * 1024
|
|
1943
|
+
|
|
1944
|
+
PI_AUTH_STATUSES = ("ready", "not_ready", "invalid")
|
|
1945
|
+
PI_AUTH_REASONS = (
|
|
1946
|
+
"provider_not_found",
|
|
1947
|
+
"credentials_not_configured",
|
|
1948
|
+
"credential_not_available",
|
|
1949
|
+
"invalid_state",
|
|
1950
|
+
)
|
|
1951
|
+
PI_AUTH_TYPES = ("api_key", "oauth")
|
|
1952
|
+
# `pi auth check` exits 0 for ready, 1 for not_ready, 2 for invalid.
|
|
1953
|
+
_PI_AUTH_EXIT_CODES = {"ready": 0, "not_ready": 1, "invalid": 2}
|
|
1954
|
+
# The `provider` field is quoted back to the operator: a bounded run of
|
|
1955
|
+
# printable ASCII (a provider id, or the model string on `invalid`).
|
|
1956
|
+
_PI_AUTH_PROVIDER_RE = re.compile(r"[\x21-\x7e]{1,200}")
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
@dataclass(frozen=True)
|
|
1960
|
+
class PiAuthCheck:
|
|
1961
|
+
"""The parsed ``pi auth check --json`` result: these four fields, nothing else."""
|
|
1962
|
+
|
|
1963
|
+
status: str
|
|
1964
|
+
provider: str
|
|
1965
|
+
reason: str = ""
|
|
1966
|
+
auth_type: str = ""
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def parse_pi_auth_check(stdout: str, exit_code: int) -> PiAuthCheck:
|
|
1970
|
+
"""Parse ``pi auth check --json`` strictly, or raise :class:`ValueError`.
|
|
1971
|
+
|
|
1972
|
+
The output is one JSON object ``{status, provider, reason?, authType?}``
|
|
1973
|
+
and the exit code agrees with ``status``. Anything else is contract
|
|
1974
|
+
drift and fails closed. The error text never quotes the output: with
|
|
1975
|
+
the wrong flags it could hold a credential (``--credentials`` adds one).
|
|
1976
|
+
"""
|
|
1977
|
+
lines = stdout.strip().splitlines()
|
|
1978
|
+
if len(lines) != 1:
|
|
1979
|
+
raise ValueError(f"expected one JSON line on stdout, got {len(lines)} line(s)")
|
|
1980
|
+
try:
|
|
1981
|
+
data = json.loads(lines[0])
|
|
1982
|
+
except json.JSONDecodeError:
|
|
1983
|
+
raise ValueError("stdout is not JSON") from None
|
|
1984
|
+
if not isinstance(data, dict):
|
|
1985
|
+
raise ValueError("stdout is not a JSON object")
|
|
1986
|
+
unknown = sorted(set(data) - {"status", "provider", "reason", "authType"})
|
|
1987
|
+
if unknown:
|
|
1988
|
+
# Counted, not named: a key is Pi output too, and may carry a secret.
|
|
1989
|
+
raise ValueError(f"{len(unknown)} unexpected field(s)")
|
|
1990
|
+
status = data.get("status")
|
|
1991
|
+
provider = data.get("provider")
|
|
1992
|
+
reason = data.get("reason", "")
|
|
1993
|
+
auth_type = data.get("authType", "")
|
|
1994
|
+
if status not in PI_AUTH_STATUSES:
|
|
1995
|
+
raise ValueError("unknown or missing 'status'")
|
|
1996
|
+
if not isinstance(provider, str) or not _PI_AUTH_PROVIDER_RE.fullmatch(provider):
|
|
1997
|
+
raise ValueError("missing or unreadable 'provider'")
|
|
1998
|
+
if status == "ready":
|
|
1999
|
+
if reason != "" or auth_type not in PI_AUTH_TYPES:
|
|
2000
|
+
raise ValueError("'ready' without a known 'authType', or with a 'reason'")
|
|
2001
|
+
elif auth_type != "" or reason not in PI_AUTH_REASONS:
|
|
2002
|
+
raise ValueError(f"{status!r} without a known 'reason', or with an 'authType'")
|
|
2003
|
+
if exit_code != _PI_AUTH_EXIT_CODES[status]:
|
|
2004
|
+
raise ValueError(f"exit code {exit_code} does not match status {status!r}")
|
|
2005
|
+
return PiAuthCheck(status=status, provider=provider, reason=reason, auth_type=auth_type)
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
class PiProvider(AgentProvider):
|
|
2009
|
+
"""Pi (``@earendil-works/pi-coding-agent``) over its stdio RPC mode (ADR 0003).
|
|
2010
|
+
|
|
2011
|
+
This class carries Pi's static surface (profile validation, the argv,
|
|
2012
|
+
the environment names, the read-only ``doctor`` probes) and the driver
|
|
2013
|
+
that couples the conversation in :mod:`autoforge.pi_rpc` to the duplex
|
|
2014
|
+
child handle. The driver owns time and the process; the reducer owns
|
|
2015
|
+
every Pi command and event name.
|
|
2016
|
+
"""
|
|
2017
|
+
|
|
2018
|
+
name = "pi"
|
|
2019
|
+
# Pi's own process settings (docs/environment-variables.md at v1.0.0):
|
|
2020
|
+
# where its config and credential store live, and the network,
|
|
2021
|
+
# version-check and telemetry switches (docs/pi-policy.md §8). Explicit
|
|
2022
|
+
# names, never a prefix: Pi exports `PI_SESSION_*`, `PI_PROVIDER`,
|
|
2023
|
+
# `PI_MODEL` to its tool children, and a provider API key
|
|
2024
|
+
# (`OPENAI_API_KEY`, ...) reaches Pi only through
|
|
2025
|
+
# `execution.env_allowlist_extra`, never by default.
|
|
2026
|
+
environment_names = (
|
|
2027
|
+
"PI_CODING_AGENT_DIR",
|
|
2028
|
+
"PI_OFFLINE",
|
|
2029
|
+
"PI_SKIP_VERSION_CHECK",
|
|
2030
|
+
"PI_TELEMETRY",
|
|
2031
|
+
)
|
|
2032
|
+
option_keys = ("require_oauth", "tools", "context_files")
|
|
2033
|
+
path_names = ("PI_CODING_AGENT_DIR",)
|
|
2034
|
+
|
|
2035
|
+
def sandbox_requirements(
|
|
2036
|
+
self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
|
|
2037
|
+
) -> ProviderRequirements:
|
|
2038
|
+
"""Pi's declaration (ADR 0005 D7.1, D13.1; docs/pi-policy.md §8).
|
|
2039
|
+
|
|
2040
|
+
An absolute ``PI_CODING_AGENT_DIR``: that directory writable at its
|
|
2041
|
+
own path (``dir-rw``: Pi writes ``auth.json`` and
|
|
2042
|
+
``models-store.json`` there by rename at startup), refused when it is
|
|
2043
|
+
or lies in ``~/.pi/agent``, the operator configuration root (D7.2).
|
|
2044
|
+
Without one, Pi finds no login inside, and its auth preflight says
|
|
2045
|
+
so. A provider key is an ``env`` item.
|
|
2046
|
+
"""
|
|
2047
|
+
needs = super().sandbox_requirements(profile, command, env, home)
|
|
2048
|
+
auth = [AuthItem(AuthMode.ENV, name) for name in PI_API_KEY_NAMES if env.get(name)]
|
|
2049
|
+
agent_dir = env.get("PI_CODING_AGENT_DIR", "")
|
|
2050
|
+
if agent_dir.startswith("/"):
|
|
2051
|
+
auth.append(
|
|
2052
|
+
AuthItem(
|
|
2053
|
+
AuthMode.DIR_RW,
|
|
2054
|
+
"Pi's agent directory ($PI_CODING_AGENT_DIR)",
|
|
2055
|
+
agent_dir,
|
|
2056
|
+
env_name="PI_CODING_AGENT_DIR",
|
|
2057
|
+
)
|
|
2058
|
+
)
|
|
2059
|
+
default = os.path.join(home, PI_DEFAULT_AGENT_DIR.removeprefix("~/"))
|
|
2060
|
+
return dataclasses.replace(needs, auth=tuple(auth), operator_config_roots=(default,))
|
|
2061
|
+
|
|
2062
|
+
def validate_profile(self, profile: ProfileConfig) -> None:
|
|
2063
|
+
super().validate_profile(profile)
|
|
2064
|
+
where = f"profile {profile.name!r}"
|
|
2065
|
+
if profile.effort not in PI_EFFORTS:
|
|
2066
|
+
raise ConfigurationError(
|
|
2067
|
+
f"{where}: pi effort (--thinking) is required and must be one of "
|
|
2068
|
+
f"{', '.join(PI_EFFORTS)}, got {profile.effort!r}"
|
|
2069
|
+
)
|
|
2070
|
+
problem = _pi_model_problem(profile.model)
|
|
2071
|
+
if problem:
|
|
2072
|
+
raise ConfigurationError(
|
|
2073
|
+
f"{where}: pi model must be '<pi-provider>/<model-id>' "
|
|
2074
|
+
f"(e.g. openai/gpt-5.6-terra): {problem}, got {profile.model!r}"
|
|
2075
|
+
)
|
|
2076
|
+
if profile.extra_args:
|
|
2077
|
+
raise ConfigurationError(
|
|
2078
|
+
f"{where}: pi takes no extra_args (got {profile.extra_args!r}); every Pi "
|
|
2079
|
+
"flag that matters is set by the adapter, and a pass-through could re-enable "
|
|
2080
|
+
"project trust, put an API key in argv, load code or prompts, or keep a session"
|
|
2081
|
+
)
|
|
2082
|
+
for key in ("require_oauth", "context_files"):
|
|
2083
|
+
if profile.options.get(key, "true") not in ("true", "false"):
|
|
2084
|
+
raise ConfigurationError(
|
|
2085
|
+
f"{where}: pi option {key} must be true or false, got {profile.options[key]!r}"
|
|
2086
|
+
)
|
|
2087
|
+
if "tools" in profile.options:
|
|
2088
|
+
problem = _pi_tools_problem(profile.options["tools"])
|
|
2089
|
+
if problem:
|
|
2090
|
+
raise ConfigurationError(
|
|
2091
|
+
f"{where}: pi option tools must be a comma-separated list of Pi's "
|
|
2092
|
+
f"built-in tools ({', '.join(PI_TOOLS)}): {problem}, "
|
|
2093
|
+
f"got {profile.options['tools']!r}"
|
|
2094
|
+
)
|
|
2095
|
+
|
|
2096
|
+
def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
|
|
2097
|
+
# The prompt is deliberately absent: it reaches Pi as a JSON `prompt`
|
|
2098
|
+
# record on stdin (ADR 0003 §2.7), never in argv. Every flag before
|
|
2099
|
+
# `--tools` is unconditional; no option and no `extra_args` (which a
|
|
2100
|
+
# Pi profile cannot have) removes one (docs/pi-policy.md §1-3).
|
|
2101
|
+
command = [
|
|
2102
|
+
self.command(profile),
|
|
2103
|
+
"--mode",
|
|
2104
|
+
"rpc",
|
|
2105
|
+
"--no-session",
|
|
2106
|
+
# Project trust off, whatever `trust.json` saved for the path:
|
|
2107
|
+
# without it a trusted project's `.pi/SYSTEM.md` and packages load
|
|
2108
|
+
# even with every resource flag below.
|
|
2109
|
+
"--no-approve",
|
|
2110
|
+
# No executable resource from the project, the agent dir or Pi's
|
|
2111
|
+
# built-in extensions (native MCP included).
|
|
2112
|
+
"--no-extensions",
|
|
2113
|
+
"--no-skills",
|
|
2114
|
+
"--no-prompt-templates",
|
|
2115
|
+
"--no-themes",
|
|
2116
|
+
# No package install or update at startup (npm or git, from the
|
|
2117
|
+
# global settings), no catalog refresh; model calls still go out.
|
|
2118
|
+
"--offline",
|
|
2119
|
+
"--tools",
|
|
2120
|
+
self.tools(profile),
|
|
2121
|
+
]
|
|
2122
|
+
if not self.context_files(profile):
|
|
2123
|
+
command.append("--no-context-files")
|
|
2124
|
+
return [*command, "--model", profile.model, "--thinking", profile.effort]
|
|
2125
|
+
|
|
2126
|
+
def __init__(
|
|
2127
|
+
self,
|
|
2128
|
+
runner: Runner | None = None,
|
|
2129
|
+
*,
|
|
2130
|
+
round_trip_seconds: float = PI_ROUND_TRIP_SECONDS,
|
|
2131
|
+
abort_seconds: float = PI_ABORT_SECONDS,
|
|
2132
|
+
max_text_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
|
|
2133
|
+
) -> None:
|
|
2134
|
+
super().__init__(runner)
|
|
2135
|
+
self.round_trip_seconds = round_trip_seconds
|
|
2136
|
+
self.abort_seconds = abort_seconds
|
|
2137
|
+
self.max_text_bytes = max_text_bytes
|
|
2138
|
+
|
|
2139
|
+
def execute(self, req: AgentRequest) -> AgentExecutionResult:
|
|
2140
|
+
"""One ``pi --mode rpc`` child, one prompt, one final text (ADR 0003).
|
|
2141
|
+
|
|
2142
|
+
The child runs under the request's idle limit and ceiling, which
|
|
2143
|
+
the duplex handle turns into one deadline that Pi's output keeps
|
|
2144
|
+
moving (#193). When a limit falls due with the conversation still
|
|
2145
|
+
running, and not before, it is cut short: the deadline is pinned
|
|
2146
|
+
(so what Pi writes while it settles cannot stretch it), ``abort``
|
|
2147
|
+
is sent, stdin is closed, and the duplex handle kills the group if
|
|
2148
|
+
Pi has not exited ``abort_seconds`` after the limit. The result is
|
|
2149
|
+
then ``timed_out`` whatever Pi's exit status, and names the limit
|
|
2150
|
+
that fell due. A failure inside the protocol is
|
|
2151
|
+
``provider_failure``; its abort runs under the deadline pinned when
|
|
2152
|
+
the failure is acted on. ``exit_code`` is always the real process
|
|
2153
|
+
status. A spawn failure raises :class:`ExecutionError`.
|
|
2154
|
+
|
|
2155
|
+
With ``require_oauth`` (the default) the launch is refused before
|
|
2156
|
+
anything is spawned when a provider API key would reach Pi, and a
|
|
2157
|
+
``pi auth check`` in the same environment must report a ChatGPT
|
|
2158
|
+
sign-in first; a refused check is a ``provider_failure`` naming it,
|
|
2159
|
+
and Pi is never started (docs/pi-policy.md §8).
|
|
2160
|
+
"""
|
|
2161
|
+
allowlist = self.environment_allowlist(req)
|
|
2162
|
+
if self.require_oauth(req.profile):
|
|
2163
|
+
self._refuse_api_keys(req, allowlist)
|
|
2164
|
+
refused = self._auth_preflight(req, allowlist)
|
|
2165
|
+
if refused is not None:
|
|
2166
|
+
return refused
|
|
2167
|
+
emit = guarded(req.progress)
|
|
2168
|
+
monitor = _loop_monitor(req, emit)
|
|
2169
|
+
conversation = PiConversation(
|
|
2170
|
+
model=req.profile.model,
|
|
2171
|
+
thinking=req.profile.effort,
|
|
2172
|
+
prompt=req.prompt,
|
|
2173
|
+
round_trip_seconds=self.round_trip_seconds,
|
|
2174
|
+
emit=emit,
|
|
2175
|
+
cwd=req.cwd,
|
|
2176
|
+
loop=monitor,
|
|
2177
|
+
)
|
|
2178
|
+
command, description = self.sandboxed(req, self.build_command(req), req.env)
|
|
2179
|
+
duplex = DuplexRequest(
|
|
2180
|
+
command=command,
|
|
2181
|
+
cwd=req.cwd,
|
|
2182
|
+
env_allowlist=allowlist,
|
|
2183
|
+
env=req.env,
|
|
2184
|
+
idle_timeout_seconds=req.idle_timeout_seconds,
|
|
2185
|
+
max_runtime_seconds=req.max_runtime_seconds,
|
|
2186
|
+
# The abort comes after a limit, so the kill waits for it.
|
|
2187
|
+
wind_down_seconds=self.abort_seconds,
|
|
2188
|
+
max_record_bytes=PI_MAX_RECORD_BYTES,
|
|
2189
|
+
max_pending_bytes=PI_MAX_PENDING_BYTES,
|
|
2190
|
+
# Pi's bash tool starts every command detached (ADR 0003 §6).
|
|
2191
|
+
contain_orphans=True,
|
|
2192
|
+
sandbox=description,
|
|
2193
|
+
)
|
|
2194
|
+
with start_duplex(duplex) as child:
|
|
2195
|
+
cut_short = self._converse(child, conversation, monitor)
|
|
2196
|
+
# The limit that fell due when the conversation was cut short:
|
|
2197
|
+
# pinned by then, so Pi's own exit inside the abort keeps it.
|
|
2198
|
+
cut_limit = child.deadline_limit if cut_short else ""
|
|
2199
|
+
if cut_short:
|
|
2200
|
+
conversation.deadline_reached()
|
|
2201
|
+
if conversation.failure is not None:
|
|
2202
|
+
# Every abort ends by a fixed deadline: what Pi writes while
|
|
2203
|
+
# it settles, or after, cannot stretch its shutdown.
|
|
2204
|
+
child.pin_deadline()
|
|
2205
|
+
self._abort(child, conversation, self.abort_seconds)
|
|
2206
|
+
child.close_stdin()
|
|
2207
|
+
res = child.finish()
|
|
2208
|
+
timed_out = cut_short or res.timed_out or res.stopped
|
|
2209
|
+
failure = conversation.failure
|
|
2210
|
+
if conversation.exited_early:
|
|
2211
|
+
failure = f"{failure} (exit {res.exit_code})"
|
|
2212
|
+
elif res.records_overflowed and failure is None:
|
|
2213
|
+
failure = "pi: protocol violation: stdout records arrived faster than they were read"
|
|
2214
|
+
summary = conversation.summary()
|
|
2215
|
+
if failure is not None:
|
|
2216
|
+
summary["failure"] = failure
|
|
2217
|
+
text = conversation.text if failure is None else None
|
|
2218
|
+
stdout, truncated = _keep_tail(encodable(text or ""), self.max_text_bytes)
|
|
2219
|
+
result = AgentExecutionResult(
|
|
2220
|
+
command=list(res.command),
|
|
2221
|
+
exit_code=res.exit_code,
|
|
2222
|
+
stdout=stdout,
|
|
2223
|
+
stderr=res.stderr,
|
|
2224
|
+
started_at=res.started_at,
|
|
2225
|
+
finished_at=res.finished_at,
|
|
2226
|
+
timed_out=timed_out,
|
|
2227
|
+
provider=req.profile.provider,
|
|
2228
|
+
model=req.profile.model,
|
|
2229
|
+
effort=req.profile.effort,
|
|
2230
|
+
stdout_truncated=truncated,
|
|
2231
|
+
stderr_truncated=res.stderr_truncated,
|
|
2232
|
+
stdout_tail_offset=0,
|
|
2233
|
+
descendants_killed=res.descendants_killed,
|
|
2234
|
+
group_survived_kill=res.group_survived_kill,
|
|
2235
|
+
capture_abandoned=res.capture_abandoned,
|
|
2236
|
+
orphans_killed=res.orphans_killed,
|
|
2237
|
+
orphan_survived_kill=res.orphan_survived_kill,
|
|
2238
|
+
orphans_unchecked=res.orphans_unchecked,
|
|
2239
|
+
# A timeout wins over whatever the protocol said (ADR 0003 §2.6).
|
|
2240
|
+
provider_failure=None if timed_out else failure,
|
|
2241
|
+
provider_summary=summary,
|
|
2242
|
+
timeout_limit=(res.timeout_limit or cut_limit) if timed_out else "",
|
|
2243
|
+
last_activity_at=res.last_activity_at,
|
|
2244
|
+
sandbox=res.sandbox,
|
|
2245
|
+
)
|
|
2246
|
+
return _finish_loop(result, monitor, killed=res.stopped)
|
|
2247
|
+
|
|
2248
|
+
def _refuse_api_keys(self, req: AgentRequest, allowlist: tuple[str, ...] | None) -> None:
|
|
2249
|
+
"""Raise :class:`ExecutionError` when a set provider API key would reach Pi."""
|
|
2250
|
+
problem = self.api_key_problem(req.profile, allowlist)
|
|
2251
|
+
if problem:
|
|
2252
|
+
raise ExecutionError(problem)
|
|
2253
|
+
|
|
2254
|
+
@staticmethod
|
|
2255
|
+
def api_key_problem(profile: ProfileConfig, allowlist: tuple[str, ...] | None) -> str:
|
|
2256
|
+
"""Why ``profile`` may not launch with ``allowlist``'s API keys, or ''.
|
|
2257
|
+
|
|
2258
|
+
A profile with ``require_oauth`` must not hand Pi (and through it
|
|
2259
|
+
every command Pi's `bash` tool runs) a provider API key: Pi would
|
|
2260
|
+
prefer a stored sign-in, so the key would sit in the tools'
|
|
2261
|
+
environment for nothing, or bill the run when the sign-in lapses.
|
|
2262
|
+
Only the names are checked and named, never a value.
|
|
2263
|
+
"""
|
|
2264
|
+
if not PiProvider.require_oauth(profile):
|
|
2265
|
+
return ""
|
|
2266
|
+
env = os.environ if allowlist is None else select_environment(allowlist)
|
|
2267
|
+
present = [name for name in PI_API_KEY_NAMES if env.get(name)]
|
|
2268
|
+
if not present:
|
|
2269
|
+
return ""
|
|
2270
|
+
return (
|
|
2271
|
+
f"profile {profile.name!r} requires Pi's ChatGPT sign-in (options.require_oauth), "
|
|
2272
|
+
f"but {', '.join(present)} is set and would reach Pi through the environment "
|
|
2273
|
+
"allow-list; remove it from execution.env_allowlist_extra (or unset it), or set "
|
|
2274
|
+
"options.require_oauth: false to run on an API key"
|
|
2275
|
+
)
|
|
2276
|
+
|
|
2277
|
+
def _auth_preflight(
|
|
2278
|
+
self, req: AgentRequest, allowlist: tuple[str, ...] | None
|
|
2279
|
+
) -> AgentExecutionResult | None:
|
|
2280
|
+
"""Run ``pi auth check`` as the launch would; None when it allows the launch.
|
|
2281
|
+
|
|
2282
|
+
Read-only (``--no-refresh``), in the cwd and environment the launch
|
|
2283
|
+
uses, judged by :meth:`auth_verdict`: ``ready`` with authType
|
|
2284
|
+
``oauth`` for the configured provider. A refusal is returned as a
|
|
2285
|
+
result whose ``provider_failure`` quotes only the check's fields;
|
|
2286
|
+
its output is never kept.
|
|
2287
|
+
"""
|
|
2288
|
+
# In the launch's own sandbox, with the same declaration: the check
|
|
2289
|
+
# must find the credential exactly where Pi will (ADR 0005 D3.1).
|
|
2290
|
+
command, description = self.sandboxed(req, self.auth_check_command(req.profile), req.env)
|
|
2291
|
+
res = self._runner(
|
|
2292
|
+
ExecutionRequest(
|
|
2293
|
+
command=command,
|
|
2294
|
+
cwd=req.cwd,
|
|
2295
|
+
timeout_seconds=PI_AUTH_CHECK_SECONDS,
|
|
2296
|
+
max_output_bytes=PI_AUTH_CHECK_MAX_OUTPUT_BYTES,
|
|
2297
|
+
env_allowlist=allowlist,
|
|
2298
|
+
env=req.env,
|
|
2299
|
+
contain_orphans=True,
|
|
2300
|
+
sandbox=description,
|
|
2301
|
+
)
|
|
2302
|
+
)
|
|
2303
|
+
if res.timed_out:
|
|
2304
|
+
detail = f"`pi auth check` did not answer within {PI_AUTH_CHECK_SECONDS}s"
|
|
2305
|
+
elif res.stdout_truncated:
|
|
2306
|
+
detail = "`pi auth check` printed more than a result"
|
|
2307
|
+
else:
|
|
2308
|
+
ok, detail = self.auth_verdict(req.profile, res.exit_code, res.stdout)
|
|
2309
|
+
if ok:
|
|
2310
|
+
return None
|
|
2311
|
+
return AgentExecutionResult(
|
|
2312
|
+
command=list(res.command),
|
|
2313
|
+
exit_code=res.exit_code,
|
|
2314
|
+
stdout="",
|
|
2315
|
+
stderr="",
|
|
2316
|
+
started_at=res.started_at,
|
|
2317
|
+
finished_at=res.finished_at,
|
|
2318
|
+
provider=req.profile.provider,
|
|
2319
|
+
model=req.profile.model,
|
|
2320
|
+
effort=req.profile.effort,
|
|
2321
|
+
descendants_killed=res.descendants_killed,
|
|
2322
|
+
group_survived_kill=res.group_survived_kill,
|
|
2323
|
+
capture_abandoned=res.capture_abandoned,
|
|
2324
|
+
orphans_killed=res.orphans_killed,
|
|
2325
|
+
orphan_survived_kill=res.orphan_survived_kill,
|
|
2326
|
+
orphans_unchecked=res.orphans_unchecked,
|
|
2327
|
+
provider_failure=f"pi: the auth preflight refused the launch: {detail}",
|
|
2328
|
+
provider_summary={"auth_preflight": "refused"},
|
|
2329
|
+
sandbox=res.sandbox,
|
|
2330
|
+
)
|
|
2331
|
+
|
|
2332
|
+
@staticmethod
|
|
2333
|
+
def _send(child: DuplexChild, conversation: PiConversation, lines: list[bytes]) -> None:
|
|
2334
|
+
for line in lines:
|
|
2335
|
+
try:
|
|
2336
|
+
child.send_line(line)
|
|
2337
|
+
except ChildStdinClosedError:
|
|
2338
|
+
conversation.stdin_closed()
|
|
2339
|
+
return
|
|
2340
|
+
|
|
2341
|
+
def _converse(
|
|
2342
|
+
self,
|
|
2343
|
+
child: DuplexChild,
|
|
2344
|
+
conversation: PiConversation,
|
|
2345
|
+
loop: LoopMonitor | None = None,
|
|
2346
|
+
) -> bool:
|
|
2347
|
+
"""Drive the conversation to its outcome; True when a limit cut it short.
|
|
2348
|
+
|
|
2349
|
+
The conversation is cut short as the child's limit falls due, which
|
|
2350
|
+
Pi's output keeps moving, so it is recomputed on every turn; once
|
|
2351
|
+
cut short the deadline is pinned, the abort window after the limit.
|
|
2352
|
+
A verdict of ``loop`` (#194) stops a Pi still running at once, with
|
|
2353
|
+
no ``abort``: the run is discarded, as on a timeout, and the caller's
|
|
2354
|
+
``finish()`` reports ``stopped``.
|
|
2355
|
+
"""
|
|
2356
|
+
try:
|
|
2357
|
+
self._send(child, conversation, conversation.start(time.monotonic()))
|
|
2358
|
+
while not conversation.done:
|
|
2359
|
+
now = time.monotonic()
|
|
2360
|
+
due = child.limit_due()
|
|
2361
|
+
if now >= due:
|
|
2362
|
+
child.pin_deadline()
|
|
2363
|
+
return True
|
|
2364
|
+
conversation.tick(now)
|
|
2365
|
+
if conversation.done:
|
|
2366
|
+
break
|
|
2367
|
+
wake = conversation.next_wakeup
|
|
2368
|
+
until = due if wake is None else min(due, wake)
|
|
2369
|
+
item = child.read_line(timeout=max(0.0, until - now))
|
|
2370
|
+
if isinstance(item, Record):
|
|
2371
|
+
replies = conversation.feed(item.data, time.monotonic())
|
|
2372
|
+
if loop is not None and loop.verdict is not None and child.stop():
|
|
2373
|
+
return False
|
|
2374
|
+
self._send(child, conversation, replies)
|
|
2375
|
+
elif isinstance(item, Oversize):
|
|
2376
|
+
conversation.oversize(item.head, item.limit)
|
|
2377
|
+
elif isinstance(item, Fragment):
|
|
2378
|
+
conversation.framing_error("stdout ended inside an unterminated record")
|
|
2379
|
+
elif isinstance(item, Overflow):
|
|
2380
|
+
conversation.framing_error("stdout records arrived faster than they were read")
|
|
2381
|
+
elif isinstance(item, Eof):
|
|
2382
|
+
conversation.stream_ended()
|
|
2383
|
+
elif item.deadline_exceeded:
|
|
2384
|
+
return True
|
|
2385
|
+
except ExecutionTimeoutError:
|
|
2386
|
+
return True # a write overran the deadline; the group is gone
|
|
2387
|
+
return False
|
|
2388
|
+
|
|
2389
|
+
def _abort(self, child: DuplexChild, conversation: PiConversation, window: float) -> None:
|
|
2390
|
+
"""Ask Pi to abort a running prompt and give it ``window`` seconds to settle.
|
|
2391
|
+
|
|
2392
|
+
Never SIGINT: Pi has no SIGINT handler in RPC mode (ADR 0003 §2.8).
|
|
2393
|
+
Dialogs that arrive meanwhile are still answered.
|
|
2394
|
+
"""
|
|
2395
|
+
end = time.monotonic() + window
|
|
2396
|
+
try:
|
|
2397
|
+
self._send(child, conversation, conversation.begin_abort(time.monotonic()))
|
|
2398
|
+
while not conversation.abort_settled:
|
|
2399
|
+
now = time.monotonic()
|
|
2400
|
+
if now >= end:
|
|
2401
|
+
return
|
|
2402
|
+
item = child.read_line(timeout=end - now)
|
|
2403
|
+
if isinstance(item, Record):
|
|
2404
|
+
replies = conversation.feed(item.data, time.monotonic())
|
|
2405
|
+
self._send(child, conversation, replies)
|
|
2406
|
+
elif isinstance(item, Eof | Overflow):
|
|
2407
|
+
return
|
|
2408
|
+
elif isinstance(item, Timeout) and item.deadline_exceeded:
|
|
2409
|
+
return
|
|
2410
|
+
except ExecutionTimeoutError:
|
|
2411
|
+
return
|
|
2412
|
+
|
|
2413
|
+
# -- read-only probes for `autoforge doctor` ---------------------------
|
|
2414
|
+
@staticmethod
|
|
2415
|
+
def command(profile: ProfileConfig) -> str:
|
|
2416
|
+
return profile.command or "pi"
|
|
2417
|
+
|
|
2418
|
+
@staticmethod
|
|
2419
|
+
def require_oauth(profile: ProfileConfig) -> bool:
|
|
2420
|
+
return profile.options.get("require_oauth", "true") != "false"
|
|
2421
|
+
|
|
2422
|
+
@staticmethod
|
|
2423
|
+
def context_files(profile: ProfileConfig) -> bool:
|
|
2424
|
+
return profile.options.get("context_files", "true") != "false"
|
|
2425
|
+
|
|
2426
|
+
@staticmethod
|
|
2427
|
+
def tools(profile: ProfileConfig) -> str:
|
|
2428
|
+
"""The ``--tools`` list: the profile's ``tools``, or its phase's default."""
|
|
2429
|
+
configured = profile.options.get("tools")
|
|
2430
|
+
if configured:
|
|
2431
|
+
return configured
|
|
2432
|
+
return PI_WRITE_TOOLS if profile.name in PI_WRITE_PROFILES else PI_READ_TOOLS
|
|
2433
|
+
|
|
2434
|
+
@staticmethod
|
|
2435
|
+
def model_provider(profile: ProfileConfig) -> str:
|
|
2436
|
+
return profile.model.split("/", 1)[0]
|
|
2437
|
+
|
|
2438
|
+
def version_command(self, profile: ProfileConfig) -> list[str]:
|
|
2439
|
+
return [self.command(profile), "--version"]
|
|
2440
|
+
|
|
2441
|
+
def auth_check_command(self, profile: ProfileConfig) -> list[str]:
|
|
2442
|
+
"""Whether Pi holds a credential for the model's provider, read-only.
|
|
2443
|
+
|
|
2444
|
+
``--no-refresh`` makes Pi open its credential store read-only (no
|
|
2445
|
+
OAuth refresh is written), and ``--credentials``, which would print
|
|
2446
|
+
the credential, is never passed.
|
|
2447
|
+
"""
|
|
2448
|
+
return [
|
|
2449
|
+
self.command(profile),
|
|
2450
|
+
"auth",
|
|
2451
|
+
"check",
|
|
2452
|
+
"--model",
|
|
2453
|
+
profile.model,
|
|
2454
|
+
"--json",
|
|
2455
|
+
"--no-refresh",
|
|
2456
|
+
]
|
|
2457
|
+
|
|
2458
|
+
def auth_verdict(self, profile: ProfileConfig, exit_code: int, stdout: str) -> tuple[bool, str]:
|
|
2459
|
+
"""``(ok, detail)`` for the result of :meth:`auth_check_command`.
|
|
2460
|
+
|
|
2461
|
+
The detail quotes only the four fields of the result, never stdout
|
|
2462
|
+
or stderr. ``ready`` shows that Pi holds a credential for the
|
|
2463
|
+
provider, not that the model exists or that the credential may use
|
|
2464
|
+
it: Pi builds a placeholder for an id outside its catalog and still
|
|
2465
|
+
reports ``ready`` (ADR 0003 §4 item 2).
|
|
2466
|
+
"""
|
|
2467
|
+
command = " ".join(self.auth_check_command(profile))
|
|
2468
|
+
try:
|
|
2469
|
+
check = parse_pi_auth_check(stdout, exit_code)
|
|
2470
|
+
except ValueError as exc:
|
|
2471
|
+
return False, (
|
|
2472
|
+
f"`pi auth check` exited {exit_code} without a result AutoForge can read "
|
|
2473
|
+
f"({exc}); Pi's output is not shown, run `{command}` yourself to see it"
|
|
2474
|
+
)
|
|
2475
|
+
configured = self.model_provider(profile)
|
|
2476
|
+
fields = f"status {check.status}, provider {check.provider!r}"
|
|
2477
|
+
if check.reason:
|
|
2478
|
+
fields += f", reason {check.reason}"
|
|
2479
|
+
if check.auth_type:
|
|
2480
|
+
fields += f", authType {check.auth_type}"
|
|
2481
|
+
# An `invalid` result names what was asked for (the model, when Pi
|
|
2482
|
+
# failed before resolving it), so only the others name a provider.
|
|
2483
|
+
if check.status != "invalid" and check.provider != configured:
|
|
2484
|
+
return False, (
|
|
2485
|
+
f"{fields}: Pi resolved model {profile.model!r} to provider "
|
|
2486
|
+
f"{check.provider!r}, not the configured {configured!r}"
|
|
2487
|
+
)
|
|
2488
|
+
if check.status == "ready":
|
|
2489
|
+
if check.auth_type == "api_key" and self.require_oauth(profile):
|
|
2490
|
+
return False, (
|
|
2491
|
+
f"{fields}: this profile requires Pi's ChatGPT sign-in; Pi resolved an "
|
|
2492
|
+
"API key (set options.require_oauth: false to run on an API key)"
|
|
2493
|
+
)
|
|
2494
|
+
# `;`, not `:`: the redactor reads `api_key: <word>` as a key's value.
|
|
2495
|
+
return True, (
|
|
2496
|
+
f"{fields}; authenticated for provider {configured!r} only; whether this "
|
|
2497
|
+
f"credential may use {profile.model!r} cannot be checked read-only (doctor "
|
|
2498
|
+
"neither refreshes nor uses it), the first real run is the proof"
|
|
2499
|
+
)
|
|
2500
|
+
if check.reason == "credentials_not_configured":
|
|
2501
|
+
if configured in ("openai", "openai-codex"):
|
|
2502
|
+
remedy = (
|
|
2503
|
+
f"run `pi`, then `/login {configured}` (an operator step; AutoForge "
|
|
2504
|
+
"never logs in for you)"
|
|
2505
|
+
)
|
|
2506
|
+
else:
|
|
2507
|
+
remedy = (
|
|
2508
|
+
f"give Pi a credential for {configured!r}; an API key reaches Pi only "
|
|
2509
|
+
"through execution.env_allowlist_extra"
|
|
2510
|
+
)
|
|
2511
|
+
return False, f"{fields}: Pi has no credential for {configured!r}; {remedy}"
|
|
2512
|
+
if check.reason == "provider_not_found":
|
|
2513
|
+
return False, (
|
|
2514
|
+
f"{fields}: Pi does not know the provider {configured!r} named by "
|
|
2515
|
+
f"model {profile.model!r}"
|
|
2516
|
+
)
|
|
2517
|
+
if check.reason == "credential_not_available":
|
|
2518
|
+
return False, f"{fields}: Pi could not produce a usable credential for {configured!r}"
|
|
2519
|
+
return False, (
|
|
2520
|
+
f"{fields}: Pi's model or credential configuration is invalid; run `{command}` "
|
|
2521
|
+
"yourself to see why"
|
|
2522
|
+
)
|
|
2523
|
+
|
|
2524
|
+
|
|
2525
|
+
def _keep_tail(text: str, limit: int) -> tuple[str, bool]:
|
|
2526
|
+
"""``text``, or its last ``limit`` UTF-8 bytes and True when it is longer.
|
|
2527
|
+
|
|
2528
|
+
A character the cut splits decodes to U+FFFD, as the executor's capture
|
|
2529
|
+
does at its bound.
|
|
2530
|
+
"""
|
|
2531
|
+
data = text.encode("utf-8")
|
|
2532
|
+
if len(data) <= limit:
|
|
2533
|
+
return text, False
|
|
2534
|
+
return data[-limit:].decode("utf-8", errors="replace"), True
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
def _pi_tools_problem(value: str) -> str:
|
|
2538
|
+
"""Why ``value`` is not a valid ``tools`` option, or ''."""
|
|
2539
|
+
if not _PI_TOOLS_RE.fullmatch(value):
|
|
2540
|
+
return "expected names separated by ',' with no spaces and no empty entry"
|
|
2541
|
+
names = value.split(",")
|
|
2542
|
+
unknown = [name for name in names if name not in PI_TOOLS]
|
|
2543
|
+
if unknown:
|
|
2544
|
+
return f"unknown tool(s) {', '.join(unknown)}"
|
|
2545
|
+
if len(set(names)) != len(names):
|
|
2546
|
+
return "a tool is listed twice"
|
|
2547
|
+
return ""
|
|
2548
|
+
|
|
2549
|
+
|
|
2550
|
+
def _pi_model_problem(model: str) -> str:
|
|
2551
|
+
"""Why ``model`` is not a valid Pi ``provider/id`` (ADR 0003 §2.2), or ''."""
|
|
2552
|
+
if not model:
|
|
2553
|
+
return "it is empty"
|
|
2554
|
+
if any(ch.isspace() for ch in model):
|
|
2555
|
+
return "it contains whitespace"
|
|
2556
|
+
if model.count("/") != 1:
|
|
2557
|
+
return "it must contain exactly one '/'"
|
|
2558
|
+
provider, model_id = model.split("/")
|
|
2559
|
+
if not PI_MODEL_PROVIDER_RE.fullmatch(provider):
|
|
2560
|
+
return "the provider part must be non-empty and use only a-z, 0-9 and '-'"
|
|
2561
|
+
if not model_id:
|
|
2562
|
+
return "the model id is empty"
|
|
2563
|
+
if ":" in model and model.rsplit(":", 1)[1] in PI_EFFORTS:
|
|
2564
|
+
return "a ':<thinking>' suffix is not allowed; set the level with 'effort'"
|
|
2565
|
+
return ""
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
# -- SDK providers (ADR 0007; policy: sdk_policy.py, #149) --------------------
|
|
2569
|
+
_SDK_MODEL_RE = re.compile(r"[A-Za-z0-9][^\s#]{0,127}")
|
|
2570
|
+
_OPENCODE_SDK_EFFORT_RE = re.compile(r"[a-z][a-z0-9_-]{0,31}")
|
|
2571
|
+
|
|
2572
|
+
|
|
2573
|
+
class _SdkProvider(AgentProvider):
|
|
2574
|
+
"""What the two SDK adapters share: a fixed policy, no pass-through, and
|
|
2575
|
+
no launch until their helper lands.
|
|
2576
|
+
|
|
2577
|
+
``command`` names the helper's interpreter (Python for ``claude-sdk``,
|
|
2578
|
+
Node for ``opencode-sdk``; ADR 0007 D2.3, D2.5) and is required: no
|
|
2579
|
+
default could name the operator's separate runtime environment.
|
|
2580
|
+
``extra_args`` are refused: every option the helper gets is a field of
|
|
2581
|
+
the policy (:mod:`autoforge.sdk_policy`), and the SDKs' ``extra_args``
|
|
2582
|
+
and ``settings`` are the pass-through #149 closes. The launch allow-list
|
|
2583
|
+
holds exact names only, without any other provider's variables or a
|
|
2584
|
+
credential-shaped name (:func:`autoforge.sdk_policy.sdk_launch_allowlist`).
|
|
2585
|
+
"""
|
|
2586
|
+
|
|
2587
|
+
# The issue whose helper launches this provider, the helper's
|
|
2588
|
+
# interpreter as ``command`` names it, and the decision that says so.
|
|
2589
|
+
transport_issue = ""
|
|
2590
|
+
interpreter = ""
|
|
2591
|
+
runtime_decision = ""
|
|
2592
|
+
# Foreign names the operator may still forward by listing them exactly.
|
|
2593
|
+
passthrough: tuple[str, ...] = ()
|
|
2594
|
+
|
|
2595
|
+
def validate_profile(self, profile: ProfileConfig) -> None:
|
|
2596
|
+
super().validate_profile(profile)
|
|
2597
|
+
where = f"profile {profile.name!r}"
|
|
2598
|
+
if not profile.command or any(ch.isspace() for ch in profile.command):
|
|
2599
|
+
raise ConfigurationError(
|
|
2600
|
+
f"{where}: {self.name} needs 'command', the {self.interpreter} of the "
|
|
2601
|
+
"separately installed runtime environment its helper runs in (ADR 0007 "
|
|
2602
|
+
f"{self.runtime_decision}), with no whitespace; got {profile.command!r}"
|
|
2603
|
+
)
|
|
2604
|
+
if profile.extra_args:
|
|
2605
|
+
# Never the arguments themselves: one may carry a settings
|
|
2606
|
+
# document or a key.
|
|
2607
|
+
raise ConfigurationError(
|
|
2608
|
+
f"{where}: {self.name} takes no extra_args ({len(profile.extra_args)} given): "
|
|
2609
|
+
"every SDK option, flag and setting is fixed by AutoForge's unattended policy "
|
|
2610
|
+
"(docs/sdk-policy.md), and a pass-through could load settings, hooks, MCP "
|
|
2611
|
+
"servers or plugins, or widen the tools"
|
|
2612
|
+
)
|
|
2613
|
+
self.policy(profile)
|
|
2614
|
+
|
|
2615
|
+
# -- to override ---------------------------------------------------------
|
|
2616
|
+
def policy(self, profile: ProfileConfig) -> ClaudeSdkPolicy | OpenCodeSdkPolicy:
|
|
2617
|
+
"""``profile``'s policy, or :class:`ConfigurationError`."""
|
|
2618
|
+
raise NotImplementedError
|
|
2619
|
+
|
|
2620
|
+
def interaction_settings(self, profile: ProfileConfig) -> InteractionSettings | None:
|
|
2621
|
+
return interaction_settings(profile.options, f"profile {profile.name!r}")
|
|
2622
|
+
|
|
2623
|
+
def own_names(self, profile: ProfileConfig | None) -> tuple[str, ...]:
|
|
2624
|
+
"""The variables this provider adds to the allow-list: its credential."""
|
|
2625
|
+
return self.environment_names
|
|
2626
|
+
|
|
2627
|
+
# -- shared --------------------------------------------------------------
|
|
2628
|
+
def _refuse_launch(self) -> ConfigurationError:
|
|
2629
|
+
return ConfigurationError(
|
|
2630
|
+
f"provider {self.name} cannot launch in this release: its policy is in place "
|
|
2631
|
+
f"(docs/sdk-policy.md), but its helper and transport are #{self.transport_issue}'s "
|
|
2632
|
+
"and have not landed. Route this profile to another provider"
|
|
2633
|
+
)
|
|
2634
|
+
|
|
2635
|
+
def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
|
|
2636
|
+
raise self._refuse_launch()
|
|
2637
|
+
|
|
2638
|
+
def integration_preflight(self, env_allowlist: tuple[str, ...] | None) -> None:
|
|
2639
|
+
"""Refused: nothing can launch yet, so the engine stops here, before
|
|
2640
|
+
a launch is charged."""
|
|
2641
|
+
raise self._refuse_launch()
|
|
2642
|
+
|
|
2643
|
+
def execute(self, req: AgentRequest) -> AgentExecutionResult:
|
|
2644
|
+
raise self._refuse_launch()
|
|
2645
|
+
|
|
2646
|
+
def launch_allowlist(self, base: tuple[str, ...]) -> tuple[str, ...]:
|
|
2647
|
+
"""``base`` with every other provider's variables and every
|
|
2648
|
+
credential-shaped name removed and ``*`` prefixes expanded, plus this
|
|
2649
|
+
provider's own names (:func:`autoforge.sdk_policy.sdk_launch_allowlist`)."""
|
|
2650
|
+
return self._allowlist(base, None)
|
|
2651
|
+
|
|
2652
|
+
def environment_allowlist(self, req: AgentRequest) -> tuple[str, ...] | None:
|
|
2653
|
+
if req.env_allowlist is None:
|
|
2654
|
+
return None
|
|
2655
|
+
return self._allowlist(req.env_allowlist, req.profile)
|
|
2656
|
+
|
|
2657
|
+
def _allowlist(self, base: tuple[str, ...], profile: ProfileConfig | None) -> tuple[str, ...]:
|
|
2658
|
+
return sdk_launch_allowlist(
|
|
2659
|
+
base,
|
|
2660
|
+
self.own_names(profile),
|
|
2661
|
+
os.environ.keys(),
|
|
2662
|
+
passthrough=self.passthrough,
|
|
2663
|
+
)
|
|
2664
|
+
|
|
2665
|
+
|
|
2666
|
+
class ClaudeSdkProvider(_SdkProvider):
|
|
2667
|
+
"""``claude-sdk``: the Claude Agent SDK in a helper process (ADR 0007; #151).
|
|
2668
|
+
|
|
2669
|
+
Authenticates with ``ANTHROPIC_API_KEY`` alone, never a claude.ai login
|
|
2670
|
+
(D10.2); a Bedrock, Vertex AI or Foundry route is not supported in this
|
|
2671
|
+
release (docs/sdk-policy.md §5).
|
|
2672
|
+
"""
|
|
2673
|
+
|
|
2674
|
+
name = CLAUDE_SDK
|
|
2675
|
+
environment_names = (CLAUDE_SDK_CREDENTIAL,)
|
|
2676
|
+
option_keys = CLAUDE_SDK_OPTION_KEYS
|
|
2677
|
+
path_names = ("CLAUDE_CONFIG_DIR",)
|
|
2678
|
+
passthrough = CLAUDE_SDK_PASSTHROUGH
|
|
2679
|
+
transport_issue = "151"
|
|
2680
|
+
interpreter = "Python interpreter"
|
|
2681
|
+
runtime_decision = "D2.3"
|
|
2682
|
+
|
|
2683
|
+
def validate_profile(self, profile: ProfileConfig) -> None:
|
|
2684
|
+
super().validate_profile(profile)
|
|
2685
|
+
where = f"profile {profile.name!r}"
|
|
2686
|
+
if profile.effort and profile.effort not in CLAUDE_EFFORTS:
|
|
2687
|
+
raise ConfigurationError(
|
|
2688
|
+
f"{where}: {self.name} effort must be one of {', '.join(CLAUDE_EFFORTS)}, "
|
|
2689
|
+
f"got {profile.effort!r}"
|
|
2690
|
+
)
|
|
2691
|
+
if not _SDK_MODEL_RE.fullmatch(profile.model):
|
|
2692
|
+
raise ConfigurationError(
|
|
2693
|
+
f"{where}: {self.name} model must be a Claude model alias or id, with no "
|
|
2694
|
+
f"whitespace and no '#', got {profile.model!r}"
|
|
2695
|
+
)
|
|
2696
|
+
|
|
2697
|
+
def policy(self, profile: ProfileConfig) -> ClaudeSdkPolicy:
|
|
2698
|
+
return claude_sdk_policy(profile.name, profile.options)
|
|
2699
|
+
|
|
2700
|
+
def sandbox_requirements(
|
|
2701
|
+
self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
|
|
2702
|
+
) -> ProviderRequirements:
|
|
2703
|
+
"""``claude-sdk``'s declaration (ADR 0005 D7.1, D13.1; ADR 0007 D10.2).
|
|
2704
|
+
|
|
2705
|
+
``ANTHROPIC_API_KEY`` is an ``env`` item; there is no login file and
|
|
2706
|
+
no keyring item, ever. The policy's ``CLAUDE_CONFIG_DIR``
|
|
2707
|
+
(:class:`autoforge.sdk_policy.ClaudeSdkRoots`) writable at its own
|
|
2708
|
+
path (``dir-rw``), refused when it is or lies in ``~/.claude``
|
|
2709
|
+
(D7.2). ``~/.claude`` and ``~/.claude.json`` are the operator
|
|
2710
|
+
configuration roots.
|
|
2711
|
+
"""
|
|
2712
|
+
needs = super().sandbox_requirements(profile, command, env, home)
|
|
2713
|
+
auth = (
|
|
2714
|
+
[AuthItem(AuthMode.ENV, CLAUDE_SDK_CREDENTIAL)]
|
|
2715
|
+
if env.get(CLAUDE_SDK_CREDENTIAL)
|
|
2716
|
+
else []
|
|
2717
|
+
)
|
|
2718
|
+
config_dir = env.get("CLAUDE_CONFIG_DIR", "")
|
|
2719
|
+
if config_dir.startswith("/"):
|
|
2720
|
+
auth.append(
|
|
2721
|
+
AuthItem(
|
|
2722
|
+
AuthMode.DIR_RW,
|
|
2723
|
+
"the claude-sdk configuration and session root ($CLAUDE_CONFIG_DIR)",
|
|
2724
|
+
config_dir,
|
|
2725
|
+
env_name="CLAUDE_CONFIG_DIR",
|
|
2726
|
+
)
|
|
2727
|
+
)
|
|
2728
|
+
return dataclasses.replace(
|
|
2729
|
+
needs,
|
|
2730
|
+
auth=tuple(auth),
|
|
2731
|
+
operator_config_roots=(
|
|
2732
|
+
os.path.join(home, ".claude"),
|
|
2733
|
+
os.path.join(home, ".claude.json"),
|
|
2734
|
+
),
|
|
2735
|
+
)
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
class OpenCodeSdkProvider(_SdkProvider):
|
|
2739
|
+
"""``opencode-sdk``: the OpenCode SDK in a Node bridge (ADR 0007; #152).
|
|
2740
|
+
|
|
2741
|
+
Its model credential is the one variable the profile names in
|
|
2742
|
+
``options.credential_env``; no stored sign-in is read (D10.2).
|
|
2743
|
+
"""
|
|
2744
|
+
|
|
2745
|
+
name = OPENCODE_SDK
|
|
2746
|
+
environment_names = ()
|
|
2747
|
+
option_keys = OPENCODE_SDK_OPTION_KEYS
|
|
2748
|
+
path_names = ("OPENCODE_CONFIG_DIR",)
|
|
2749
|
+
transport_issue = "152"
|
|
2750
|
+
interpreter = "Node executable"
|
|
2751
|
+
runtime_decision = "D2.5"
|
|
2752
|
+
|
|
2753
|
+
def validate_profile(self, profile: ProfileConfig) -> None:
|
|
2754
|
+
super().validate_profile(profile)
|
|
2755
|
+
where = f"profile {profile.name!r}"
|
|
2756
|
+
problem = opencode_model_problem(profile.model)
|
|
2757
|
+
if problem:
|
|
2758
|
+
raise ConfigurationError(
|
|
2759
|
+
f"{where}: {self.name} model must be an OpenCode model id: {problem}, "
|
|
2760
|
+
f"got {profile.model!r}"
|
|
2761
|
+
)
|
|
2762
|
+
if profile.effort and not _OPENCODE_SDK_EFFORT_RE.fullmatch(profile.effort):
|
|
2763
|
+
raise ConfigurationError(
|
|
2764
|
+
f"{where}: {self.name} effort must be a reasoning variant name (lower-case "
|
|
2765
|
+
f"letters, digits, '_' and '-'), got {profile.effort!r}"
|
|
2766
|
+
)
|
|
2767
|
+
|
|
2768
|
+
def policy(self, profile: ProfileConfig) -> OpenCodeSdkPolicy:
|
|
2769
|
+
return opencode_sdk_policy(profile.name, profile.options)
|
|
2770
|
+
|
|
2771
|
+
def own_names(self, profile: ProfileConfig | None) -> tuple[str, ...]:
|
|
2772
|
+
if profile is None:
|
|
2773
|
+
return ()
|
|
2774
|
+
return (self.policy(profile).credential_env,)
|
|
2775
|
+
|
|
2776
|
+
def sandbox_requirements(
|
|
2777
|
+
self, profile: ProfileConfig, command: list[str], env: Mapping[str, str], home: str
|
|
2778
|
+
) -> ProviderRequirements:
|
|
2779
|
+
"""``opencode-sdk``'s declaration (ADR 0005 D7.1, D13.1; ADR 0007 D10.2).
|
|
2780
|
+
|
|
2781
|
+
The profile's ``credential_env`` is an ``env`` item, the only
|
|
2782
|
+
credential; no OpenCode store is projected. The policy's roots
|
|
2783
|
+
(:class:`autoforge.sdk_policy.OpenCodeSdkRoots`) are #152's to
|
|
2784
|
+
project. The operator's OpenCode configuration, data and cache
|
|
2785
|
+
directories are the operator configuration roots.
|
|
2786
|
+
"""
|
|
2787
|
+
needs = super().sandbox_requirements(profile, command, env, home)
|
|
2788
|
+
credential = self.policy(profile).credential_env
|
|
2789
|
+
auth = (AuthItem(AuthMode.ENV, credential),) if env.get(credential) else ()
|
|
2790
|
+
return dataclasses.replace(
|
|
2791
|
+
needs,
|
|
2792
|
+
auth=auth,
|
|
2793
|
+
operator_config_roots=(
|
|
2794
|
+
os.path.join(home, ".config", "opencode"),
|
|
2795
|
+
os.path.join(home, ".local", "share", "opencode"),
|
|
2796
|
+
os.path.join(home, ".cache", "opencode"),
|
|
2797
|
+
os.path.join(home, ".opencode"),
|
|
2798
|
+
),
|
|
2799
|
+
)
|
|
2800
|
+
|
|
2801
|
+
|
|
2802
|
+
ScriptHandler = Callable[[AgentRequest], str]
|
|
2803
|
+
|
|
2804
|
+
|
|
2805
|
+
class ScriptedProvider(AgentProvider):
|
|
2806
|
+
"""Test/integration provider: returns canned stdout, never spawns a process.
|
|
2807
|
+
|
|
2808
|
+
``script`` is either a list of stdout strings consumed in order, or a
|
|
2809
|
+
callable ``(AgentRequest) -> stdout``. Every request is recorded in
|
|
2810
|
+
``calls`` so tests can assert on prompts, phases and cwd.
|
|
2811
|
+
"""
|
|
2812
|
+
|
|
2813
|
+
name = "scripted"
|
|
2814
|
+
|
|
2815
|
+
def __init__(
|
|
2816
|
+
self,
|
|
2817
|
+
script: list[str] | ScriptHandler | None = None,
|
|
2818
|
+
exit_code: int = 0,
|
|
2819
|
+
stderr: str = "",
|
|
2820
|
+
) -> None:
|
|
2821
|
+
super().__init__(runner=None)
|
|
2822
|
+
self._queue: list[str] = list(script) if isinstance(script, list) else []
|
|
2823
|
+
self._handler: ScriptHandler | None = script if callable(script) else None
|
|
2824
|
+
self.exit_code = exit_code
|
|
2825
|
+
self.stderr = stderr
|
|
2826
|
+
self.calls: list[AgentRequest] = []
|
|
2827
|
+
# The description each sandboxed request compiled to: the engine's
|
|
2828
|
+
# facts alone, since a scripted agent declares nothing (ADR 0005).
|
|
2829
|
+
self.descriptions: list[LaunchDescription] = []
|
|
2830
|
+
|
|
2831
|
+
def build_command_for(self, profile: ProfileConfig, prompt: str) -> list[str]:
|
|
2832
|
+
return ["scripted-agent", f"--profile={profile.name}", "--", prompt]
|
|
2833
|
+
|
|
2834
|
+
def execute(self, req: AgentRequest) -> AgentExecutionResult:
|
|
2835
|
+
self.calls.append(req)
|
|
2836
|
+
if req.sandbox is not None:
|
|
2837
|
+
self.descriptions.append(req.sandbox.describe(ProviderRequirements(), req.env or {}))
|
|
2838
|
+
if self._handler is not None:
|
|
2839
|
+
stdout = self._handler(req)
|
|
2840
|
+
elif self._queue:
|
|
2841
|
+
stdout = self._queue.pop(0)
|
|
2842
|
+
else:
|
|
2843
|
+
stdout = ""
|
|
2844
|
+
return AgentExecutionResult(
|
|
2845
|
+
command=self.build_command(req),
|
|
2846
|
+
exit_code=self.exit_code,
|
|
2847
|
+
stdout=stdout,
|
|
2848
|
+
stderr=self.stderr,
|
|
2849
|
+
started_at="2026-01-01T00:00:00+00:00",
|
|
2850
|
+
finished_at="2026-01-01T00:00:01+00:00",
|
|
2851
|
+
provider="scripted",
|
|
2852
|
+
model=req.profile.model,
|
|
2853
|
+
effort=req.profile.effort,
|
|
2854
|
+
)
|
|
2855
|
+
|
|
2856
|
+
|
|
2857
|
+
_REGISTRY: dict[str, type[AgentProvider]] = {
|
|
2858
|
+
"claude": ClaudeCodeProvider,
|
|
2859
|
+
"opencode": OpenCodeProvider,
|
|
2860
|
+
"pi": PiProvider,
|
|
2861
|
+
"scripted": ScriptedProvider,
|
|
2862
|
+
CLAUDE_SDK: ClaudeSdkProvider,
|
|
2863
|
+
OPENCODE_SDK: OpenCodeSdkProvider,
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
|
|
2867
|
+
def provider_for(profile: ProfileConfig, runner: Runner | None = None) -> AgentProvider:
|
|
2868
|
+
cls = _REGISTRY.get(profile.provider)
|
|
2869
|
+
if cls is None:
|
|
2870
|
+
raise ConfigurationError(
|
|
2871
|
+
f"profile {profile.name!r}: unknown provider {profile.provider!r} "
|
|
2872
|
+
f"(known: {', '.join(sorted(_REGISTRY))})"
|
|
2873
|
+
)
|
|
2874
|
+
if cls is ScriptedProvider:
|
|
2875
|
+
return ScriptedProvider()
|
|
2876
|
+
return cls(runner=runner)
|
|
2877
|
+
|
|
2878
|
+
|
|
2879
|
+
class ProviderRegistry:
|
|
2880
|
+
"""Resolves providers per profile; allows injecting fakes per provider name."""
|
|
2881
|
+
|
|
2882
|
+
def __init__(
|
|
2883
|
+
self,
|
|
2884
|
+
runner: Runner | None = None,
|
|
2885
|
+
overrides: dict[str, AgentProvider] | None = None,
|
|
2886
|
+
) -> None:
|
|
2887
|
+
self._runner = runner
|
|
2888
|
+
self._overrides = dict(overrides or {})
|
|
2889
|
+
|
|
2890
|
+
def get(self, profile: ProfileConfig) -> AgentProvider:
|
|
2891
|
+
if profile.provider in self._overrides:
|
|
2892
|
+
return self._overrides[profile.provider]
|
|
2893
|
+
return provider_for(profile, runner=self._runner)
|