efdc7281c7
Phase 2 commit #5 per docs/PHASE2.md §12. Streaming broker grows tool-call support without taking a dependency on mcp.lua (caller supplies the tools array — B5 from review). chat_stream signature widens to (cfg, msgs, on_delta, opts): opts.tools - optional array, passed to the request body as the OpenAI-shape tools field. OMITTED entirely when nil or empty (#tools == 0) — some servers reject "tools": []. on_delta callback shape widens to (kind, payload): kind = "text", payload = string (Phase 1 path; unchanged semantics, signature changes from (delta) to ("text", delta)) kind = "tool_call", payload = {id, name, arguments} emitted ONCE per call on finish_reason "tool_calls" after the streaming accumulator pulls fragmented JSON-string arguments together. Accumulator behavior: - Keyed by delta.tool_calls[i].index. - If index is absent on a delta (some llama.cpp builds omit it on single-call streams; C2 in review), default to 0 with a one-shot stderr debug status per stream. - id and name captured from the opening delta of each slot. - function.arguments concatenated across all deltas as the raw JSON-string; caller (repl.lua / future Phase 2 commit #6) does dkjson.decode. - On finish_reason "tool_calls" the accumulator emits all collected calls in index order and resets. M.chat external contract unchanged (C1): wrapper now uses the new (kind, payload) shape internally but exposes the same text-string return. No caller of M.chat passes opts.tools so tool_call kinds are silently dropped. repl.lua minimal companion edit: ask_ai's chat_stream callback updated to the new shape. Text path unchanged; tool_call kinds are no-op placeholders until commit #6 lands the sub-loop. Keeps Phase 1 streaming functional between #5 and #6. Smoke-tested against hossenfelder/8082 (post-#23 fix): - text-only: ok=true, kind="text" deltas received - with opts.tools: model emitted one tool_call, accumulator collected id + name=get_weather + args={"city":"Paris"} correctly across fragmented deltas - opts.tools={}: server accepted (field omitted as required) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
294 lines
12 KiB
Lua
294 lines
12 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 history = require("history")
|
|
|
|
local M = {}
|
|
|
|
local HELP = [[
|
|
Meta commands:
|
|
:quit / :q exit aish (current session is flushed and closed)
|
|
: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
|
|
:sessions list session log files
|
|
:save <name> rename current session log to <name>.jsonl
|
|
:resume <name> load <name>.jsonl turns into the in-memory context
|
|
: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 {})
|
|
|
|
-- Session log (PHASE1.md §6). Always open one on startup; auto-write
|
|
-- every user/assistant turn; close on :quit. If history.dir is set but
|
|
-- unwritable, log a status and continue without persistence.
|
|
local history_dir = (config.history and config.history.dir) or nil
|
|
local sessions_dir = history_dir and (history_dir .. "/sessions") or nil
|
|
local session_path = sessions_dir
|
|
and (sessions_dir .. "/" .. os.date("!%Y-%m-%dT%H-%M-%SZ") .. ".jsonl")
|
|
local session
|
|
if session_path then
|
|
local sess, serr = history.open(session_path, {
|
|
started = os.date("!%Y-%m-%dT%H:%M:%SZ"),
|
|
model = active_name,
|
|
aish_version = "phase1",
|
|
})
|
|
if sess then
|
|
session = sess
|
|
else
|
|
renderer.status("session log disabled: " .. tostring(serr))
|
|
end
|
|
end
|
|
local function log_turn(turn)
|
|
if session then session:append(turn) end
|
|
end
|
|
|
|
local function prompt()
|
|
return ("[aish:%s]> "):format(active_name)
|
|
end
|
|
|
|
-- Phase 1 reserved-key wiring (PHASE1.md §7). The mechanism is real; the
|
|
-- handlers are placeholders that emit a status. Phase 3 (Norris) is the
|
|
-- first consumer that replaces the body with real work.
|
|
rl.bind("\\C-n", function()
|
|
renderer.status("Norris mode not yet implemented (Phase 3)")
|
|
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
|
|
renderer.exec_begin()
|
|
local out, code = executor.exec(cmd)
|
|
renderer.exec_end(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 token-by-token
|
|
-- via broker.chat_stream, and (per §6 + config.shell.confirm_cmd) optionally
|
|
-- execute extracted CMD: lines on the reassembled full text.
|
|
local function ask_ai(text)
|
|
local prev_pending = ctx.pending_exec_output
|
|
ctx:append_user(text) -- flushes any pending [exec output] as prefix
|
|
log_turn(ctx.turns[#ctx.turns]) -- merged user turn (may include exec)
|
|
|
|
local parts = {}
|
|
local ok, err = broker.chat_stream(active_cfg, ctx:to_messages(),
|
|
function(kind, payload)
|
|
-- Phase 2: callback shape widened to (kind, payload).
|
|
-- tool_call kinds are handled by the sub-loop landing in
|
|
-- commit #6; this commit ships only the text path so Phase
|
|
-- 1 streaming stays functional between #5 and #6.
|
|
if kind == "text" then
|
|
parts[#parts + 1] = payload
|
|
renderer.assistant_delta(payload)
|
|
end
|
|
end)
|
|
renderer.assistant_flush()
|
|
|
|
if not ok 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
|
|
local resp = table.concat(parts)
|
|
ctx:append({ role = "assistant", content = resp })
|
|
log_turn(ctx.turns[#ctx.turns])
|
|
status_evictions(ctx:enforce_budget())
|
|
|
|
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
|
|
|
|
local function shutdown_session()
|
|
if session then session:close(); session = nil end
|
|
end
|
|
|
|
-- Meta dispatch table.
|
|
local meta = {
|
|
quit = function() shutdown_session(); os.exit(0) end,
|
|
q = function() shutdown_session(); 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,
|
|
sessions = function()
|
|
if not sessions_dir then renderer.status("(no history.dir configured)"); return end
|
|
local names = history.list_sessions(sessions_dir)
|
|
if #names == 0 then renderer.status("(no sessions in " .. sessions_dir .. ")"); return end
|
|
for _, n in ipairs(names) do
|
|
local mark = (session_path and session_path:match("[^/]+$") == n)
|
|
and "*" or " "
|
|
io.write((" %s %s\n"):format(mark, n))
|
|
end
|
|
end,
|
|
save = function(args)
|
|
local name = args:match("^%s*(%S+)")
|
|
if not name then renderer.status("usage: :save <name>"); return end
|
|
if not (session and session_path and sessions_dir) then
|
|
renderer.status("no active session to save")
|
|
return
|
|
end
|
|
name = name:gsub("%.jsonl$", "")
|
|
local new_path = sessions_dir .. "/" .. name .. ".jsonl"
|
|
if new_path == session_path then
|
|
renderer.status("already named " .. name)
|
|
return
|
|
end
|
|
session:close()
|
|
local ok, rerr = os.rename(session_path, new_path)
|
|
if not ok then
|
|
renderer.status("rename failed: " .. tostring(rerr))
|
|
-- best-effort reopen of original path so logging continues
|
|
session = history.open(session_path)
|
|
return
|
|
end
|
|
session_path = new_path
|
|
session = history.open(session_path) -- reopen for continued append
|
|
renderer.status("saved as " .. name .. ".jsonl")
|
|
end,
|
|
resume = function(args)
|
|
local name = args:match("^%s*(%S+)")
|
|
if not name then renderer.status("usage: :resume <name>"); return end
|
|
if not sessions_dir then renderer.status("(no history.dir configured)"); return end
|
|
-- Refuse to silently clobber an active conversation; the user has
|
|
-- to :reset first to express intent. The current session log on
|
|
-- disk is unaffected by either choice.
|
|
if #ctx.turns > 0 then
|
|
renderer.status(("resume into non-empty ctx refused (%d turns); :reset first")
|
|
:format(#ctx.turns))
|
|
return
|
|
end
|
|
name = name:gsub("%.jsonl$", "")
|
|
local path = sessions_dir .. "/" .. name .. ".jsonl"
|
|
local turns, _meta_hdr = history.load(path)
|
|
if not turns then
|
|
renderer.status("resume failed: cannot load " .. path)
|
|
return
|
|
end
|
|
ctx:reset()
|
|
for _, t in ipairs(turns) do ctx:append(t) end
|
|
renderer.status(("resumed %d turns from %s"):format(#turns, name))
|
|
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")
|
|
shutdown_session()
|
|
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
|