python-agent-harness 1.5.0__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 (61) hide show
  1. python_agent_harness/__init__.py +20 -0
  2. python_agent_harness/__main__.py +5 -0
  3. python_agent_harness/agent.py +703 -0
  4. python_agent_harness/cli.py +273 -0
  5. python_agent_harness/client.py +832 -0
  6. python_agent_harness/commands.py +181 -0
  7. python_agent_harness/config.py +464 -0
  8. python_agent_harness/context_manager.py +100 -0
  9. python_agent_harness/diffrender.py +84 -0
  10. python_agent_harness/mcp/__init__.py +21 -0
  11. python_agent_harness/mcp/client.py +161 -0
  12. python_agent_harness/mcp/config.py +130 -0
  13. python_agent_harness/mcp/manager.py +290 -0
  14. python_agent_harness/models.py +149 -0
  15. python_agent_harness/persistence.py +297 -0
  16. python_agent_harness/planmode.py +112 -0
  17. python_agent_harness/prompts/agent.md +362 -0
  18. python_agent_harness/prompts/build-switch.md +5 -0
  19. python_agent_harness/prompts/commands/explain.md +13 -0
  20. python_agent_harness/prompts/compact.md +33 -0
  21. python_agent_harness/prompts/initialize.md +66 -0
  22. python_agent_harness/prompts/plan-mode.md +70 -0
  23. python_agent_harness/prompts/plan.md +26 -0
  24. python_agent_harness/prompts/review.md +100 -0
  25. python_agent_harness/prompts/subagent.md +208 -0
  26. python_agent_harness/prompts/summary.md +11 -0
  27. python_agent_harness/prompts/task-completion-rules.md +50 -0
  28. python_agent_harness/prompts/title.md +44 -0
  29. python_agent_harness/prompts.py +498 -0
  30. python_agent_harness/session.py +781 -0
  31. python_agent_harness/subagent.py +61 -0
  32. python_agent_harness/token_estimator.py +125 -0
  33. python_agent_harness/tool_runner.py +247 -0
  34. python_agent_harness/tools/__init__.py +56 -0
  35. python_agent_harness/tools/agent_tool.py +75 -0
  36. python_agent_harness/tools/base.py +147 -0
  37. python_agent_harness/tools/bash.py +298 -0
  38. python_agent_harness/tools/edit.py +272 -0
  39. python_agent_harness/tools/filesystem.py +180 -0
  40. python_agent_harness/tools/glob.py +161 -0
  41. python_agent_harness/tools/grep.py +149 -0
  42. python_agent_harness/tools/insert.py +61 -0
  43. python_agent_harness/tools/mcp.py +203 -0
  44. python_agent_harness/tools/mkdir.py +30 -0
  45. python_agent_harness/tools/planexit.py +45 -0
  46. python_agent_harness/tools/question.py +70 -0
  47. python_agent_harness/tools/read.py +104 -0
  48. python_agent_harness/tools/skill.py +32 -0
  49. python_agent_harness/tools/todo.py +60 -0
  50. python_agent_harness/tools/write.py +56 -0
  51. python_agent_harness/tui/__init__.py +68 -0
  52. python_agent_harness/tui/commands.py +652 -0
  53. python_agent_harness/tui/core.py +385 -0
  54. python_agent_harness/tui/input.py +412 -0
  55. python_agent_harness/tui/render.py +535 -0
  56. python_agent_harness-1.5.0.dist-info/METADATA +251 -0
  57. python_agent_harness-1.5.0.dist-info/RECORD +61 -0
  58. python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
  59. python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
  60. python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
  61. python_agent_harness-1.5.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,703 @@
1
+ """Agent execution as a finite state machine.
2
+
3
+ The run is driven by a small state machine with the harness's
4
+ completion supervision as an extension:
5
+
6
+ WAIT -> TOOL -> TRET -> WAIT -> ...
7
+ | '-> ERRS (API error)
8
+ '-> SUPERVISE -> WAIT (nudge) | DONE
9
+ cancelled at any point -> ABRT
10
+
11
+ - WAIT prepares the round (prompt injection, context accounting,
12
+ compaction, sub-agent round budget) and fires the request; its
13
+ transition predicates classify the response (error -> ERRS, tool
14
+ calls -> TOOL, terminal -> SUPERVISE)
15
+ - a terminal response on an agentic top-level loop nudges the model
16
+ back to work while nudge budget remains (max 2), reset on tool calls
17
+ - tool results are sanitized (None -> error placeholder, non-str -> str)
18
+ - tool-call batches never strand the machine: failures become error
19
+ results
20
+ - tool execution mirrors gptel's `gptel--handle-tool-use': synchronous
21
+ tools (Read, Edit, Glob, ...) run ONE AT A TIME in model-emitted
22
+ order; asynchronous tools (Bash, Agent) return a ``PendingToolResult``
23
+ and run concurrently in the background, their results awaited
24
+ afterwards in original call order; interactive prompts stay serialized
25
+ - token calibration is updated from API-reported input tokens
26
+ - sessions are auto-saved after each response
27
+ - a cancelled run with no successor salvages its partial history
28
+ (truncated to the last complete tool round) instead of losing it;
29
+ a stale worker superseded by a newer run never touches shared state
30
+
31
+ Tool execution and context/compaction live in tool_runner.py and
32
+ context_manager.py (extracted, no logic changes); the FSM delegates
33
+ to them via ``_tool_runner`` / ``_context_manager``.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from typing import Any, Protocol
39
+
40
+ from . import config
41
+ from .context_manager import ContextManager
42
+ from .models import Message, ToolCall
43
+ from .token_estimator import context_window_for, estimate_payload_tokens
44
+ from .tool_runner import (
45
+ NIL_RESULT_PLACEHOLDER, # noqa: F401 (re-exported for backward compat)
46
+ ToolRunner,
47
+ sanitize_tool_result, # noqa: F401 (tests import it from .agent)
48
+ )
49
+ from .tools.base import PendingToolResult
50
+
51
+
52
+ class _SupervisorSession(Protocol):
53
+ """The slice of an agent session the Supervisor reads."""
54
+
55
+ alive: bool
56
+ compacting: bool
57
+
58
+
59
+ class AgentLoop:
60
+ """Runs one agent session as a finite state machine until terminal
61
+ (main) or max rounds (sub-agent).
62
+
63
+ ``run()`` drives the steps until a terminal state
64
+ (DONE/ERRS/ABRT) records the result. State-specific work lives in
65
+ the ``_handle_*`` methods; routing between states lives in
66
+ ``TRANSITIONS`` (predicates over ``self.info``), so adding a state
67
+ or changing the flow never touches the driver.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ session: Any,
73
+ messages: list[Message] | None = None,
74
+ top_level: bool = True,
75
+ system: str | None = None,
76
+ max_rounds: int = 60,
77
+ client: Any | None = None,
78
+ ) -> None:
79
+ self.session = session
80
+ self.messages: list[Message] = messages if messages is not None else []
81
+ self.top_level = top_level
82
+ # an explicit per-run client (a dedicated clone for this
83
+ # sub-agent invocation, see Session.run_subagent) wins
84
+ # over the session's shared sub-agent client
85
+ self._client = client
86
+ # fall back to the session's prompt so a run never loses it;
87
+ # sub-agent loops use the session's SUB-AGENT prompt (their own),
88
+ # never the parent's system prompt (which carries the parent's
89
+ # context and task-completion rules)
90
+ if system is not None:
91
+ self.system = system
92
+ else:
93
+ attr = "system_prompt" if top_level else "subagent_system_prompt"
94
+ self.system = getattr(session, attr, None)
95
+ # max_rounds only bounds sub-agent loops: the main agent runs until
96
+ # the model gives a terminal response or the user aborts it (Ctrl-C)
97
+ self.max_rounds = max_rounds if not top_level else None
98
+ self.pending: list[ToolCall] = []
99
+ self.error: str | None = None
100
+ self.harness_injected: bool = False
101
+ self.supervisor = Supervisor(session)
102
+ # extracted machinery (no logic changes): tool execution and
103
+ # context/compaction live in tool_runner.py / context_manager.py;
104
+ # the loop delegates to them and keeps the FSM state
105
+ self._tool_runner = ToolRunner(self)
106
+ self._context_manager = ContextManager(self)
107
+ # FSM state: `state` is the current state, `info` the per-round
108
+ # context read by the transition-table predicates, `history`
109
+ # the states visited so far (newest last), `rounds` the number
110
+ # of WAIT visits (sub-agent budget), and `result` the final
111
+ # return value recorded by the terminal state's handler.
112
+ self.state = self.WAIT
113
+ self.info: dict[str, Any] = {}
114
+ self.history: list[str] = []
115
+ self.rounds = 0
116
+ self.terminal_text: str | None = None
117
+ self.result: str | None = None
118
+ # Cancellation identity for this run: cancel() bumps the session
119
+ # generation, so a stale worker from a cancelled run stays
120
+ # cancelled even after the next run clears the shared event (and
121
+ # must not touch shared state). Captured at construction — the
122
+ # worker thread starts right after, and a run superseded between
123
+ # construction and start must not adopt the new generation.
124
+ self._cancel_gen = session.cancel_generation
125
+ # Run identity for this run: a newer top-level run bumps
126
+ # `session.run_generation`, marking this worker stale —
127
+ # superseded, so it must never touch shared state. Distinct
128
+ # from cancellation: a cancelled run with no successor still
129
+ # owns the session and may salvage its partial history.
130
+ self._run_gen = session.run_generation
131
+
132
+ def _is_cancelled(self) -> bool:
133
+ """Whether THIS run must stop (cancelled or superseded).
134
+
135
+ The plain event is not enough: `_start_agent` clears it before
136
+ every run, so a worker from a cancelled run that finishes late
137
+ (e.g. after a long tool call) would otherwise see it cleared and
138
+ clobber the new run's `session.last_messages`. A superseded
139
+ worker (a newer run bumped `run_generation`) is dead too: it
140
+ must stop working and must not touch shared state.
141
+ """
142
+ return (
143
+ self.session.cancel_event.is_set()
144
+ or self.session.cancel_generation != self._cancel_gen
145
+ or self.session.run_generation != self._run_gen
146
+ )
147
+
148
+ def _is_stale(self) -> bool:
149
+ """Whether a newer top-level run owns the session.
150
+
151
+ Distinct from cancelled: a cancelled run with no successor still
152
+ owns the session and may salvage its partial history; a stale
153
+ worker must never touch shared state (its partial history would
154
+ clobber the new run's).
155
+ """
156
+ return self.session.run_generation != self._run_gen
157
+
158
+ # ------------------------------------------------------------------
159
+ # finite state machine
160
+ #
161
+ # WAIT -> ABRT (cancel) | DONE (budget) | WAIT (compaction)
162
+ # | ERRS (error) | TOOL (tool calls) | SUPERVISE (terminal)
163
+ # TOOL -> ABRT (cancel) | TRET
164
+ # TRET -> ABRT (cancel) | WAIT (next round)
165
+ # SUPERVISE -> WAIT (nudge) | DONE (harness extension:
166
+ # a terminal response would end the run, so it is
167
+ # intercepted here to nudge the model back to work)
168
+ #
169
+ # INIT and TYPE are intentionally absent: they only make sense in
170
+ # an asynchronous machine (built before the request is realized,
171
+ # with the response classified in a network callback). The Python
172
+ # driver is synchronous: the run starts directly in WAIT and WAIT's
173
+ # handler classifies the response itself.
174
+ #
175
+ # Routing lives in TRANSITIONS: each entry is a (predicate, next)
176
+ # pair evaluated in order over self.info, with True as the default.
177
+ # Handlers only do state work and set info flags; they never route
178
+ # themselves. DONE/ERRS/ABRT are terminal: their handlers record
179
+ # self.result and the driver stops.
180
+ # ------------------------------------------------------------------
181
+ WAIT = "WAIT"
182
+ TOOL = "TOOL"
183
+ TRET = "TRET"
184
+ SUPERVISE = "SUPERVISE"
185
+ DONE = "DONE"
186
+ ERRS = "ERRS"
187
+ ABRT = "ABRT"
188
+ TERMINAL = frozenset({DONE, ERRS, ABRT})
189
+
190
+ # -- transition predicates -------------------------------------------
191
+ def _cancelled_p(self, info: dict[str, Any]) -> bool:
192
+ return self._is_cancelled()
193
+
194
+ def _error_p(self, info: dict[str, Any]) -> bool:
195
+ return bool(info.get("error"))
196
+
197
+ def _tool_use_p(self, info: dict[str, Any]) -> bool:
198
+ return bool(info.get("tool_calls"))
199
+
200
+ def _budget_exhausted_p(self, info: dict[str, Any]) -> bool:
201
+ return bool(info.get("budget"))
202
+
203
+ def _compacted_p(self, info: dict[str, Any]) -> bool:
204
+ return bool(info.get("compacted"))
205
+
206
+ def _nudged_p(self, info: dict[str, Any]) -> bool:
207
+ return bool(info.get("nudged"))
208
+
209
+ # -- transition table ------------------------------------------------
210
+ TRANSITIONS = {
211
+ WAIT: (
212
+ (_budget_exhausted_p, DONE),
213
+ (_cancelled_p, ABRT),
214
+ (_compacted_p, WAIT),
215
+ (_error_p, ERRS),
216
+ (_tool_use_p, TOOL),
217
+ (True, SUPERVISE),
218
+ ),
219
+ TOOL: ((_cancelled_p, ABRT), (True, TRET)),
220
+ TRET: ((_cancelled_p, ABRT), (True, WAIT)),
221
+ SUPERVISE: ((_nudged_p, WAIT), (True, DONE)),
222
+ }
223
+
224
+ # ------------------------------------------------------------------
225
+ # context management
226
+ # ------------------------------------------------------------------
227
+ def _update_context_ratio(self) -> None:
228
+ """Update the session's context ratio (see ContextManager)."""
229
+ # the estimator functions are resolved here — in the agent
230
+ # module namespace — so tests patching
231
+ # python_agent_harness.agent.estimate_payload_tokens keep
232
+ # intercepting the call site
233
+ self._context_manager.update_context_ratio(estimate_payload_tokens, context_window_for)
234
+
235
+ def _need_compaction(self) -> bool:
236
+ """Whether the context ratio is past the trigger (see ContextManager)."""
237
+ return self._context_manager.need_compaction()
238
+
239
+ # ------------------------------------------------------------------
240
+ # prompt injection (plan/build mode)
241
+ # ------------------------------------------------------------------
242
+ def _inject_pending_prompts(self) -> None:
243
+ if not self.top_level:
244
+ if self.session.plan_mode.is_plan and not self.harness_injected:
245
+ self.messages.insert(
246
+ len(self.messages),
247
+ Message(
248
+ role="user",
249
+ content=self.session.plan_mode.plan_reminder(),
250
+ injected=True,
251
+ ),
252
+ )
253
+ self.harness_injected = True
254
+ return
255
+ prompts = self.session.plan_mode.consume_prompts()
256
+ prompts = prompts + list(self.session.pending_user_prompts)
257
+ self.session.pending_user_prompts = []
258
+ if not prompts:
259
+ return
260
+ # inject before the last user message when it is a plain request;
261
+ # otherwise append (tool result last -> must not split call/result)
262
+ insert_at = len(self.messages)
263
+ if self.messages:
264
+ last = self.messages[-1]
265
+ if last.role == "user" and isinstance(last.content, str) and not last.tool_call_id:
266
+ insert_at = len(self.messages) - 1
267
+ for i, text in enumerate(prompts):
268
+ self.messages.insert(insert_at + i, Message(role="user", content=text, injected=True))
269
+
270
+ # ------------------------------------------------------------------
271
+ # compaction
272
+ # ------------------------------------------------------------------
273
+ def compact(self) -> bool:
274
+ """Compact the conversation; return True on success (see ContextManager)."""
275
+ return self._context_manager.compact()
276
+
277
+ # ------------------------------------------------------------------
278
+ # tool execution
279
+ # ------------------------------------------------------------------
280
+ def _execute_tool_call(self, call: ToolCall) -> str | PendingToolResult:
281
+ """Run one tool call (see ToolRunner.execute_tool_call)."""
282
+ return self._tool_runner.execute_tool_call(call)
283
+
284
+ def _deliver_tool_result(self, p: ToolCall, result: str) -> None:
285
+ """Append one tool result message for call P (parent thread only).
286
+
287
+ See ToolRunner.deliver_tool_result. Kept on the loop (instead
288
+ of being called directly on the runner) so subclass or test
289
+ overrides of this method keep intercepting deliveries.
290
+ """
291
+ self._tool_runner.deliver_tool_result(p, result)
292
+
293
+ def _run_tools(self, calls: list[ToolCall], results: dict[str, str]) -> None:
294
+ """Run CALLS in model-emitted order, filling RESULTS.
295
+
296
+ See ToolRunner.run_tools for the full contract (gptel-style
297
+ sync one-at-a-time / async concurrent dispatch, cancel
298
+ semantics).
299
+ """
300
+ self._tool_runner.run_tools(calls, results)
301
+
302
+ def _execute_pending(self) -> None:
303
+ """TOOL state: run the round's pending tool calls.
304
+
305
+ See ToolRunner.execute_pending (sync one-at-a-time,
306
+ async-concurrent dispatch, cancel semantics).
307
+ """
308
+ self._tool_runner.execute_pending()
309
+
310
+ def _deliver_results(self) -> None:
311
+ """TRET state: deliver the round's results to the conversation.
312
+
313
+ See ToolRunner.deliver_results (results are appended in
314
+ original tool-call order regardless of execution order).
315
+ """
316
+ self._tool_runner.deliver_results()
317
+
318
+ def _run_tool_round(self) -> None:
319
+ """Execute all pending tool calls (sync one at a time, async
320
+ dispatched); deliver results.
321
+
322
+ Convenience wrapper around the FSM's TOOL (execute) and TRET
323
+ (deliver) handlers, kept for direct callers and tests; the
324
+ machine itself runs the two steps through its handlers.
325
+ """
326
+ self._execute_pending()
327
+ self._deliver_results()
328
+
329
+ def _salvage_messages(self) -> list[Message]:
330
+ """Longest valid prefix of ``self.messages`` for the shared history.
331
+
332
+ See ToolRunner.salvage_messages (cuts a dangling tool round so
333
+ no tool call is left unanswered in the shared history).
334
+ """
335
+ return self._tool_runner.salvage_messages()
336
+
337
+ # ------------------------------------------------------------------
338
+ # state machine driver
339
+ # ------------------------------------------------------------------
340
+ def run(self) -> str | None:
341
+ """Drive the state machine to a terminal state.
342
+
343
+ Returns the final assistant text (or None when cancelled, or
344
+ the error text on failure) recorded by the terminal state's
345
+ handler.
346
+ """
347
+ session = self.session
348
+ try:
349
+ # The machine starts directly in WAIT — an asynchronous
350
+ # INIT state is not needed for the synchronous driver.
351
+ # Each step runs the current state's handler first (routing
352
+ # needs the info flags the handler just set), then routes
353
+ # via the transition table; the driver stops only once a
354
+ # terminal state's handler has run and recorded the result.
355
+ while True:
356
+ handler = self.HANDLERS.get(self.state)
357
+ if handler is not None:
358
+ handler(self)
359
+ if self.state in self.TERMINAL:
360
+ break
361
+ self.history.append(self.state)
362
+ self.state = self._next_state()
363
+ finally:
364
+ # A stale worker (a newer run has started) must never touch
365
+ # shared state, and a sub-agent must never overwrite the
366
+ # parent's history. A merely cancelled run still owns the
367
+ # session, though: commit its partial history (truncated to
368
+ # the last complete tool round) so the interrupted turn is
369
+ # not lost — the next turn resumes from it instead of
370
+ # re-asking.
371
+ if not self._is_stale() and self.top_level:
372
+ salvaged = self._salvage_messages()
373
+ session.last_messages = list(salvaged)
374
+ if self._is_cancelled():
375
+ # Persist the partial turn now: auto-save only runs
376
+ # after successful responses, so without this the
377
+ # interrupted turn would never reach the session
378
+ # file — the next turn's save would overwrite it
379
+ # without ever containing it.
380
+ session.auto_save(salvaged, self.system)
381
+ # The title is generated on the first save, not
382
+ # only on clean completion — an interrupted session
383
+ # still gets a meaningful name (one-shot; no-op when
384
+ # already titled/pending).
385
+ session.generate_session_title()
386
+ else:
387
+ # Machine finished: give the session a meaningful
388
+ # title from the first real user message (one-shot;
389
+ # no-op when the title already exists or generation
390
+ # is in flight)
391
+ session.generate_session_title()
392
+ return self.result
393
+
394
+ def _next_state(self) -> str:
395
+ """Next state per the transition table.
396
+
397
+ Predicates are evaluated in order against ``self.info``; True
398
+ is the default. A state with no matching predicate is a
399
+ programming error — surface it loudly instead of stalling.
400
+ """
401
+ for pred, nxt in self.TRANSITIONS[self.state]:
402
+ if pred is True or pred(self, self.info):
403
+ return nxt
404
+ raise RuntimeError(f"agent FSM: no matching transition from state {self.state!r}")
405
+
406
+ # ------------------------------------------------------------------
407
+ # state handlers
408
+ # ------------------------------------------------------------------
409
+ def _handle_wait(self) -> None:
410
+ """WAIT — prepare and fire a request.
411
+
412
+ Resets the per-round info flags, enforces the sub-agent round
413
+ budget (the run-top check), injects pending plan/build-mode
414
+ prompts, updates the context ratio and compacts past the
415
+ trigger, then sends the request. The table routes the
416
+ outcome: DONE on budget exhaustion, ABRT on cancel, WAIT again
417
+ after compaction, ERRS on API error, TOOL on tool calls,
418
+ SUPERVISE on a terminal response.
419
+ """
420
+ session = self.session
421
+ self.info.clear()
422
+ if self.max_rounds is not None and self.rounds >= self.max_rounds:
423
+ self.info["budget"] = True
424
+ return
425
+ self.rounds += 1
426
+ if self._is_cancelled():
427
+ return
428
+ self._inject_pending_prompts()
429
+ if self.top_level:
430
+ # sub-agents must not touch the shared context accounting:
431
+ # their payload (fresh context) is structurally different,
432
+ # so their ratio/usage would skew the parent's
433
+ self._update_context_ratio()
434
+ if self._need_compaction():
435
+ session.log(f"compacting context {session.context_ratio:.1%}")
436
+ if self.compact():
437
+ self.info["compacted"] = True
438
+ return
439
+
440
+ def safe_delta(text: str) -> None:
441
+ if not self._is_cancelled() and session.on_delta is not None:
442
+ session.on_delta(text)
443
+
444
+ try:
445
+ # sub-agents are one-shot tasks: they must not see (or
446
+ # call) parent-only tools — Agent (no nesting), Question
447
+ # and PlanExit (interactive/handoff), TodoWrite (the
448
+ # parent's own progress tracking) — filtered from the
449
+ # specs before sending
450
+ tools = session.tool_specs(
451
+ exclude=config.SUBAGENT_EXCLUDED_TOOLS if not self.top_level else ()
452
+ )
453
+ # sub-agent runs use their own LLM when one is configured
454
+ # (a per-invocation clone of session.subagent_client,
455
+ # mirroring gptel-agent-harness-subagent-model/-backend);
456
+ # everything unset inherits the main agent's settings, so
457
+ # the sub-agent path is identical when no separate LLM is
458
+ # configured
459
+ if self.top_level:
460
+ client = session.client
461
+ temperature = session.temperature
462
+ max_tokens = session.max_tokens
463
+ reasoning_effort = session.reasoning_effort
464
+ stream = session.stream
465
+ else:
466
+ client = self._client or session.subagent_client
467
+ temperature = session.subagent_temperature
468
+ max_tokens = session.subagent_max_tokens
469
+ reasoning_effort = session.subagent_reasoning_effort
470
+ stream = session.subagent_stream
471
+ assistant, usage = client.chat(
472
+ self.messages,
473
+ tools=tools if session.tools_enabled else None,
474
+ system=self.system,
475
+ temperature=temperature,
476
+ max_tokens=max_tokens,
477
+ reasoning_effort=reasoning_effort,
478
+ stream=stream,
479
+ # sub-agents must not stream into the parent's live
480
+ # stream row — their text is private until returned
481
+ on_delta=(safe_delta if self.top_level else None),
482
+ # a connection error mid-stream discards the partial
483
+ # output and retries on a fresh client: tell the TUI to
484
+ # clear the partial text and show that the request is
485
+ # being restarted
486
+ on_retry=((lambda: session.notify("retry")) if self.top_level else None),
487
+ # poll cancellation during retry backoff so Ctrl-C
488
+ # aborts promptly instead of after the full sleep
489
+ cancel_check=self._is_cancelled,
490
+ )
491
+ except Exception as e: # noqa: BLE001 - API errors become ERRS
492
+ if self._is_cancelled():
493
+ return # cancelled (Ctrl-C), not an error
494
+ self.error = f"Error: {e}"
495
+ self.info["error"] = self.error
496
+ session.notify("error")
497
+ return
498
+
499
+ if self._is_cancelled():
500
+ return # response arrived after cancel: drop it
501
+
502
+ # persist the assistant response in the conversation history
503
+ # (text and/or tool calls) so later turns and the UI see it;
504
+ # always append to maintain role alternation — a missing
505
+ # assistant message before a nudge creates consecutive user
506
+ # messages which some APIs reject with a 400 error.
507
+ self.messages.append(assistant)
508
+ if (
509
+ (assistant.text().strip() or assistant.tool_calls)
510
+ and not assistant.tool_calls
511
+ and self.top_level
512
+ ):
513
+ session.last_messages = list(self.messages)
514
+
515
+ if self.top_level:
516
+ session.calibrator.update(usage.input_tokens)
517
+ session.remember_user_text(self.messages)
518
+ session.auto_save(self.messages, self.system)
519
+
520
+ self.info["assistant"] = assistant
521
+ self.info["usage"] = usage
522
+ self.info["tool_calls"] = list(assistant.tool_calls) if assistant.tool_calls else None
523
+
524
+ def _handle_tool(self) -> None:
525
+ """TOOL — run the round's tools (see ``_execute_pending``); the
526
+ table routes ABRT on cancel and TRET otherwise."""
527
+ self.pending = list(self.info["tool_calls"])
528
+ self.supervisor.reset_nudges()
529
+ # Notify the TUI that tool execution is starting: this clears
530
+ # the stale stream text (which duplicates the now-committed
531
+ # assistant message) and shows which tools are about to run,
532
+ # so the display stays alive instead of appearing frozen.
533
+ # Mirror messages first so the history includes the assistant
534
+ # message (with tool calls) — without this the text would
535
+ # briefly vanish between stream-clear and the first tool
536
+ # result delivery.
537
+ if self.top_level:
538
+ self.session.last_messages = list(self.messages)
539
+ names = [tc.name for tc in self.pending]
540
+ self.session.notify("tool_start", names)
541
+ self._execute_pending()
542
+
543
+ def _handle_tret(self) -> None:
544
+ """TRET — deliver the round's results into the conversation
545
+ (see ``_deliver_results``); the table routes ABRT on cancel and
546
+ WAIT (next round) otherwise."""
547
+ self._deliver_results()
548
+
549
+ def _handle_supervise(self) -> None:
550
+ """SUPERVISE — completion supervision.
551
+
552
+ A terminal response would otherwise end the run; this state
553
+ nudges the model back to work while the nudge budget lasts
554
+ (top-level agentic loops only). The nudge flag routes WAIT,
555
+ otherwise DONE."""
556
+ self.terminal_text = self.info["assistant"].text()
557
+ if self.supervisor.supervise(
558
+ terminal=True,
559
+ agentic=bool(self.session.tools_enabled),
560
+ top_level=self.top_level,
561
+ pending=bool(self.pending),
562
+ ):
563
+ self.messages.append(Message(role="user", content=config.NUDGE_MESSAGE, injected=True))
564
+ self.info["nudged"] = True
565
+
566
+ def _handle_done(self) -> None:
567
+ """DONE — record the final answer.
568
+
569
+ Precedence mirrors the machine's exit paths: an error beats the
570
+ terminal text; the terminal response (from SUPERVISE) wins over
571
+ the budget-exhaustion scan; a run that ended mid-tool-round
572
+ with no text anywhere reports the exhaustion explicitly; a run
573
+ that never produced anything (e.g. max_rounds=0) yields None.
574
+ """
575
+ if self.error:
576
+ self.result = f"Error: {self.error or 'unknown error'}"
577
+ return
578
+ if self.terminal_text is not None:
579
+ self.result = self.terminal_text
580
+ return
581
+ # round budget exhausted (sub-agents only): best-effort final
582
+ # answer — the last real assistant text. The final message at
583
+ # exhaustion is a tool result or an empty-text tool-call round:
584
+ # surfacing either as the "final answer" would feed raw tool
585
+ # output (or "") to the parent, so scan backward for the last
586
+ # actual text instead.
587
+ for m in reversed(self.messages):
588
+ if m.role == "assistant" and m.text().strip():
589
+ self.result = m.text()
590
+ return
591
+ if self.messages and self.messages[-1].role == "tool":
592
+ self.result = (
593
+ "Error: sub-agent round budget exhausted mid-tool-round; "
594
+ "no final answer was produced"
595
+ )
596
+ return
597
+ self.result = None
598
+
599
+ def _handle_errs(self) -> None:
600
+ """ERRS — API error terminal.
601
+
602
+ ``self.error`` is already prefixed with "Error: " by the WAIT
603
+ handler; surface it verbatim (the old loop's break path did the
604
+ same — no second prefix)."""
605
+ self.result = self.error or "Error: unknown error"
606
+
607
+ def _handle_abrt(self) -> None:
608
+ """ABRT — cancelled or superseded run: no result."""
609
+ self.result = None
610
+
611
+ # -- handler registry ------------------------------------------------
612
+ # Every state has a handler; the terminal ones (DONE/ERRS/ABRT)
613
+ # record self.result, and the driver stops once one is entered.
614
+ HANDLERS = {
615
+ WAIT: _handle_wait,
616
+ TOOL: _handle_tool,
617
+ TRET: _handle_tret,
618
+ SUPERVISE: _handle_supervise,
619
+ DONE: _handle_done,
620
+ ERRS: _handle_errs,
621
+ ABRT: _handle_abrt,
622
+ }
623
+
624
+
625
+ def run_agent_loop(
626
+ session: Any,
627
+ messages: list[Message],
628
+ top_level: bool = True,
629
+ system: str | None = None,
630
+ max_rounds: int = 60,
631
+ client: Any | None = None,
632
+ ) -> str | None:
633
+ """Convenience wrapper running a full agent run (FSM).
634
+
635
+ ``client`` (when given) overrides the session's client for this
636
+ run — the per-invocation sub-agent clone."""
637
+ return AgentLoop(
638
+ session,
639
+ messages=messages,
640
+ top_level=top_level,
641
+ system=system,
642
+ max_rounds=max_rounds,
643
+ client=client,
644
+ ).run()
645
+
646
+
647
+ class Supervisor:
648
+ """Completion supervision: nudge the model when it stops too early."""
649
+
650
+ def __init__(self, session: _SupervisorSession) -> None:
651
+ self.session = session
652
+ self.nudge_count = 0
653
+
654
+ # -- helpers -----------------------------------------------------------
655
+ @property
656
+ def alive(self) -> bool:
657
+ return self.session.alive
658
+
659
+ def can_nudge(self) -> bool:
660
+ """Fails closed: a dead session has NO nudge budget.
661
+
662
+ A dead session can never record nudges, so without this guard
663
+ the machine could loop forever on terminal responses.
664
+ """
665
+ return self.alive and self.nudge_count < config.MAX_NUDGES
666
+
667
+ def inject_nudge(self) -> bool:
668
+ """Increment the nudge counter. Returns True on success; never raises.
669
+
670
+ The caller (agent loop) appends the nudge message itself when the
671
+ supervision decides to nudge.
672
+ """
673
+ self.nudge_count += 1
674
+ return True
675
+
676
+ def reset_nudges(self) -> None:
677
+ self.nudge_count = 0
678
+
679
+ # -- supervision -------------------------------------------------------
680
+ def supervise(
681
+ self,
682
+ *,
683
+ terminal: bool,
684
+ agentic: bool,
685
+ top_level: bool,
686
+ pending: bool,
687
+ ) -> bool:
688
+ """Decide whether to nudge the model back to work.
689
+
690
+ Returns True to inject a nudge and run another round; False to
691
+ let the loop terminate.
692
+
693
+ Handles:
694
+ - compaction in progress -> never nudge, let the loop terminate
695
+ - terminal response on an agentic top-level loop with nudge
696
+ budget left and no pending tool calls -> nudge
697
+ """
698
+ if self.session.compacting:
699
+ return False
700
+ if terminal and agentic and top_level and self.can_nudge() and not pending:
701
+ self.inject_nudge()
702
+ return True
703
+ return False