From 047d629a66eb15ad72f0c065cfed70ff32b8d60d Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sun, 17 May 2026 05:41:15 +0000 Subject: [PATCH] context + repl + config: per-class system_prompt override (closes #86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- config.lua | 20 ++++++++++++++++++++ context.lua | 12 ++++++++++-- repl.lua | 18 +++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/config.lua b/config.lua index c2ba6e4..d81f911 100644 --- a/config.lua +++ b/config.lua @@ -240,6 +240,26 @@ return { -- -- Toggle at runtime with :fallback on / :fallback off. -- fallback = false, -- default off (cost-safety) -- fallback_model = "cloud", + -- + -- -- Issue #86: per-class system_prompt override. When the + -- -- classified request falls into a class with an entry here, + -- -- the BASE system_prompt is REPLACED for that one request + -- -- (dynamic blocks — [background], [project], [earlier + -- -- summary], NORRIS suffix — still compose on top). Mostly + -- -- useful for tightening small local models' instruction + -- -- adherence. Default {} (no override). + -- system_prompts = { + -- code = [[You are a code assistant. Rules: + -- 1. Output ONLY the requested code or command. + -- 2. No prose explanation unless explicitly asked. + -- 3. Wrap shell commands in CMD: prefix. + -- 4. Max response: 200 tokens.]], + -- default = [[You are a shell assistant. + -- Output shell commands as: CMD: + -- Output answers as single short sentences. + -- Do not ask clarifying questions.]], + -- -- reasoning routes to cloud; no override usually needed + -- }, -- }, -- ── Phase 5 context summarization on sliding-window eviction. diff --git a/context.lua b/context.lua index 095d110..6acbe36 100644 --- a/context.lua +++ b/context.lua @@ -228,8 +228,16 @@ The user will be prompted to confirm destructive actions; expect their verdict in the next turn as a synthesized "[aish] ... skipped by user" message if they declined.]] -function Context:to_messages() - local sys_content = self.system_prompt +function Context:to_messages(opts) + -- Phase 10 (#86): per-call system_prompt_override. Replaces the + -- BASE system_prompt for THIS render only (state unchanged); the + -- dynamic blocks ([background], [project], [earlier summary], + -- NORRIS suffix) still compose on top. Used by ask_ai's routing + -- path when cfg.routing.system_prompts[class] is set — gives + -- small local models tighter instructions while preserving + -- ambient memory/project context. + local sys_content = (opts and opts.system_prompt_override) + or self.system_prompt -- Phase 4 [background] memory block + Phase 6 [project] file-tree -- block + Phase 5 [earlier summary] block. All suppressed during -- Norris (R-C1 / R-C4 — avoid redundant tokens per planning diff --git a/repl.lua b/repl.lua index 12212a8..dcf5d79 100644 --- a/repl.lua +++ b/repl.lua @@ -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.