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
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""Claude Code stream-json protocol (#192): lines in, progress out, one outcome.
|
|
2
|
+
|
|
3
|
+
:class:`ClaudeStream` is the whole of one ``claude -p --output-format
|
|
4
|
+
stream-json --verbose`` invocation as a pure reducer, the way
|
|
5
|
+
:class:`autoforge.pi_rpc.PiConversation` is Pi's. The driver
|
|
6
|
+
(:meth:`autoforge.providers.ClaudeCodeProvider.execute`) feeds it every
|
|
7
|
+
stdout line as bytes, reports a line past the per-line bound, an
|
|
8
|
+
unterminated last line, an overflow and the end of stdout; nothing here
|
|
9
|
+
starts a process, knows a CLI flag or reads a clock. The module knows
|
|
10
|
+
Claude's record names and nothing else, so its tests drive it from
|
|
11
|
+
synthetic lines with no process at all.
|
|
12
|
+
|
|
13
|
+
What a stream is (verified on claude 2.1.292, 2026-10-06): one JSON object
|
|
14
|
+
per line. ``system`` records (``init``, which names the model;
|
|
15
|
+
``thinking_tokens``, which carries ``estimated_tokens`` and is emitted
|
|
16
|
+
repeatedly while the model is still thinking; ``hook_started`` and
|
|
17
|
+
``hook_response`` from the operator's own hooks, which quote the hook's
|
|
18
|
+
output and are never shown), ``assistant`` messages whose content blocks
|
|
19
|
+
are ``thinking``, ``text`` or ``tool_use`` (``id``, ``name``, ``input``),
|
|
20
|
+
``user`` messages carrying ``tool_result`` blocks (``tool_use_id``,
|
|
21
|
+
``is_error``), ``rate_limit_event``, and a ``result`` record that ends the
|
|
22
|
+
turn. ``system/api_retry`` (``attempt``, ``max_retries``) is mapped too, from
|
|
23
|
+
the CLI's documented message set; it did not occur in the verified sample.
|
|
24
|
+
|
|
25
|
+
One invocation can run more than one turn (verified on claude 2.1.293,
|
|
26
|
+
2026-10-07): a background task or a scheduled wakeup still pending when a
|
|
27
|
+
turn ends keeps the CLI alive, and when it fires the CLI runs another turn,
|
|
28
|
+
from its own ``system/init`` to its own ``result`` (``result_index`` 0, 1,
|
|
29
|
+
...), with notices such as ``system/task_notification`` or
|
|
30
|
+
``command_lifecycle`` between turns. ``--output-format text`` prints only
|
|
31
|
+
the last turn's result text, at exit. A turn that ends while a background
|
|
32
|
+
agent (``Agent`` run in the background) is still running does not get its
|
|
33
|
+
``result`` then: the CLI holds it back, the next turn opens with its own
|
|
34
|
+
``system/init`` all the same, and once no background agent is left the
|
|
35
|
+
held results arrive together, in turn order, after the last turn's records
|
|
36
|
+
(verified on claude 2.1.293, 2026-10-07). A turn that ends with only a
|
|
37
|
+
background shell task pending gets its ``result`` at once. Either way the
|
|
38
|
+
n-th ``result`` belongs to the n-th turn. A background agent's own records
|
|
39
|
+
reach the stream too, each with the ``parent_tool_use_id`` of the call
|
|
40
|
+
that started the agent, but only as ``assistant`` and ``user`` records; the
|
|
41
|
+
agent's start and end are main-conversation notices (``system/task_*``),
|
|
42
|
+
and every ``system/init`` and ``result`` is the main conversation's
|
|
43
|
+
(verified on claude 2.1.293, 2026-10-07, with three background agents).
|
|
44
|
+
The turn count rests on that: a subagent's own ``system/init`` or
|
|
45
|
+
``result`` would throw it off.
|
|
46
|
+
|
|
47
|
+
The outcome:
|
|
48
|
+
|
|
49
|
+
* The last ``result`` record's ``result`` string is the final assistant
|
|
50
|
+
text, verbatim once decoded: it becomes the invocation's stdout, so the
|
|
51
|
+
CONTROL_RESULT block is parsed exactly as it was from ``--output-format
|
|
52
|
+
text``. Every ``result`` is checked: ``is_error`` first (``subtype:
|
|
53
|
+
success`` can come with ``is_error: true``), then ``subtype`` must be
|
|
54
|
+
``success``, then ``result`` must be a string. Anything else is a provider
|
|
55
|
+
failure, also in a turn a later one would have followed.
|
|
56
|
+
* A line that is not a JSON object with a string ``type`` (including one
|
|
57
|
+
the decoder refuses for an integer or nesting limit), an unterminated
|
|
58
|
+
last line, a queue overflow, a ``result`` outside a turn (one with every
|
|
59
|
+
turn so far closed), and stdout ending before every turn has its
|
|
60
|
+
``result`` are provider failures too. The first turn opens with the
|
|
61
|
+
stream; a later one opens with its own ``system/init`` or, with every
|
|
62
|
+
turn so far closed, with an ``assistant`` or ``user`` record, whose
|
|
63
|
+
``system/init`` is then that turn's own. A turn's ``result`` closes it.
|
|
64
|
+
* A line past the per-line bound is counted and skipped: a ``tool_result``
|
|
65
|
+
carries whole files, and progress is not worth failing a run for. The
|
|
66
|
+
``result`` line has no such allowance; if it was the line skipped, the
|
|
67
|
+
stream ends without one and fails, the way Pi's final text must arrive
|
|
68
|
+
whole in one record (ADR 0003).
|
|
69
|
+
* Records that arrive with every turn closed and open no turn (notices
|
|
70
|
+
between turns, the operator's ``SessionEnd`` hooks after the last one)
|
|
71
|
+
are counted and otherwise ignored.
|
|
72
|
+
|
|
73
|
+
Progress: each record becomes at least one :class:`~autoforge.progress.ProgressEvent`
|
|
74
|
+
(liveness for #193), and the only strings an event carries are the model
|
|
75
|
+
name and one allow-listed input per tool (:data:`TOOL_DETAILS`) -- never a
|
|
76
|
+
command line, a tool result, thinking or assistant text. The summary is
|
|
77
|
+
flat bounded scalars from the ``result`` records (no nested ``usage`` or
|
|
78
|
+
``modelUsage``): the last one's, with ``num_turns`` and ``duration_ms``,
|
|
79
|
+
which each ``result`` reports for its own turn, summed over all of them, and
|
|
80
|
+
their count; ``total_cost_usd`` is the session's so far, so the last one's
|
|
81
|
+
is the invocation's. The cost is integer micro-USD, and a value that is
|
|
82
|
+
absent or unreadable is left out rather than reported as zero. So is a sum
|
|
83
|
+
once one ``result`` lacks its count: what the others add up to is not the
|
|
84
|
+
invocation's total.
|
|
85
|
+
|
|
86
|
+
Loop detection (#194): given a :class:`~autoforge.loop_detect.LoopObserver`,
|
|
87
|
+
each completed tool call is reported as an action fingerprinted from its
|
|
88
|
+
name, the digest of its whole ``input`` (kept from the ``tool_use`` until
|
|
89
|
+
its ``tool_result`` arrives) and the digest of the whole ``tool_result``
|
|
90
|
+
block but its ``tool_use_id``; ``system/api_retry`` is a retry and an
|
|
91
|
+
``assistant`` record a completed turn. Only names and digests leave here.
|
|
92
|
+
|
|
93
|
+
Refused requests (#258, run on claude 2.1.312 against a loopback stand-in
|
|
94
|
+
of the Messages API, 2026-10-27): a ``-p`` run asks no one. A tool call
|
|
95
|
+
that needs a permission its mode does not grant (``manual``, ``dontAsk``,
|
|
96
|
+
``acceptEdits`` for a command) or that a deny rule of the user settings
|
|
97
|
+
names is refused at once: a ``system/permission_denied`` record (``tool_name``,
|
|
98
|
+
``tool_use_id``), an error ``tool_result`` telling the model to go on
|
|
99
|
+
without it, and the call listed in the turn's ``result`` under
|
|
100
|
+
``permission_denials`` (``tool_name``, ``tool_use_id``, ``tool_input``); the
|
|
101
|
+
run goes on and exits 0. ``bypassPermissions``, the adapter's default,
|
|
102
|
+
asked for nothing in that run (a write under ``.git/`` and ``.claude/``
|
|
103
|
+
included). ``AskUserQuestion`` is not offered to the model under ``-p``; a
|
|
104
|
+
call to it anyway gets an error ``tool_result``. Each of these is kept in
|
|
105
|
+
:attr:`ClaudeStream.refused`, once per ``tool_use_id``: its kind and, for a
|
|
106
|
+
permission, the tool's name and its one allow-listed input
|
|
107
|
+
(:data:`TOOL_DETAILS`), never a command line.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
from __future__ import annotations
|
|
111
|
+
|
|
112
|
+
import json
|
|
113
|
+
import math
|
|
114
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
115
|
+
|
|
116
|
+
from .interaction import RequestKind
|
|
117
|
+
from .loop_detect import LoopObserver, action_fingerprint, digest
|
|
118
|
+
from .progress import ProgressEvent, ProgressKind, ProgressSink, clean, relative_path
|
|
119
|
+
|
|
120
|
+
# The one tool input shown per tool. A tool not listed shows its name only.
|
|
121
|
+
# Bash shows its ``description``, never its ``command``: a command line is
|
|
122
|
+
# where credentials are typed, and redaction is a baseline, not a proof.
|
|
123
|
+
TOOL_DETAILS: dict[str, str] = {
|
|
124
|
+
"Read": "file_path",
|
|
125
|
+
"Edit": "file_path",
|
|
126
|
+
"MultiEdit": "file_path",
|
|
127
|
+
"Write": "file_path",
|
|
128
|
+
"NotebookEdit": "notebook_path",
|
|
129
|
+
"Grep": "pattern",
|
|
130
|
+
"Glob": "pattern",
|
|
131
|
+
"Bash": "description",
|
|
132
|
+
"Task": "description",
|
|
133
|
+
"Agent": "description",
|
|
134
|
+
}
|
|
135
|
+
_PATH_INPUTS = frozenset({"file_path", "notebook_path"})
|
|
136
|
+
# The tool through which the model asks the user a question.
|
|
137
|
+
QUESTION_TOOL = "AskUserQuestion"
|
|
138
|
+
# Record types that are activity only. Any other type is counted as unknown
|
|
139
|
+
# (forward compatibility) and is activity too.
|
|
140
|
+
_ACTIVITY_TYPES = frozenset({"rate_limit_event"})
|
|
141
|
+
# Per-turn counts in a ``result``, summed over the invocation's turns.
|
|
142
|
+
_PER_TURN_COUNTS = ("num_turns", "duration_ms")
|
|
143
|
+
# Tool calls whose result has not arrived yet, kept to name a failed tool
|
|
144
|
+
# and to fingerprint the call once it completes. Past the bound a result is
|
|
145
|
+
# still counted, only not named, and is no action for the loop detector.
|
|
146
|
+
MAX_OPEN_TOOLS = 1024
|
|
147
|
+
# Refused requests kept to name; past it they are still counted.
|
|
148
|
+
MAX_REFUSED = 64
|
|
149
|
+
MAX_REASON_CHARS = 400
|
|
150
|
+
MAX_VALUE_CHARS = 120
|
|
151
|
+
MAX_SUBJECT_CHARS = 160
|
|
152
|
+
_MAX_COUNT = 10**15
|
|
153
|
+
# A run that cost more than this is not a reading to believe.
|
|
154
|
+
_MAX_COST_USD = Decimal(1_000_000)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _count(value: object) -> int | None:
|
|
158
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= _MAX_COUNT:
|
|
159
|
+
return None
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _micro_usd(value: object) -> int | None:
|
|
164
|
+
"""``value`` USD as integer micro-USD, or None when it is no finite, non-negative amount."""
|
|
165
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
166
|
+
return None
|
|
167
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
168
|
+
return None
|
|
169
|
+
amount = Decimal(repr(value)) if isinstance(value, float) else Decimal(value)
|
|
170
|
+
if not Decimal(0) <= amount <= _MAX_COST_USD:
|
|
171
|
+
return None
|
|
172
|
+
return int((amount * 1_000_000).to_integral_value(rounding=ROUND_HALF_UP))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ClaudeStream:
|
|
176
|
+
"""One stream-json invocation as a reducer. Not thread-safe; one driver owns it.
|
|
177
|
+
|
|
178
|
+
When the stream is over, :attr:`text` holds the final assistant text on
|
|
179
|
+
success and :attr:`failure` the bounded, redacted reason otherwise, and
|
|
180
|
+
:attr:`refused` the requests the CLI refused, whatever the outcome.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
cwd: str = "",
|
|
187
|
+
emit: ProgressSink | None = None,
|
|
188
|
+
loop: LoopObserver | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
self._cwd = cwd
|
|
191
|
+
self._emit = emit
|
|
192
|
+
self._loop = loop
|
|
193
|
+
self.text: str | None = None
|
|
194
|
+
self.failure: str | None = None
|
|
195
|
+
# stdout ended inside a turn; the driver names the exit code.
|
|
196
|
+
self.exited_early = False
|
|
197
|
+
# Turns opened so far: the first with the stream, each later one by
|
|
198
|
+
# its ``system/init`` or, with every turn so far closed, by a record
|
|
199
|
+
# that opens one. Result n closes turn n, so a turn waits for its
|
|
200
|
+
# result while ``_results < _turns``.
|
|
201
|
+
self._turns = 1
|
|
202
|
+
# The newest turn has had its ``system/init``; the next one opens a turn.
|
|
203
|
+
self._turn_has_init = False
|
|
204
|
+
self._results = 0
|
|
205
|
+
# The per-turn counts summed over the results so far. A count one
|
|
206
|
+
# result lacks makes its sum unknown, and it is dropped for good.
|
|
207
|
+
self._per_turn: dict[str, int] = dict.fromkeys(_PER_TURN_COUNTS, 0)
|
|
208
|
+
# Open tool calls: their name, with a loop observer their input's
|
|
209
|
+
# digest, and their allow-listed input (:data:`TOOL_DETAILS`).
|
|
210
|
+
self._open_tools: dict[str, tuple[str, bytes, str]] = {}
|
|
211
|
+
# Requests the CLI refused (module docstring): the first
|
|
212
|
+
# :data:`MAX_REFUSED`, each its kind and its cleaned subject ("" for
|
|
213
|
+
# none), and how many there were; a ``tool_use_id`` counts once.
|
|
214
|
+
self.refused: list[tuple[RequestKind, str]] = []
|
|
215
|
+
self.refused_count = 0
|
|
216
|
+
self._refused_ids: set[str] = set()
|
|
217
|
+
self._records = 0
|
|
218
|
+
self._oversize = 0
|
|
219
|
+
self._unknown = 0
|
|
220
|
+
self._after_result = 0
|
|
221
|
+
self._tool_calls = 0
|
|
222
|
+
self._tool_errors = 0
|
|
223
|
+
self._retries = 0
|
|
224
|
+
self._result: dict[str, str | int | bool] = {}
|
|
225
|
+
|
|
226
|
+
# -- driver interface ----------------------------------------------------
|
|
227
|
+
def feed(self, data: bytes) -> None:
|
|
228
|
+
"""Reduce one stdout line (without its LF)."""
|
|
229
|
+
self._records += 1
|
|
230
|
+
if self.failure is not None:
|
|
231
|
+
return
|
|
232
|
+
try:
|
|
233
|
+
record = json.loads(data.decode("utf-8"))
|
|
234
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
235
|
+
self._protocol("a stdout line is not JSON")
|
|
236
|
+
return
|
|
237
|
+
except (ValueError, RecursionError):
|
|
238
|
+
# The decoder's own limits, which are not JSONDecodeError: an
|
|
239
|
+
# integer past ``sys.get_int_max_str_digits()`` and nesting past
|
|
240
|
+
# the recursion limit or the C stack, both well inside the
|
|
241
|
+
# per-line bound. An interrupt is neither and still propagates.
|
|
242
|
+
self._protocol("a stdout line exceeds the JSON decoder's integer or nesting limit")
|
|
243
|
+
return
|
|
244
|
+
if not isinstance(record, dict) or not isinstance(record.get("type"), str):
|
|
245
|
+
self._protocol("a stdout line is not a JSON object with a 'type'")
|
|
246
|
+
return
|
|
247
|
+
kind = record["type"]
|
|
248
|
+
if kind == "result":
|
|
249
|
+
if self._results < self._turns:
|
|
250
|
+
self._take_result(record)
|
|
251
|
+
else:
|
|
252
|
+
self._protocol("a result event outside a turn")
|
|
253
|
+
return
|
|
254
|
+
is_init = kind == "system" and record.get("subtype") == "init"
|
|
255
|
+
if self._results == self._turns:
|
|
256
|
+
if kind not in ("assistant", "user") and not is_init:
|
|
257
|
+
self._after_result += 1
|
|
258
|
+
return
|
|
259
|
+
self._turns += 1
|
|
260
|
+
self._turn_has_init = is_init
|
|
261
|
+
elif is_init:
|
|
262
|
+
if self._turn_has_init:
|
|
263
|
+
self._turns += 1
|
|
264
|
+
self._turn_has_init = True
|
|
265
|
+
self._observe(kind, record)
|
|
266
|
+
|
|
267
|
+
def oversize(self, limit: int) -> None:
|
|
268
|
+
"""A line past ``limit`` bytes was skipped by the framer."""
|
|
269
|
+
self._oversize += 1
|
|
270
|
+
self._send(ProgressEvent(ProgressKind.ACTIVITY))
|
|
271
|
+
|
|
272
|
+
def framing_error(self, what: str) -> None:
|
|
273
|
+
self._protocol(what)
|
|
274
|
+
|
|
275
|
+
def stream_ended(self) -> None:
|
|
276
|
+
"""stdout reached EOF."""
|
|
277
|
+
if self._results == self._turns or self.failure is not None:
|
|
278
|
+
return
|
|
279
|
+
self.exited_early = True
|
|
280
|
+
if self._results:
|
|
281
|
+
self._fail("claude: exited inside a turn that has no result event")
|
|
282
|
+
return
|
|
283
|
+
reason = "claude: exited without a result event"
|
|
284
|
+
if self._oversize:
|
|
285
|
+
reason += (
|
|
286
|
+
f" ({self._oversize} line(s) past the per-line bound were skipped; "
|
|
287
|
+
"the result must arrive whole in one line)"
|
|
288
|
+
)
|
|
289
|
+
self._fail(reason)
|
|
290
|
+
|
|
291
|
+
def summary(self) -> dict[str, str | int | bool]:
|
|
292
|
+
"""The flat, bounded, redacted summary for ``execution.json``."""
|
|
293
|
+
return {
|
|
294
|
+
**self._result,
|
|
295
|
+
"results": self._results,
|
|
296
|
+
"records": self._records,
|
|
297
|
+
"oversize_records": self._oversize,
|
|
298
|
+
"unknown_events": self._unknown,
|
|
299
|
+
"records_after_result": self._after_result,
|
|
300
|
+
"tool_calls": self._tool_calls,
|
|
301
|
+
"tool_errors": self._tool_errors,
|
|
302
|
+
"api_retries": self._retries,
|
|
303
|
+
"failure": self.failure or "",
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# -- internals -----------------------------------------------------------
|
|
307
|
+
def _send(self, event: ProgressEvent) -> None:
|
|
308
|
+
if self._emit is not None:
|
|
309
|
+
self._emit(event)
|
|
310
|
+
|
|
311
|
+
def _fail(self, reason: str) -> None:
|
|
312
|
+
if self.failure is None:
|
|
313
|
+
self.failure = clean(reason, MAX_REASON_CHARS)
|
|
314
|
+
self.text = None
|
|
315
|
+
|
|
316
|
+
def _protocol(self, what: str) -> None:
|
|
317
|
+
self._fail(f"claude: stream-json protocol violation: {what}")
|
|
318
|
+
|
|
319
|
+
def _take_result(self, record: dict) -> None:
|
|
320
|
+
self._results += 1
|
|
321
|
+
subtype = record.get("subtype")
|
|
322
|
+
is_error = record.get("is_error")
|
|
323
|
+
summary: dict[str, str | int | bool] = {
|
|
324
|
+
"result_subtype": clean(subtype, MAX_VALUE_CHARS),
|
|
325
|
+
"is_error": is_error is True,
|
|
326
|
+
"terminal_reason": clean(record.get("terminal_reason"), MAX_VALUE_CHARS),
|
|
327
|
+
"stop_reason": clean(record.get("stop_reason"), MAX_VALUE_CHARS),
|
|
328
|
+
}
|
|
329
|
+
for key, total in list(self._per_turn.items()):
|
|
330
|
+
value = _count(record.get(key))
|
|
331
|
+
if value is None:
|
|
332
|
+
del self._per_turn[key]
|
|
333
|
+
else:
|
|
334
|
+
self._per_turn[key] = min(total + value, _MAX_COUNT)
|
|
335
|
+
summary.update(self._per_turn)
|
|
336
|
+
cost = _micro_usd(record.get("total_cost_usd"))
|
|
337
|
+
if cost is not None:
|
|
338
|
+
summary["cost_micro_usd"] = cost
|
|
339
|
+
denials = record.get("permission_denials")
|
|
340
|
+
if isinstance(denials, list):
|
|
341
|
+
summary["permission_denials"] = len(denials)
|
|
342
|
+
for denial in denials:
|
|
343
|
+
if isinstance(denial, dict):
|
|
344
|
+
name = denial.get("tool_name")
|
|
345
|
+
name = name if isinstance(name, str) else ""
|
|
346
|
+
detail = self._detail(name, denial.get("tool_input"))
|
|
347
|
+
self._refuse(RequestKind.PERMISSION, denial.get("tool_use_id"), name, detail)
|
|
348
|
+
self._result = summary
|
|
349
|
+
result = record.get("result")
|
|
350
|
+
if is_error is True:
|
|
351
|
+
facts = [f"subtype {summary['result_subtype'] or '-'}"]
|
|
352
|
+
if summary["terminal_reason"]:
|
|
353
|
+
facts.append(f"terminal_reason {summary['terminal_reason']}")
|
|
354
|
+
status = _count(record.get("api_error_status"))
|
|
355
|
+
if status is not None:
|
|
356
|
+
facts.append(f"api_error_status {status}")
|
|
357
|
+
excerpt = clean(result, MAX_VALUE_CHARS * 2)
|
|
358
|
+
self._fail(
|
|
359
|
+
f"claude: the run ended in an error ({', '.join(facts)})"
|
|
360
|
+
+ (f": {excerpt}" if excerpt else "")
|
|
361
|
+
)
|
|
362
|
+
elif is_error is not False:
|
|
363
|
+
self._protocol("the result event has no boolean 'is_error'")
|
|
364
|
+
elif subtype != "success":
|
|
365
|
+
self._fail(f"claude: the run did not succeed (subtype {clean(subtype, 60) or '-'})")
|
|
366
|
+
elif not isinstance(result, str):
|
|
367
|
+
self._protocol("the result event has no string 'result'")
|
|
368
|
+
else:
|
|
369
|
+
self.text = result
|
|
370
|
+
|
|
371
|
+
def _observe(self, kind: str, record: dict) -> None:
|
|
372
|
+
if kind == "system":
|
|
373
|
+
self._system(record)
|
|
374
|
+
elif kind == "assistant":
|
|
375
|
+
self._assistant(record)
|
|
376
|
+
elif kind == "user":
|
|
377
|
+
self._user(record)
|
|
378
|
+
else:
|
|
379
|
+
if kind not in _ACTIVITY_TYPES:
|
|
380
|
+
self._unknown += 1
|
|
381
|
+
self._send(ProgressEvent(ProgressKind.ACTIVITY))
|
|
382
|
+
|
|
383
|
+
def _system(self, record: dict) -> None:
|
|
384
|
+
subtype = record.get("subtype")
|
|
385
|
+
if subtype == "init":
|
|
386
|
+
model = record.get("model")
|
|
387
|
+
self._send(
|
|
388
|
+
ProgressEvent(ProgressKind.STARTED, detail=model if isinstance(model, str) else "")
|
|
389
|
+
)
|
|
390
|
+
elif subtype == "thinking_tokens":
|
|
391
|
+
tokens = _count(record.get("estimated_tokens")) or 0
|
|
392
|
+
self._send(ProgressEvent(ProgressKind.THINKING, tokens=tokens))
|
|
393
|
+
elif subtype == "api_retry":
|
|
394
|
+
self._retries += 1
|
|
395
|
+
if self._loop is not None:
|
|
396
|
+
self._loop.retry()
|
|
397
|
+
attempt, limit = _count(record.get("attempt")), _count(record.get("max_retries"))
|
|
398
|
+
detail = ""
|
|
399
|
+
if attempt is not None:
|
|
400
|
+
detail = f"attempt {attempt}" + (f" of {limit}" if limit is not None else "")
|
|
401
|
+
self._send(ProgressEvent(ProgressKind.PROVIDER_RETRY, detail=detail))
|
|
402
|
+
elif subtype == "permission_denied":
|
|
403
|
+
tool_id = record.get("tool_use_id")
|
|
404
|
+
call = self._open_tools.get(tool_id) if isinstance(tool_id, str) else None
|
|
405
|
+
name = record.get("tool_name")
|
|
406
|
+
if not isinstance(name, str):
|
|
407
|
+
name = call[0] if call is not None else ""
|
|
408
|
+
self._refuse(RequestKind.PERMISSION, tool_id, name, call[2] if call else "")
|
|
409
|
+
self._send(ProgressEvent(ProgressKind.ACTIVITY))
|
|
410
|
+
else:
|
|
411
|
+
# Hooks, status and compaction notices: liveness only. A hook's
|
|
412
|
+
# output is the operator's, and is never shown.
|
|
413
|
+
self._send(ProgressEvent(ProgressKind.ACTIVITY))
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _blocks(record: dict) -> list[dict]:
|
|
417
|
+
message = record.get("message")
|
|
418
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
419
|
+
if not isinstance(content, list):
|
|
420
|
+
return []
|
|
421
|
+
return [block for block in content if isinstance(block, dict)]
|
|
422
|
+
|
|
423
|
+
def _assistant(self, record: dict) -> None:
|
|
424
|
+
sent = False
|
|
425
|
+
for block in self._blocks(record):
|
|
426
|
+
kind = block.get("type")
|
|
427
|
+
if kind == "thinking":
|
|
428
|
+
self._send(ProgressEvent(ProgressKind.THINKING))
|
|
429
|
+
elif kind == "text":
|
|
430
|
+
self._send(ProgressEvent(ProgressKind.ASSISTANT_TEXT))
|
|
431
|
+
elif kind == "tool_use":
|
|
432
|
+
self._tool_use(block)
|
|
433
|
+
else:
|
|
434
|
+
continue
|
|
435
|
+
sent = True
|
|
436
|
+
if not sent:
|
|
437
|
+
self._send(ProgressEvent(ProgressKind.ACTIVITY))
|
|
438
|
+
if self._loop is not None:
|
|
439
|
+
self._loop.turn()
|
|
440
|
+
|
|
441
|
+
def _tool_use(self, block: dict) -> None:
|
|
442
|
+
self._tool_calls += 1
|
|
443
|
+
name = block.get("name")
|
|
444
|
+
name = name if isinstance(name, str) else ""
|
|
445
|
+
tool_id = block.get("id")
|
|
446
|
+
tool_input = block.get("input")
|
|
447
|
+
detail = self._detail(name, tool_input)
|
|
448
|
+
if isinstance(tool_id, str) and len(self._open_tools) < MAX_OPEN_TOOLS:
|
|
449
|
+
self._open_tools[tool_id] = (
|
|
450
|
+
name,
|
|
451
|
+
digest(tool_input) if self._loop is not None else b"",
|
|
452
|
+
detail,
|
|
453
|
+
)
|
|
454
|
+
self._send(ProgressEvent(ProgressKind.TOOL_STARTED, tool=name, detail=detail))
|
|
455
|
+
|
|
456
|
+
def _detail(self, name: str, tool_input: object) -> str:
|
|
457
|
+
"""The one input of ``name``'s call shown for it (:data:`TOOL_DETAILS`), or ""."""
|
|
458
|
+
field = TOOL_DETAILS.get(name)
|
|
459
|
+
if field is None or not isinstance(tool_input, dict):
|
|
460
|
+
return ""
|
|
461
|
+
value = tool_input.get(field)
|
|
462
|
+
if field in _PATH_INPUTS:
|
|
463
|
+
return relative_path(value, self._cwd)
|
|
464
|
+
return value if isinstance(value, str) else ""
|
|
465
|
+
|
|
466
|
+
def _refuse(self, kind: RequestKind, tool_id: object, name: str, detail: str) -> None:
|
|
467
|
+
"""Keep a request the CLI refused, once per ``tool_use_id``."""
|
|
468
|
+
if isinstance(tool_id, str):
|
|
469
|
+
if tool_id in self._refused_ids:
|
|
470
|
+
return
|
|
471
|
+
if len(self._refused_ids) < MAX_OPEN_TOOLS:
|
|
472
|
+
self._refused_ids.add(tool_id)
|
|
473
|
+
self.refused_count += 1
|
|
474
|
+
if len(self.refused) < MAX_REFUSED:
|
|
475
|
+
subject = f"{name}: {detail}" if name and detail else name
|
|
476
|
+
self.refused.append((kind, clean(subject, MAX_SUBJECT_CHARS)))
|
|
477
|
+
|
|
478
|
+
def _user(self, record: dict) -> None:
|
|
479
|
+
sent = False
|
|
480
|
+
for block in self._blocks(record):
|
|
481
|
+
if block.get("type") != "tool_result":
|
|
482
|
+
continue
|
|
483
|
+
tool_id = block.get("tool_use_id")
|
|
484
|
+
call = self._open_tools.pop(tool_id, None) if isinstance(tool_id, str) else None
|
|
485
|
+
name = call[0] if call is not None else ""
|
|
486
|
+
if block.get("is_error") is True:
|
|
487
|
+
self._tool_errors += 1
|
|
488
|
+
if name == QUESTION_TOOL:
|
|
489
|
+
# Not offered under -p (module docstring): the question
|
|
490
|
+
# reached no one. Its text is the model's; only the kind is kept.
|
|
491
|
+
self._refuse(RequestKind.QUESTION, tool_id, "", "")
|
|
492
|
+
self._send(ProgressEvent(ProgressKind.TOOL_FAILED, tool=name))
|
|
493
|
+
else:
|
|
494
|
+
self._send(ProgressEvent(ProgressKind.TOOL_FINISHED, tool=name))
|
|
495
|
+
if self._loop is not None and call is not None:
|
|
496
|
+
# The id differs on every call; everything else is the result.
|
|
497
|
+
result = {key: value for key, value in block.items() if key != "tool_use_id"}
|
|
498
|
+
self._loop.action(name, action_fingerprint(name, call[1], digest(result)))
|
|
499
|
+
sent = True
|
|
500
|
+
if not sent:
|
|
501
|
+
self._send(ProgressEvent(ProgressKind.ACTIVITY))
|