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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. 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'}"