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.
- python_agent_harness/__init__.py +20 -0
- python_agent_harness/__main__.py +5 -0
- python_agent_harness/agent.py +703 -0
- python_agent_harness/cli.py +273 -0
- python_agent_harness/client.py +832 -0
- python_agent_harness/commands.py +181 -0
- python_agent_harness/config.py +464 -0
- python_agent_harness/context_manager.py +100 -0
- python_agent_harness/diffrender.py +84 -0
- python_agent_harness/mcp/__init__.py +21 -0
- python_agent_harness/mcp/client.py +161 -0
- python_agent_harness/mcp/config.py +130 -0
- python_agent_harness/mcp/manager.py +290 -0
- python_agent_harness/models.py +149 -0
- python_agent_harness/persistence.py +297 -0
- python_agent_harness/planmode.py +112 -0
- python_agent_harness/prompts/agent.md +362 -0
- python_agent_harness/prompts/build-switch.md +5 -0
- python_agent_harness/prompts/commands/explain.md +13 -0
- python_agent_harness/prompts/compact.md +33 -0
- python_agent_harness/prompts/initialize.md +66 -0
- python_agent_harness/prompts/plan-mode.md +70 -0
- python_agent_harness/prompts/plan.md +26 -0
- python_agent_harness/prompts/review.md +100 -0
- python_agent_harness/prompts/subagent.md +208 -0
- python_agent_harness/prompts/summary.md +11 -0
- python_agent_harness/prompts/task-completion-rules.md +50 -0
- python_agent_harness/prompts/title.md +44 -0
- python_agent_harness/prompts.py +498 -0
- python_agent_harness/session.py +781 -0
- python_agent_harness/subagent.py +61 -0
- python_agent_harness/token_estimator.py +125 -0
- python_agent_harness/tool_runner.py +247 -0
- python_agent_harness/tools/__init__.py +56 -0
- python_agent_harness/tools/agent_tool.py +75 -0
- python_agent_harness/tools/base.py +147 -0
- python_agent_harness/tools/bash.py +298 -0
- python_agent_harness/tools/edit.py +272 -0
- python_agent_harness/tools/filesystem.py +180 -0
- python_agent_harness/tools/glob.py +161 -0
- python_agent_harness/tools/grep.py +149 -0
- python_agent_harness/tools/insert.py +61 -0
- python_agent_harness/tools/mcp.py +203 -0
- python_agent_harness/tools/mkdir.py +30 -0
- python_agent_harness/tools/planexit.py +45 -0
- python_agent_harness/tools/question.py +70 -0
- python_agent_harness/tools/read.py +104 -0
- python_agent_harness/tools/skill.py +32 -0
- python_agent_harness/tools/todo.py +60 -0
- python_agent_harness/tools/write.py +56 -0
- python_agent_harness/tui/__init__.py +68 -0
- python_agent_harness/tui/commands.py +652 -0
- python_agent_harness/tui/core.py +385 -0
- python_agent_harness/tui/input.py +412 -0
- python_agent_harness/tui/render.py +535 -0
- python_agent_harness-1.5.0.dist-info/METADATA +251 -0
- python_agent_harness-1.5.0.dist-info/RECORD +61 -0
- python_agent_harness-1.5.0.dist-info/WHEEL +5 -0
- python_agent_harness-1.5.0.dist-info/entry_points.txt +2 -0
- python_agent_harness-1.5.0.dist-info/licenses/LICENSE +21 -0
- 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
|