repl: Norris driver + \C-n + :norris/:safety meta (Phase 3 commit #5)
Phase 3 commit #5 per docs/PHASE3.md §12. Wires safety.norris_step (commit #4) into the REPL with the user-facing surface. ffi/readline.lua extensions (A1 + R-C4): - rl_insert_text + rl_redisplay added to ffi.cdef block; M.insert_text and M.redisplay wrappers exposed. - M.bind: removed `:free()` on previous callback. Now keeps every bound callback pinned for process lifetime in `_pinned` list (alongside `_bound[seq]` for current lookup). Avoids the use-after-free window between unbind and rebind that R-C4 flagged. Memory cost is bounded — one closure per key sequence binding. context.lua Norris suffix (R-C3 / §8): - to_messages() composes a dynamic NORRIS MODE block onto the system prompt when ctx.norris_active is set. The block carries ctx.norris_goal so eviction of the user's "[norris] goal:" turn doesn't lose the anchor. Returns to plain system prompt when Norris exits. repl.lua Norris driver: - prompt() now shows ⚡ marker when ctx.norris_active per PHASE0.md §9. - \C-n bound to a real handler — inserts ":norris " at the cursor (replaces Phase 1 status placeholder). - run_norris(goal) function: sets norris_active + norris_goal, appends a "[norris] <goal>" user turn, renders the banner, then loops calling safety.norris_step with an injected helpers table until a terminal status returns. Renders the closing banner. - norris_halt(): the [N] proceed/skip/abort prompt called by safety.norris_step via helpers.halt. Empty input → abort (safe). - dispatch_tool(): factored from the Phase 2 ask_ai code so safety.norris_step can call it. - norris_exec(): factored exec path for autonomous mode (skips the interactive run_shell cd-status renderer). - :norris <goal> meta — launches autonomous mode - :norris off meta — drops Norris flag (rare; usually 'abort') - :safety patterns meta — lists active is_destructive rules - :safety check <cmd> meta — probes a hypothetical command End-to-end mock-driven test: Submitted ":norris find files in /tmp" → banner → step 1 emits tool_call (auto_approved per policy) → dispatched → frame rendered → step 2 emits "GOAL: complete" → sub-loop exits → DONE banner. 2 broker invocations, no stalls. config.lua safety example block lands in commit #6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-1
@@ -136,8 +136,40 @@ end
|
||||
-- model (user → assistant → user → assistant), no strict-template breakage.
|
||||
--
|
||||
-- The system prompt is NOT stored in self.turns per §6.
|
||||
-- Phase 3: NORRIS MODE suffix appended to the system prompt when
|
||||
-- self.norris_active. Carries self.norris_goal so eviction of the
|
||||
-- user's "[norris] goal: ..." turn doesn't lose the anchor.
|
||||
local NORRIS_SUFFIX_TEMPLATE = [[
|
||||
|
||||
|
||||
[NORRIS MODE] You are operating autonomously toward the following goal:
|
||||
|
||||
%s
|
||||
|
||||
Plan and execute step by step using CMD: lines (for shell) or tool_calls
|
||||
(when MCP tools are available). After each action, you will see its
|
||||
result in the next turn. Re-plan based on what you observe.
|
||||
|
||||
When the goal is achieved, emit a single line:
|
||||
GOAL: complete
|
||||
on its own line, optionally followed by a brief summary.
|
||||
|
||||
If the goal is unreachable or you need user input, emit:
|
||||
GOAL: blocked
|
||||
with a one-line reason.
|
||||
|
||||
Avoid destructive operations unless the goal explicitly requires them.
|
||||
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 msgs = { { role = "system", content = self.system_prompt } }
|
||||
local sys_content = self.system_prompt
|
||||
if self.norris_active and self.norris_goal then
|
||||
sys_content = sys_content
|
||||
.. string.format(NORRIS_SUFFIX_TEMPLATE, self.norris_goal)
|
||||
end
|
||||
local msgs = { { role = "system", content = sys_content } }
|
||||
|
||||
if self.use_tool_role then
|
||||
for _, t in ipairs(self.turns) do
|
||||
|
||||
+36
-8
@@ -1,7 +1,10 @@
|
||||
-- ffi/readline.lua — GNU readline binding.
|
||||
-- Phase 0: readline + add_history + EOF handling.
|
||||
-- Phase 1: custom key bindings via rl_bind_keyseq.
|
||||
-- See docs/PHASE0.md §9 and docs/PHASE1.md §7.
|
||||
-- Phase 3: rl_insert_text + rl_redisplay so bound key handlers can
|
||||
-- stuff text into the in-progress line buffer (used by \C-n
|
||||
-- to insert ":norris " in repl.lua).
|
||||
-- See docs/PHASE0.md §9 and docs/PHASE1.md §7 and docs/PHASE3.md §3.
|
||||
|
||||
local ffi = require("ffi")
|
||||
|
||||
@@ -12,6 +15,8 @@ void free(void *ptr);
|
||||
|
||||
typedef int (*rl_command_func_t)(int, int);
|
||||
int rl_bind_keyseq(const char *keyseq, rl_command_func_t function);
|
||||
int rl_insert_text(const char *text);
|
||||
int rl_redisplay(void);
|
||||
]]
|
||||
|
||||
-- libreadline-dev (which ships the unversioned `libreadline.so` symlink) is
|
||||
@@ -53,15 +58,21 @@ end
|
||||
|
||||
-- Bind `seq` (e.g. "\\C-n") to a Lua function that runs when the user types
|
||||
-- that key sequence at the readline prompt. The Lua fn takes no arguments
|
||||
-- (readline passes count + key, but Phase 1 consumers don't need them).
|
||||
-- Callback trampolines are pinned in module-local state so they outlive the
|
||||
-- M.bind call — readline retains the function pointer indefinitely.
|
||||
-- (readline passes count + key, but consumers don't need them).
|
||||
-- Callback trampolines are pinned in module-local state for process
|
||||
-- lifetime. We do NOT free the previous binding on rebind: readline
|
||||
-- retains the function pointer in its keymap, and the window between
|
||||
-- :free() and the new rl_bind_keyseq is a potential use-after-free.
|
||||
-- Memory cost is bounded — one closure per bound key sequence.
|
||||
-- (Phase 3 R-C4 fold-in.)
|
||||
-- `_pinned` keeps every callback ever cast alive for process lifetime
|
||||
-- (so readline's keymap pointers never dangle even after a re-bind).
|
||||
-- `_bound` indexes by seq for "what's currently bound here" lookup but
|
||||
-- both old and new closures stay reachable via _pinned.
|
||||
local _bound = {}
|
||||
local _pinned = {}
|
||||
|
||||
function M.bind(seq, fn)
|
||||
if _bound[seq] then
|
||||
_bound[seq]:free()
|
||||
end
|
||||
local cb = ffi.cast("rl_command_func_t", function(_count, _key)
|
||||
local ok, err = pcall(fn)
|
||||
if not ok then
|
||||
@@ -69,8 +80,25 @@ function M.bind(seq, fn)
|
||||
end
|
||||
return 0
|
||||
end)
|
||||
_pinned[#_pinned + 1] = cb -- never freed; bounded by N rebinds
|
||||
local rc = rl.rl_bind_keyseq(seq, cb)
|
||||
_bound[seq] = cb
|
||||
return rl.rl_bind_keyseq(seq, cb) == 0
|
||||
return rc == 0
|
||||
end
|
||||
|
||||
-- Insert `text` at the cursor in the in-progress readline buffer.
|
||||
-- Used by bound key handlers to stuff e.g. ":norris " into the line.
|
||||
-- Caller typically follows with M.redisplay() to refresh the display.
|
||||
function M.insert_text(text)
|
||||
if text and text ~= "" then
|
||||
rl.rl_insert_text(text)
|
||||
end
|
||||
end
|
||||
|
||||
-- Force readline to redraw the current line. Call after insert_text or
|
||||
-- any other buffer mutation from inside a bound handler.
|
||||
function M.redisplay()
|
||||
rl.rl_redisplay()
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -33,6 +33,10 @@ Meta commands:
|
||||
:mcp tool <alias__name> show one tool's inputSchema
|
||||
:mcp connect <url> [a] open an MCP session at runtime
|
||||
:mcp disconnect <alias> drop an MCP session
|
||||
:norris <goal> launch Chuck Norris autonomous mode on <goal>
|
||||
:norris off exit Norris mode (rare — usually 'abort' at halt)
|
||||
:safety patterns list active destructive-op patterns
|
||||
:safety check <cmd> probe is_destructive against <cmd> without running
|
||||
:help this message
|
||||
]]
|
||||
|
||||
@@ -186,14 +190,18 @@ function M.run(config)
|
||||
end
|
||||
|
||||
local function prompt()
|
||||
if ctx.norris_active then
|
||||
return ("[aish:%s \xE2\x9A\xA1]> "):format(active_name)
|
||||
end
|
||||
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.
|
||||
-- Phase 3: \C-n inserts ":norris " at the cursor so the user can type
|
||||
-- their goal and press Enter — routes through the meta dispatch
|
||||
-- normally. The :norris handler is implemented in `meta` below.
|
||||
rl.bind("\\C-n", function()
|
||||
renderer.status("Norris mode not yet implemented (Phase 3)")
|
||||
rl.insert_text(":norris ")
|
||||
rl.redisplay()
|
||||
end)
|
||||
|
||||
local function status_evictions(n)
|
||||
@@ -357,6 +365,113 @@ function M.run(config)
|
||||
if session then session:close(); session = nil end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- Norris driver
|
||||
-- The Phase 3 autonomous mode driver. Sets ctx.norris_active +
|
||||
-- ctx.norris_goal so context.to_messages() composes the NORRIS MODE
|
||||
-- system-prompt suffix on each broker call. Loops calling
|
||||
-- safety.norris_step until the planner returns a terminal status.
|
||||
local max_norris_steps =
|
||||
(config.safety and config.safety.max_norris_steps) or 8
|
||||
|
||||
-- The HALT prompt — proceed / skip / abort. Returns one of those
|
||||
-- three verdict strings. Used by safety.norris_step via the helpers
|
||||
-- table. \C-x\C-c also aborts (PHASE1.md §7 reserved key).
|
||||
local function norris_halt(step_n, max_n, reason, action)
|
||||
renderer.norris_halt(step_n, max_n, reason, action)
|
||||
local ans = rl.readline("[N] proceed / skip / abort? ") or ""
|
||||
local first = ans:lower():sub(1, 1)
|
||||
if first == "p" then return "proceed" end
|
||||
if first == "s" then return "skip" end
|
||||
return "abort" -- empty input or anything else → abort (safe default)
|
||||
end
|
||||
|
||||
-- Dispatch an MCP tool by name. Returns (content_string, is_error).
|
||||
-- Mirrors what the Phase 2 ask_ai tool path does, but factored so
|
||||
-- safety.norris_step can call it via helpers.
|
||||
local function dispatch_tool(name, args)
|
||||
local alias, tool_name = name:match("^(.-)__(.+)$")
|
||||
if not alias or alias == "" then
|
||||
return ("[aish] tool name has no alias prefix: %s"):format(name), true
|
||||
end
|
||||
local sess = mcp_sessions[alias]
|
||||
if not sess then
|
||||
return ("[aish] no MCP server connected for alias '%s'")
|
||||
:format(alias), true
|
||||
end
|
||||
local result, kind, err = sess:call_tool(tool_name, args)
|
||||
if not result then
|
||||
if kind == "rpc_error" then
|
||||
local msg = (type(err) == "table" and err.message) or tostring(err)
|
||||
return ("[aish] tool dispatch failed: %s"):format(msg), true
|
||||
else
|
||||
return ("[aish] tool transport error: %s"):format(tostring(err)), true
|
||||
end
|
||||
end
|
||||
local parts = {}
|
||||
for _, b in ipairs(result.content or {}) do
|
||||
if b.type == "text" then parts[#parts + 1] = b.text or "" end
|
||||
end
|
||||
return table.concat(parts, "\n"), (kind == "handler_error")
|
||||
end
|
||||
|
||||
-- Exec a shell command for Norris (mirrors run_shell minus the cd
|
||||
-- intercept which is interactive-only). Returns (output, exit_code).
|
||||
local function norris_exec(cmd)
|
||||
local chd, _ = executor.maybe_chdir(cmd)
|
||||
if chd ~= nil then
|
||||
-- cd in autonomous mode just changes our cwd silently
|
||||
return chd and "" or "[aish] cd failed", 0
|
||||
end
|
||||
return executor.exec(cmd)
|
||||
end
|
||||
|
||||
local function run_norris(goal)
|
||||
ctx.norris_active = true
|
||||
ctx.norris_goal = goal
|
||||
ctx.norris_consecutive_skips = 0
|
||||
ctx:append_user(("[norris] %s"):format(goal))
|
||||
log_turn(ctx.turns[#ctx.turns])
|
||||
|
||||
renderer.norris_begin(goal)
|
||||
|
||||
local helpers = {
|
||||
tools_schema = tools_schema,
|
||||
exec_cmd = norris_exec,
|
||||
dispatch_tool = dispatch_tool,
|
||||
extract_cmd_lines = executor.extract_cmd_lines,
|
||||
halt = norris_halt,
|
||||
render_step = renderer.norris_step,
|
||||
render_tool_begin = renderer.tool_call_begin,
|
||||
render_tool_end = renderer.tool_call_end,
|
||||
render_exec_begin = renderer.exec_begin,
|
||||
render_exec_end = renderer.exec_end,
|
||||
render_assistant_delta = renderer.assistant_delta,
|
||||
render_assistant_flush = renderer.assistant_flush,
|
||||
log_turn = log_turn,
|
||||
}
|
||||
|
||||
local step_n = 1
|
||||
local final_status, final_reason
|
||||
while true do
|
||||
local result = safety.norris_step(ctx, active_cfg, helpers, {
|
||||
step_n = step_n,
|
||||
max_steps = max_norris_steps,
|
||||
cfg = config,
|
||||
})
|
||||
if result.status == "continue" then
|
||||
step_n = step_n + 1
|
||||
else
|
||||
final_status, final_reason = result.status, result.reason
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
ctx.norris_active = false
|
||||
ctx.norris_goal = nil
|
||||
renderer.norris_end(final_status, final_reason)
|
||||
status_evictions(ctx:enforce_budget())
|
||||
end
|
||||
|
||||
-- Meta dispatch table.
|
||||
local meta = {
|
||||
quit = function() shutdown_session(); os.exit(0) end,
|
||||
@@ -540,6 +655,49 @@ function M.run(config)
|
||||
renderer.status("usage: :mcp {list|tools|tool|connect|disconnect}")
|
||||
end
|
||||
end,
|
||||
norris = function(args)
|
||||
local sub = args:match("^%s*(%S*)")
|
||||
if sub == "off" then
|
||||
if ctx.norris_active then
|
||||
ctx.norris_active = false
|
||||
ctx.norris_goal = nil
|
||||
renderer.status("Norris mode off")
|
||||
else
|
||||
renderer.status("Norris mode is not active")
|
||||
end
|
||||
return
|
||||
end
|
||||
local goal = args:match("^%s*(.-)%s*$")
|
||||
if not goal or goal == "" then
|
||||
renderer.status("usage: :norris <goal text>"); return
|
||||
end
|
||||
run_norris(goal)
|
||||
end,
|
||||
safety = function(args)
|
||||
local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
|
||||
if sub == "patterns" then
|
||||
for i, rule in ipairs(safety._patterns) do
|
||||
local ci = rule.ci and " (ci)" or ""
|
||||
io.write((" %2d. %-32s %s%s\n"):format(
|
||||
i, rule.reason, rule.pat, ci))
|
||||
end
|
||||
elseif sub == "check" then
|
||||
local cmd = sub_args:match("^%s*(.-)%s*$")
|
||||
if not cmd or cmd == "" then
|
||||
renderer.status("usage: :safety check <cmd>"); return
|
||||
end
|
||||
-- Pass cfg so the LLM probe runs; user can opt-out via
|
||||
-- :safety check --no-llm <cmd> if added in v2.
|
||||
local hit, reason = safety.is_destructive(cmd, config)
|
||||
if hit then
|
||||
renderer.status(("DESTRUCTIVE — %s"):format(reason or "?"))
|
||||
else
|
||||
renderer.status("not destructive")
|
||||
end
|
||||
else
|
||||
renderer.status("usage: :safety {patterns|check}")
|
||||
end
|
||||
end,
|
||||
help = function() io.write(HELP) end,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user