diff --git a/renderer.lua b/renderer.lua index a3185ae..676d5aa 100644 --- a/renderer.lua +++ b/renderer.lua @@ -266,7 +266,10 @@ end -- Norris loop exit. status ∈ {"done", "aborted", "budget_exhausted", -- "stalled", "broker_error"}. function M.norris_end(status, reason) - local color = (status == "done") and A.cyan or A.red + -- 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)), diff --git a/repl.lua b/repl.lua index 657919f..8446f36 100644 --- a/repl.lua +++ b/repl.lua @@ -1308,14 +1308,100 @@ function M.run(config) end local function run_norris(goal) + -- Phase 10 R4: clear all Norris state at the TOP. If a prior + -- Norris session crashed (uncaught broker error) leaving + -- norris_active/_goal/_tasks stale, a fresh launch should + -- start clean. Cheaper than wrapping the driver in pcall. ctx.norris_active = true ctx.norris_goal = goal ctx.norris_consecutive_skips = 0 + ctx.norris_tasks = nil ctx:append_user(("[norris] %s"):format(goal)) log_turn(ctx.turns[#ctx.turns]) renderer.norris_begin(goal) + -- Phase 10 / #89: cloud preplanner emits a TASK list ONCE per + -- :norris launch when cfg.norris.preplanner names a model + -- preset. Cheap fall-back paths everywhere: any failure keeps + -- single-model Norris behavior intact. + if config.norris and config.norris.preplanner then + local pre_name = config.norris.preplanner + local pre_cfg = config.models and config.models[pre_name] + if not pre_cfg then + renderer.status(("preplanner '%s' is not in cfg.models; " + .. "running single-model"):format(pre_name)) + else + local sys = (config.norris.preplan_system) or [[ +You are a task decomposer. Given the user's goal, decompose it into a +sequence of single-step imperative TASKs. Output format: one TASK per +line, EXACTLY this shape: + + TASK: + +Output AT MOST %d tasks. No prose; no numbering; no commentary outside +the TASK: lines. +]] + -- R1: use %d/format; gsub("N", ...) would corrupt "No prose". + local tasks_max = config.norris.tasks_max or 16 + sys = string.format(sys, tasks_max) + + local msgs = scrub_messages({ + { role = "system", content = sys }, + { role = "user", content = goal }, + }, secrets_mode_for(pre_cfg)) + + local text, usage = broker.chat(pre_cfg, msgs, { + category = "norris-preplan", + max_tokens = 800, + -- R7: respect the configured per-model timeout. + timeout_ms = pre_cfg.timeout_ms or 60000, + }) + + if not text then + renderer.status(("preplan failed: %s; " + .. "running single-model"):format(tostring(usage))) + else + if secrets_session then + text = secrets_session:rehydrate(text) + end + if usage then + _record_usage(usage.model, usage.category, usage) + end + local parsed = executor.extract_task_lines(text) + if #parsed > tasks_max then + for i = #parsed, tasks_max + 1, -1 do parsed[i] = nil end + renderer.status(("preplan emitted >%d tasks; " + .. "truncated"):format(tasks_max)) + end + if #parsed == 0 then + renderer.status("preplan produced no TASK lines; " + .. "running single-model") + else + ctx.norris_tasks = { current = 1, list = parsed } + renderer.status(("preplanned %d tasks via %s") + :format(#parsed, pre_name)) + end + end + end + end + + -- Phase 10: resolve the EXECUTOR cfg independently of preplan + -- (Q-PP1: cfg.norris.executor applies even without preplanner). + -- Fall-back: unset OR not in cfg.models -> active_cfg (the user's + -- current :model selection — existing Phase 3 behavior). + local executor_cfg = active_cfg + if config.norris and config.norris.executor then + local exe_name = config.norris.executor + local exe_cfg = config.models and config.models[exe_name] + if exe_cfg then + executor_cfg = exe_cfg + else + renderer.status(("executor '%s' is not in cfg.models; " + .. "using active model"):format(exe_name)) + end + end + local helpers = { tools_schema = tools_schema, exec_cmd = norris_exec, @@ -1355,7 +1441,9 @@ function M.run(config) local step_n = 1 local final_status, final_reason while true do - local result = safety.norris_step(ctx, active_cfg, helpers, { + -- Phase 10: pass executor_cfg (resolved above) instead of + -- active_cfg; safety.norris_step signature unchanged. + local result = safety.norris_step(ctx, executor_cfg, helpers, { step_n = step_n, max_steps = max_norris_steps, cfg = config, @@ -1367,6 +1455,18 @@ function M.run(config) status_evictions(ctx:enforce_budget()) if result.status == "continue" then step_n = step_n + 1 + -- Phase 10: advance the task pointer after each + -- non-terminal step. When exhausted (current > #list), + -- exit with synthesized "tasks_complete" status. + if ctx.norris_tasks then + ctx.norris_tasks.current = ctx.norris_tasks.current + 1 + if ctx.norris_tasks.current > #ctx.norris_tasks.list then + final_status = "tasks_complete" + final_reason = string.format("all %d preplanned tasks executed", + #ctx.norris_tasks.list) + break + end + end else final_status, final_reason = result.status, result.reason break @@ -1375,6 +1475,7 @@ function M.run(config) ctx.norris_active = false ctx.norris_goal = nil + ctx.norris_tasks = nil -- Phase 10: clear on exit for clean re-launch renderer.norris_end(final_status, final_reason) end