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/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
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()
|