Files
aish/executor.lua
T
marfrit cdf4e86679 repl: sub-broker delegation via DELEGATE: marker (closes #6)
Cost and context-window control: a "heavy" preset's model can offload
work to a cheaper preset without spending its own tokens on the result.
Example: deep model is mid-conversation and asks fast to summarize a
20k-line build log; the summary comes back as exec-output for the
next turn, deep stays small.

Marker syntax: DELEGATE: <preset> "<prompt>"

(Single or double quotes; one DELEGATE per line; lines without the
quoted shape are dropped — let the user write about delegation in
prose without accidental dispatch.)

Dispatch flow (mirrors CMD: / CMD&: extraction):
  1. ask_ai's stream completes
  2. extract_delegate_lines walks the final response
  3. For each {preset, prompt}: broker.chat(config.models[preset], ...)
     synchronously; result is appended via ctx:append_exec_output as
     "[delegate <preset>]: <result>"
  4. The model sees the delegate result on its next turn

Implementation choice — marker over tool: option 1 from the issue
("inline delegate marker") works with any model regardless of
tool_calls support. Option 2 (aish_delegate as a tool dispatched in
the existing Phase 2 sub-loop) is the better UX for capable models
since it returns the result mid-turn — filed as follow-up if needed.

Meta surface:
  :delegate <preset> <prompt>   one-shot direct invocation (useful for
                                testing without depending on the model
                                emitting DELEGATE:, and as a manual
                                "ask <preset> something" verb)

Scope:
  - Plan mode: emits "PLAN: DELEGATE <preset> <prompt>" without dispatch
  - Norris: not extended; the planner's model anchor would conflict with
    mid-plan switching (R-C3-adjacent risk)
  - No self-delegation guard: each DELEGATE is a separate broker call,
    not recursive; a delegate result reaching the next turn could
    contain another DELEGATE but that's bounded by max_tool_depth-style
    iteration cap on the parent
  - No cost prompt: configuring a paid cloud preset already implies
    consent to spend on it
  - Unknown preset → error status + exec-output note "[delegate X failed:
    unknown preset]"

Extractor unit-tested with 8 cases (single-quote, double-quote, multi-
line prose, empty prompt, no-quotes, case-sensitive, wrong prefix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:29:09 +00:00

170 lines
6.2 KiB
Lua

-- executor.lua — command execution.
-- Phase 1: forkpty via ffi/pty + bidirectional multiplex. Replaces Phase 0's
-- io.popen + sentinel-echo workaround. The multiplex loop forwards stdin
-- keystrokes to the child master fd while streaming master output to stdout,
-- so vim / less / htop / nano are usable end-to-end. Parent's tty (fd 0) is
-- flipped to raw mode for the duration so single-key UIs work.
-- `cd` interception is unchanged (still libc.chdir per §3, §7).
-- See docs/PHASE0.md §7 and docs/PHASE1.md §5.
local ffi = require("ffi")
local bit = require("bit")
local libc = require("ffi.libc")
local pty = require("ffi.pty")
local M = {}
local pollfd_arr2 = ffi.typeof("struct pollfd[2]")
-- Multiplex stdin (fd 0) <-> sess.master_fd until the child writes EOF.
-- Output is streamed live to stdout AND collected for the (output, code)
-- return so context.append_exec_output still has the body to inject into
-- the next user turn.
local function multiplex(sess)
local saved_termios = libc.set_raw(0) -- nil if stdin isn't a tty
local stdin_is_tty = (saved_termios ~= nil)
local fds = pollfd_arr2()
-- Only poll stdin when it's a tty. With piped stdin (scripted runs /
-- tests), aish's stdin holds the *next* aish commands queued for the
-- repl loop — draining it into the child would swallow those.
fds[0].fd = stdin_is_tty and 0 or -1
fds[0].events = libc.POLLIN
fds[1].fd = sess.master_fd
fds[1].events = libc.POLLIN
local chunks = {}
while true do
fds[0].revents = 0
fds[1].revents = 0
local rc = libc.poll(fds, 2, -1)
if rc < 0 then
if libc.errno() == libc.EINTR then
-- signal during poll; loop and retry
else
break
end
else
-- Drain master first (output priority). Read on *any* revents —
-- POLLHUP fires (and POLLIN doesn't) when the child closes its
-- slave PTY end on exit; reading then returns 0 = EOF.
if fds[1].revents ~= 0 then
local data, n = sess:read()
if not data or n == 0 then break end
chunks[#chunks + 1] = data
io.write(data); io.flush()
end
-- Forward stdin keystrokes (or piped-in bytes) to the child.
if fds[0].revents ~= 0 then
local input, n = libc.read(0, 4096)
if input and n > 0 then
sess:write(input)
elseif input == "" then
-- aish's own stdin closed; stop forwarding but keep
-- draining master until child exits
fds[0].fd = -1
end
end
end
end
if saved_termios then libc.restore_termios(0, saved_termios) end
return chunks
end
-- Execute a shell command.
-- Returns: (output_string, exit_code).
-- 0 success
-- 1..255 child exited with that status
-- 128+N child killed by signal N (bash convention)
-- -1 forkpty / spawn / wait failure
function M.exec(cmd)
if not cmd or cmd:match("^%s*$") then
return "(empty command)", -1
end
local sess, err = pty.spawn(cmd)
if not sess then
return "(pty.spawn failed: " .. tostring(err) .. ")", -1
end
local chunks = multiplex(sess)
local kind, code = sess:wait()
sess:close()
-- PTY line discipline emits \r\n for every \n the child writes; collapse
-- back to \n so the Phase 0 caller contract ("output uses \n separators")
-- still holds for context-injection purposes.
local output = table.concat(chunks):gsub("\r\n", "\n")
if kind == "exit" then return output, code end
if kind == "signal" then return output, 128 + code end
return output, -1
end
-- Intercept and apply `cd <path>` (or bare `cd` -> $HOME) without forking.
-- Returns:
-- nil : the command is not a `cd` (caller falls through to exec)
-- true : it was a cd, libc.chdir succeeded
-- false, err : it was a cd, libc.chdir failed with errmsg
function M.maybe_chdir(cmd)
local rest = cmd:match("^%s*cd%s*$") and ""
or cmd:match("^%s*cd%s+(.+)$")
if not rest then return nil end
local target = rest:match("^%s*(.-)%s*$") or ""
-- Phase 0: no $OLDPWD support, so `cd -` is not handled.
if target == "" then target = os.getenv("HOME") or "/" end
if target == "~" then target = os.getenv("HOME") or "/" end
if target:sub(1, 2) == "~/" then
target = (os.getenv("HOME") or "") .. target:sub(2)
end
return libc.chdir(target)
end
-- Extract `CMD: ` lines from an assistant response per the §6 broker contract.
-- The "CMD: " prefix is a §3 substrate invariant: exact prefix, single space,
-- start-of-line only. Leading whitespace before CMD: does NOT match.
-- "CMD&: " lines are issue #8 background variants — extracted separately so
-- repl.lua can route them to the bg spawner instead of the synchronous gate.
function M.extract_cmd_lines(text)
local cmds = {}
for line in (text or ""):gmatch("[^\n]+") do
local cmd = line:match("^CMD: (.*)$")
if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end
end
return cmds
end
function M.extract_cmd_bg_lines(text)
local cmds = {}
for line in (text or ""):gmatch("[^\n]+") do
local cmd = line:match("^CMD&: (.*)$")
if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end
end
return cmds
end
-- Issue #6: `DELEGATE: <preset> "<prompt>"` lines. Parses each into
-- (preset, prompt) — quotes around the prompt are required so the
-- parser can find the boundary unambiguously (the prompt may contain
-- arbitrary punctuation otherwise). Lines that don't match the
-- quoted shape are silently dropped (rendered as text to the user).
function M.extract_delegate_lines(text)
local out = {}
for line in (text or ""):gmatch("[^\n]+") do
local preset, prompt = line:match([[^DELEGATE: (%S+)%s+"(.+)"%s*$]])
if not preset then
preset, prompt = line:match([[^DELEGATE: (%S+)%s+'(.+)'%s*$]])
end
if preset and prompt and prompt:match("%S") then
out[#out + 1] = { preset = preset, prompt = prompt }
end
end
return out
end
return M