Agent Framework
The agent layer runs autonomous, tool-calling loops on top of the model harness. BaseAgent (model_harness/agents/base.py:83) holds all shared machinery; two concrete agents implement the loop. Since 2026-08-15 the loops' shared scaffolding also lives in BaseAgent (§2.2 Phase 1: _seed_run_messages, _reject_with_observation + the unified guard/nudge texts, _complete_terminal_step, _synthesize_cap_failure) — the file-action honesty guard that used to be copy-pasted ×4 across the loops now has one implementation.
ReActAgent(model_harness/agents/react.py:92) — THINK → ACT → OBSERVE, repeat.ReflectiveAgent(model_harness/agents/reflective.py:124) — same loop plus a REFLECT phase after each tool execution.
run(goal, **kwargs) is an async generator that yields an AgentStep per iteration; kwargs may override model_id, max_steps, and seed prior_messages for resume-after-cap (react.py:111, react.py:135).
ReAct loop mechanics
Each iteration of the while True loop (react.py:202):
- Cap checks — time budget warnings/cap and step-cap extension prompt run before the step starts (see Budgets below).
- Budget notices —
_step_budget_noticesinjects a 50% scope-checkpoint message (runs ≥ 10 steps) and 70%/90% wrap-up warnings (runs ≥ 15 steps) (base.py:309). - Steering drain —
_drain_pending_instructionsmoves user mid-run instructions (queued viaPOST /api/agent/instruct) into the message history as[STEER FROM USER — act on this now]user messages (base.py:481). - THINK —
_think(messages, tools)queries the model with native tool calling; compaction runs inside_thinkbefore the call (base.py:720,base.py:753). - FINAL_ANSWER detection — a
FINAL_ANSWER:prefix at a line start terminates the run as COMPLETED, subject to the file-action guard (react.py:359). - No tool call → final answer — a response with no parsed tool call is also treated as the final answer, subject to leaked-markup recovery and the file-action guard (
react.py:424). - ACT — only the first parsed tool call executes per step (
react.py:640). Exact-repeat calls hit the repeat-call cache; then loop detection runs (react.py:542). - Pre-tool steering checkpoint — instructions drained again right before execution; if any landed, the model gets one reconsideration turn that may replace the pending call (
react.py:582). - OBSERVE — the tool executes via
_execute_tool_call; the observation (orError: ...) is recorded and appended to history as atoolmessage.
Reflective loop mechanics
Identical pipeline through OBSERVE, with these differences:
- The thought is cleaned first:
_strip_model_reflectionsremoves model-echoed[Reflection: ...]blocks and_dedup_linescollapses repetition loops (reflective.py:90,reflective.py:101). - After the tool result, a REFLECT phase calls
_reflect, which prompts the model for a JSON assessment{assessment, reasoning, next_action, revised_approach}(truncating the observation to 500 chars) and parses it tolerantly — fence stripping, outermost-brace extraction, regex field fallback,unknown/continueon failure (reflective.py:143,REFLECTION_PROMPTatreflective.py:60). - The reflection is appended to
step.thoughtfor display only; the clean thought goes into history so the model does not echo the pattern (reflective.py:690). - Outcomes:
assessment == "failure"→ retry counter increments; the loop detector resets andrevised_approachis fed back as a user message. Atmax_retries(default 3) the run FAILS (reflective.py:699).next_action == "change_plan"→ the revised plan is injected as a user message and the loop continues (reflective.py:730).next_action == "complete"→ the run terminates COMPLETED (reflective.py:759).assessment == "success"→ retry counter resets (reflective.py:795).
- Failed tool calls are not recorded in the repeat-call cache, so the retry mechanism may legitimately re-execute the same call (
reflective.py:657).
sequenceDiagram
participant RL as ReAct loop
participant Guard as Guards
participant Comp as Compaction
participant LLM as Model
participant Tool as ToolSystem
RL->>Guard: cap checks (time/steps)
Guard-->>RL: continue or offer extension
RL->>Comp: _think -> _maybe_compact_messages
Comp->>Comp: est tokens > 0.8 x window?
Comp-->>RL: compacted history or None
RL->>LLM: chat_complete(messages, tools)
LLM-->>RL: content + tool_calls
RL->>Guard: FINAL_ANSWER with unwritten file goal?
Guard-->>RL: reject, inject gather-content-FIRST observation
RL->>Guard: exact repeat call?
Guard-->>RL: return cached observation
RL->>Tool: execute tool (confirm/mode passed)
Tool-->>RL: success, output, error
RL->>RL: OBSERVE, cache call, invalidate read cache
Agent modes and policies
Each run operates in exactly one AgentMode (modes.py:36); policy data lives in POLICIES (modes.py:76). parse_mode tolerantly maps strings/aliases to a mode and falls back to EXPLORE (the safest, read-only mode) on anything unrecognized (modes.py:142).
| Mode | Read-only | Scoped write dir | Writes need approval | Prompt intent |
|---|---|---|---|---|
EXPLORE |
yes | docs/explore |
n/a | investigation; cite paths/lines |
PLAN |
yes | docs/plan |
n/a | structured plan; ask_user for ambiguities |
WRITE_TEST |
no | — | yes (unless auto-confirm) | write code, then run it/tests to verify |
WRITE_NO_TEST |
no | — | yes (unless auto-confirm) | write code; do NOT run tests/builds unless asked (prompt-driven only) |
Enforcement is two-layered (modes.py:17):
- Executor —
ToolExecutorrejects non-read-only tools when the mode policy isallowed_read_only_only, except writes whose path arguments all resolve insidescoped_write_dirs; tools without a recognized path argument (execute_python,run_command, git mutations) stay fully blocked. The agent passesmode=self.modeinto everyexecute_toolcall (base.py:1275). - Prompt —
BaseAgent._system_prompt_with_modeappends the mode'ssystem_prompt_addendumto the system message (base.py:227). Read-only modes still show write tools in the schema list; attempting one fails at the executor with a readable read-only-mode error (base.py:647). Run-context blocks (project rules, mission contract, dead-end recall, memory recall, SkillDAG, skills catalog, domain context, user preferences) are then appended by the prompt-section pipeline (agents/prompt_sections.py, 2026-08-15): each block is a fail-openPromptSectionProviderwith its own token budget, assembled once per run when the system message is first built inside_think. Adding a context source = registering a provider (agent.prompt_sections.add(...));_think()itself no longer grows.
Write-mode confirmation works through auto_confirm_tools (off by default, base.py:106): when off, tools declaring requires_confirmation (e.g. write_file, execute_python) are blocked; if a confirmation_handler is installed, the user is asked and an approved call is re-executed with confirm=True, otherwise the observation is User denied '<tool>' (base.py:1283).
File-action write guard
Both loops refuse a premature FINAL_ANSWER (or a no-tool-call plain answer) when the goal requires a file that was never written (react.py:374, react.py:449; reflective.py:479, reflective.py:541):
_needs_file_action(goal)matches create/write/save/generate/… verbs against file/directory/site/app/script/extension nouns via_FILE_GOAL_KEYWORDS(base.py:548)._has_successful_write()scans run steps for a successful call to any of_WRITE_TOOL_ACTIONS—write_file,create_file,append_file,append_to_file,insert_lines,replace_in_file,apply_diff,edit_file_line,remove_line_range,move_file,copy_file(base.py:560,base.py:571).- On rejection the step becomes an OBSERVING step whose observation tells the model it has "NOT actually called write_file yet this run", that printing contents does not create the file, and: "If you still need to gather content (e.g. finish reading a document — read_pdf supports start_page for later pages), call those tools FIRST. You MUST then call the write_file tool with the full content and the correct path before the task is complete." (ReAct wording,
react.py:375; Reflective uses a shorter variant,reflective.py:481.) The loop then continues instead of terminating.
Repeat-call cache and read-cache invalidation
Each loop keeps executed_calls: key = tool name + "\x00" + sorted-args JSON, value = the earlier observation truncated to 500 chars (react.py:508). An exact repeat (same tool, same arguments) is not re-executed; the agent gets a synthetic observation quoting the earlier result and is told to use it or answer. ReAct records every executed call (success or failure, react.py:612); Reflective records only successes so retries can re-run failed calls (reflective.py:661).
A successful mutating call (_WRITE_TOOL_ACTIONS ∪ {delete_file}, base.py:589) stales cached reads: _invalidate_read_cache drops cached entries for read tools (read_file, read_file_lines, read_pdf, file_exists, list_files, find_files, file_tree, grep_search, compare_file_content) whose key mentions a mutated path argument; if no path is extractable, all read entries are dropped (base.py:596).
Leaked-markup detection
Some models emit a tool call as text instead of a native tool_call (DeepSeek DSML, stray <thought> tags). _LEAKED_MARKUP_RE detects <|DSML|, <thought>, <tool_calls>, <invoke, <antml: (base.py:625). When a no-tool-call response matches, the loop nudges the model to re-issue a proper call instead of terminating — capped at 2 nudges (_MAX_MARKUP_NUDGES), after which the run terminates and _strip_leaked_markup cleans the final answer (react.py:429, react.py:482; base.py:639).
Context compaction
_maybe_compact_messages runs inside _think, before the system message is prepended, so the caller's message list is mutated in place and stays compacted across iterations (base.py:1014):
- Trigger:
estimate_messages_tokens(messages) > compaction_threshold * max_context_window— defaults threshold 0.8, window from the model's registry config; unknown window disables compaction (compaction.py:149,base.py:1027). - Keep: leading system message(s) verbatim plus the last
compaction_keep_recentmessages (default 6); the cut point backs off so a tool result is never separated from its assistant tool-call message (compaction.py:201). - Summarizer: the dropped middle messages are rendered as a transcript (≤ 8000 chars) and summarized by the model from
compaction_model_id, else the cheapest configured chat model by combined per-1K cost (base.py:956,SUMMARY_PROMPTatcompaction.py:33). - Fallback: if no summarizer is resolvable or the call fails, a deterministic digest is used — tool outputs truncated to 300 chars, other messages to 500, whole digest ≤ 4000 chars — so compaction works offline and never blocks the loop (
compaction.py:122). - The summary replaces the middle as one user message prefixed
[Context compacted — summary of the earlier conversation]; a notice (Context compacted (X → Y tokens, N messages, method=summary|truncation)) is surfaced on the step and recorded instate.context["compactions"](base.py:1044).
Time, step, and token budgets
Three caps, all sharing the _offer_continue gate (base.py:384): on hitting a cap the user is asked (via the same question_handler plumbing as ask_user) to Continue with a preset extension or Stop; headless/timeout/dismiss always stops with stop_reason set to max_steps / max_tokens / max_time.
- Steps —
max_steps(default 10). Exhaustion offers +20/+60 steps; granted extensions re-arm the budget notices (react.py:250). - Tokens —
max_token_budget(0 = unlimited), checked per THINK againststate.total_tokens + usage; Continue grants +20,000/+60,000 tokens (react.py:317,base.py:1407). - Time —
max_time_sec(0 = unlimited), checked at the top of each iteration. At 80% and 90% of the budget (each once) a[TIME BUDGET WARNING]message is injected and acap_warningentry{kind, pct, elapsed, budget}is queued in_pending_cap_warningsfor the web layer to emit as an SSE event; crossing the cap offers +120/+300 seconds before stopping (base.py:452,base.py:462,react.py:207). - A resume after a cap-hit re-creates the agent with
prior_messagesand prepends a[CONTINUING AFTER A CAP]user message (react.py:135). The stored history lives inAgentMessageStore— since 2026-08-15 a derived view over the append-only session event log (web/session_log.py,<DATA_DIR>/sessions/*.jsonl), so a Continue also survives a server restart; same for direct-chat turns inChatSessionStore(§2.4 of the opportunities plan).
ask_user tool policy
ask_user is exposed whenever a question_handler is installed (the web layer), in all modes, and never goes through the ToolSystem — it routes to the handler, which turns it into a UI question card (base.py:662, base.py:1255, base.py:1306). Its tool description restricts use to material choices only — "a choice is material and hard to reverse or infer (overwriting an existing deliverable, unclear output location, ambiguous format) … Do NOT ask when a reasonable default exists — state the default and proceed. At most one question per run" (model_harness/tools/builtin.py:158). Without a handler (headless), the tool reports itself unavailable; None answers (timeout/dismissed) come back as a tool error so the agent proceeds (base.py:1316).