76a8f97009
Phase 10 C4 — the orchestration commit. Splits Norris autonomous
mode into a one-shot cloud preplan + per-step local executor flow,
with graceful fall-back to single-model Norris when preplan is
disabled or fails.
run_norris additions (in order):
1. R4 fix: clear ctx.norris_active/_goal/_tasks at the TOP so a
prior crashed Norris can't leak stale state into the new launch.
2. Preplan block (gated on cfg.norris.preplanner):
- Look up the preplanner preset in cfg.models; warn + skip if
absent.
- Build a system prompt asking for TASK: <imperative> lines
(R1: %d via string.format — gsub("N", ...) would corrupt
"No prose / commentary / numbering" to "16o prose").
- Scrub messages per the preplan model's redact policy; run
broker.chat (non-streaming, per Q-PP2) with category
"norris-preplan"; R7: respect pre_cfg.timeout_ms.
- On success: rehydrate; record usage via _record_usage;
extract_task_lines; cap to tasks_max; populate
ctx.norris_tasks = { current = 1, list = parsed }.
- On ANY failure (transport err / empty list / bogus preset):
status log + leave ctx.norris_tasks nil → single-model
fall-back. R3 design: NOT routed via call_broker; a fallback
retry would silently swap planning models which is worse
than a clean hard-fail.
3. Executor cfg resolution (independent of preplan per Q-PP1):
cfg.norris.executor names a preset → executor_cfg = that cfg.
Unset / missing preset → executor_cfg = active_cfg (existing
:model-selection behavior).
4. Loop body: pass executor_cfg (not active_cfg) to
safety.norris_step. After each "continue" result, advance
ctx.norris_tasks.current. When current > #list, exit with
synthesized status "tasks_complete" + reason "all N preplanned
tasks executed".
5. Exit cleanup: clear ctx.norris_tasks alongside the existing
norris_active/_goal clears so a re-launch starts fresh.
renderer.norris_end gains "tasks_complete" as a non-error status
(cyan, same as "done"). Distinct from "done" (executor said
GOAL: complete) — executor exhausted the plan but didn't confirm
goal, which is a clean exit, not an error.
E2E verified (preplanner=fast, executor=fast on hossenfelder:8082):
:norris print the date and the current uptime
→ preplanned 2 tasks via fast
→ ─ step 1/3 ─ Print the current date.
→ CMD: date → Sun May 17 ...
→ ─ step 2/3 ─ Print the current uptime.
→ CMD: uptime → ... up 1 day ...
→ NORRIS TASKS COMPLETE: all 2 preplanned tasks executed
:cost detail correctly shows two rows for the same model:
norris-preplan 1 calls, 95 / 12 tokens
norris 1 calls, 364 / 9 tokens
Fall-back verified:
cfg.norris.preplanner = "doesnotexist" →
"[aish] preplanner 'doesnotexist' is not in cfg.models;
running single-model" → Norris runs as Phase 6.
No-preplan path verified (no cfg.norris block):
Norris runs exactly as Phase 6, no behavior change.
Regression: 87/87 safety, 31/31 router_model, repl loads.
Closes #89.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
285 lines
11 KiB
Lua
285 lines
11 KiB
Lua
-- renderer.lua — output formatting and ANSI sequences.
|
|
-- Phase 0: assistant text plain-printed with `CMD: ` lines highlighted;
|
|
-- exec output framed with the exit code on the closing rule.
|
|
-- Phase 1: assistant_delta + assistant_flush for streaming render. CMD:
|
|
-- highlighting in streaming mode is deferred (Q12); deltas print raw, the
|
|
-- §6 substrate `CMD: ` line is still extractable by executor afterwards.
|
|
-- Syntax highlighting hooks land in Phase 6 (was Phase 5 pre-MCP renumber).
|
|
|
|
local M = {}
|
|
|
|
local A = {
|
|
reset = "\27[0m",
|
|
bold = "\27[1m",
|
|
dim = "\27[2m",
|
|
cyan = "\27[36m",
|
|
red = "\27[31m",
|
|
}
|
|
|
|
local function emit(...) io.write(...); io.flush() end
|
|
|
|
-- Print assistant response text. Lines beginning with `CMD: ` (per the §3
|
|
-- substrate-locked extraction marker) are emitted bold+cyan so the user
|
|
-- can spot the suggestion without scanning prose.
|
|
function M.assistant(text)
|
|
for line in ((text or "") .. "\n"):gmatch("([^\n]*)\n") do
|
|
if line:sub(1, 5) == "CMD: " then
|
|
emit(A.bold, A.cyan, line, A.reset, "\n")
|
|
else
|
|
emit(line, "\n")
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Phase 1: executor.exec streams output live to stdout (PTY multiplex), so
|
|
-- the frame is split — exec_begin before the spawn, exec_end after wait().
|
|
-- The body is not re-rendered here; live output lands directly between the
|
|
-- two rules.
|
|
function M.exec_begin()
|
|
emit(A.dim, "─── exec output ───", A.reset, "\n")
|
|
end
|
|
|
|
function M.exec_end(exit_code)
|
|
if exit_code and exit_code ~= 0 then
|
|
emit(A.dim, "─── exit ", A.reset,
|
|
A.red, tostring(exit_code), A.reset,
|
|
A.dim, " ───", A.reset, "\n")
|
|
else
|
|
emit(A.dim, "─── exit 0 ───", A.reset, "\n")
|
|
end
|
|
end
|
|
|
|
-- Single-line dim status (e.g. §8 eviction notice, model switch confirms).
|
|
function M.status(line)
|
|
emit(A.dim, "[aish] ", tostring(line), A.reset, "\n")
|
|
end
|
|
|
|
-- Streaming assistant output. Phase 1: deltas are written raw — the §6 CMD:
|
|
-- highlighting from M.assistant() is not applied incrementally because
|
|
-- mid-line cursor manipulation isn't worth the complexity for Phase 1.
|
|
-- Q12 (PHASE1.md §10) tracks the upgrade. The full assistant text is still
|
|
-- captured by repl.lua and CMD: extraction works against the reassembled
|
|
-- string after the stream ends.
|
|
|
|
local stream_buf = nil -- non-nil while a stream is in progress
|
|
|
|
-- Phase 6: fence-aware highlight filter. Off by default; toggled via
|
|
-- M.set_highlight(enabled, detected, highlight_fn). State machine:
|
|
-- outside: pass chunks through; hold a small tail when the suffix
|
|
-- could be the start of an opening fence (R1 — split fences
|
|
-- from local llama.cpp need accumulation).
|
|
-- inside: buffer until closing "\n```" is seen; emit
|
|
-- highlight_fn(body, lang) then the closing fence verbatim.
|
|
-- N1: fences only open at start-of-stream OR after a newline ("^```"
|
|
-- or "\n```"); inline backticks in prose don't trigger.
|
|
local hl_enabled = false
|
|
local hl_detected = false
|
|
local hl_fn = nil -- function(body, lang) -> rendered
|
|
local hl_state = "outside" -- "outside" | "inside"
|
|
local hl_tail = "" -- outside-state lookahead
|
|
local hl_inside_buf = "" -- inside-state buffer
|
|
local hl_lang = nil -- captured at fence open
|
|
|
|
function M.set_highlight(enabled, detected, highlight_fn)
|
|
hl_enabled = not not enabled
|
|
hl_detected = not not detected
|
|
hl_fn = highlight_fn
|
|
end
|
|
|
|
function M.highlight_state()
|
|
return { enabled = hl_enabled, detected = hl_detected }
|
|
end
|
|
|
|
-- Longest suffix of `s` that is a prefix of any well-formed fence-open
|
|
-- marker ("\n```<lang>\n" or "```<lang>\n" at SOL). Returns the suffix
|
|
-- string. Bounded by max-lang-tag-length + 5.
|
|
local function _hl_partial_suffix(s)
|
|
-- Look back up to 32 chars.
|
|
local hi = math.min(#s, 32)
|
|
for k = hi, 1, -1 do
|
|
local cand = s:sub(#s - k + 1)
|
|
-- Possible prefixes of a fence-open:
|
|
-- "\n", "\n`", "\n``", "\n```", "\n```<langchars>"
|
|
-- if k == #s (full string == cand), also bare "`", "``", "```"
|
|
if cand:match("^\n`*[%w_-]*$") then return cand end
|
|
if (k == #s) and cand:match("^`*[%w_-]*$") and cand:find("`") then
|
|
return cand
|
|
end
|
|
end
|
|
return ""
|
|
end
|
|
|
|
-- Find fence open in combined string. Returns (fence_start, content_start,
|
|
-- lang) or nil. fence_start = index of first backtick; content_start =
|
|
-- index after the closing newline of the fence-info line.
|
|
local function _hl_find_open(combined)
|
|
-- Match at start-of-string OR after a newline.
|
|
local s, e, lang = combined:find("^```([%w_-]*)\n")
|
|
if s then return 1, e + 1, lang end
|
|
s, e, lang = combined:find("\n```([%w_-]*)\n")
|
|
if s then return s + 1, e + 1, lang end
|
|
return nil
|
|
end
|
|
|
|
local function _hl_push(chunk)
|
|
if not hl_enabled or not hl_fn then
|
|
emit(chunk)
|
|
return
|
|
end
|
|
if hl_state == "outside" then
|
|
local combined = hl_tail .. chunk
|
|
local fs, cs, lang = _hl_find_open(combined)
|
|
if fs then
|
|
if fs > 1 then emit(combined:sub(1, fs - 1)) end
|
|
-- Emit the fence-open line verbatim too (model + user both
|
|
-- see "```python\n" — the highlighter only colorizes BODY).
|
|
emit(combined:sub(fs, cs - 1))
|
|
hl_state = "inside"
|
|
hl_lang = (lang ~= "" and lang) or nil
|
|
hl_inside_buf = combined:sub(cs)
|
|
hl_tail = ""
|
|
-- If the closing fence is already in the inside buffer
|
|
-- (cloud may deliver whole blocks in one chunk), drain.
|
|
if hl_inside_buf:find("\n```", 1, true) then
|
|
_hl_push("") -- triggers the inside branch's close detect
|
|
end
|
|
return
|
|
end
|
|
-- No opening fence — hold the trailing partial-fence so a
|
|
-- split-fence ("``" then "`python\n") gets recognized.
|
|
local hold = _hl_partial_suffix(combined)
|
|
if #hold < #combined then
|
|
emit(combined:sub(1, #combined - #hold))
|
|
end
|
|
hl_tail = hold
|
|
return
|
|
end
|
|
|
|
-- state == "inside"
|
|
hl_inside_buf = hl_inside_buf .. chunk
|
|
local cpos = hl_inside_buf:find("\n```", 1, true)
|
|
if not cpos then return end -- still buffering
|
|
local body = hl_inside_buf:sub(1, cpos - 1)
|
|
local closing = hl_inside_buf:sub(cpos, cpos + 3) -- "\n```"
|
|
local rest = hl_inside_buf:sub(cpos + 4)
|
|
local ok, rendered = pcall(hl_fn, body, hl_lang or "")
|
|
emit((ok and rendered) or body)
|
|
emit(closing)
|
|
hl_state = "outside"
|
|
hl_inside_buf = ""
|
|
hl_lang = nil
|
|
if rest ~= "" then _hl_push(rest) end
|
|
end
|
|
|
|
function M.assistant_delta(chunk)
|
|
if not chunk or chunk == "" then return end
|
|
if stream_buf == nil then stream_buf = "" end
|
|
stream_buf = stream_buf .. chunk
|
|
_hl_push(chunk)
|
|
end
|
|
|
|
function M.assistant_flush()
|
|
if stream_buf == nil then return end
|
|
-- Flush any held tail or in-progress fence body so the user sees it.
|
|
if hl_state == "inside" and hl_inside_buf ~= "" then
|
|
-- Stream ended mid-fence — emit raw (no highlight; no closing
|
|
-- fence was seen). User sees the partial code as-is.
|
|
emit(hl_inside_buf)
|
|
hl_inside_buf = ""
|
|
hl_state = "outside"
|
|
hl_lang = nil
|
|
elseif hl_tail ~= "" then
|
|
emit(hl_tail)
|
|
hl_tail = ""
|
|
end
|
|
if not stream_buf:match("\n$") then emit("\n") end
|
|
stream_buf = nil
|
|
end
|
|
|
|
-- Phase 2: MCP tool-call frame. Visual parity with the exec_begin/exec_end
|
|
-- frame so the user reads tool dispatch and shell dispatch the same way.
|
|
-- tool_call_begin renders the top rule + (optionally) the args as a dim
|
|
-- preview; tool_call_end renders the result content followed by a status
|
|
-- rule. Status is "ok" (dim) by default; "error" (red) if is_error is true.
|
|
-- See docs/PHASE2.md §3 renderer.lua row + §4 Tool invocation.
|
|
|
|
function M.tool_call_begin(name, args)
|
|
emit(A.dim, "─── tool: ", A.reset,
|
|
A.cyan, name, A.reset,
|
|
A.dim, " ───", A.reset, "\n")
|
|
if args and args ~= "" and args ~= "{}" then
|
|
local shown = (#args <= 200) and args or (args:sub(1, 197) .. "...")
|
|
emit(A.dim, shown, A.reset, "\n")
|
|
end
|
|
end
|
|
|
|
function M.tool_call_end(content, is_error)
|
|
if content and content ~= "" then
|
|
emit(content)
|
|
if not content:match("\n$") then emit("\n") end
|
|
end
|
|
if is_error then
|
|
emit(A.dim, "─── ", A.reset,
|
|
A.red, "error", A.reset,
|
|
A.dim, " ───", A.reset, "\n")
|
|
else
|
|
emit(A.dim, "─── ok ───", A.reset, "\n")
|
|
end
|
|
end
|
|
|
|
-- Phase 3: Norris autonomous mode frames. Banner-style on enter/exit,
|
|
-- step counter per iteration, red HALT banner when the destructive-op
|
|
-- gate fires. The interactive prompt also gets a ⚡ marker when Norris
|
|
-- is active (handled in repl.lua's prompt() function per PHASE0.md §9).
|
|
-- See docs/PHASE3.md §3 renderer row.
|
|
|
|
function M.norris_begin(goal)
|
|
emit(A.bold, A.cyan, "─── NORRIS MODE ─────────────────────────",
|
|
A.reset, "\n")
|
|
if goal and goal ~= "" then
|
|
emit(A.dim, " goal: ", A.reset, goal, "\n")
|
|
end
|
|
emit(A.bold, A.cyan, "─────────────────────────────────────────",
|
|
A.reset, "\n")
|
|
end
|
|
|
|
function M.norris_step(n, max_n, descr)
|
|
emit(A.dim, (" ─ step %d/%d ─ "):format(n, max_n), A.reset)
|
|
if descr and descr ~= "" then emit(A.dim, descr, A.reset) end
|
|
emit("\n")
|
|
end
|
|
|
|
function M.norris_halt(step_n, max_n, reason, action)
|
|
emit(A.bold, A.red, "─── NORRIS HALT ──────────────────────────",
|
|
A.reset, "\n")
|
|
emit(A.dim, " step: ", A.reset, ("%d/%d"):format(step_n, max_n), "\n")
|
|
emit(A.dim, " reason: ", A.reset, A.red, tostring(reason), A.reset, "\n")
|
|
-- action may be a long string (command line or JSON-serialized tool call);
|
|
-- truncate at 400 chars to keep the banner readable
|
|
local act = tostring(action or ""):gsub("\n", " ")
|
|
if #act > 400 then act = act:sub(1, 397) .. "..." end
|
|
emit(A.dim, " action: ", A.reset, act, "\n")
|
|
emit(A.bold, A.red, "──────────────────────────────────────────",
|
|
A.reset, "\n")
|
|
end
|
|
|
|
-- Norris loop exit. status ∈ {"done", "aborted", "budget_exhausted",
|
|
-- "stalled", "broker_error"}.
|
|
function M.norris_end(status, reason)
|
|
-- Phase 10: "tasks_complete" is a success-ish exit (executor ran
|
|
-- through all preplanned tasks but didn't explicitly say GOAL: done).
|
|
local non_error = (status == "done") or (status == "tasks_complete")
|
|
local color = non_error and A.cyan or A.red
|
|
local label = status:upper():gsub("_", " ")
|
|
emit(A.bold, color, "─── NORRIS ", label, " ──",
|
|
(" "):rep(math.max(0, 28 - #label)),
|
|
A.reset, "\n")
|
|
if reason and reason ~= "" then
|
|
emit(A.dim, " ", reason, A.reset, "\n")
|
|
end
|
|
emit(A.bold, color, "──────────────────────────────────────────",
|
|
A.reset, "\n")
|
|
end
|
|
|
|
return M
|