fix: buffer exec output for next user turn; alternation for strict templates
User-test surfaced the bug: with `deep` (mistral-nemo-12b) active,
running `list files` -> y on `CMD: ls` -> `Are there directory entries
beginning with "lor"?` returned a Jinja exception:
api: ... Error: Jinja Exception: After the optional system message,
conversation roles must alternate user/assistant/user/assistant/...
Cause: §6 specified "exec output injected into context uses role 'user'
with a prefix tag '[exec output]'." This works for permissive templates
(qwen2.5-coder-1.5b, the `fast` preset) but produces a back-to-back
user/user pair on strict templates that enforce the OpenAI alternation
contract — `[exec output]` user turn followed by the user's actual
follow-up question.
Fix:
context.lua:
- new field `pending_exec_output` (initially nil)
- new method `:append_exec_output(out)` buffers (concat on subsequent
captures so multi-shell-then-ai still merges everything)
- new method `:append_user(content)` flushes buffered exec output as
a `[exec output]\n...\n\n` prefix and appends a user turn
- `:reset()` also clears the buffer
repl.lua:
- run_shell calls ctx:append_exec_output(out) instead of
ctx:append({role="user", content="[exec output]\n"..out})
- ask_ai calls ctx:append_user(text) instead of raw :append; saves
prev_pending so a broker error can restore the buffer for retry
PHASE0.md §6:
- amended the role-injection paragraph to describe the buffer-and-
prepend policy; the §3 invariants list is untouched (this was a §6
design detail, not a locked invariant)
Verification:
- context unit tests cover: alternation after the failing sequence,
multi-shell merge, reset clears buffer, broker-error retry path
- live reproduction against `deep` (mistral-nemo) of the exact
user-reported sequence succeeds; model responds with a sensible
`CMD: ls | grep '^lor'` instead of a Jinja exception
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+30
-4
@@ -20,10 +20,11 @@ Context.__index = Context
|
|||||||
function M.new(opts)
|
function M.new(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
return setmetatable({
|
return setmetatable({
|
||||||
system_prompt = opts.system_prompt or DEFAULT_SYSTEM_PROMPT,
|
system_prompt = opts.system_prompt or DEFAULT_SYSTEM_PROMPT,
|
||||||
turns = {},
|
turns = {},
|
||||||
max_turns = opts.max_turns or 40,
|
pending_exec_output = nil, -- buffered until next user turn (§6)
|
||||||
token_budget = opts.token_budget or 4096,
|
max_turns = opts.max_turns or 40,
|
||||||
|
token_budget = opts.token_budget or 4096,
|
||||||
}, Context)
|
}, Context)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -33,6 +34,30 @@ function Context:append(turn)
|
|||||||
self.turns[#self.turns + 1] = { role = turn.role, content = turn.content }
|
self.turns[#self.turns + 1] = { role = turn.role, content = turn.content }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Buffer captured shell-exec output. Per §6 (post user-test fix), exec output
|
||||||
|
-- is NOT appended as its own user turn — strict chat templates (e.g. mistral-
|
||||||
|
-- nemo's Jinja) reject the resulting user/user back-to-back. Instead it is
|
||||||
|
-- held until the next user turn arrives, then prepended via :append_user.
|
||||||
|
function Context:append_exec_output(out)
|
||||||
|
if not out or out == "" then return end
|
||||||
|
local block = "[exec output]\n" .. out
|
||||||
|
if self.pending_exec_output then
|
||||||
|
self.pending_exec_output = self.pending_exec_output .. "\n" .. block
|
||||||
|
else
|
||||||
|
self.pending_exec_output = block
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Append a user turn, flushing any pending exec output as a prefix. Use this
|
||||||
|
-- (rather than raw :append) for any turn whose role is "user".
|
||||||
|
function Context:append_user(content)
|
||||||
|
if self.pending_exec_output then
|
||||||
|
content = self.pending_exec_output .. "\n\n" .. content
|
||||||
|
self.pending_exec_output = nil
|
||||||
|
end
|
||||||
|
self:append({ role = "user", content = content })
|
||||||
|
end
|
||||||
|
|
||||||
-- Render the messages array for broker.chat (system prompt prepended; turns
|
-- Render the messages array for broker.chat (system prompt prepended; turns
|
||||||
-- in order). The system prompt is NOT stored in self.turns per §6.
|
-- in order). The system prompt is NOT stored in self.turns per §6.
|
||||||
function Context:to_messages()
|
function Context:to_messages()
|
||||||
@@ -72,6 +97,7 @@ end
|
|||||||
|
|
||||||
function Context:reset()
|
function Context:reset()
|
||||||
self.turns = {}
|
self.turns = {}
|
||||||
|
self.pending_exec_output = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
+3
-1
@@ -141,7 +141,9 @@ Each turn is stored in `context.lua` as:
|
|||||||
{ role = "system" | "user" | "assistant", content = "..." }
|
{ role = "system" | "user" | "assistant", content = "..." }
|
||||||
```
|
```
|
||||||
|
|
||||||
The system prompt is prepended on every request and is not stored as a history turn. Exec output injected into context uses role `"user"` with a prefix tag `[exec output]`.
|
The system prompt is prepended on every request and is not stored as a history turn.
|
||||||
|
|
||||||
|
**Exec output injection.** Captured shell-exec output is **not** appended as its own user turn — that produces user/user back-to-back, which strict chat templates (e.g. `mistral-nemo-instruct`'s Jinja) reject with `roles must alternate user/assistant/...`. Instead, exec output is buffered on the context and prepended to the **next** user turn with a `[exec output]` tag. Multiple shell calls between AI turns concatenate. `:reset` clears the buffer. The user-visible behavior is unchanged; only the role alternation seen by the broker differs.
|
||||||
|
|
||||||
### System prompt (Phase 0 default)
|
### System prompt (Phase 0 default)
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ function M.run(config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Run a shell command, framing output and (per config.shell.capture_output)
|
-- Run a shell command, framing output and (per config.shell.capture_output)
|
||||||
-- injecting it back into context as a `[exec output]`-tagged user turn.
|
-- buffering it for the NEXT user turn — context.append_exec_output keeps
|
||||||
|
-- a [exec output] block pending until ask_ai flushes it via append_user.
|
||||||
|
-- Direct user-role injection violated chat-template alternation (mistral-
|
||||||
|
-- nemo's Jinja rejects user/user back-to-back); see PHASE0.md §6.
|
||||||
local function run_shell(cmd)
|
local function run_shell(cmd)
|
||||||
local chd, err = executor.maybe_chdir(cmd)
|
local chd, err = executor.maybe_chdir(cmd)
|
||||||
if chd ~= nil then
|
if chd ~= nil then
|
||||||
@@ -61,19 +64,20 @@ function M.run(config)
|
|||||||
local out, code = executor.exec(cmd)
|
local out, code = executor.exec(cmd)
|
||||||
renderer.exec_output(out, code)
|
renderer.exec_output(out, code)
|
||||||
if config.shell and config.shell.capture_output then
|
if config.shell and config.shell.capture_output then
|
||||||
ctx:append({ role = "user", content = "[exec output]\n" .. out })
|
ctx:append_exec_output(out)
|
||||||
status_evictions(ctx:enforce_budget())
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Send user text to the active model, render the response, and (per
|
-- Send user text to the active model, render the response, and (per
|
||||||
-- §6 + config.shell.confirm_cmd) optionally execute extracted CMD: lines.
|
-- §6 + config.shell.confirm_cmd) optionally execute extracted CMD: lines.
|
||||||
local function ask_ai(text)
|
local function ask_ai(text)
|
||||||
ctx:append({ role = "user", content = text })
|
local prev_pending = ctx.pending_exec_output
|
||||||
|
ctx:append_user(text) -- flushes any pending [exec output] as prefix
|
||||||
local resp, err = broker.chat(active_cfg, ctx:to_messages())
|
local resp, err = broker.chat(active_cfg, ctx:to_messages())
|
||||||
if not resp then
|
if not resp then
|
||||||
renderer.status("broker error: " .. tostring(err))
|
renderer.status("broker error: " .. tostring(err))
|
||||||
table.remove(ctx.turns) -- back out the user turn we just added
|
table.remove(ctx.turns) -- back out the merged user turn
|
||||||
|
ctx.pending_exec_output = prev_pending -- restore buffered exec output
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
ctx:append({ role = "assistant", content = resp })
|
ctx:append({ role = "assistant", content = resp })
|
||||||
|
|||||||
Reference in New Issue
Block a user