Kucatoo-Code · Wiki

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.

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):

  1. Cap checks — time budget warnings/cap and step-cap extension prompt run before the step starts (see Budgets below).
  2. Budget notices_step_budget_notices injects a 50% scope-checkpoint message (runs ≥ 10 steps) and 70%/90% wrap-up warnings (runs ≥ 15 steps) (base.py:309).
  3. Steering drain_drain_pending_instructions moves user mid-run instructions (queued via POST /api/agent/instruct) into the message history as [STEER FROM USER — act on this now] user messages (base.py:481).
  4. THINK_think(messages, tools) queries the model with native tool calling; compaction runs inside _think before the call (base.py:720, base.py:753).
  5. 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).
  6. 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).
  7. 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).
  8. 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).
  9. OBSERVE — the tool executes via _execute_tool_call; the observation (or Error: ...) is recorded and appended to history as a tool message.

Reflective loop mechanics

Identical pipeline through OBSERVE, with these differences:

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):

  1. ExecutorToolExecutor rejects non-read-only tools when the mode policy is allowed_read_only_only, except writes whose path arguments all resolve inside scoped_write_dirs; tools without a recognized path argument (execute_python, run_command, git mutations) stay fully blocked. The agent passes mode=self.mode into every execute_tool call (base.py:1275).
  2. PromptBaseAgent._system_prompt_with_mode appends the mode's system_prompt_addendum to 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-open PromptSectionProvider with 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):

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):

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.

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).

verified against code: 2026-08-15