context + repl + config: per-class system_prompt override (closes #86)

Small local models follow precise structured instructions better than
natural language. Per-routing-class system_prompt override gives them
tighter instructions for THAT request while preserving ambient context.

Changes:

- Context:to_messages(opts) — opts.system_prompt_override REPLACES
  the base system_prompt for THIS render only (state unchanged).
  Dynamic blocks ([background], [project], [earlier summary], NORRIS
  suffix) still compose on top. opts is optional; nil-safe for old
  callers.

- repl.lua ask_ai — captures req_class from router.classify_model
  (already returned by Phase 5; previously discarded after the
  status line). Looks up config.routing.system_prompts[req_class];
  passes as opts.system_prompt_override to ctx:to_messages each
  iteration of the tool-sub-loop.

- Gating: override fires only when routing.auto is on (no class ->
  no override). If system_prompts[class] absent for a class, fall
  through to the default system_prompt (no surprise).

- Norris unaffected: safety.norris_step builds its own messages
  array; doesn't go through this path.

- config.lua gains a commented-out example showing routing.system_
  prompts with the code/default examples from the FR body.

Smoke verified:
  - 12-case context.lua unit test: opts nil/absent/present, override
    replaces base, dynamic blocks still compose, state unchanged
    after call, Norris-mode coexistence (suffix still present;
    background still suppressed).
  - E2E against cloud broker with routing.system_prompts.code set:
    triple-backtick prompt -> code class -> override fires; model
    emits terse code-only output. Non-code prompt -> default class
    -> no override -> normal verbose-ish reply.

Regression: test_safety 87/87, test_router_model 31/31, repl loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 05:41:15 +00:00
parent df59ee2f2c
commit 047d629a66
3 changed files with 47 additions and 3 deletions
+17 -1
View File
@@ -978,13 +978,27 @@ function M.run(config)
-- tool-sub-loop; active_name/active_cfg are NOT mutated so the
-- user's :model selection survives the request.
local req_name, req_cfg = active_name, active_cfg
local req_class
if config.routing and config.routing.auto then
local routed, class = router.classify_model(text, config)
req_class = class
if routed and config.models[routed] and routed ~= active_name then
renderer.status(("routed to %s (%s class)"):format(routed, class))
req_name, req_cfg = routed, config.models[routed]
end
end
-- Phase 10 (#86): per-class system_prompt override. Tightens
-- instruction adherence on small local models. Lookup is
-- gated on auto-routing being on (no class -> no override).
-- When the override is set, ctx:to_messages REPLACES the base
-- system_prompt for THIS render only; dynamic blocks
-- ([background], [project], [earlier summary], NORRIS suffix)
-- still compose on top. Norris path is unaffected — it builds
-- its own messages array inside safety.norris_step.
local sys_override = config.routing
and config.routing.system_prompts
and req_class
and config.routing.system_prompts[req_class]
local depth = 0
local final_resp = ""
@@ -994,7 +1008,9 @@ function M.run(config)
local text_parts = {}
local tool_calls_seen = {}
local redact_mode = secrets_mode_for(req_cfg)
local scrubbed_msgs = scrub_messages(ctx:to_messages(), redact_mode)
local scrubbed_msgs = scrub_messages(
ctx:to_messages({ system_prompt_override = sys_override }),
redact_mode)
-- Streaming rehydrator wraps the on_delta so the user sees real
-- values; text_parts accumulates the REHYDRATED chunks so
-- final_resp (used for CMD: / DELEGATE: extraction) is plain.