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/pi_rpc.py
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"""Pi RPC wire protocol (ADR 0003): records in, commands out, one outcome.
|
|
2
|
+
|
|
3
|
+
:class:`PiConversation` is the whole of one ``pi --mode rpc`` invocation as
|
|
4
|
+
a pure reducer. The caller (:class:`autoforge.providers.PiProvider`) feeds
|
|
5
|
+
it every stdout record as bytes and the current ``time.monotonic()``, and
|
|
6
|
+
writes back the command records it returns; nothing here starts a process,
|
|
7
|
+
knows a CLI flag or reads a clock. The module knows Pi's command, response
|
|
8
|
+
and event names and nothing else, so its tests drive it from synthetic
|
|
9
|
+
records with no process at all.
|
|
10
|
+
|
|
11
|
+
The conversation, in order (ADR 0003 §2.1 to §2.8):
|
|
12
|
+
|
|
13
|
+
1. ``get_state`` and ``get_available_models``, correlated by id (Pi handles
|
|
14
|
+
commands concurrently, so never by order). The resolved model must be
|
|
15
|
+
present, its ``provider`` and ``id`` must equal the configured ones
|
|
16
|
+
exactly, the pair must be in the available models (Pi starts with a
|
|
17
|
+
placeholder for an unknown id, so ``get_state`` alone cannot catch a
|
|
18
|
+
typo), and ``thinkingLevel`` must equal the configured effort (a clamp
|
|
19
|
+
is a hard failure). Each is a bounded round trip.
|
|
20
|
+
2. One ``prompt``, never in argv. Its response must be ``success: true``
|
|
21
|
+
with ``disposition: "started"``; ``success: false`` is a rejection
|
|
22
|
+
before acceptance, and any other disposition is a protocol failure.
|
|
23
|
+
3. Events until ``agent_settled``. ``agent_end`` is never terminal. The
|
|
24
|
+
last assistant ``message_end`` decides: ``stop`` goes on, ``length``,
|
|
25
|
+
``error`` and ``aborted`` are provider failures, anything else is a
|
|
26
|
+
protocol failure. An extension dialog is answered at once with
|
|
27
|
+
``cancelled: true`` and fails the run. Unknown event types are counted
|
|
28
|
+
and otherwise ignored. A record past the stdout bound is a protocol
|
|
29
|
+
failure, except an ``agent_end`` or ``turn_end``: both repeat messages
|
|
30
|
+
of the run and the reducer needs neither's content, so one is counted
|
|
31
|
+
from its leading bytes and its content never read (#144).
|
|
32
|
+
4. ``get_last_assistant_text``, a bounded round trip. A missing, ``null``,
|
|
33
|
+
non-string or blank ``text`` is "no assistant text"; a text that differs
|
|
34
|
+
from the one derived from the last assistant ``message_end`` (its
|
|
35
|
+
``text`` blocks joined with no separator, then trimmed as JavaScript's
|
|
36
|
+
``String.prototype.trim`` does) is a protocol failure.
|
|
37
|
+
|
|
38
|
+
Every string that leaves this module for a log (a failure reason, a summary
|
|
39
|
+
value) is bounded, made encodable, redacted and put on one line here. Raw
|
|
40
|
+
records, the ``get_state`` object, the model list and message contents
|
|
41
|
+
other than the final text never leave it.
|
|
42
|
+
|
|
43
|
+
Live progress (#192): with an ``emit`` sink, every event record becomes a
|
|
44
|
+
:class:`~autoforge.progress.ProgressEvent`. ``tool_execution_start`` is a
|
|
45
|
+
tool start whose detail is one allow-listed argument (:data:`TOOL_DETAILS`;
|
|
46
|
+
never ``bash``'s command), ``tool_execution_end`` a finished or failed tool
|
|
47
|
+
by its ``isError``, a ``message_update`` whose ``assistantMessageEvent`` is
|
|
48
|
+
a thinking or text delta is thinking or assistant text (never its content),
|
|
49
|
+
``auto_retry_start`` a provider retry with its attempt numbers, the
|
|
50
|
+
accepted prompt the start, and anything else bare activity. The argument
|
|
51
|
+
names are read from Pi's tool schemas at v1.0.x, not from a run.
|
|
52
|
+
|
|
53
|
+
Loop detection (#194): given a :class:`~autoforge.loop_detect.LoopMonitor`,
|
|
54
|
+
each ``tool_execution_end`` whose ``tool_execution_start`` was seen is one
|
|
55
|
+
action, fingerprinted from its ``toolName``, the digest of the start's whole
|
|
56
|
+
``args`` and the digest of the whole end record but its ``toolCallId``, at
|
|
57
|
+
the time ``feed`` was given; ``auto_retry_start`` is a retry and an
|
|
58
|
+
assistant ``message_end`` that completed (:data:`COMPLETED_STOP_REASONS`) a
|
|
59
|
+
completed turn. A failed or aborted attempt is not one: Pi emits it before
|
|
60
|
+
each ``agent_end(willRetry)`` and ``auto_retry_start``, and counting it would
|
|
61
|
+
end every retry streak at one. Only names and digests leave here.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import json
|
|
67
|
+
import re
|
|
68
|
+
import uuid
|
|
69
|
+
from collections.abc import Callable
|
|
70
|
+
from dataclasses import dataclass
|
|
71
|
+
|
|
72
|
+
from .loop_detect import LoopMonitor, action_fingerprint, digest
|
|
73
|
+
from .progress import ProgressEvent, ProgressKind, ProgressSink, relative_path
|
|
74
|
+
from .redaction import redact
|
|
75
|
+
|
|
76
|
+
# Extension UI methods that wait for an answer (``editor`` has no Pi-side
|
|
77
|
+
# timeout and would wait forever), and the fire-and-forget ones.
|
|
78
|
+
DIALOG_METHODS = frozenset({"select", "confirm", "input", "editor"})
|
|
79
|
+
NOTIFY_METHODS = frozenset({"notify", "setStatus", "setWidget", "setTitle", "set_editor_text"})
|
|
80
|
+
SUCCESS_STOP_REASONS = ("stop",)
|
|
81
|
+
# Stop reasons of a response the model completed: a completed turn for the
|
|
82
|
+
# loop detector. ``error`` and ``aborted`` are failed attempts; Pi emits an
|
|
83
|
+
# ``error`` one before each automatic retry.
|
|
84
|
+
COMPLETED_STOP_REASONS = ("stop", "toolUse", "length")
|
|
85
|
+
# Events the reducer reads, and known events it deliberately ignores. Any
|
|
86
|
+
# other type is counted as unknown (forward compatibility); the count is a
|
|
87
|
+
# diagnostic only.
|
|
88
|
+
_REDUCED_EVENTS = frozenset(
|
|
89
|
+
{
|
|
90
|
+
"agent_end",
|
|
91
|
+
"agent_settled",
|
|
92
|
+
"message_end",
|
|
93
|
+
"tool_execution_start",
|
|
94
|
+
"tool_execution_end",
|
|
95
|
+
"auto_retry_start",
|
|
96
|
+
"auto_retry_end",
|
|
97
|
+
"extension_ui_request",
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
_IGNORED_EVENTS = frozenset(
|
|
101
|
+
{
|
|
102
|
+
"agent_start",
|
|
103
|
+
"turn_start",
|
|
104
|
+
"turn_end",
|
|
105
|
+
"message_start",
|
|
106
|
+
"message_update",
|
|
107
|
+
"tool_execution_update",
|
|
108
|
+
"auto_compaction_start",
|
|
109
|
+
"auto_compaction_end",
|
|
110
|
+
"queue_update",
|
|
111
|
+
"extension_error",
|
|
112
|
+
# Pi 1.0.1 emits it after a `length` stop or a retried error, when it
|
|
113
|
+
# drops the abandoned message from the context.
|
|
114
|
+
"entry_appended",
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
# Events whose record may outgrow any stdout bound because it repeats
|
|
118
|
+
# messages of the run (#144): `agent_end` carries every message (the
|
|
119
|
+
# system and user prompts, every assistant message with its tool-call
|
|
120
|
+
# arguments, every tool result) and compaction does not shrink it;
|
|
121
|
+
# `turn_end` carries a turn's assistant message and its tool results. The
|
|
122
|
+
# reducer reads only `agent_end`'s `willRetry`, a summary counter, so a
|
|
123
|
+
# dropped one costs nothing the outcome depends on. It is recognised by its
|
|
124
|
+
# leading bytes: Pi 1.0.1 writes JSON.stringify output, `type` first and
|
|
125
|
+
# no whitespace, and those bytes are Pi's own (what the model writes is
|
|
126
|
+
# inside JSON strings further on). Any other leading bytes (another
|
|
127
|
+
# serialisation included) leave the record a protocol failure.
|
|
128
|
+
OVERSIZE_TOLERATED_EVENTS = ("agent_end", "turn_end")
|
|
129
|
+
_OVERSIZE_HEADS = {f'{{"type":"{kind}",'.encode(): kind for kind in OVERSIZE_TOLERATED_EVENTS}
|
|
130
|
+
# What Pi reports when a ChatGPT subscription has run out of usage.
|
|
131
|
+
_USAGE_LIMIT_CODE = "subscription_sharing_usage_limit_exceeded"
|
|
132
|
+
# Commands answered within the round-trip bound. ``prompt`` is not one: its
|
|
133
|
+
# response follows Pi's preflight, which may refresh a credential.
|
|
134
|
+
_ROUND_TRIPS = frozenset({"get_state", "get_available_models", "get_last_assistant_text"})
|
|
135
|
+
|
|
136
|
+
MAX_REASON_CHARS = 400
|
|
137
|
+
MAX_VALUE_CHARS = 200
|
|
138
|
+
# Tool executions whose end has not arrived yet, kept for the loop detector.
|
|
139
|
+
# Past the bound an end is still counted, only not an action.
|
|
140
|
+
MAX_OPEN_TOOLS = 1024
|
|
141
|
+
# The one argument shown per Pi tool in progress; ``bash`` has only its
|
|
142
|
+
# command, which is never shown.
|
|
143
|
+
TOOL_DETAILS: dict[str, str] = {
|
|
144
|
+
"read": "path",
|
|
145
|
+
"edit": "path",
|
|
146
|
+
"write": "path",
|
|
147
|
+
"ls": "path",
|
|
148
|
+
"grep": "pattern",
|
|
149
|
+
"find": "pattern",
|
|
150
|
+
}
|
|
151
|
+
_PATH_ARGS = frozenset({"path"})
|
|
152
|
+
# JavaScript's ``String.prototype.trim`` set (WhiteSpace and LineTerminator,
|
|
153
|
+
# ECMA-262): not Python's ``str.isspace``, which adds U+001C..U+001F and
|
|
154
|
+
# U+0085 and lacks U+FEFF.
|
|
155
|
+
_JS_SPACE = "\t\n\v\f\r
"
|
|
156
|
+
_WHITESPACE_RUN = re.compile(r"\s+")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def js_trim(text: str) -> str:
|
|
160
|
+
"""``text`` trimmed exactly as JavaScript's ``String.prototype.trim`` trims."""
|
|
161
|
+
return text.strip(_JS_SPACE)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def encodable(text: str) -> str:
|
|
165
|
+
"""``text`` with any lone surrogate (legal in a JSON string, not in UTF-8)
|
|
166
|
+
replaced by U+FFFD, so it can be logged; everything else unchanged."""
|
|
167
|
+
return text.encode("utf-16", "surrogatepass").decode("utf-16", "replace")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def bounded(value: object, limit: int = MAX_VALUE_CHARS) -> str:
|
|
171
|
+
"""A loggable one-line rendering of Pi-supplied ``value``: redacted, then clipped."""
|
|
172
|
+
if not isinstance(value, str):
|
|
173
|
+
return ""
|
|
174
|
+
text = _WHITESPACE_RUN.sub(" ", redact(encodable(value))).strip()
|
|
175
|
+
return text if len(text) <= limit else text[: limit - 3] + "..."
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _encode(record: dict[str, object]) -> bytes:
|
|
179
|
+
# ASCII-only, so neither a raw LF nor a U+2028 ever reaches the wire,
|
|
180
|
+
# and a lone surrogate in the prompt is escaped rather than unencodable.
|
|
181
|
+
return json.dumps(record, ensure_ascii=True, separators=(",", ":")).encode("ascii")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class _Pending:
|
|
186
|
+
command: str
|
|
187
|
+
sent_at: float
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class PiConversation:
|
|
191
|
+
"""One Pi RPC invocation as a reducer. Not thread-safe; one driver owns it.
|
|
192
|
+
|
|
193
|
+
``start`` returns the first commands; ``feed`` takes one stdout record
|
|
194
|
+
and returns the commands to write in reply. The conversation is over
|
|
195
|
+
when :attr:`done`: :attr:`text` holds the final assistant text on
|
|
196
|
+
success, :attr:`failure` the bounded reason otherwise.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
model: str,
|
|
203
|
+
thinking: str,
|
|
204
|
+
prompt: str,
|
|
205
|
+
round_trip_seconds: float,
|
|
206
|
+
new_id: Callable[[], str] | None = None,
|
|
207
|
+
emit: ProgressSink | None = None,
|
|
208
|
+
cwd: str = "",
|
|
209
|
+
loop: LoopMonitor | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
self._emit = emit
|
|
212
|
+
self._cwd = cwd
|
|
213
|
+
self._loop = loop
|
|
214
|
+
# Open tool executions by call id: their name and their args' digest.
|
|
215
|
+
self._open_tools: dict[str, tuple[str, bytes]] = {}
|
|
216
|
+
self._model = model
|
|
217
|
+
self._provider, _, self._model_id = model.partition("/")
|
|
218
|
+
self._thinking = thinking
|
|
219
|
+
self._prompt = prompt
|
|
220
|
+
self._round_trip = round_trip_seconds
|
|
221
|
+
self._new_id = new_id or (lambda: str(uuid.uuid4()))
|
|
222
|
+
self._pending: dict[str, _Pending] = {}
|
|
223
|
+
self.text: str | None = None
|
|
224
|
+
self.failure: str | None = None
|
|
225
|
+
# Pi's stdout ended before the conversation was over; the driver
|
|
226
|
+
# names the exit code once the process has been reaped.
|
|
227
|
+
self.exited_early = False
|
|
228
|
+
self._state_checked = False
|
|
229
|
+
self._models: list[tuple[object, object]] | None = None
|
|
230
|
+
self._prompt_sent = False
|
|
231
|
+
self._prompt_started = False
|
|
232
|
+
self._settled = False
|
|
233
|
+
self._abort_sent = False
|
|
234
|
+
self._deadline = False
|
|
235
|
+
self._last_assistant: dict | None = None
|
|
236
|
+
self._last_text_message: dict | None = None
|
|
237
|
+
self._retry_failure: str | None = None
|
|
238
|
+
# The summary (ADR 0003 §2.6): scalars only.
|
|
239
|
+
self._resolved = {"provider": "", "id": "", "thinking": ""}
|
|
240
|
+
self._disposition = ""
|
|
241
|
+
self._stop_reason = ""
|
|
242
|
+
self._agent_ends = 0
|
|
243
|
+
self._will_retry = 0
|
|
244
|
+
self._auto_retries = 0
|
|
245
|
+
self._tool_executions = 0
|
|
246
|
+
self._dialogs_cancelled = 0
|
|
247
|
+
self._notifications_ignored = 0
|
|
248
|
+
self._unknown_events = 0
|
|
249
|
+
self._oversize_records = 0
|
|
250
|
+
|
|
251
|
+
# -- driver interface ----------------------------------------------------
|
|
252
|
+
@property
|
|
253
|
+
def done(self) -> bool:
|
|
254
|
+
return self.text is not None or self.failure is not None
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def abort_settled(self) -> bool:
|
|
258
|
+
"""After :meth:`begin_abort`: whether Pi has settled the aborted run."""
|
|
259
|
+
return self._settled or not self._abort_sent
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def next_wakeup(self) -> float | None:
|
|
263
|
+
"""When the oldest bounded round trip runs out, if one is pending."""
|
|
264
|
+
times = [
|
|
265
|
+
p.sent_at + self._round_trip
|
|
266
|
+
for p in self._pending.values()
|
|
267
|
+
if p.command in _ROUND_TRIPS
|
|
268
|
+
]
|
|
269
|
+
return min(times) if times and not self.done else None
|
|
270
|
+
|
|
271
|
+
def start(self, now: float) -> list[bytes]:
|
|
272
|
+
return [self._command("get_state", now), self._command("get_available_models", now)]
|
|
273
|
+
|
|
274
|
+
def feed(self, data: bytes, now: float) -> list[bytes]:
|
|
275
|
+
"""Reduce one stdout record; returns the records to write in reply."""
|
|
276
|
+
try:
|
|
277
|
+
record = json.loads(data.decode("utf-8"))
|
|
278
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
279
|
+
self._protocol("a stdout record is not JSON")
|
|
280
|
+
return []
|
|
281
|
+
except (ValueError, RecursionError):
|
|
282
|
+
# The decoder's integer-digit and nesting limits, which are not
|
|
283
|
+
# JSONDecodeError (as in ``claude_stream``); an interrupt still
|
|
284
|
+
# propagates.
|
|
285
|
+
self._protocol("a stdout record exceeds the JSON decoder's integer or nesting limit")
|
|
286
|
+
return []
|
|
287
|
+
if not isinstance(record, dict) or not isinstance(record.get("type"), str):
|
|
288
|
+
self._protocol("a stdout record is not a JSON object with a 'type'")
|
|
289
|
+
return []
|
|
290
|
+
kind = record["type"]
|
|
291
|
+
if kind == "response":
|
|
292
|
+
return self._response(record, now)
|
|
293
|
+
if kind == "extension_ui_request":
|
|
294
|
+
return self._ui_request(record)
|
|
295
|
+
if self.done:
|
|
296
|
+
# The outcome is fixed; only the end of an aborted run matters.
|
|
297
|
+
if kind == "agent_settled":
|
|
298
|
+
self._settled = True
|
|
299
|
+
return []
|
|
300
|
+
self._progress(kind, record)
|
|
301
|
+
if kind in _REDUCED_EVENTS:
|
|
302
|
+
return self._event(kind, record, now)
|
|
303
|
+
if kind not in _IGNORED_EVENTS:
|
|
304
|
+
self._unknown_events += 1
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
def tick(self, now: float) -> None:
|
|
308
|
+
"""Fail when a bounded round trip has gone unanswered past its bound."""
|
|
309
|
+
wake = self.next_wakeup
|
|
310
|
+
if wake is not None and now >= wake:
|
|
311
|
+
late = min(
|
|
312
|
+
(p for p in self._pending.values() if p.command in _ROUND_TRIPS),
|
|
313
|
+
key=lambda p: p.sent_at,
|
|
314
|
+
)
|
|
315
|
+
self._fail(f"pi: no response to {late.command} within {self._round_trip:g}s")
|
|
316
|
+
|
|
317
|
+
def framing_error(self, what: str) -> None:
|
|
318
|
+
self._protocol(what)
|
|
319
|
+
|
|
320
|
+
def oversize(self, head: bytes, limit: int) -> None:
|
|
321
|
+
"""A stdout record past ``limit`` bytes was dropped; ``head`` is its start.
|
|
322
|
+
|
|
323
|
+
An ``agent_end`` or ``turn_end`` (:data:`OVERSIZE_TOLERATED_EVENTS`)
|
|
324
|
+
is counted, as an event of its kind and in ``oversize_records``, and
|
|
325
|
+
reported as activity; an ``agent_end``'s ``willRetry`` is not known,
|
|
326
|
+
so it is not counted. Any other record is a protocol violation.
|
|
327
|
+
"""
|
|
328
|
+
kind = next((k for h, k in _OVERSIZE_HEADS.items() if head.startswith(h)), None)
|
|
329
|
+
if kind is None:
|
|
330
|
+
self._protocol(f"a stdout record exceeded {limit} bytes")
|
|
331
|
+
return
|
|
332
|
+
if self.done:
|
|
333
|
+
return # as for any event once the outcome is fixed
|
|
334
|
+
self._oversize_records += 1
|
|
335
|
+
self._progress(kind, {})
|
|
336
|
+
if kind == "agent_end":
|
|
337
|
+
self._agent_ends += 1
|
|
338
|
+
|
|
339
|
+
def stream_ended(self) -> None:
|
|
340
|
+
"""Pi's stdout reached EOF."""
|
|
341
|
+
if self.done:
|
|
342
|
+
return
|
|
343
|
+
self.exited_early = True
|
|
344
|
+
self._fail(f"pi: exited {self._stage()}")
|
|
345
|
+
|
|
346
|
+
def stdin_closed(self) -> None:
|
|
347
|
+
self._fail(f"pi: closed its stdin {self._stage()}")
|
|
348
|
+
|
|
349
|
+
def deadline_reached(self) -> None:
|
|
350
|
+
"""The invocation's deadline cut the conversation short."""
|
|
351
|
+
self._deadline = True
|
|
352
|
+
self._fail(f"pi: the deadline was reached {self._stage()}")
|
|
353
|
+
|
|
354
|
+
def begin_abort(self, now: float) -> list[bytes]:
|
|
355
|
+
"""The ``abort`` record when a prompt is running, else nothing."""
|
|
356
|
+
if not self._prompt_sent or self._settled or self._abort_sent:
|
|
357
|
+
return []
|
|
358
|
+
self._abort_sent = True
|
|
359
|
+
return [self._command("abort", now)]
|
|
360
|
+
|
|
361
|
+
def summary(self) -> dict[str, str | int | bool]:
|
|
362
|
+
"""The flat, bounded, redacted summary for ``execution.json``."""
|
|
363
|
+
return {
|
|
364
|
+
"resolved_provider": bounded(self._resolved["provider"]),
|
|
365
|
+
"resolved_model_id": bounded(self._resolved["id"]),
|
|
366
|
+
"resolved_thinking": bounded(self._resolved["thinking"]),
|
|
367
|
+
"prompt_disposition": bounded(self._disposition),
|
|
368
|
+
"stop_reason": bounded(self._stop_reason),
|
|
369
|
+
"agent_ends": self._agent_ends,
|
|
370
|
+
"will_retry": self._will_retry,
|
|
371
|
+
"auto_retries": self._auto_retries,
|
|
372
|
+
"auto_retry_failed": self._retry_failure is not None,
|
|
373
|
+
"tool_executions": self._tool_executions,
|
|
374
|
+
"ui_dialogs_cancelled": self._dialogs_cancelled,
|
|
375
|
+
"ui_notifications_ignored": self._notifications_ignored,
|
|
376
|
+
"unknown_events": self._unknown_events,
|
|
377
|
+
"oversize_records": self._oversize_records,
|
|
378
|
+
"abort_sent": self._abort_sent,
|
|
379
|
+
"failure": self.failure or "",
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
# -- internals -----------------------------------------------------------
|
|
383
|
+
def _command(self, command: str, now: float, **fields: object) -> bytes:
|
|
384
|
+
request_id = self._new_id()
|
|
385
|
+
self._pending[request_id] = _Pending(command, now)
|
|
386
|
+
return _encode({"id": request_id, "type": command, **fields})
|
|
387
|
+
|
|
388
|
+
def _fail(self, reason: str) -> None:
|
|
389
|
+
if not self.done:
|
|
390
|
+
self.failure = bounded(reason, MAX_REASON_CHARS)
|
|
391
|
+
|
|
392
|
+
def _protocol(self, what: str) -> None:
|
|
393
|
+
self._fail(f"pi: protocol violation: {what}")
|
|
394
|
+
|
|
395
|
+
def _stage(self) -> str:
|
|
396
|
+
if not self._prompt_sent:
|
|
397
|
+
return "before answering get_state and get_available_models"
|
|
398
|
+
if not self._prompt_started:
|
|
399
|
+
return "before answering the prompt"
|
|
400
|
+
if not self._settled:
|
|
401
|
+
return "before agent_settled"
|
|
402
|
+
return "before answering get_last_assistant_text"
|
|
403
|
+
|
|
404
|
+
def _response(self, record: dict, now: float) -> list[bytes]:
|
|
405
|
+
request_id = record.get("id")
|
|
406
|
+
if request_id is None and record.get("command") == "parse":
|
|
407
|
+
self._protocol(f"Pi could not parse a command: {bounded(record.get('error'))}")
|
|
408
|
+
return []
|
|
409
|
+
pending = self._pending.pop(request_id, None) if isinstance(request_id, str) else None
|
|
410
|
+
if pending is None:
|
|
411
|
+
self._protocol("a response to an id AutoForge never sent (or already answered)")
|
|
412
|
+
return []
|
|
413
|
+
command = record.get("command")
|
|
414
|
+
if command is not None and command != pending.command:
|
|
415
|
+
self._protocol(f"the response to {pending.command} names command {bounded(command)!r}")
|
|
416
|
+
return []
|
|
417
|
+
if self.done:
|
|
418
|
+
return []
|
|
419
|
+
success = record.get("success")
|
|
420
|
+
if not isinstance(success, bool):
|
|
421
|
+
self._protocol(f"the response to {pending.command} has no boolean 'success'")
|
|
422
|
+
return []
|
|
423
|
+
if pending.command == "abort":
|
|
424
|
+
return []
|
|
425
|
+
error = bounded(record.get("error"))
|
|
426
|
+
if not success:
|
|
427
|
+
if pending.command == "prompt":
|
|
428
|
+
self._fail(f"pi: prompt rejected before acceptance: {error or 'no reason given'}")
|
|
429
|
+
else:
|
|
430
|
+
self._protocol(f"{pending.command} failed: {error or 'no reason given'}")
|
|
431
|
+
return []
|
|
432
|
+
data = record.get("data")
|
|
433
|
+
if pending.command == "get_state":
|
|
434
|
+
self._check_state(data)
|
|
435
|
+
elif pending.command == "get_available_models":
|
|
436
|
+
self._check_models(data)
|
|
437
|
+
elif pending.command == "prompt":
|
|
438
|
+
self._check_disposition(data)
|
|
439
|
+
elif pending.command == "get_last_assistant_text":
|
|
440
|
+
self._check_text(data)
|
|
441
|
+
return self._advance(now)
|
|
442
|
+
|
|
443
|
+
def _check_state(self, data: object) -> None:
|
|
444
|
+
if not isinstance(data, dict):
|
|
445
|
+
self._protocol("the get_state response has no 'data' object")
|
|
446
|
+
return
|
|
447
|
+
model = data.get("model")
|
|
448
|
+
if model is None:
|
|
449
|
+
self._fail(f"pi: model unavailable: Pi resolved no model for {self._model}")
|
|
450
|
+
return
|
|
451
|
+
if not isinstance(model, dict):
|
|
452
|
+
self._protocol("get_state 'model' is not an object")
|
|
453
|
+
return
|
|
454
|
+
provider, model_id = model.get("provider"), model.get("id")
|
|
455
|
+
if not isinstance(provider, str) or not isinstance(model_id, str):
|
|
456
|
+
self._protocol("get_state 'model' lacks a string 'provider' or 'id'")
|
|
457
|
+
return
|
|
458
|
+
self._resolved["provider"], self._resolved["id"] = provider, model_id
|
|
459
|
+
if (provider, model_id) != (self._provider, self._model_id):
|
|
460
|
+
self._fail(
|
|
461
|
+
f"pi: model mismatch: configured {self._model}, Pi resolved "
|
|
462
|
+
f"{bounded(provider)}/{bounded(model_id)}"
|
|
463
|
+
)
|
|
464
|
+
return
|
|
465
|
+
level = data.get("thinkingLevel")
|
|
466
|
+
if not isinstance(level, str):
|
|
467
|
+
self._protocol("get_state lacks a string 'thinkingLevel'")
|
|
468
|
+
return
|
|
469
|
+
self._resolved["thinking"] = level
|
|
470
|
+
if level != self._thinking:
|
|
471
|
+
self._fail(
|
|
472
|
+
f"pi: thinking mismatch: configured {self._thinking}, Pi resolved "
|
|
473
|
+
f"{bounded(level)} (set an effort this model supports)"
|
|
474
|
+
)
|
|
475
|
+
return
|
|
476
|
+
self._state_checked = True
|
|
477
|
+
|
|
478
|
+
def _check_models(self, data: object) -> None:
|
|
479
|
+
models = data.get("models") if isinstance(data, dict) else None
|
|
480
|
+
if not isinstance(models, list):
|
|
481
|
+
self._protocol("the get_available_models response has no 'models' list")
|
|
482
|
+
return
|
|
483
|
+
self._models = [(m.get("provider"), m.get("id")) for m in models if isinstance(m, dict)]
|
|
484
|
+
|
|
485
|
+
def _check_disposition(self, data: object) -> None:
|
|
486
|
+
disposition = data.get("disposition") if isinstance(data, dict) else None
|
|
487
|
+
if not isinstance(disposition, str):
|
|
488
|
+
self._protocol("the prompt response has no 'disposition'")
|
|
489
|
+
return
|
|
490
|
+
self._disposition = disposition
|
|
491
|
+
if disposition != "started":
|
|
492
|
+
# AutoForge sent one prompt into an idle session: `queued` means it
|
|
493
|
+
# was not idle, `handled` that an extension or template consumed it.
|
|
494
|
+
self._protocol(f"prompt disposition {bounded(disposition)!r}, expected 'started'")
|
|
495
|
+
return
|
|
496
|
+
self._prompt_started = True
|
|
497
|
+
self._send(ProgressEvent(ProgressKind.STARTED, detail=self._model))
|
|
498
|
+
|
|
499
|
+
def _check_text(self, data: object) -> None:
|
|
500
|
+
if not isinstance(data, dict):
|
|
501
|
+
self._protocol("the get_last_assistant_text response has no 'data' object")
|
|
502
|
+
return
|
|
503
|
+
text = data.get("text")
|
|
504
|
+
if not isinstance(text, str) or not js_trim(text):
|
|
505
|
+
self._fail("pi: no assistant text: the run ended without a final answer")
|
|
506
|
+
return
|
|
507
|
+
if text != self._derived_text():
|
|
508
|
+
self._protocol(
|
|
509
|
+
"get_last_assistant_text differs from the text of the last assistant message_end"
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
self.text = text
|
|
513
|
+
|
|
514
|
+
def _derived_text(self) -> str | None:
|
|
515
|
+
message = self._last_text_message
|
|
516
|
+
content = message.get("content") if message is not None else None
|
|
517
|
+
if not isinstance(content, list):
|
|
518
|
+
return None
|
|
519
|
+
joined = "".join(
|
|
520
|
+
block["text"]
|
|
521
|
+
for block in content
|
|
522
|
+
if isinstance(block, dict)
|
|
523
|
+
and block.get("type") == "text"
|
|
524
|
+
and isinstance(block.get("text"), str)
|
|
525
|
+
)
|
|
526
|
+
return js_trim(joined) or None
|
|
527
|
+
|
|
528
|
+
def _advance(self, now: float) -> list[bytes]:
|
|
529
|
+
"""Send what the conversation is ready for next."""
|
|
530
|
+
if self.done:
|
|
531
|
+
return []
|
|
532
|
+
if not self._prompt_sent:
|
|
533
|
+
if not self._state_checked or self._models is None:
|
|
534
|
+
return []
|
|
535
|
+
if (self._provider, self._model_id) not in self._models:
|
|
536
|
+
self._fail(
|
|
537
|
+
f"pi: model unavailable: {self._model} is not among Pi's available models "
|
|
538
|
+
f"(an id outside Pi's catalog, or no credential for {self._provider!r})"
|
|
539
|
+
)
|
|
540
|
+
return []
|
|
541
|
+
self._prompt_sent = True
|
|
542
|
+
return [self._command("prompt", now, message=self._prompt)]
|
|
543
|
+
if not (self._prompt_started and self._settled):
|
|
544
|
+
return []
|
|
545
|
+
if any(p.command == "get_last_assistant_text" for p in self._pending.values()):
|
|
546
|
+
return []
|
|
547
|
+
if not self._classify_end():
|
|
548
|
+
return []
|
|
549
|
+
return [self._command("get_last_assistant_text", now)]
|
|
550
|
+
|
|
551
|
+
def _classify_end(self) -> bool:
|
|
552
|
+
"""After settling: whether the run ended in an answer worth reading."""
|
|
553
|
+
if self._retry_failure is not None:
|
|
554
|
+
self._fail(self._retry_failure)
|
|
555
|
+
return False
|
|
556
|
+
message = self._last_assistant
|
|
557
|
+
if message is None:
|
|
558
|
+
self._fail("pi: no assistant text: the run settled without an assistant message")
|
|
559
|
+
return False
|
|
560
|
+
stop = message.get("stopReason")
|
|
561
|
+
self._stop_reason = stop if isinstance(stop, str) else ""
|
|
562
|
+
if stop in SUCCESS_STOP_REASONS:
|
|
563
|
+
return True
|
|
564
|
+
if stop == "length":
|
|
565
|
+
# Pi drops a cut-off answer from its context (verified on Pi 1.0.1),
|
|
566
|
+
# so get_last_assistant_text cannot return it.
|
|
567
|
+
self._fail("pi: the model hit its output limit (stopReason length)")
|
|
568
|
+
elif stop == "error":
|
|
569
|
+
self._fail(_model_error("model error after acceptance", message.get("errorMessage")))
|
|
570
|
+
elif stop == "aborted":
|
|
571
|
+
self._fail("pi: the run was aborted, not by AutoForge")
|
|
572
|
+
else:
|
|
573
|
+
self._protocol(f"final stopReason {bounded(stop)!r} is not a completed answer")
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
def _ui_request(self, record: dict) -> list[bytes]:
|
|
577
|
+
method = record.get("method")
|
|
578
|
+
if not isinstance(method, str):
|
|
579
|
+
# An array or object is unhashable: test the type before membership.
|
|
580
|
+
self._protocol("an extension UI request without a string 'method'")
|
|
581
|
+
return []
|
|
582
|
+
if method in NOTIFY_METHODS:
|
|
583
|
+
self._notifications_ignored += 1
|
|
584
|
+
return []
|
|
585
|
+
request_id = record.get("id")
|
|
586
|
+
if method not in DIALOG_METHODS or not isinstance(request_id, str):
|
|
587
|
+
self._protocol(f"an extension UI request AutoForge cannot answer ({bounded(method)!r})")
|
|
588
|
+
return []
|
|
589
|
+
# Answered at once, whatever state the run is in: an unanswered
|
|
590
|
+
# `editor` would wait forever.
|
|
591
|
+
self._dialogs_cancelled += 1
|
|
592
|
+
self._fail(f"pi: an extension asked for input ({method}); the dialog was cancelled")
|
|
593
|
+
return [_encode({"type": "extension_ui_response", "id": request_id, "cancelled": True})]
|
|
594
|
+
|
|
595
|
+
def _send(self, event: ProgressEvent) -> None:
|
|
596
|
+
if self._emit is not None:
|
|
597
|
+
self._emit(event)
|
|
598
|
+
|
|
599
|
+
def _progress(self, kind: str, record: dict) -> None:
|
|
600
|
+
"""Report one event record as progress (module docstring)."""
|
|
601
|
+
if self._emit is None:
|
|
602
|
+
return
|
|
603
|
+
if kind == "tool_execution_start":
|
|
604
|
+
name = record.get("toolName")
|
|
605
|
+
name = name if isinstance(name, str) else ""
|
|
606
|
+
args = record.get("args")
|
|
607
|
+
detail = ""
|
|
608
|
+
arg = TOOL_DETAILS.get(name)
|
|
609
|
+
if arg is not None and isinstance(args, dict):
|
|
610
|
+
value = args.get(arg)
|
|
611
|
+
if arg in _PATH_ARGS:
|
|
612
|
+
detail = relative_path(value, self._cwd)
|
|
613
|
+
elif isinstance(value, str):
|
|
614
|
+
detail = value
|
|
615
|
+
self._send(ProgressEvent(ProgressKind.TOOL_STARTED, tool=name, detail=detail))
|
|
616
|
+
elif kind == "tool_execution_end":
|
|
617
|
+
name = record.get("toolName")
|
|
618
|
+
failed = record.get("isError") is True
|
|
619
|
+
self._send(
|
|
620
|
+
ProgressEvent(
|
|
621
|
+
ProgressKind.TOOL_FAILED if failed else ProgressKind.TOOL_FINISHED,
|
|
622
|
+
tool=name if isinstance(name, str) else "",
|
|
623
|
+
)
|
|
624
|
+
)
|
|
625
|
+
elif kind == "message_update":
|
|
626
|
+
update = record.get("assistantMessageEvent")
|
|
627
|
+
step = update.get("type") if isinstance(update, dict) else None
|
|
628
|
+
if isinstance(step, str) and step.startswith("thinking"):
|
|
629
|
+
self._send(ProgressEvent(ProgressKind.THINKING))
|
|
630
|
+
elif isinstance(step, str) and step.startswith("text"):
|
|
631
|
+
self._send(ProgressEvent(ProgressKind.ASSISTANT_TEXT))
|
|
632
|
+
else:
|
|
633
|
+
self._send(ProgressEvent(ProgressKind.ACTIVITY))
|
|
634
|
+
elif kind == "auto_retry_start":
|
|
635
|
+
attempt, limit = record.get("attempt"), record.get("maxAttempts")
|
|
636
|
+
detail = ""
|
|
637
|
+
if isinstance(attempt, int) and not isinstance(attempt, bool):
|
|
638
|
+
detail = f"attempt {attempt}"
|
|
639
|
+
if isinstance(limit, int) and not isinstance(limit, bool):
|
|
640
|
+
detail += f" of {limit}"
|
|
641
|
+
self._send(ProgressEvent(ProgressKind.PROVIDER_RETRY, detail=detail))
|
|
642
|
+
else:
|
|
643
|
+
self._send(ProgressEvent(ProgressKind.ACTIVITY))
|
|
644
|
+
|
|
645
|
+
def _event(self, kind: str, record: dict, now: float) -> list[bytes]:
|
|
646
|
+
if kind == "agent_settled":
|
|
647
|
+
if self._prompt_sent:
|
|
648
|
+
self._settled = True
|
|
649
|
+
return self._advance(now)
|
|
650
|
+
elif kind == "agent_end":
|
|
651
|
+
self._agent_ends += 1
|
|
652
|
+
if record.get("willRetry") is True:
|
|
653
|
+
self._will_retry += 1
|
|
654
|
+
elif kind == "message_end":
|
|
655
|
+
message = record.get("message")
|
|
656
|
+
if isinstance(message, dict) and message.get("role") == "assistant":
|
|
657
|
+
self._last_assistant = message
|
|
658
|
+
stop = message.get("stopReason")
|
|
659
|
+
content = message.get("content")
|
|
660
|
+
# Pi's own reading skips an aborted message with no content.
|
|
661
|
+
if not (stop == "aborted" and not content):
|
|
662
|
+
self._last_text_message = message
|
|
663
|
+
if stop in SUCCESS_STOP_REASONS:
|
|
664
|
+
self._retry_failure = None
|
|
665
|
+
if self._loop is not None and stop in COMPLETED_STOP_REASONS:
|
|
666
|
+
self._loop.turn(now)
|
|
667
|
+
elif kind == "tool_execution_start":
|
|
668
|
+
self._tool_executions += 1
|
|
669
|
+
self._tool_started(record)
|
|
670
|
+
elif kind == "tool_execution_end":
|
|
671
|
+
self._tool_ended(record, now)
|
|
672
|
+
elif kind == "auto_retry_start":
|
|
673
|
+
self._auto_retries += 1
|
|
674
|
+
if self._loop is not None:
|
|
675
|
+
self._loop.retry(now)
|
|
676
|
+
elif kind == "auto_retry_end":
|
|
677
|
+
if record.get("success") is False:
|
|
678
|
+
self._retry_failure = _model_error(
|
|
679
|
+
"automatic retries failed", record.get("finalError")
|
|
680
|
+
)
|
|
681
|
+
else:
|
|
682
|
+
self._retry_failure = None
|
|
683
|
+
return []
|
|
684
|
+
|
|
685
|
+
def _tool_started(self, record: dict) -> None:
|
|
686
|
+
call_id = record.get("toolCallId")
|
|
687
|
+
if self._loop is None or not isinstance(call_id, str):
|
|
688
|
+
return
|
|
689
|
+
if len(self._open_tools) < MAX_OPEN_TOOLS:
|
|
690
|
+
name = record.get("toolName")
|
|
691
|
+
self._open_tools[call_id] = (
|
|
692
|
+
name if isinstance(name, str) else "",
|
|
693
|
+
digest(record.get("args")),
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
def _tool_ended(self, record: dict, now: float) -> None:
|
|
697
|
+
call_id = record.get("toolCallId")
|
|
698
|
+
if self._loop is None or not isinstance(call_id, str):
|
|
699
|
+
return
|
|
700
|
+
call = self._open_tools.pop(call_id, None)
|
|
701
|
+
if call is None:
|
|
702
|
+
return
|
|
703
|
+
name, args = call
|
|
704
|
+
# The id differs on every call; everything else is the result.
|
|
705
|
+
result = {key: value for key, value in record.items() if key != "toolCallId"}
|
|
706
|
+
self._loop.action(name, action_fingerprint(name, args, digest(result)), now)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _model_error(what: str, message: object) -> str:
|
|
710
|
+
if isinstance(message, str) and _USAGE_LIMIT_CODE in message:
|
|
711
|
+
return "pi: subscription usage limit reached"
|
|
712
|
+
detail = bounded(message, MAX_REASON_CHARS - 60)
|
|
713
|
+
return f"pi: {what}: {detail or 'no error message'}"
|