Files
aish/repl.lua
T
marfrit 16490e6905 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>
2026-05-10 18:41:21 +00:00

177 lines
6.4 KiB
Lua

-- repl.lua — readline loop, input dispatch, prompt rendering.
-- Wires ffi/readline + router + executor + broker + context + renderer.
-- See docs/PHASE0.md §5 (dispatch), §9 (prompt + readline).
local rl = require("ffi.readline")
local router = require("router")
local executor = require("executor")
local broker = require("broker")
local renderer = require("renderer")
local Context = require("context")
local M = {}
local HELP = [[
Meta commands (Phase 0):
:quit / :q exit aish
:clear clear screen (history kept)
:reset clear in-memory conversation history
:model <name> switch active model
:models list configured models (* = active)
:history show conversation turns
:exec <cmd> force shell execution
:ask <text> force AI query
:help this message
]]
function M.run(config)
assert(config and config.models, "repl.run: config.models required")
local active_name = config.default_model or next(config.models)
local active_cfg = config.models[active_name]
if not active_cfg then
error("aish: default_model '" .. tostring(active_name)
.. "' not found in config.models")
end
local ctx = Context.new(config.context or {})
local function prompt()
return ("[aish:%s]> "):format(active_name)
end
local function status_evictions(n)
if n and n > 0 then
renderer.status(("oldest %d turns evicted"):format(n))
end
end
-- Run a shell command, framing output and (per config.shell.capture_output)
-- 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 chd, err = executor.maybe_chdir(cmd)
if chd ~= nil then
if chd then
local pwd = io.popen("pwd"):read("*l") or "?"
renderer.status("cwd -> " .. pwd)
else
renderer.status("cd: " .. tostring(err))
end
return
end
local out, code = executor.exec(cmd)
renderer.exec_output(out, code)
if config.shell and config.shell.capture_output then
ctx:append_exec_output(out)
end
end
-- Send user text to the active model, render the response, and (per
-- §6 + config.shell.confirm_cmd) optionally execute extracted CMD: lines.
local function ask_ai(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())
if not resp then
renderer.status("broker error: " .. tostring(err))
table.remove(ctx.turns) -- back out the merged user turn
ctx.pending_exec_output = prev_pending -- restore buffered exec output
return
end
ctx:append({ role = "assistant", content = resp })
status_evictions(ctx:enforce_budget())
renderer.assistant(resp)
for _, cmd in ipairs(executor.extract_cmd_lines(resp)) do
local doit
if config.shell and config.shell.confirm_cmd then
local ans = rl.readline(("execute '%s'? [y/N] "):format(cmd)) or ""
doit = (ans:lower():sub(1, 1) == "y")
else
doit = true
end
if doit then run_shell(cmd) end
end
end
-- Meta dispatch table.
local meta = {
quit = function() os.exit(0) end,
q = function() os.exit(0) end,
clear = function() io.write("\27[H\27[2J"); io.flush() end,
reset = function()
ctx:reset(); renderer.status("context reset")
end,
model = function(args)
local name = args:match("^%s*(%S+)")
if not name or not config.models[name] then
renderer.status("usage: :model <name>; not found: " .. tostring(name))
return
end
active_name, active_cfg = name, config.models[name]
renderer.status("model -> " .. name)
end,
models = function()
renderer.status(("models (active: %s):"):format(active_name))
for name, cfg in pairs(config.models) do
local mark = (name == active_name) and "*" or " "
io.write((" %s %-8s %s @ %s\n"):format(
mark, name, cfg.model or "?", cfg.endpoint or "?"))
end
end,
history = function()
if #ctx.turns == 0 then
renderer.status("(empty)"); return
end
for i, t in ipairs(ctx.turns) do
io.write(("[%d] %s: %s\n"):format(
i, t.role, t.content:gsub("\n", " ")))
end
end,
exec = function(args)
args = (args or ""):match("^%s*(.-)%s*$")
if args == "" then renderer.status("usage: :exec <cmd>"); return end
run_shell(args)
end,
ask = function(args)
args = (args or ""):match("^%s*(.-)%s*$")
if args == "" then renderer.status("usage: :ask <text>"); return end
ask_ai(args)
end,
help = function() io.write(HELP) end,
}
-- Main loop.
while true do
local line = rl.readline(prompt())
if line == nil then -- EOF (Ctrl-D on empty line)
io.write("\n"); break
end
if line:gsub("%s", "") == "" then
-- empty / whitespace-only: skip silently
else
rl.add_history(line)
local kind, payload = router.classify(line, config)
if kind == "meta" then
local name, rest = payload:match("^(%S+)%s*(.*)$")
local handler = name and meta[name]
if handler then
handler(rest or "")
else
renderer.status("unknown meta command: :" .. tostring(name))
end
elseif kind == "shell" then
run_shell(payload)
else -- "ai"
ask_ai(payload)
end
end
end
end
-- Phase 0 module export. Meta-command list shown above lives in HELP and
-- is implemented inline in run().
return M