chad-code 0.1.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.
chad/agent.py ADDED
@@ -0,0 +1,1093 @@
1
+ """Agentic loop + REPL for chad.
2
+
3
+ Renders the conversation through the model's chat template (with tool schemas),
4
+ streams the assistant turn, parses Qwen/Hermes-style <tool_call> blocks, runs the
5
+ tools, feeds results back, and repeats until the model stops calling tools.
6
+
7
+ Prefix caching lives in Engine: re-rendering the whole transcript each step is
8
+ cheap because only the newly appended tokens get prefilled.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+ import time
16
+
17
+ from . import compaction, config, guardrails, session
18
+ from .base_engine import BaseEngine
19
+ from .diag import args_preview, log, redact, result_preview
20
+ from .prompt import build_subagent_prompt, build_system_prompt, classify_intent
21
+ from .render import (
22
+ C_DIM,
23
+ C_RED,
24
+ C_RST,
25
+ C_YEL,
26
+ _default_emit,
27
+ _disp_path,
28
+ _StreamView,
29
+ banner,
30
+ confirm_preview,
31
+ render_tool_result,
32
+ render_tool_start,
33
+ )
34
+ from .toolcall_parse import parse_tool_calls, strip_think
35
+ from .tools import IGNORE_DIRS, TERMINAL, _under_plans, active_schemas, dispatch_for, is_mutating
36
+
37
+ # Sub-agent / Task tool (plan 041). A spawned sub-agent runs on the SAME engine (after a
38
+ # cache push) but a fresh transcript, with a tight step/context budget and — by default —
39
+ # a read-only toolset, so it can spelunk without mutating anything or bloating the main
40
+ # context. The read-only set is exploration + planning + the terminal tools; bash, write,
41
+ # edit and the symbol editors are excluded (they land only under tools="all").
42
+ SUBAGENT_MAX_STEPS = 12
43
+ SUBAGENT_CTX_LIMIT = 16000
44
+ SUBAGENT_READ_ONLY = {
45
+ "read", "grep", "glob", "repo_map", "overview", "view_symbol",
46
+ "find_symbol", "find_refs", "done", "finish", "stop",
47
+ }
48
+ # `write_todos` is deliberately absent: a sub-agent that plans its own work would mutate
49
+ # the process-global `_TODOS` and clobber the parent's pinned todo panel (and `_sub_emit`
50
+ # has no panel route for the "todos" kind anyway). A sub-agent's plan is not the parent's
51
+ # (plan 052).
52
+
53
+
54
+ def subagent_tools_for(parent_mode: str, requested: str) -> str:
55
+ """The toolset a sub-agent may run with. A sub-agent auto-approves its own tool
56
+ calls (mode='auto', no confirm callback), so it must never hold more autonomy
57
+ than its parent: only an 'auto' parent (--yolo / headless) may delegate 'all';
58
+ a 'normal' parent's human-approval promise and plan mode's read-only promise
59
+ both clamp the sub-agent to read-only."""
60
+ if parent_mode == "auto" and requested == "all":
61
+ return "all"
62
+ return "read-only"
63
+
64
+
65
+ from .validate import VALIDATE, coerce_and_validate, legacy_validate, render_repair
66
+
67
+ # Validation (VALIDATE knob, legacy_validate baseline) lives in validate.py, the
68
+ # single source of truth shared with toolcall_parse.py — imported above.
69
+
70
+ # Plan 035 (measurement spike): env-gated per-step prefill telemetry. Resolved ONCE at
71
+ # import (like diag._DISABLED) so the hot loop pays only a truthiness check when unset —
72
+ # zero string/dict/IO work, per plan 020's "instrumentation must not tax the product"
73
+ # rule. Set CHAD_PREFILL_TRACE=path/to/trace.jsonl to capture one JSON row per engine
74
+ # generate() call (== one prefill event) for scripts/prefill_tax.py to analyze offline.
75
+ _PREFILL_TRACE = config.env_str("CHAD_PREFILL_TRACE")
76
+
77
+
78
+ def _trace_prefill(row: dict) -> None:
79
+ """Append one JSON line to the prefill trace and flush (sessions get killed mid-run,
80
+ so we durably land every row). Best-effort: a trace IO error must never break a turn,
81
+ so swallow it. Only ever called when _PREFILL_TRACE is set."""
82
+ assert _PREFILL_TRACE is not None
83
+ try:
84
+ with open(_PREFILL_TRACE, "a") as f:
85
+ f.write(json.dumps(row) + "\n")
86
+ f.flush()
87
+ except OSError:
88
+ pass
89
+
90
+ # @file mentions (Claude-Code parity): a message like "why is @geo.py slow?" pulls the
91
+ # named file into context directly, no `read` round-trip. `@` must be at start-of-string
92
+ # or after whitespace so an email (foo@bar.com) or decorator never matches; trailing
93
+ # sentence punctuation is trimmed off the path.
94
+ _MENTION_RE = re.compile(r"(?:^|\s)@([A-Za-z0-9_.~/-]+)")
95
+
96
+
97
+ def expand_mentions(text: str):
98
+ """Expand each @path in `text` into attached context: a real FILE becomes a bounded
99
+ snapshot (reusing the read tool's skeleton/char-cap policy, so a big file can't blow
100
+ up prefill); a DIRECTORY becomes a short listing of its entries. Returns
101
+ (augmented_text, [resolved_paths]); text unchanged and list empty when nothing
102
+ resolves."""
103
+ resolved = [] # (kind, path)
104
+ seen = set()
105
+ for m in _MENTION_RE.finditer(text):
106
+ raw = m.group(1).rstrip(".,;:!?)") # trailing punctuation isn't part of the path
107
+ path = os.path.expanduser(raw)
108
+ if path in seen:
109
+ continue
110
+ if os.path.isfile(path):
111
+ resolved.append(("file", path)); seen.add(path)
112
+ elif os.path.isdir(path):
113
+ resolved.append(("dir", path)); seen.add(path)
114
+ if not resolved:
115
+ return text, []
116
+ from .tools import tool_read
117
+ blocks = []
118
+ for kind, p in resolved:
119
+ if kind == "file":
120
+ blocks.append(f"@{p}:\n{tool_read(p)}")
121
+ else:
122
+ entries = sorted(e + ("/" if os.path.isdir(os.path.join(p, e)) else "")
123
+ for e in os.listdir(p) if e not in IGNORE_DIRS)
124
+ listing = "\n".join(entries[:200]) or "(empty)"
125
+ if len(entries) > 200:
126
+ listing += f"\n… (+{len(entries) - 200} more)"
127
+ blocks.append(f"@{p}/ (directory listing):\n{listing}")
128
+ augmented = text + "\n\n[Attached for the @ reference(s) above:]\n" + "\n\n".join(blocks)
129
+ return augmented, [p for _, p in resolved]
130
+
131
+
132
+ def _has_open_tool_call(text: str) -> bool:
133
+ """True if `text` opened a tool-call block it never closed — the signature of a
134
+ generation truncated mid-call (most often a `write` whose content blew the token
135
+ cap). Used to give targeted "write it in parts" guidance instead of generic advice."""
136
+ return (text.count("<tool_call>") > text.count("</tool_call>")
137
+ or text.count("<function=") > text.count("</function>"))
138
+
139
+
140
+ def reject_escalation(name: str) -> str:
141
+ """Extra guidance appended when the SAME tool call has been rejected identically
142
+ back-to-back. The plain repair message clearly is not landing — the model can't see
143
+ the fix (or the call genuinely can't succeed as written), so re-emitting it verbatim
144
+ just burns turns. Break the loop: stop repeating, and — critically — do NOT fabricate
145
+ the result the tool would have returned. A silently-failed `activate_skill` that the
146
+ model papers over by reciting a skill from memory is the worst outcome: confident
147
+ output that never loaded the real instructions."""
148
+ extra = ("\n[you have now emitted this exact call twice and it was rejected both "
149
+ "times — re-emitting it unchanged will not work. Either change the flagged "
150
+ "field(s), or use a DIFFERENT tool. Do NOT invent or guess the output this "
151
+ "tool would have returned.]")
152
+ if name == "activate_skill":
153
+ extra += ("\n[the skill was NOT loaded. Do not proceed from memory or fabricate "
154
+ "its steps. Check the exact skill `name` against the '# Skills' list, or "
155
+ "continue the task with the normal read/grep/bash tools instead.]")
156
+ return extra
157
+
158
+
159
+ def close_unclosed_think(text: str, thinking: bool) -> str:
160
+ """Close a dangling `<think>` block so the stored assistant turn re-tokenizes into
161
+ a prefix of the live KV cache.
162
+
163
+ On a thinking model the chat template auto-opens `<think>\\n` in the generation
164
+ prompt, so the model's output *continues inside* the think block and a normal turn
165
+ emits its own `</think>`. A turn truncated at the token cap can stop BEFORE that
166
+ close. The template then renders the stored (unclosed) content as an EMPTY think
167
+ block + trailing content — which diverges from the cache at the very first content
168
+ token. On Ornith's non-trimmable cache that single divergence forces a FULL
169
+ re-prefill of the whole transcript next step (measured: tens of thousands of tokens
170
+ at large context). Appending the missing `</think>` keeps the cached tokens a strict
171
+ prefix of the re-render, so only a couple of tokens prefill instead. No-op when
172
+ thinking is off or the block is already closed."""
173
+ if thinking and "<think>" not in text and "</think>" not in text and text:
174
+ return text + "\n</think>"
175
+ return text
176
+
177
+
178
+ # Permission modes (Claude Code parity, cycled with shift-tab in the TUI):
179
+ # normal — confirm each mutating tool (bash/write/edit)
180
+ # auto — auto-approve mutating tools (yolo)
181
+ # plan — read-only: changes are blocked; the model researches and proposes a plan
182
+ MODES = ("normal", "auto", "plan")
183
+ MODE_LABEL = {"normal": "normal", "auto": "auto-accept edits", "plan": "plan mode"}
184
+ _PLAN_PREFIX = (
185
+ "[PLAN MODE. Research first (read/grep/glob/repo_map/overview) — do NOT edit "
186
+ "project files or run commands. Then write ONE self-contained plan to "
187
+ "./plans/NNN-kebab-title.md (continue the existing number sequence) with the "
188
+ "`write` tool. The plan must inline everything an executor needs WITHOUT this "
189
+ "chat: a Context section (why), exact file paths with current-state code "
190
+ "excerpts, numbered step-by-step changes, the verify commands to run, repo "
191
+ "conventions, and explicit out-of-scope. After writing the file, call done. "
192
+ "Writing the plan file is your only allowed change.]\n\n"
193
+ )
194
+
195
+ # Canned task behind the /init slash command (Claude-Code parity): scaffold a CLAUDE.md
196
+ # the way `claude /init` does. Runs through the normal agentic loop (repo_map → read the
197
+ # config files → write), so it benefits from every reliability fix above.
198
+ INIT_PROMPT = (
199
+ "Create a CLAUDE.md file at the root of this project to help an AI coding assistant "
200
+ "work here effectively. First orient yourself: call repo_map, then read the key "
201
+ "config/entry files that exist (README, pyproject.toml/package.json/go.mod/Cargo.toml, "
202
+ "Makefile). Then write CLAUDE.md with the `write` tool containing, concisely (aim for "
203
+ "under 60 lines): a one-paragraph overview of what the project does; the main "
204
+ "components and how they fit together; the ACTUAL commands to build, run, and test it "
205
+ "(copy them from the config you read, don't invent); and any conventions worth noting. "
206
+ "If a CLAUDE.md already exists, read it first and improve it rather than clobbering it. "
207
+ "After writing, call done."
208
+ )
209
+
210
+
211
+ class Agent:
212
+ def __init__(self, engine: BaseEngine, yolo: bool = False, max_steps: int = 40,
213
+ ctx_limit: int = 24000, mode: str = None, emit=None,
214
+ confirm=None, should_stop=None, thinking: bool = True,
215
+ max_gen_tokens: int = 8192, resume: list = None, persist: bool = False,
216
+ think_budget: int = None, turn_budget_tokens: int = None,
217
+ turn_budget_s: float = None, subagent: bool = False,
218
+ subagent_tools: str = "read-only", session_id: str = None):
219
+ self.engine = engine
220
+ # A spawned sub-agent (plan 041) SHARES the parent's session — the same engine,
221
+ # the same live skills/MCP connections — so it must NOT tear those down. Only a
222
+ # top-level Agent resets them (a fresh session clears stale activation state and
223
+ # reaps prior MCP processes; matches engine._reset_cache on /reset).
224
+ self._subagent = subagent
225
+ # Which tools a sub-agent may see: "read-only" (default) or "all". Ignored for a
226
+ # top-level agent (it always gets the full toolset minus the CHAD_NO_* gates).
227
+ self._subagent_tools = subagent_tools
228
+ if not subagent:
229
+ from . import skills
230
+ skills.reset_session()
231
+ from . import mcp
232
+ mcp.reset_session()
233
+ self.mode = mode or ("auto" if yolo else "normal")
234
+ self.thinking = thinking # Ornith is a reasoning model; toggles <think> blocks
235
+ self.max_steps = max_steps
236
+ # Session persistence (cli.py --continue): when `persist`, the conversation is
237
+ # saved to disk (keyed by cwd) after each turn; `resume` seeds it from a prior
238
+ # save. A fresh system prompt is always rebuilt (cwd/workspace may have changed);
239
+ # only the non-system turns are restored.
240
+ self.persist = persist
241
+ # This turn-thread's session id (plan 043), minted here so every resume forks: a
242
+ # resumed conversation is loaded into a FRESH Agent, which mints a NEW id and
243
+ # saves to a NEW file — the original session file is never rewritten. `save()`
244
+ # writes only to this id's file.
245
+ self.session_id = session_id or session.new_session_id()
246
+ # Per-step generation cap. The old 2048 default truncated legitimate work —
247
+ # a reasoning turn that thinks, then emits a `write` of a whole test file can
248
+ # exceed 2048 tokens, and the cut-off was being misread as a final answer
249
+ # (the "answers on paper then stops" bug). 8192 leaves room for think + a
250
+ # full-file write; truncation past it is now detected and nudged, not accepted.
251
+ self.max_gen_tokens = max_gen_tokens
252
+ # Soft think-cap base (plan 039). None => the mechanism is OFF and generation is
253
+ # byte-identical to before. Falls back to the CHAD_THINK_BUDGET env knob so the
254
+ # eval harness can arm an arm without a code change (matches the CHAD_* family).
255
+ # When set, run_turn stops each step's <think> run once it exceeds this many
256
+ # tokens and force-closes the block (prefix-safe); the cap escalates with the
257
+ # turn's stuck-signals (see guardrails.think_budget).
258
+ if think_budget is None:
259
+ think_budget = config.env_int("CHAD_THINK_BUDGET")
260
+ self.think_budget = think_budget
261
+ self.ctx_limit = ctx_limit # prompt-token budget before compaction kicks in
262
+ # Runaway-turn governor (plan 040): a per-turn budget on cumulative prefill tokens
263
+ # (and optional wall-clock) that ends a turn which has burned through a checkpoint
264
+ # WITHOUT landing+verifying a change — banking a deterministic progress note so the
265
+ # caller can relaunch fresh (shedding both the ramble and the huge prefill a stuck
266
+ # weak model drags around). Off entirely under CHAD_NO_GOVERNOR=1 (A/B family). The
267
+ # token budget defaults to 3× the context limit — far above a normal task's use
268
+ # (passing eval tasks: 14–35k prefill tokens; the pathological timeout tail:
269
+ # 130–187k). Wall budget is off unless set: interactively the human is the wall
270
+ # clock; evals/one-shot set it via --turn-budget-s / CHAD_TURN_BUDGET_S.
271
+ self._no_governor = config.flag("CHAD_NO_GOVERNOR")
272
+ if turn_budget_tokens is None:
273
+ turn_budget_tokens = config.env_int("CHAD_TURN_BUDGET_TOKENS", max(0, 3 * self.ctx_limit))
274
+ self._turn_budget_tokens = turn_budget_tokens
275
+ if turn_budget_s is None:
276
+ turn_budget_s = config.env_float("CHAD_TURN_BUDGET_S")
277
+ self._turn_budget_s = turn_budget_s
278
+ # Set when a turn hard-stops on budget (like last_plan_path): holds the progress
279
+ # note so the caller (TUI / one-shot / evals) can relaunch a fresh turn seeded
280
+ # with it. Reset at the start of every run_turn.
281
+ self.budget_note: str | None = None
282
+ self.messages = [{"role": "system",
283
+ "content": build_subagent_prompt() if subagent
284
+ else build_system_prompt()}]
285
+ if resume:
286
+ self.messages += [m for m in resume if m.get("role") != "system"]
287
+ self._emit = emit or _default_emit
288
+ self._confirm_cb = confirm # callable(name, args)->bool; None => input() prompt
289
+ self._should_stop = should_stop or (lambda: False)
290
+ self.interrupted = False
291
+ # Absolute path of the plan file written during a plan-mode turn (consumed by
292
+ # the TUI to offer the steer/accept handoff); reset each time it's read.
293
+ self.last_plan_path = None
294
+ # rolling throughput accounting (read by evals / status line)
295
+ self.gen_tokens = 0
296
+ self.gen_time = 0.0
297
+ self.forwards = 0
298
+ self.draft_proposed = 0
299
+ self.draft_accepted = 0
300
+ self.think_tokens = 0 # tokens spent inside <think> blocks (reasoning overhead)
301
+ self.think_capped = 0 # times the soft think-cap force-closed a step (plan 039)
302
+ # prefill accounting: the master cost for a local model is how many *new*
303
+ # tokens it has to prefill across a turn (context bloat -> big prefills).
304
+ # This is the metric symbolic/repo-map retrieval is meant to shrink.
305
+ self.prefill_tokens = 0 # sum of newly-prefilled (uncached) tokens, all steps
306
+ self.peak_ctx = 0 # largest prompt the turn ever rendered (tokens)
307
+ # Plan 035: total cache length (cached+prefilled+generated) after the prior
308
+ # traced step, so the trace can name this step's sync_kind by comparing this
309
+ # step's `cached_tokens` against it. Persists across run_turn calls (file order
310
+ # is chronological). Only updated when CHAD_PREFILL_TRACE is set.
311
+ self._prefill_trace_prev = 0
312
+ self._prefill_trace_seq = 0
313
+ # Tool executions since the last traced row: (name, wall_s) pairs. The tools a
314
+ # step runs produce the *next* step's appended prompt, so they're recorded on
315
+ # that next row (field `prev_tools`). Only populated when tracing is on.
316
+ self._trace_tools_pending: list = []
317
+
318
+ @property
319
+ def yolo(self) -> bool:
320
+ return self.mode == "auto"
321
+
322
+ @property
323
+ def tok_per_s(self) -> float:
324
+ return self.gen_tokens / self.gen_time if self.gen_time else 0.0
325
+
326
+ @property
327
+ def accept_rate(self) -> float:
328
+ return self.draft_accepted / self.draft_proposed if self.draft_proposed else 0.0
329
+
330
+ def cycle_mode(self) -> str:
331
+ self.mode = MODES[(MODES.index(self.mode) + 1) % len(MODES)]
332
+ return self.mode
333
+
334
+ # Compaction logic now lives in compaction.py (operates on a messages list +
335
+ # render/emit callbacks). These aliases keep `self._COLLAPSED`/`self._headtail`
336
+ # working for compact_now below, byte-identical to the old in-class versions.
337
+ _COLLAPSED = compaction._COLLAPSED
338
+ _headtail = staticmethod(compaction._headtail)
339
+
340
+ def _compact_if_needed(self, prompt_ids):
341
+ return compaction.compact_if_needed(
342
+ self.messages, self._render, self._emit, self.ctx_limit, prompt_ids)
343
+
344
+ def save(self):
345
+ """Persist the conversation for the current dir (no-op unless `persist`)."""
346
+ if self.persist:
347
+ session.save_session(os.getcwd(), self.messages,
348
+ {"mode": self.mode, "thinking": self.thinking},
349
+ session_id=self.session_id)
350
+
351
+ def compact_now(self):
352
+ """Manual context reclaim (the /compact command). Runs only the SAFE, lossless-
353
+ ish passes — strip <think> reasoning from older assistant turns and head/tail-
354
+ truncate older tool outputs — and never drops a message, so it can't break the
355
+ conversation. The next turn re-syncs the prefix cache normally. Returns
356
+ (before_tokens, after_tokens)."""
357
+ before = len(self._render())
358
+ for i in [i for i, m in enumerate(self.messages)
359
+ if m.get("role") == "assistant" and "</think>" in m["content"]][:-2]:
360
+ c = self.messages[i]["content"]
361
+ self.messages[i]["content"] = c.split("</think>", 1)[1].lstrip("\n")
362
+ idxs = [i for i, m in enumerate(self.messages)
363
+ if m.get("role") == "tool" and self._COLLAPSED not in m["content"]]
364
+ for i in idxs[:max(0, len(idxs) - 4)]: # keep the last 4 tool outputs verbatim
365
+ if len(self.messages[i]["content"]) > 400:
366
+ self.messages[i]["content"] = self._headtail(self.messages[i]["content"])
367
+ return before, len(self._render())
368
+
369
+ def _active_schemas(self):
370
+ """The tool schemas to expose THIS agent, on top of the module-level gates
371
+ (CHAD_NO_SYMBOLS / CHAD_NO_TASK / skills / MCP). A sub-agent (plan 041) never
372
+ sees `task` — reentrancy guard, subagents can't spawn subagents — and, unless it
373
+ was granted tools="all", is restricted to the read-only exploration set so it
374
+ can't mutate the repo it's spelunking through."""
375
+ schemas = active_schemas()
376
+ if not self._subagent:
377
+ return schemas
378
+ allow = None if self._subagent_tools == "all" else SUBAGENT_READ_ONLY
379
+ out = []
380
+ for s in schemas:
381
+ n = s["function"]["name"]
382
+ if n == "task":
383
+ continue
384
+ if allow is not None and n not in allow:
385
+ continue
386
+ out.append(s)
387
+ return out
388
+
389
+ def _render(self):
390
+ return self.engine.tok.apply_chat_template(
391
+ self.messages, tools=self._active_schemas(), add_generation_prompt=True,
392
+ enable_thinking=self.thinking,
393
+ )
394
+
395
+ def _stable_prefix_ids(self):
396
+ """The byte-identical head of every session: system prompt + tool schemas,
397
+ up to (but not including) the first user turn. Computed by diffing two
398
+ first-turn renders with different user text — their common token prefix is
399
+ exactly the system+tools block. This is what warm_prefix checkpoints to disk
400
+ so a cold start skips re-prefilling it (the template can't render a system
401
+ message alone — it raises 'No user query found' — hence the diff trick)."""
402
+ sysm = self.messages[0]
403
+ def render1(u):
404
+ return self.engine.tok.apply_chat_template(
405
+ [sysm, {"role": "user", "content": u}], tools=self._active_schemas(),
406
+ add_generation_prompt=True, enable_thinking=self.thinking)
407
+ a, b = render1("a"), render1("the quick brown fox jumps")
408
+ n = 0
409
+ for x, y in zip(a, b):
410
+ if x != y:
411
+ break
412
+ n += 1
413
+ return list(a[:n])
414
+
415
+ def _confirm(self, name, args) -> bool:
416
+ # Destructive-bash seatbelt: a catastrophic shell command (rm -rf ~, mkfs,
417
+ # curl|sh, …) is screened even in --yolo/auto mode, because the model acts on
418
+ # untrusted repo contents and auto mode has no human in the loop. If a confirm
419
+ # channel exists (TTY or callback) we force the prompt; headless with no channel
420
+ # we BLOCK rather than execute on injection. CHAD_NO_DESTRUCTIVE_GUARD=1 opts out.
421
+ dangerous = (name == "bash" and isinstance(args, dict)
422
+ and not config.flag("CHAD_NO_DESTRUCTIVE_GUARD")
423
+ and guardrails.is_destructive_bash(str(args.get("command", ""))))
424
+ if self.mode == "auto" or not is_mutating(name):
425
+ if not dangerous:
426
+ return True
427
+ if self._confirm_cb is None and not sys.stdin.isatty():
428
+ self._emit("info", f" [blocked destructive command in auto mode: "
429
+ f"{args.get('command', '')!r}; set CHAD_NO_DESTRUCTIVE_GUARD=1 to allow]")
430
+ return False
431
+ if self._confirm_cb is not None:
432
+ return self._confirm_cb(name, args)
433
+ preview = confirm_preview(name, args)
434
+ warn = f"{C_RED} ⚠ looks destructive — review carefully\n{C_RST}" if dangerous else ""
435
+ ans = input(f"{C_YEL} allow {name}:\n{preview}\n{warn} approve? [y/N] {C_RST}").strip().lower()
436
+ return ans in ("y", "yes")
437
+
438
+ def _sub_emit(self, kind: str, text: str):
439
+ """Emit callback handed to a spawned sub-agent so its activity renders DIMMED and
440
+ subordinate in the main transcript. Live status gauges (spinner verb, ctx/gen/
441
+ prefill counters) pass through so the UI still reflects the sub-agent's work; its
442
+ streamed prose and reasoning are suppressed (only its final return matters); its
443
+ tool activity and notices are downgraded to the dim 'muted' channel."""
444
+ if kind in ("status", "ctx", "gen", "prefill"):
445
+ self._emit(kind, text)
446
+ elif kind in ("stream", "think"):
447
+ return # sub-agent prose/reasoning isn't the deliverable — keep it out
448
+ elif kind == "tool":
449
+ self._emit("muted", " ⌊ " + text)
450
+ else: # muted / info / add / del / error
451
+ self._emit("muted", " " + str(text).lstrip())
452
+
453
+ def _run_subagent(self, description: str, prompt: str, tools: str = "read-only") -> str:
454
+ """Run a scoped sub-agent (plan 041) on a QUARANTINED cache and return its final
455
+ text as the `task` tool result. push_cache stashes the main session's warm cache
456
+ aside; the sub-agent runs on a fresh one with a tight step/context budget and a
457
+ (default) read-only toolset; pop_cache restores the main cache bit-identically —
458
+ even on error/interrupt (the finally), so a stuck sub-agent never corrupts the
459
+ parent. Its grep/read churn never enters the main transcript; only this return
460
+ does. Depth 1 only: a sub-agent can't itself call `task` (its schema omits it)."""
461
+ # Never grant a sub-agent more autonomy than its parent: the sub-agent runs
462
+ # mode='auto' with no confirm callback, so an 'all' toolset is honored only when
463
+ # the parent itself is auto-approved (--yolo/headless); 'normal' (human confirms
464
+ # every mutation) and 'plan' (read-only) parents clamp it to read-only.
465
+ requested, tools = tools, subagent_tools_for(self.mode, tools)
466
+ if requested == "all" and tools == "read-only":
467
+ self._emit("muted", " ⌊ sub-agent clamped to read-only (parent mode is not auto)")
468
+ self._emit("muted", f" ⌊ delegating to sub-agent: {description}")
469
+ sub = None
470
+ self.engine.push_cache()
471
+ try:
472
+ sub = Agent(
473
+ self.engine,
474
+ mode="auto", # auto-approve within its restricted toolset
475
+ max_steps=SUBAGENT_MAX_STEPS,
476
+ ctx_limit=min(self.ctx_limit, SUBAGENT_CTX_LIMIT),
477
+ thinking=self.thinking,
478
+ max_gen_tokens=self.max_gen_tokens,
479
+ emit=self._sub_emit,
480
+ should_stop=self._should_stop,
481
+ think_budget=self.think_budget,
482
+ turn_budget_tokens=0, # the sub-agent's own max_steps bounds it
483
+ turn_budget_s=0.0,
484
+ subagent=True,
485
+ subagent_tools=tools,
486
+ )
487
+ result = sub.run_turn(prompt)
488
+ except Exception as e: # noqa: BLE001 — a sub-agent crash must not kill the parent turn
489
+ result = f"[task failed: {type(e).__name__}: {e}]"
490
+ finally:
491
+ self.engine.pop_cache()
492
+ # Roll the sub-agent's cost into the parent's rolling accounting so total turn
493
+ # spend stays visible (its prefill also feeds the governor budget); peak_ctx is
494
+ # left as the MAIN transcript's — the whole point is that it does NOT grow.
495
+ if sub is not None:
496
+ self.gen_tokens += sub.gen_tokens
497
+ self.gen_time += sub.gen_time
498
+ self.prefill_tokens += sub.prefill_tokens
499
+ self.think_tokens += sub.think_tokens
500
+ self.forwards += sub.forwards
501
+ if sub.interrupted:
502
+ return "[task interrupted]"
503
+ return result or "[task returned nothing]"
504
+
505
+ def run_turn(self, user_text: str, stream=True):
506
+ self.interrupted = False
507
+ # ds4-style warm start: on a cold cache, load the system+tools KV from disk
508
+ # (or prefill+persist it once) so the first turn doesn't re-prefill the
509
+ # ~3.2k-token stable prefix every session. Cheap no-op on a warm cache.
510
+ if self.engine.cache_dir and not self.engine._cached_ids:
511
+ try:
512
+ status, n = self.engine.warm_prefix(self._stable_prefix_ids(),
513
+ should_stop=self._should_stop)
514
+ log.info("CACHE warm-start %s: %d prefix tokens (disk KV cache)", status, n)
515
+ if status == "hit":
516
+ self._emit("info", f" [warm start: {n:,} prefix tokens from disk cache]")
517
+ except Exception as e: # never let cache warming break a turn
518
+ log.warning("warm_prefix failed: %s", e)
519
+ # @file mentions: inline any referenced files so the model has them without a
520
+ # read round-trip (intent classification below still uses the original text).
521
+ expanded, attached = expand_mentions(user_text)
522
+ for p in attached:
523
+ self._emit("info", f" [attached @{_disp_path(p)}]")
524
+ prompt = (_PLAN_PREFIX + expanded) if self.mode == "plan" else expanded
525
+ self.messages.append({"role": "user", "content": prompt})
526
+ log.info("TURN start | cwd=%s | mode=%s | attached=%s | query=%r",
527
+ os.getcwd(), self.mode, attached, redact(user_text[:200]))
528
+ recent_sigs = [] # forge-style loop guard: detect repeated identical tool calls
529
+ self._loop_nudges = 0
530
+ self._validate_fails = 0 # typia self-repair rounds this turn (telemetry)
531
+ self._last_reject_sig = None # (name,args) of the last validation-rejected call
532
+ self._reject_repeats = 0 # consecutive identical rejections (loop breaker)
533
+ unverified_edit = False # files changed but not run/tested since
534
+ verify_nudges = 0
535
+ did_work = False # a substantive tool (not plan/done) ran this turn
536
+ empty_done_nudges = 0
537
+ made_edit = False # an edit/write/replace/insert actually landed this turn
538
+ answer_nudges = 0
539
+ truncation_nudges = 0 # times we pushed past a token-cap truncation this turn
540
+ landing_nudges = 0 # one-shot "you're out of steps, land the edit" near the cap
541
+ consecutive_failed_bash = 0 # back-to-back errored bash with no edit (thrash)
542
+ thrash_nudges = 0
543
+ think_cap_hits = 0 # soft think-cap firings this turn (plan 039; drives escalation)
544
+ # Runaway-turn governor state (plan 040). turn_start drives the optional wall
545
+ # budget; gov_band is the highest budget checkpoint already evaluated; gov_progress
546
+ # tracks whether a change landed+verified within the CURRENT band (resets each time
547
+ # a checkpoint is crossed, so every band must re-earn progress); gov_soft_fired
548
+ # bounds the soft nudge to one per turn.
549
+ self.budget_note = None
550
+ turn_start = time.monotonic()
551
+ gov_band = 0
552
+ gov_progress = False
553
+ gov_soft_fired = False
554
+ # Action vs Q&A intent (see classify_intent): a task that asks to change code
555
+ # should END in an applied edit, not a prose explanation. Telemetry caught the
556
+ # model navigating to the right function then "answering on paper" without
557
+ # applying the fix; action_task arms the nudge that pushes it to actually edit,
558
+ # and read_only_intent exempts genuine explain-only asks.
559
+ _intent = classify_intent(user_text)
560
+ action_task = self.mode != "plan" and _intent["action"]
561
+ read_only_intent = _intent["read_only"]
562
+ for step in range(self.max_steps):
563
+ # Runaway-turn governor (plan 040): watch the GLOBAL trajectory — cumulative
564
+ # prefill tokens (+ optional wall clock) vs progress made. It fires only when a
565
+ # budget checkpoint is CROSSED, evaluating the just-completed band's progress:
566
+ # a change landed+verified in that band resets the checkpoint (slow-but-working
567
+ # turns are never interrupted); no progress -> one soft nudge at ~50%, then bank
568
+ # a deterministic progress note and end the turn at ~80% so the caller can
569
+ # relaunch fresh. Off under CHAD_NO_GOVERNOR / when no budget is configured.
570
+ if not self._no_governor:
571
+ frac = guardrails.budget_fraction(
572
+ self.prefill_tokens, self._turn_budget_tokens,
573
+ time.monotonic() - turn_start, self._turn_budget_s)
574
+ new_band = guardrails.budget_band(frac)
575
+ gov, gov_band, gov_progress = guardrails.advance_governor(
576
+ gov_band, new_band, gov_progress, gov_soft_fired)
577
+ if gov == "hard":
578
+ self.budget_note = guardrails.progress_note(self.messages)
579
+ log.info("GOVERNOR hard-stop at step %d: %d/%s prefill tokens, %.0fs — "
580
+ "banking progress note, ending turn", step, self.prefill_tokens,
581
+ self._turn_budget_tokens, time.monotonic() - turn_start)
582
+ self._emit("info", " [turn hit its budget with no landed+verified "
583
+ "change — stopping and banking a progress note]")
584
+ return f"{guardrails.BUDGET_SENTINEL} {self.budget_note}"
585
+ if gov == "soft":
586
+ gov_soft_fired = True
587
+ log.info("GOVERNOR soft-nudge at step %d: %d/%s prefill tokens", step,
588
+ self.prefill_tokens, self._turn_budget_tokens)
589
+ self.messages.append({"role": "tool", "name": "edit",
590
+ "content": guardrails.GOVERNOR_SOFT_NUDGE})
591
+ # Forced landing (A): inside the last few steps with nothing cleanly applied,
592
+ # tell the model to stop exploring and commit its edit before the hard cap —
593
+ # otherwise the loop just dies at max_steps with the task untouched.
594
+ land = guardrails.landing_nudge(
595
+ step, self.max_steps, made_edit, unverified_edit, landing_nudges)
596
+ if land:
597
+ landing_nudges += 1
598
+ log.info("LANDING nudge at step %d (remaining=%d, made_edit=%s, "
599
+ "unverified_edit=%s)", step, self.max_steps - step,
600
+ made_edit, unverified_edit)
601
+ self.messages.append({"role": "tool", "name": "edit", "content": land})
602
+ # The engine diffs this full render against its live KV cache and prefills
603
+ # only the appended tokens, so a plain re-render IS the cache-extension path
604
+ # (truncated-turn divergence is handled inside engine._sync_to, not here).
605
+ _t0 = time.perf_counter()
606
+ prompt_ids = self._render()
607
+ _render_s = time.perf_counter() - _t0
608
+ _pre_compact_len = len(prompt_ids)
609
+ _t0 = time.perf_counter()
610
+ prompt_ids = self._compact_if_needed(prompt_ids)
611
+ _compact_s = time.perf_counter() - _t0
612
+ # Did compaction materially shrink the render this step? Derived from the
613
+ # length delta at the call site (not compaction internals — see plan 034),
614
+ # so a big next-prefill can be named as a re-prefill rather than a mystery.
615
+ compacted = len(prompt_ids) < _pre_compact_len
616
+ # Live context gauge + honest activity verb (overrides any stale tool
617
+ # verb like "Searching" left over from the previous step).
618
+ self._emit("ctx", str(len(prompt_ids)))
619
+ self._emit("status", "Thinking")
620
+
621
+ # Plan 039 soft think-cap: when armed (self.think_budget set) and thinking is
622
+ # on, stop this step's generation once its <think> run exceeds a budget so
623
+ # reasoning can't balloon. The budget escalates with the turn's stuck-signals
624
+ # (prior caps this turn + loop/thrash/verify nudges) so a genuinely hard step
625
+ # gets more room instead of being chunked repeatedly. None => generation is
626
+ # byte-identical to before (the mechanism is off by default).
627
+ stop_condition = None
628
+ think_cap = None
629
+ if self.think_budget and self.thinking:
630
+ stuck = (think_cap_hits + self._loop_nudges + thrash_nudges
631
+ + verify_nudges)
632
+ think_cap = guardrails.think_budget(stuck, base=self.think_budget)
633
+
634
+ def stop_condition(text_so_far, n, _cap=think_cap):
635
+ # Still inside <think> (no close emitted) and over budget -> stop; the
636
+ # turn is then force-closed by close_unclosed_think, a prefix-safe append.
637
+ return n >= _cap and "</think>" not in text_so_far
638
+
639
+ view = _StreamView(self._emit, started_in_think=self.thinking) if stream else None
640
+ gen_count = [0] # decoded chunks this step (~tokens); fed to the live ↓ counter
641
+
642
+ def on_token(t):
643
+ if view:
644
+ view.feed(t)
645
+ gen_count[0] += 1
646
+ # Throttle: a status emit per ~16 tokens keeps the queue cheap while the
647
+ # bottom-line ↓ counter still climbs visibly (the refresher renders ~20 Hz).
648
+ if gen_count[0] % 16 == 0:
649
+ self._emit("gen", str(gen_count[0]))
650
+
651
+ def on_prefill(new, cached):
652
+ # A large prefill (e.g. a full recompute after compaction on a
653
+ # non-trimmable cache) blocks for a while — name the cause and let the
654
+ # progress hook below advance a %, so the wait is legible and ctrl-c'able
655
+ # rather than a frozen spinner.
656
+ if new > 2000:
657
+ self._emit("status", "Re-prefilling after compaction"
658
+ if compacted else "Prefilling context")
659
+
660
+ last_pct = [-1]
661
+
662
+ def on_prefill_progress(done, total):
663
+ # Forward an advancing prefill % to the status line, throttled to whole-
664
+ # percent changes (≤101 emits per prefill). Only for big prefills — small
665
+ # appends stay silent (verb only), matching on_prefill's >2000 gate.
666
+ if total <= 2000:
667
+ return
668
+ pct = int(100 * done / total)
669
+ if pct != last_pct[0]:
670
+ last_pct[0] = pct
671
+ self._emit("prefill", f"{done}/{total}")
672
+
673
+ text, stats = self.engine.generate(
674
+ prompt_ids, max_tokens=self.max_gen_tokens, on_token=on_token,
675
+ should_stop=self._should_stop, on_prefill=on_prefill,
676
+ on_prefill_progress=on_prefill_progress, stop_condition=stop_condition)
677
+ if gen_count[0] % 16: # flush the final (un-throttled) count for the ↓ readout
678
+ self._emit("gen", str(gen_count[0]))
679
+ # The generation ran to the token cap: the turn was cut off, not finished.
680
+ # A truncated assistant turn is NOT a final answer (and on Ornith's
681
+ # non-trimmable cache its decoded text won't re-tokenize identically, so it
682
+ # also forces the next-step re-prefill). Tracked so the no-call branch can
683
+ # tell "truncated mid-thought" apart from "deliberately answered."
684
+ hit_cap = stats.generated_tokens >= self.max_gen_tokens
685
+ self.gen_tokens += stats.generated_tokens
686
+ self.gen_time += stats.gen_s
687
+ self.prefill_tokens += stats.prompt_tokens
688
+ self.peak_ctx = max(self.peak_ctx, len(prompt_ids))
689
+ self.forwards += stats.forwards
690
+ self.draft_proposed += stats.draft_proposed
691
+ self.draft_accepted += stats.draft_accepted
692
+ if view:
693
+ view.close()
694
+ if view.saw_prose:
695
+ self._emit("stream", "\n")
696
+ # strip any trailing special tokens the template will re-add
697
+ text = text.replace("<|im_end|>", "").rstrip()
698
+
699
+ # Interrupted (often mid-prefill, so text is empty): stop cleanly without
700
+ # appending an empty assistant turn.
701
+ if self._should_stop():
702
+ self.interrupted = True
703
+ if text:
704
+ self.messages.append({"role": "assistant",
705
+ "content": close_unclosed_think(text, self.thinking)})
706
+ self._emit("info", " [interrupted]")
707
+ return "[interrupted]"
708
+
709
+ # Close a think block left dangling by a token-cap/interrupt truncation so the
710
+ # stored turn stays a prefix of the live KV cache (else: full re-prefill next
711
+ # step on the non-trimmable cache). See close_unclosed_think.
712
+ self.messages.append({"role": "assistant",
713
+ "content": close_unclosed_think(text, self.thinking)})
714
+
715
+ # Estimate reasoning overhead: the generation opens inside <think> (the
716
+ # template emits the opening tag), so everything up to </think> is thinking.
717
+ # A soft-cap stop (plan 039) fires only while still inside <think>, so ALL of
718
+ # this step's tokens are reasoning — count them so think-token telemetry (the
719
+ # metric the budget is measured against) doesn't under-report the capped runs.
720
+ if stats.stop_condition_fired:
721
+ self.think_tokens += stats.generated_tokens
722
+ elif "</think>" in text and len(text):
723
+ frac = len(text.split("</think>", 1)[0]) / len(text)
724
+ self.think_tokens += int(stats.generated_tokens * frac)
725
+
726
+ log.info("step %d: %d tok @ %.1f tok/s | prefill %d new + %d cached | "
727
+ "accept %.2f", step, stats.generated_tokens, stats.tok_per_s,
728
+ stats.prompt_tokens, stats.cached_tokens, self.accept_rate)
729
+ # Plan 035: env-gated per-step prefill telemetry (one row per prefill event).
730
+ # Guarded so an unset trace pays only this truthiness check — no string/dict/IO.
731
+ # sync_kind names *why* this step prefilled what it did, derived purely from
732
+ # cached_tokens vs the prior step's total cache length + the `compacted` flag
733
+ # (no engine internals): a step whose cached prefix shrank re-prefilled lost
734
+ # content; tagging that by cause is what feeds the A/B/C attribution.
735
+ if _PREFILL_TRACE:
736
+ common = stats.cached_tokens
737
+ if common >= self._prefill_trace_prev:
738
+ sync_kind = "append" # prefix fully retained; only new tail
739
+ elif compacted:
740
+ sync_kind = "reprefill-compaction" # head rewrite shrank the render
741
+ elif common > 0:
742
+ sync_kind = "warm-reload" # diverged but salvaged a prefix (cheap)
743
+ else:
744
+ sync_kind = "reprefill-other" # cold reset (think-mismatch / mid-edit)
745
+ self._prefill_trace_seq += 1
746
+ _trace_prefill({
747
+ "seq": self._prefill_trace_seq, "step": step,
748
+ "prompt_tokens": stats.prompt_tokens,
749
+ "cached_tokens": stats.cached_tokens,
750
+ "prefill_s": round(stats.prefill_s, 4),
751
+ "gen_tokens": stats.generated_tokens,
752
+ "gen_s": round(stats.gen_s, 4),
753
+ "compacted": compacted, "sync_kind": sync_kind,
754
+ "peak_ctx": len(prompt_ids),
755
+ # Loop overhead outside the engine: chat-template re-tokenization
756
+ # and compaction wall time this step, plus the tools the *previous*
757
+ # step ran (they produced the transcript this step prefilled).
758
+ "render_s": round(_render_s, 4),
759
+ "compact_s": round(_compact_s, 4),
760
+ "prev_tools": self._trace_tools_pending,
761
+ })
762
+ self._trace_tools_pending = []
763
+ self._prefill_trace_prev = (
764
+ common + stats.prompt_tokens + stats.generated_tokens)
765
+ # Cache divergence: a mid-session step that re-prefills everything (0 cached)
766
+ # means the re-rendered conversation diverged from the cache — on a
767
+ # non-trimmable cache that's a full, costly rebuild (usually triggered by a
768
+ # max_tokens-truncated turn whose decoded text doesn't re-tokenize identically).
769
+ if step > 0 and stats.cached_tokens == 0 and stats.prompt_tokens > 2000:
770
+ log.warning("CACHE DIVERGENCE at step %d: full re-prefill of %d tokens "
771
+ "(non-trimmable cache reset — see prior turn for a truncated "
772
+ "generation)", step, stats.prompt_tokens)
773
+
774
+ # Plan 039: the soft think-cap fired — generation was stopped while still
775
+ # inside <think> (the reasoning run exceeded this step's budget). The assistant
776
+ # turn was force-closed by close_unclosed_think above (it ends with </think>,
777
+ # no content after), so it re-tokenizes as a strict prefix of the live KV cache
778
+ # — next step is a ~2-token append, not a rebuild (the close_unclosed_think
779
+ # contract). Count it (escalates the budget for the next step so we don't chunk
780
+ # forever) and continue; the model resumes reasoning / acts next step.
781
+ if stats.stop_condition_fired:
782
+ think_cap_hits += 1
783
+ self.think_capped += 1
784
+ # Display-only signal (plan 057): tell the surface a step was trimmed this
785
+ # turn, carrying the running per-turn count so the status line can show it.
786
+ # Only ever emitted when the cap is armed (self.think_budget set), so the
787
+ # default path is byte-identical — the surface renders nothing when off.
788
+ self._emit("thinkcap", str(think_cap_hits))
789
+ log.info("THINK-CAP at step %d: closed <think> after %d tok "
790
+ "(cap=%d, hits=%d)", step, stats.generated_tokens,
791
+ think_cap, think_cap_hits)
792
+ continue
793
+
794
+ calls = parse_tool_calls(text)
795
+ if not calls:
796
+ # No tool call this step. Decide whether this is a genuine final answer
797
+ # or a stall to push past. Three stalls telemetry caught, in priority
798
+ # order: (1) TRUNCATED — generation hit the token cap mid-thought, so it
799
+ # isn't an answer at all; (2) ANSWERED ON PAPER — produced code / described
800
+ # an edit but never applied it (the demonstrated "write test cases" bug:
801
+ # the old keyword gate missed "write" entirely); (3) UNVERIFIED EDIT — it
802
+ # edited but never ran the check. Each nudge is bounded so real answers and
803
+ # genuinely-stuck cases still escape.
804
+ has_code = "```" in text
805
+ kind, nudge = guardrails.nudge_for_no_calls(
806
+ text, hit_cap, made_edit, unverified_edit, read_only_intent,
807
+ action_task, truncation_nudges, answer_nudges, verify_nudges,
808
+ _has_open_tool_call(text))
809
+ if kind == "truncated":
810
+ truncation_nudges += 1
811
+ elif kind == "no-edit":
812
+ answer_nudges += 1
813
+ elif kind == "unverified-edit":
814
+ verify_nudges += 1
815
+ if nudge:
816
+ log.info("END-ANSWER rejected step %d: %s (hit_cap=%s, has_code=%s, "
817
+ "action_task=%s)", step, kind, hit_cap, has_code, action_task)
818
+ self.messages.append({"role": "tool", "name": "edit", "content": nudge})
819
+ continue
820
+ log.info("END step %d: model produced a FINAL ANSWER, no tool calls "
821
+ "(did_work=%s, made_edit=%s, unverified_edit=%s)",
822
+ step, did_work, made_edit, unverified_edit)
823
+ return strip_think(text).strip()
824
+ log.info("step %d: model emitted %d tool call(s): %s",
825
+ step, len(calls), ", ".join(n for n, _ in calls))
826
+
827
+ # Terminal tool -> end the turn cleanly, but enforce verify-before-done
828
+ # (forge's prerequisite idea): if files were changed and nothing has been
829
+ # run since, send the model back to actually test its work.
830
+ terminal = next((a for n, a in calls if n in TERMINAL), None)
831
+ if terminal is not None:
832
+ # Don't accept `done` if the model only narrated/planned without running
833
+ # any real tool (the markdown-code-fence failure mode).
834
+ log.info("step %d: model says DONE (summary=%r) | did_work=%s "
835
+ "unverified_edit=%s", step, terminal.get("summary"),
836
+ did_work, unverified_edit)
837
+ rejection = guardrails.done_rejection(
838
+ did_work, unverified_edit, empty_done_nudges, verify_nudges)
839
+ if self.mode == "plan" and rejection == "verify":
840
+ # Writing the plan file marks unverified_edit, but in plan mode the
841
+ # plan IS the deliverable — there is nothing to run/verify. Accept.
842
+ rejection = None
843
+ if rejection == "empty":
844
+ empty_done_nudges += 1
845
+ log.info("DONE rejected: no real work yet -> nudge #%d", empty_done_nudges)
846
+ self.messages.append({
847
+ "role": "tool", "name": "done",
848
+ "content": "[you have not actually done anything yet — no file was "
849
+ "read or changed. Markdown code fences are not executed. "
850
+ "Use real <tool_call> blocks: grep/read the file, edit it, "
851
+ "run the check with bash. Then call done.]",
852
+ })
853
+ continue
854
+ if rejection == "verify":
855
+ verify_nudges += 1
856
+ log.info("DONE rejected: edits not verified -> nudge #%d", verify_nudges)
857
+ self.messages.append({
858
+ "role": "tool", "name": "done",
859
+ "content": "[not done yet: you changed files but have not run anything "
860
+ "to verify them. Run the project's tests (or the code) with "
861
+ "bash, check the output is correct, then call done. If a test "
862
+ "fails, fix the code first.]",
863
+ })
864
+ continue
865
+ log.info("END step %d: DONE accepted | summary=%r", step,
866
+ terminal.get("summary"))
867
+ return terminal.get("summary") or text or "Done."
868
+
869
+ # Loop guard: count identical tool-call sets across the whole turn (not a
870
+ # sliding window) so alternating cycles like read A / read B / read A are
871
+ # caught too. 3rd identical occurrence -> nudge; if nudges don't help, abort.
872
+ sig = guardrails.loop_signature(calls)
873
+ seen_before = recent_sigs.count(sig)
874
+ recent_sigs.append(sig)
875
+ if guardrails.is_repeat_loop(seen_before):
876
+ self._loop_nudges += 1
877
+ log.info("LOOP detected at step %d (identical call set seen %dx) -> nudge #%d",
878
+ step, seen_before + 1, self._loop_nudges)
879
+ if guardrails.loop_should_abort(self._loop_nudges):
880
+ log.info("END step %d: LOOP ABORT (nudges exhausted)", step)
881
+ return ("[stopped: the model is stuck in a loop, repeating the same "
882
+ "tool calls without making progress.]")
883
+ self.messages.append({
884
+ "role": "tool", "name": calls[0][0],
885
+ "content": "[loop detected: you have made this exact tool call 3 times "
886
+ "with no new progress. Do NOT repeat it. Either run the test "
887
+ "with bash to verify, take a different action, or — if the task "
888
+ "is already done — stop and summarize the result.]",
889
+ })
890
+ continue
891
+
892
+ # Governor progress watermark (plan 040): capture the edit flags before this
893
+ # step's tools run so we can tell afterward whether a change actually LANDED
894
+ # or a pending edit got VERIFIED this step — the forward motion that resets the
895
+ # budget checkpoint (vs mere reads/greps, which don't count as progress).
896
+ _gov_prev_made = made_edit
897
+ _gov_prev_unverified = unverified_edit
898
+ for name, args in calls:
899
+ render_tool_start(self._emit, name, args)
900
+ # typia stages 2+3: coerce loosely-typed args toward the schema
901
+ # ("10"->10, '["x"]'->[...]) and validate. On failure, feed the
902
+ # model its own args annotated with exactly which fields are
903
+ # wrong (self-repair loop) instead of dispatching garbage.
904
+ if VALIDATE:
905
+ coerced, verrs = coerce_and_validate(name, args)
906
+ reject = render_repair(name, coerced, verrs) if verrs else None
907
+ detail = " | ".join(str(e) for e in verrs)
908
+ else: # legacy: terse missing-required check, no coercion
909
+ coerced, reject, detail = args, legacy_validate(name, args), ""
910
+ fn = dispatch_for(name)
911
+ if reject is not None:
912
+ result = reject
913
+ self._validate_fails += 1
914
+ # Loop breaker (plan): the same call rejected identically back-to-back
915
+ # means the repair message isn't landing (the model can't see the fix,
916
+ # or the call can't succeed as written). Escalate — tell it to stop
917
+ # re-emitting and to NOT fabricate the tool's result — instead of
918
+ # letting it flail. Signature is over (name, args) so a *different*
919
+ # attempt at the same tool resets the counter.
920
+ rej_sig = guardrails.loop_signature([(name, args)])
921
+ if rej_sig == self._last_reject_sig:
922
+ self._reject_repeats += 1
923
+ else:
924
+ self._last_reject_sig, self._reject_repeats = rej_sig, 0
925
+ if self._reject_repeats >= 1: # 2nd identical rejection
926
+ result += reject_escalation(name)
927
+ log.info("VALIDATE %s rejected identically %dx -> escalate",
928
+ name, self._reject_repeats + 1)
929
+ log.info("VALIDATE %s rejected (%d): %s", name, self._validate_fails, detail)
930
+ render_tool_result(self._emit, name, args, result)
931
+ self.messages.append({"role": "tool", "name": name, "content": result})
932
+ continue
933
+ if coerced != args:
934
+ log.info("VALIDATE %s coerced: %s -> %s", name,
935
+ args_preview(args), args_preview(coerced))
936
+ args = coerced
937
+ # Subagent/Task tool (plan 041): run a scoped sub-agent on a quarantined
938
+ # cache and feed its condensed return back as this call's result. Handled
939
+ # here (not via DISPATCH) because it needs the engine + a fresh Agent. Only
940
+ # a top-level agent dispatches it; a sub-agent never sees `task` in its
941
+ # schema, so if one somehow emits it we fall through to the unknown-tool
942
+ # repair path rather than nesting.
943
+ if name == "task" and not self._subagent:
944
+ result = self._run_subagent(
945
+ args.get("description", ""), args.get("prompt", ""),
946
+ args.get("tools", "read-only"))
947
+ render_tool_result(self._emit, name, args, result)
948
+ self.messages.append({"role": "tool", "name": name, "content": result})
949
+ did_work = True # exploration counts as work (like a read), not an edit
950
+ if self._should_stop():
951
+ self.interrupted = True
952
+ self._emit("info", " [interrupted]")
953
+ return "[interrupted]"
954
+ continue
955
+ # Plan mode is read-only EXCEPT for writing the plan file itself:
956
+ # write/edit are allowed only under ./plans/. Every other mutating
957
+ # tool (bash, symbol edits) and any write outside ./plans/ is blocked.
958
+ plan_write = (self.mode == "plan" and name in ("write", "edit")
959
+ and _under_plans(args.get("path", "")))
960
+ _tool_s = 0.0 # stays 0 when the tool is blocked/denied (fn never ran)
961
+ if self.mode == "plan" and is_mutating(name) and not plan_write:
962
+ result = ("[plan mode: only writing the plan file under ./plans/ is "
963
+ "allowed. Do not edit project files or run commands. "
964
+ "Investigate with read/grep/glob/repo_map, then write your "
965
+ "plan to ./plans/NNN-title.md.]")
966
+ elif not plan_write and not self._confirm(name, args):
967
+ # A plan write is the expected action in plan mode, so it skips the
968
+ # confirm prompt; everything else still goes through _confirm.
969
+ result = "[denied by user]"
970
+ else:
971
+ _t0 = time.perf_counter()
972
+ try:
973
+ result = fn(args, self._should_stop)
974
+ if plan_write and result.startswith("[wrote"):
975
+ self.last_plan_path = os.path.abspath(args["path"])
976
+ except Exception as e: # noqa: BLE001 - surface tool errors to model
977
+ result = f"[tool error: {type(e).__name__}: {e}]"
978
+ _tool_s = time.perf_counter() - _t0
979
+ if _PREFILL_TRACE:
980
+ self._trace_tools_pending.append([name, round(_tool_s, 4)])
981
+ render_tool_result(self._emit, name, args, result)
982
+ log.info("TOOL %s(%s) -> %s [%.2fs]", name, args_preview(args),
983
+ result_preview(result), _tool_s)
984
+ self.messages.append({"role": "tool", "name": name, "content": result})
985
+ # Update the guardrail bookkeeping flags (did_work / made_edit /
986
+ # unverified_edit) for this tool result — see guardrails.update_work_flags.
987
+ did_work, made_edit, unverified_edit = guardrails.update_work_flags(
988
+ name, args, result, did_work, made_edit, unverified_edit)
989
+ # Thrash counter (C): count back-to-back failed bash with no edit between.
990
+ consecutive_failed_bash = guardrails.update_thrash(
991
+ name, result, consecutive_failed_bash)
992
+
993
+ # Governor progress (plan 040): a fresh edit LANDED, or a pending edit got
994
+ # VERIFIED (unverified_edit cleared by a clean bash) this step — real forward
995
+ # motion, so mark the current budget band as having made progress (resets the
996
+ # checkpoint that would otherwise fire the governor).
997
+ if (made_edit and not _gov_prev_made) or (_gov_prev_unverified and not unverified_edit):
998
+ gov_progress = True
999
+
1000
+ # Break a flailing-probe run (e.g. guessing the test runner, repeated
1001
+ # `python -c import` checks) that the exact-call loop guard can't see because
1002
+ # each failing command differs by a few characters.
1003
+ thrash = guardrails.bash_thrash_nudge(consecutive_failed_bash, thrash_nudges)
1004
+ if thrash:
1005
+ thrash_nudges += 1
1006
+ log.info("THRASH nudge: %d consecutive failed bash -> nudge #%d",
1007
+ consecutive_failed_bash, thrash_nudges)
1008
+ self.messages.append({"role": "tool", "name": "bash", "content": thrash})
1009
+
1010
+ if self._should_stop():
1011
+ self.interrupted = True
1012
+ log.info("END step %d: INTERRUPTED by user", step)
1013
+ self._emit("info", " [interrupted]")
1014
+ return "[interrupted]"
1015
+ log.info("END: hit max tool steps (%d) | did_work=%s unverified_edit=%s",
1016
+ self.max_steps, did_work, unverified_edit)
1017
+ return "[stopped: hit max tool steps]"
1018
+
1019
+
1020
+ def repl(engine: BaseEngine, yolo: bool, ctx_limit: int = 24000, resume: list = None,
1021
+ thinking: bool = True):
1022
+ agent = Agent(engine, yolo=yolo, ctx_limit=ctx_limit, thinking=thinking,
1023
+ resume=resume, persist=True)
1024
+ label = engine.model_id.split("/")[-1] + (" + draft" if getattr(engine, "draft", None) else "")
1025
+ print(banner(label, ctx_limit, mode=agent.mode))
1026
+ print(f"{C_DIM}type a task, or /reset, /exit.{C_RST}")
1027
+ while True:
1028
+ try:
1029
+ line = input(f"{C_YEL}» {C_RST}").strip()
1030
+ except (EOFError, KeyboardInterrupt):
1031
+ print(); break
1032
+ if not line:
1033
+ continue
1034
+ if line in ("/exit", "/quit"):
1035
+ break
1036
+ if line in ("/reset", "/clear"):
1037
+ agent = Agent(engine, yolo=yolo, ctx_limit=ctx_limit, thinking=thinking,
1038
+ persist=True)
1039
+ engine.reset()
1040
+ print(f"{C_DIM}session reset.{C_RST}")
1041
+ continue
1042
+ if line == "/mode":
1043
+ print(f"{C_DIM}mode: {MODE_LABEL[agent.cycle_mode()]}{C_RST}")
1044
+ continue
1045
+ if line == "/compact":
1046
+ b, a = agent.compact_now()
1047
+ print(f"{C_DIM}compacted context: {b:,}→{a:,} tokens{C_RST}")
1048
+ continue
1049
+ if line == "/model":
1050
+ print(f"{C_DIM}model {engine.model_id} · context {engine.effective_ctx:,} "
1051
+ f"(compact at {ctx_limit:,}) · mode {agent.mode}{C_RST}")
1052
+ continue
1053
+ if line == "/skills":
1054
+ from . import skills
1055
+ for ln in skills.summary_lines():
1056
+ print(f"{C_DIM} {ln}{C_RST}")
1057
+ continue
1058
+ if line == "/mcp trust":
1059
+ from . import mcp
1060
+ mcp.trust()
1061
+ print(f"{C_DIM}trusted this project — its .mcp.json servers will connect "
1062
+ f"on the next turn{C_RST}")
1063
+ continue
1064
+ if line.startswith("/mcp login"):
1065
+ from . import mcp
1066
+ name = line[len("/mcp login"):].strip()
1067
+ if not name:
1068
+ print(f"{C_DIM}usage: /mcp login <server>{C_RST}")
1069
+ continue
1070
+ print(f"{C_DIM}{mcp.login(name, emit=lambda m: print(f'{C_DIM}{m}{C_RST}'))}{C_RST}")
1071
+ continue
1072
+ if line == "/mcp":
1073
+ from . import mcp
1074
+ for ln in mcp.summary_lines():
1075
+ print(f"{C_DIM} {ln}{C_RST}")
1076
+ continue
1077
+ if line == "/help":
1078
+ print(f"{C_DIM}/init /skills /mcp /mcp trust /mcp login <server> /reset /clear "
1079
+ f"/compact /model /mode /exit · !cmd runs a shell command · "
1080
+ f"@path attaches a file/dir{C_RST}")
1081
+ continue
1082
+ if line == "/init":
1083
+ agent.run_turn(INIT_PROMPT)
1084
+ agent.save()
1085
+ continue
1086
+ if line.startswith("!"): # shell passthrough — run directly, don't call the model
1087
+ cmd = line[1:].strip()
1088
+ if cmd:
1089
+ from .tools import tool_bash
1090
+ print(f"{C_DIM}{tool_bash(cmd)}{C_RST}")
1091
+ continue
1092
+ agent.run_turn(line)
1093
+ agent.save()