From 3b14304f93c5d9e5ab9514d7b9d65a0432c023a3 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 20 Jul 2026 06:41:30 -0400 Subject: [PATCH 1/7] fix(agents): restore controllable background sessions Signed-off-by: w0wl0lxd --- n00n-config/src/lib.rs | 1 + n00n-lua/src/api/session.rs | 15 +++- n00n-lua/src/api/util/command.rs | 1 + n00n-lua/src/loader.rs | 13 ++++ n00n-ui/src/app/mod.rs | 4 + n00n-ui/src/app/mouse.rs | 8 ++ n00n-ui/src/chat.rs | 4 + n00n-ui/src/components/messages/mod.rs | 12 +++ n00n-ui/src/components/messages/tests.rs | 11 ++- n00n-ui/src/components/status_bar.rs | 13 +--- n00n-ui/src/event_loop.rs | 14 ++++ plugins/agent_control/init.lua | 89 +++++++++++++++++++++++ plugins/almas/init.lua | 23 +++++- plugins/almas/refine.lua | 74 ------------------- plugins/almas/tests/spec.lua | 15 ---- plugins/task/init.lua | 51 +++++++++---- site/docs/content/configuration/_index.md | 9 +++ site/docs/content/lua-api/_index.md | 16 ++++ site/docs/content/tools/_index.md | 34 +++++++-- 19 files changed, 283 insertions(+), 124 deletions(-) create mode 100644 plugins/agent_control/init.lua delete mode 100644 plugins/almas/refine.lua diff --git a/n00n-config/src/lib.rs b/n00n-config/src/lib.rs index 4ceb5996d..faa79d4f6 100644 --- a/n00n-config/src/lib.rs +++ b/n00n-config/src/lib.rs @@ -53,6 +53,7 @@ pub const MIN_LOW_SPEED_TIMEOUT_SECS: u64 = 1; pub const MIN_STREAM_TIMEOUT_SECS: u64 = 10; pub const DEFAULT_BUILTINS: &[&str] = &[ + "agent_control", "almas", "bash", "batch", diff --git a/n00n-lua/src/api/session.rs b/n00n-lua/src/api/session.rs index da11c4431..d3dcfcd7c 100644 --- a/n00n-lua/src/api/session.rs +++ b/n00n-lua/src/api/session.rs @@ -143,6 +143,19 @@ async fn prompt( roundtrip(lua, tx, SessionRequest::Prompt { id, text }).await } +/// Cancels the current turn in a live session without deleting the session. +/// +/// @param id string Live session id. +/// @return (boolean|nil, string|nil) true on success, or nil and an error. +#[lua_fn] +async fn cancel( + lua: Lua, + #[ctx] tx: Option>, + id: String, +) -> LuaResult { + roundtrip(lua, tx, SessionRequest::Cancel { id }).await +} + /// Renames a session, live or stored. /// /// @param opts table Required fields: id (string) session to rename; @@ -170,7 +183,7 @@ lua_table! { /// the pair `(value, err)`. Without an interactive UI attached, every /// call returns `nil, "no interactive UI attached"`. "n00n.session" => pub(crate) fn create_session_table(tx: Option>), - DOCS [list(tx), live(tx), current(tx), focus(tx), delete(tx), new(tx), prompt(tx), set_title(tx)] + DOCS [list(tx), live(tx), current(tx), focus(tx), delete(tx), new(tx), prompt(tx), cancel(tx), set_title(tx)] } #[cfg(test)] diff --git a/n00n-lua/src/api/util/command.rs b/n00n-lua/src/api/util/command.rs index a02c32fc0..3a430c180 100644 --- a/n00n-lua/src/api/util/command.rs +++ b/n00n-lua/src/api/util/command.rs @@ -393,6 +393,7 @@ pub enum SessionRequest { Current, New { prompt: Option, focus: bool }, Prompt { id: Option, text: String }, + Cancel { id: String }, Focus { id: String }, Delete { id: String }, SetTitle { id: String, title: String }, diff --git a/n00n-lua/src/loader.rs b/n00n-lua/src/loader.rs index 1d012edab..928c84ced 100644 --- a/n00n-lua/src/loader.rs +++ b/n00n-lua/src/loader.rs @@ -27,6 +27,10 @@ struct BundledPlugin { /// `lib` is not a default builtin; it exists so plugins can /// `require()` shared modules across boundaries. static BUNDLED_PLUGINS: &[BundledPlugin] = &[ + BundledPlugin { + name: "agent_control", + dir: include_dir!("$CARGO_MANIFEST_DIR/../plugins/agent_control"), + }, BundledPlugin { name: "sessions", dir: include_dir!("$CARGO_MANIFEST_DIR/../plugins/sessions"), @@ -660,6 +664,15 @@ mod tests { assert!(contents(&slots, PromptId::System, Slot::ToolUsage).is_empty()); } + #[test] + fn task_and_almas_builtins_coexist() { + let reg = Arc::new(ToolRegistry::new()); + let _host = PluginHost::with_all_builtins(Arc::clone(®)).unwrap(); + assert!(reg.has("agent_control")); + assert!(reg.has("task")); + assert!(reg.has("almas")); + } + #[test] fn pipelined_load_registers_every_builtin() { let reg = Arc::new(ToolRegistry::new()); diff --git a/n00n-ui/src/app/mod.rs b/n00n-ui/src/app/mod.rs index 3e8da8183..ba0104d87 100644 --- a/n00n-ui/src/app/mod.rs +++ b/n00n-ui/src/app/mod.rs @@ -962,6 +962,10 @@ impl App { self.submit_or_queue(sub.into()) } + pub(crate) fn cancel_current_run(&mut self) -> Vec { + self.handle_cancel() + } + fn handle_cancel(&mut self) -> Vec { let cancelled_run = self.run_id; self.run_id += 1; diff --git a/n00n-ui/src/app/mouse.rs b/n00n-ui/src/app/mouse.rs index 207df88ec..85980653d 100644 --- a/n00n-ui/src/app/mouse.rs +++ b/n00n-ui/src/app/mouse.rs @@ -72,6 +72,14 @@ impl App { { self.copy_text(&text, format!("Copied {label}")); } else { + let session = self.chats[self.active_chat] + .tool_id_at(event.row, area) + .and_then(|id| self.chat_index.get(id)) + .copied(); + if let Some(idx) = session { + self.active_chat = idx; + return; + } self.chats[self.active_chat].handle_click(event.row, area); } } diff --git a/n00n-ui/src/chat.rs b/n00n-ui/src/chat.rs index 4fd622928..ee4fb50d9 100644 --- a/n00n-ui/src/chat.rs +++ b/n00n-ui/src/chat.rs @@ -279,6 +279,10 @@ impl Chat { self.messages_panel.copy_at(row, col, area) } + pub fn tool_id_at(&self, row: u16, area: Rect) -> Option<&str> { + self.messages_panel.tool_id_at(row, area) + } + pub fn handle_click(&mut self, row: u16, area: Rect) { self.messages_panel.handle_click(row, area); } diff --git a/n00n-ui/src/components/messages/mod.rs b/n00n-ui/src/components/messages/mod.rs index a7290ae58..b76e743ef 100644 --- a/n00n-ui/src/components/messages/mod.rs +++ b/n00n-ui/src/components/messages/mod.rs @@ -716,6 +716,18 @@ impl MessagesPanel { Some((text.to_owned(), label)) } + pub fn tool_id_at(&self, row: u16, area: Rect) -> Option<&str> { + if area.height == 0 { + return None; + } + let doc_row = (row.saturating_sub(area.y)) as u32 + self.scroll_top as u32; + self.cache + .segment_at_row(doc_row, self.viewport_width)? + .1 + .tool_id + .as_deref() + } + pub fn handle_click(&mut self, row: u16, area: Rect) -> bool { if area.height == 0 { return false; diff --git a/n00n-ui/src/components/messages/tests.rs b/n00n-ui/src/components/messages/tests.rs index 4bcbb4305..b18e11518 100644 --- a/n00n-ui/src/components/messages/tests.rs +++ b/n00n-ui/src/components/messages/tests.rs @@ -256,7 +256,7 @@ fn working_state_does_not_overlay_thinking_status_on_chat() { ); assert!( !text.contains("thinking..."), - "working status belongs only in the bottom status bar: {text:?}" + "thinking status belongs in expandable chat lines: {text:?}" ); } @@ -1305,6 +1305,15 @@ fn handle_click_returns_nothing_when_no_segment_at_row() { assert!(!panel.handle_click(23, area)); } +#[test] +fn tool_id_at_returns_clicked_tool() { + let mut panel = MessagesPanel::new(UiConfig::default()); + panel.tool_start(start("task1", "task")); + render(&mut panel, 80, 24); + let area = Rect::new(0, 0, 80, 24); + assert_eq!(panel.tool_id_at(area.y, area), Some("task1")); +} + #[test] fn handle_click_on_done_tool_records_click_row() { let mut panel = MessagesPanel::new(UiConfig::default()); diff --git a/n00n-ui/src/components/status_bar.rs b/n00n-ui/src/components/status_bar.rs index c56fb7580..706dc4b88 100644 --- a/n00n-ui/src/components/status_bar.rs +++ b/n00n-ui/src/components/status_bar.rs @@ -108,14 +108,6 @@ impl StatusBar { pub fn view(&self, frame: &mut Frame, area: Rect, ctx: &StatusBarContext) { let mut left_spans = Vec::new(); - if *ctx.status == Status::Streaming { - let ch = spinner_frame(self.started_at.elapsed().as_millis()); - left_spans.push(Span::styled( - format!(" {ch} thinking..."), - theme::current().spinner, - )); - } - if ctx.restoring { let ch = spinner_frame(self.started_at.elapsed().as_millis()); left_spans.push(Span::styled( @@ -367,7 +359,7 @@ mod tests { } #[test] - fn streaming_status_renders_thinking_at_bottom_left() { + fn streaming_status_does_not_render_thinking_in_status_bar() { use n00n_providers::{ModelPricing, TokenUsage}; use ratatui::{Terminal, backend::TestBackend}; @@ -413,7 +405,8 @@ mod tests { .map(|cell| cell.symbol()) .collect(); - assert!(text.contains("thinking... NORMAL"), "status bar: {text:?}"); + assert!(!text.contains("thinking..."), "status bar: {text:?}"); + assert!(text.contains("NORMAL"), "status bar: {text:?}"); } #[test] diff --git a/n00n-ui/src/event_loop.rs b/n00n-ui/src/event_loop.rs index b434d4807..38116ddd5 100644 --- a/n00n-ui/src/event_loop.rs +++ b/n00n-ui/src/event_loop.rs @@ -733,6 +733,20 @@ impl<'t> EventLoop<'t> { }; let _ = reply_tx.send(idx.and_then(|idx| self.submit_text(idx, text))); } + SessionRequest::Cancel { id } => { + let reply = parse_session_id(&id).and_then(|id| { + let idx = self + .position(id) + .ok_or_else(|| format!("{NOT_LIVE_ERR}: {id}"))?; + if SessionStatus::of(&self.sessions[idx].app) != SessionStatus::Working { + return Err(format!("session is not working: {id}")); + } + let actions = self.sessions[idx].app.cancel_current_run(); + self.dispatch(idx, actions); + Ok(json!(true)) + }); + let _ = reply_tx.send(reply); + } SessionRequest::Focus { id } => { let reply = parse_session_id(&id) .and_then(|id| self.focus_session(id)) diff --git a/plugins/agent_control/init.lua b/plugins/agent_control/init.lua new file mode 100644 index 000000000..029d7e80b --- /dev/null +++ b/plugins/agent_control/init.lua @@ -0,0 +1,89 @@ +local description = [[Control background agents started by task or ALMAS. + +Actions: +- list: list every live agent and its status. +- status: inspect one live agent by agent_id. +- message: queue steering instructions for an agent. +- stop: cancel an agent's current run without deleting its session.]] + +local schema = { + type = "object", + required = { "action" }, + additionalProperties = false, + properties = { + action = { + type = "string", + enum = { "list", "status", "message", "stop" }, + description = "Control action.", + }, + agent_id = { + type = "string", + description = "Background agent id. Required for status, message, and stop.", + }, + message = { + type = "string", + description = "Steering instructions. Required for message.", + }, + }, +} + +local function find_agent(id) + local agents, err = n00n.session.live() + if not agents then + return nil, err + end + for _, agent in ipairs(agents) do + if agent.id == id then + return agent + end + end + return nil, "background agent is not live: " .. id +end + +local function handler(input) + if input.action == "list" then + local agents, err = n00n.session.live() + if not agents then + return { llm_output = err, is_error = true } + end + return n00n.json.encode(agents) + end + + if not input.agent_id or input.agent_id == "" then + return { llm_output = "agent_id is required for " .. input.action, is_error = true } + end + + if input.action == "status" then + local agent, err = find_agent(input.agent_id) + if not agent then + return { llm_output = err, is_error = true } + end + return n00n.json.encode(agent) + end + + if input.action == "message" then + if not input.message or input.message == "" then + return { llm_output = "message is required for message", is_error = true } + end + local state, err = n00n.session.prompt(input.message, { session = input.agent_id }) + if not state then + return { llm_output = err, is_error = true } + end + return n00n.json.encode({ agent_id = input.agent_id, state = state }) + end + + local stopped, err = n00n.session.cancel(input.agent_id) + if not stopped then + return { llm_output = err, is_error = true } + end + return n00n.json.encode({ agent_id = input.agent_id, stopped = true }) +end + +n00n.api.register_tool({ + name = "agent_control", + description = description, + kind = "execute", + audiences = { "main" }, + schema = schema, + handler = handler, +}) diff --git a/plugins/almas/init.lua b/plugins/almas/init.lua index 0781672f2..900f030d7 100644 --- a/plugins/almas/init.lua +++ b/plugins/almas/init.lua @@ -4,7 +4,6 @@ -- subagent on a cost-aware model tier. Built entirely on n00n.agent.* and the -- existing provider/model-tier machinery — no core changes. local memory = require("mem") -local refine = require("refine") local retrieve = require("retrieve") local roles = require("roles") local ibn = require("ibn") @@ -58,6 +57,8 @@ Notes: 4. With compact (opt-in), retrieved context is TOON-encoded to save tokens (PR-C). 5. swarm mode runs bounded rounds (max_rounds); the β gate may fall back to a single strong-agent pass when coordination would not help. +6. Set background=true to start a non-blocking ALMAS run and receive an agent_id. + Use agent_control to inspect, steer, or stop it. ]] local schema = { @@ -114,6 +115,10 @@ local schema = { default = true, description = "Require validator quorum for autonomous validation and swarm acceptance.", }, + background = { + type = "boolean", + description = "Start ALMAS in a separate background session and return its agent_id immediately.", + }, compact = { type = "boolean", default = false, @@ -305,6 +310,20 @@ end local finish_run local function handler(input, ctx) + if input.background then + local forwarded = {} + for key, value in pairs(input) do + forwarded[key] = value + end + forwarded.background = false + local prompt = "Use the almas tool now. Do not only describe this request.\n\n" .. n00n.json.encode(forwarded) + local id, err = n00n.session.new({ prompt = prompt, focus = false }) + if not id then + return { llm_output = err, is_error = true } + end + return n00n.json.encode({ agent_id = id, status = "started" }) + end + input.mode = input.mode or "supervised" input.model_tier = input.model_tier or "strong" if input.auto_tier == nil then @@ -313,7 +332,7 @@ local function handler(input, ctx) if input.thinking == nil then input.thinking = "max" end - local goal = refine.refine_goal(ctx, input.goal, input) + local goal = input.goal local slug = memory.slug(input.goal) local prior = memory.load(ctx, slug) diff --git a/plugins/almas/refine.lua b/plugins/almas/refine.lua deleted file mode 100644 index 9bbffdb5b..000000000 --- a/plugins/almas/refine.lua +++ /dev/null @@ -1,74 +0,0 @@ --- Adaptive prompt refinement (HALO): turn a raw goal into an actionable brief --- with an explicit acceptance criterion. Lexical only in v1 (no model call); --- a strong-model rewrite can replace this later without changing callers. -local M = {} - -function M.refine_goal_lexical(goal) - local g = (goal or ""):gsub("^%s+", ""):gsub("%s+$", "") - if g == "" then - return goal - end - local brief = g - local has_criterion = brief:match("%?$") - or brief:lower():find("verify", 1, true) - or brief:lower():find("test", 1, true) - or brief:lower():find("accept", 1, true) - if not has_criterion then - brief = brief .. "\n\nAcceptance: implement the change and verify it builds and tests pass." - end - return brief -end - --- @param ctx AgentContext? (optional, triggers model-based refinement when present) --- @param goal string Raw user goal. --- @param opts table? Model selection and thinking options. --- @return string Refined brief. -function M.refine_goal(ctx, goal, opts) - opts = opts or {} - local resolved_ctx = ctx - local resolved_goal = goal - - if not goal and type(ctx) == "string" then - resolved_goal = ctx - resolved_ctx = nil - end - - if not resolved_ctx then - return M.refine_goal_lexical(resolved_goal) - end - - local model, merr = n00n.agent.resolve_model( - resolved_ctx, - { spec = opts.model, tier = not opts.model and (opts.model_tier or "strong") or nil } - ) - if merr then - return M.refine_goal_lexical(resolved_goal) - end - - local system = "You are an expert SDLC project manager. Refine this high-level development goal " - .. "into a detailed, clear, and actionable development brief. Identify any implicit requirements, " - .. "edge cases, or potential technical risks, and define concrete acceptance criteria. Be concise and precise." - - local sess, sess_err = n00n.agent.session(resolved_ctx, { - model_spec = model.spec, - system = system, - audience = "general_sub", - name = "almas-halo-refiner", - thinking = opts.thinking, - }) - if sess_err then - return M.refine_goal_lexical(resolved_goal) - end - - local prompt = "Refine this goal:\n" .. resolved_goal - local res, rerr = sess:prompt(prompt) - sess:close() - - if rerr or not res or not res.text or res.text == "" then - return M.refine_goal_lexical(resolved_goal) - end - - return res.text -end - -return M diff --git a/plugins/almas/tests/spec.lua b/plugins/almas/tests/spec.lua index f5059f510..193aee1d4 100644 --- a/plugins/almas/tests/spec.lua +++ b/plugins/almas/tests/spec.lua @@ -1,4 +1,3 @@ -local refine = require("refine") local roles = require("roles") local route_tier = require("n00n.route_tier") @@ -15,20 +14,6 @@ local function assert(c, m) end end -case("refine_adds_acceptance", function() - local out = refine.refine_goal_lexical("add a retry helper") - assert(out:find("Acceptance"), "refine must add an acceptance criterion") -end) - -case("refine_keeps_existing_criterion", function() - local out = refine.refine_goal_lexical("add a retry helper and verify tests pass") - assert(not out:find("Acceptance:"), "refine must not double-add a criterion") -end) - -case("refine_empty_goal", function() - assert(refine.refine_goal_lexical("") == "", "empty goal stays empty") -end) - case("roles_catalogue_has_five", function() local n = 0 for _ in pairs(roles.ROLES) do diff --git a/plugins/task/init.lua b/plugins/task/init.lua index 57d27c689..5959b527a 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -40,6 +40,8 @@ Notes: 4. Tell it to return concise summaries with file:line refs, not full file contents. 5. With `auto_tier`, the model tier is picked from the prompt (cheap work -> weak, hard work -> strong). This is opt-in and off by default. +6. Set background=true to start a non-blocking agent and receive an agent_id. + Use agent_control to inspect, steer, or stop it. ]] local schema = { @@ -75,6 +77,10 @@ local schema = { type = "boolean", description = "Pick model_tier from the prompt automatically (opt-in). Overrides model_tier when set.", }, + background = { + type = "boolean", + description = "Start this task in a separate background session and return its agent_id immediately.", + }, output_schema = { description = "JSON Schema (object) the subagent's final result must match. When set, the result is returned as a validated JSON string.", }, @@ -141,6 +147,26 @@ local function make_preview(ctx, description) end local function handler(input, ctx) + if input.background then + local prompt = "Use the task tool now with background=false. Do not only describe this request.\n\n" + .. n00n.json.encode({ + description = input.description, + prompt = input.prompt, + subagent_type = input.subagent_type, + model = input.model, + model_tier = input.model_tier, + thinking = input.thinking, + auto_tier = input.auto_tier, + output_schema = input.output_schema, + background = false, + }) + local id, err = n00n.session.new({ prompt = prompt, focus = false }) + if not id then + return { llm_output = err, is_error = true } + end + return n00n.json.encode({ agent_id = id, status = "started" }) + end + local subagent_type = input.subagent_type or "research" if subagent_type ~= "research" and subagent_type ~= "general" then return { llm_output = "unknown subagent type: " .. subagent_type, is_error = true } @@ -213,19 +239,6 @@ local function handler(input, ctx) local preview = make_preview(ctx, input.description or "task") - local function on_finish(err, result) - if err then - ctx:finish({ llm_output = "task failed: " .. tostring(err), is_error = true, body = preview.buf }) - else - ctx:finish({ - llm_output = result.llm_output, - body = preview.buf, - is_error = result.is_error, - format = result.format, - }) - end - end - n00n.async.run(function() local permit = semaphore:acquire() local ok, out = pcall(function() @@ -286,10 +299,16 @@ local function handler(input, ctx) end) permit:release() if not ok then - error(out, 0) + ctx:finish({ llm_output = "task failed: " .. tostring(out), is_error = true, body = preview.buf }) + return end - return out - end, on_finish) + ctx:finish({ + llm_output = out.llm_output, + body = preview.buf, + is_error = out.is_error, + format = out.format, + }) + end) return nil end diff --git a/site/docs/content/configuration/_index.md b/site/docs/content/configuration/_index.md index f93a36acb..401b9d230 100644 --- a/site/docs/content/configuration/_index.md +++ b/site/docs/content/configuration/_index.md @@ -80,6 +80,7 @@ How many lines of output to show per tool in the UI. All values are `usize` with | `bash` | 5 | | `code_execution` | 5 | | `task` | 5 | +| `workflow` | 8 | | `index` | 3 | | `grep` | 3 | | `read` | 3 | @@ -215,6 +216,14 @@ n00n.setup({ | `max_output_lines` | integer | - | - | Override `agent.max_output_lines` for this tool. | | `max_response_bytes` | integer | `5242880` | 1024 | Stop reading a response after this many bytes. | +### `plugins.workflow` + +| Field | Type | Default | Min | Description | +|-------|------|---------|-----|-------------| +| `max_concurrent_agents` | integer | `8` | 1 | Max subagents one parallel()/pipeline() call runs at once. | +| `max_concurrent_workflows` | integer | `4` | 1 | Max concurrently running workflows. | +| `timeout_secs` | integer | `600` | 1 | Hard deadline for one workflow run (cancels pure-Lua runaway loops via the VM watchdog). | + ## Validation If a value is below its minimum, N00n shows a `ConfigError` with the field name, value, and minimum. diff --git a/site/docs/content/lua-api/_index.md b/site/docs/content/lua-api/_index.md index 2d782d020..51c6a0b98 100644 --- a/site/docs/content/lua-api/_index.md +++ b/site/docs/content/lua-api/_index.md @@ -2619,6 +2619,22 @@ local state, err = n00n.session.prompt("run the tests", { session = id }) --- +### `n00n.session.cancel()` {#n00n-session-cancel} + +```lua +n00n.session.cancel({id}) +``` + +Cancels the current turn in a live session without deleting the session. + +**Parameters:** + +- `{id}` (`string`) Live session id. + +**Returns:** (`boolean|nil`, `string|nil`) true on success, or nil and an error. + +--- + ### `n00n.session.set_title()` {#n00n-session-set_title} ```lua diff --git a/site/docs/content/tools/_index.md b/site/docs/content/tools/_index.md index 271533862..8623a59d9 100644 --- a/site/docs/content/tools/_index.md +++ b/site/docs/content/tools/_index.md @@ -7,7 +7,7 @@ group = "Reference" # Tools -N00n ships with 21 built-in tools. This is the full reference. +N00n ships with 23 built-in tools. This is the full reference. ## File Operations @@ -163,6 +163,7 @@ Launch an ALMAS team. A supervisor decomposes an SDLC goal into role agents and |-----------|------|----------|---------|-------------| | `compact` | boolean | no | | Encode retrieved context as TOON (token-saving, opt-in). | | `model_tier` | string | no | | Supervisor/model tier (weak/medium/strong). Defaults to strong when model is omitted. | +| `background` | boolean | no | | Start ALMAS in a separate background session and return its agent_id immediately. | | `quorum` | boolean | no | | Require validator quorum for autonomous validation and swarm acceptance. | | `max_concurrent` | integer | no | 8 | Swarm mode only: max concurrent subagents per round. | | `ibn_gate` | boolean | no | | Use the information-bottleneck fan-out gate in swarm mode. | @@ -180,15 +181,26 @@ Launch an autonomous subagent to perform tasks independently. Best combined with | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `output_schema` | object | no | JSON Schema (object) the subagent's final result must match. When set, the result is returned as a validated JSON string. | -| `auto_tier` | boolean | no | Pick model_tier from the prompt automatically (opt-in). Overrides model_tier when set. | | `description` | string | yes | Short (3-5 words) description of the task | -| `model` | string | no | Exact model spec (optional, e.g. openai/gpt-5.6-luna). Overrides model_tier. | | `model_tier` | string | no | Model tier (optional, omit to use current model, capped at current tier):
- "strong" (e.g. Opus): Deep reasoning, complex architecture, subtle bugs, most critical sections. ~5x cost of medium.
- "medium" (e.g. Sonnet): Balanced. Refactors, features, multi-file changes.
- "weak" (e.g. Haiku): Fast/cheap. Search, summarize, boilerplate, simple edits. | +| `auto_tier` | boolean | no | Pick model_tier from the prompt automatically (opt-in). Overrides model_tier when set. | +| `background` | boolean | no | Start this task in a separate background session and return its agent_id immediately. | +| `model` | string | no | Exact model spec (optional, e.g. openai/gpt-5.6-luna). Overrides model_tier. | +| `output_schema` | object | no | JSON Schema (object) the subagent's final result must match. When set, the result is returned as a validated JSON string. | | `prompt` | string | yes | Detailed task prompt for the agent | | `thinking` | string/integer | no | Thinking mode: "off", "adaptive", "minimal", "low", "medium", "high", "xhigh", "max", or a token budget. Omit to inherit the user setting. | | `subagent_type` | string | no | Subagent type: "research" (read-only, default) or "general" (can modify files) | +### `workflow` *(lua plugin)* + +Run a workflow script that orchestrates many subagents at scale. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `inputs` | object | no | Free-form object exposed to the script as the global `inputs`. | +| `script` | string | yes | Lua workflow script. First statement: meta({...}). Then orchestrate with agent/parallel/pipeline/phase/log. Must return the final answer as a string. | +| `resume` | string | no | Prior run_id. Replays journaled agent() results and only spends tokens on new calls. | + ### `todo_write` *(lua plugin)* Create or update a structured todo list to track tasks. @@ -234,4 +246,16 @@ Search the web for real-time information using Exa AI. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `num_results` | integer | no | 8 | Number of results to return | -| `query` | string | yes | | Search query | \ No newline at end of file +| `query` | string | yes | | Search query | + +## Additional tools + +### `agent_control` *(lua plugin)* + +Control background agents started by task or ALMAS. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `message` | string | no | Steering instructions. Required for message. | +| `action` | string | yes | Control action. | +| `agent_id` | string | no | Background agent id. Required for status, message, and stop. | \ No newline at end of file From 64b4d4b5dd30535aa2ffd3ada0d7eaaad65d9160 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 20 Jul 2026 10:03:21 -0400 Subject: [PATCH 2/7] fix(agents): address background session review Signed-off-by: w0wl0lxd --- n00n-docgen/src/gen_tools.rs | 10 +++++++++- n00n-lua/src/api/session.rs | 22 ++++++++++++++++++++++ n00n-ui/src/app/mouse.rs | 7 +++++-- n00n-ui/src/app/tests.rs | 13 +++++++++++++ n00n-ui/src/event_loop.rs | 4 ++-- plugins/task/init.lua | 17 ++++++----------- site/docs/content/tools/_index.md | 24 +++++++++++------------- 7 files changed, 68 insertions(+), 29 deletions(-) diff --git a/n00n-docgen/src/gen_tools.rs b/n00n-docgen/src/gen_tools.rs index 7cf4f52fe..28155b671 100644 --- a/n00n-docgen/src/gen_tools.rs +++ b/n00n-docgen/src/gen_tools.rs @@ -34,7 +34,15 @@ const SECTIONS: &[(&str, &[&str])] = &[ ), ( "Agent & Knowledge", - &["almas", "task", "workflow", "todo_write", "memory", "skill"], + &[ + "agent_control", + "almas", + "task", + "workflow", + "todo_write", + "memory", + "skill", + ], ), ("Web", &["webfetch", "websearch"]), ]; diff --git a/n00n-lua/src/api/session.rs b/n00n-lua/src/api/session.rs index d3dcfcd7c..44d28a45e 100644 --- a/n00n-lua/src/api/session.rs +++ b/n00n-lua/src/api/session.rs @@ -228,6 +228,28 @@ mod tests { assert_eq!(val.get::("focused").unwrap(), "abc"); } + #[test] + fn cancel_forwards_session_id() { + let (tx, rx) = flume::unbounded::(); + let lua = lua_with_session(Some(tx)); + let checker = std::thread::spawn(move || { + let Ok(UiAction::Session { + req: SessionRequest::Cancel { id }, + reply_tx, + }) = rx.recv() + else { + panic!("expected cancel request"); + }; + assert_eq!(id, "abc"); + reply_tx.send(Ok(json!(true))).unwrap(); + }); + let (val, err): (bool, Option) = + smol::block_on(lua.load("return session.cancel('abc')").eval_async()).unwrap(); + checker.join().unwrap(); + assert_eq!(err, None); + assert!(val); + } + #[test_case("return session.prompt('hi', { session = 'abc' })", Some("abc") ; "explicit_session_id")] #[test_case("return session.prompt('hi')", None ; "defaults_to_focused")] fn prompt_forwards_text_and_session_id(code: &str, expected_id: Option<&str>) { diff --git a/n00n-ui/src/app/mouse.rs b/n00n-ui/src/app/mouse.rs index 85980653d..96ad0bd15 100644 --- a/n00n-ui/src/app/mouse.rs +++ b/n00n-ui/src/app/mouse.rs @@ -74,8 +74,11 @@ impl App { } else { let session = self.chats[self.active_chat] .tool_id_at(event.row, area) - .and_then(|id| self.chat_index.get(id)) - .copied(); + .and_then(|id| { + self.chats.iter().position(|chat| { + chat.tool_use_id.as_deref() == Some(id) + }) + }); if let Some(idx) = session { self.active_chat = idx; return; diff --git a/n00n-ui/src/app/tests.rs b/n00n-ui/src/app/tests.rs index 3056c2471..adc5279f9 100644 --- a/n00n-ui/src/app/tests.rs +++ b/n00n-ui/src/app/tests.rs @@ -797,6 +797,19 @@ fn batch_subagent_done_marker(is_error: bool, expected_text: &str, expected_role assert_eq!(app.chats[1].last_message_role(), Some(expected_role)); } +#[test] +fn completed_subagent_chat_remains_discoverable_by_tool_id() { + let mut app = app_with_subagent(); + finish_subagent_task(&mut app, false); + app.chat_index.clear(); + + let idx = app + .chats + .iter() + .position(|chat| chat.tool_use_id.as_deref() == Some("task1")); + assert_eq!(idx, Some(1)); +} + fn open_tasks_picker(app: &mut App) { for c in "/tasks".chars() { app.update(Msg::Key(key(KeyCode::Char(c)))); diff --git a/n00n-ui/src/event_loop.rs b/n00n-ui/src/event_loop.rs index 38116ddd5..7dc443342 100644 --- a/n00n-ui/src/event_loop.rs +++ b/n00n-ui/src/event_loop.rs @@ -738,8 +738,8 @@ impl<'t> EventLoop<'t> { let idx = self .position(id) .ok_or_else(|| format!("{NOT_LIVE_ERR}: {id}"))?; - if SessionStatus::of(&self.sessions[idx].app) != SessionStatus::Working { - return Err(format!("session is not working: {id}")); + if SessionStatus::of(&self.sessions[idx].app) == SessionStatus::Idle { + return Err(format!("session is idle: {id}")); } let actions = self.sessions[idx].app.cancel_current_run(); self.dispatch(idx, actions); diff --git a/plugins/task/init.lua b/plugins/task/init.lua index 5959b527a..91bab9050 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -148,18 +148,13 @@ end local function handler(input, ctx) if input.background then + local forwarded = {} + for key, value in pairs(input) do + forwarded[key] = value + end + forwarded.background = false local prompt = "Use the task tool now with background=false. Do not only describe this request.\n\n" - .. n00n.json.encode({ - description = input.description, - prompt = input.prompt, - subagent_type = input.subagent_type, - model = input.model, - model_tier = input.model_tier, - thinking = input.thinking, - auto_tier = input.auto_tier, - output_schema = input.output_schema, - background = false, - }) + .. n00n.json.encode(forwarded) local id, err = n00n.session.new({ prompt = prompt, focus = false }) if not id then return { llm_output = err, is_error = true } diff --git a/site/docs/content/tools/_index.md b/site/docs/content/tools/_index.md index 8623a59d9..79528354b 100644 --- a/site/docs/content/tools/_index.md +++ b/site/docs/content/tools/_index.md @@ -155,6 +155,16 @@ Use this tool when you need to ask the user questions during execution. This all ## Agent & Knowledge +### `agent_control` *(lua plugin)* + +Control background agents started by task or ALMAS. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `message` | string | no | Steering instructions. Required for message. | +| `action` | string | yes | Control action. | +| `agent_id` | string | no | Background agent id. Required for status, message, and stop. | + ### `almas` *(lua plugin)* Launch an ALMAS team. A supervisor decomposes an SDLC goal into role agents and runs each as its own subagent on a cost-aware model tier: @@ -246,16 +256,4 @@ Search the web for real-time information using Exa AI. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `num_results` | integer | no | 8 | Number of results to return | -| `query` | string | yes | | Search query | - -## Additional tools - -### `agent_control` *(lua plugin)* - -Control background agents started by task or ALMAS. - -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `message` | string | no | Steering instructions. Required for message. | -| `action` | string | yes | Control action. | -| `agent_id` | string | no | Background agent id. Required for status, message, and stop. | \ No newline at end of file +| `query` | string | yes | | Search query | \ No newline at end of file From 59dc3ca3b50bcec03b1893522952359a3c5af273 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 20 Jul 2026 10:37:20 -0400 Subject: [PATCH 3/7] fix(agents): harden task and ALMAS execution Signed-off-by: w0wl0lxd --- n00n-lua/tests/task_policy.rs | 28 ++++++- plugins/almas/init.lua | 114 +++++++++++++------------- plugins/task/init.lua | 128 +++++++++++++++--------------- site/docs/content/tools/_index.md | 7 +- 4 files changed, 149 insertions(+), 128 deletions(-) diff --git a/n00n-lua/tests/task_policy.rs b/n00n-lua/tests/task_policy.rs index b4e79d284..c8fa3f4c8 100644 --- a/n00n-lua/tests/task_policy.rs +++ b/n00n-lua/tests/task_policy.rs @@ -403,11 +403,8 @@ fn raising_prompt_does_not_leak_semaphore_permit() { assert_eq!(out, PLAIN_TEXT); } -/// A task handler that uses `n00n.async.run` with an `on_finish` callback must -/// be able to finish after `DISPATCH_POLL_INTERVAL` even when no OS jobs are -/// visible to the parent task. #[test] -fn slow_async_callback_finishes() { +fn slow_nested_tool_finishes() { let (reg, _host) = load_task_host(); let out = exec_tool(®, TASK_TOOL, task_input(SCENARIO_SLOW, None)) .expect("slow async callback should finish"); @@ -417,3 +414,26 @@ fn slow_async_callback_finishes() { assert_eq!(snap["prompt_count"], json!(1)); assert_eq!(snap["closed"], json!(1)); } + +#[test] +fn three_concurrent_tasks_return_results_without_nil_lifecycle_errors() { + let (reg, _host) = load_task_host(); + let calls: Vec<_> = (0..3) + .map(|_| { + let reg = Arc::clone(®); + std::thread::spawn(move || exec_tool(®, TASK_TOOL, task_input(SCENARIO_PLAIN, None))) + }) + .collect(); + + for call in calls { + let out = call + .join() + .expect("task thread panicked") + .expect("concurrent task failed"); + assert_eq!(out, PLAIN_TEXT); + } + + let snap = probe(®); + assert_eq!(snap["sessions"], json!(3)); + assert_eq!(snap["closed"], json!(3)); +} diff --git a/plugins/almas/init.lua b/plugins/almas/init.lua index 900f030d7..4cdb49bf2 100644 --- a/plugins/almas/init.lua +++ b/plugins/almas/init.lua @@ -10,6 +10,13 @@ local ibn = require("ibn") local quorum = require("quorum") local swarm = require("swarm") +local MAX_PLAN_STEPS = 8 +local DEFAULT_PLAN_STEPS = 6 +local DEFAULT_SWARM_ROUNDS = 2 +local MAX_SWARM_ROUNDS = 4 +local ALMAS_TIMEOUT_SECS = 1800 +local MAX_RELAY_BYTES = 12000 + local PLANNER_OUTPUT = { type = "object", required = { "steps" }, @@ -78,12 +85,22 @@ local schema = { }, max_rounds = { type = "integer", - description = "Swarm mode only: max coordination rounds (default 4).", + minimum = 1, + maximum = MAX_SWARM_ROUNDS, + description = "Swarm mode only: max coordination rounds (default 2, maximum 4).", }, max_concurrent = { type = "integer", + minimum = 1, + maximum = 8, description = "Swarm mode only: max concurrent subagents per round (default 8).", }, + max_steps = { + type = "integer", + minimum = 1, + maximum = MAX_PLAN_STEPS, + description = "Maximum supervisor plan steps to execute (default 6, maximum 8).", + }, model = { type = "string", description = "Exact model spec for every ALMAS agent. Overrides model_tier and role tiers.", @@ -94,7 +111,7 @@ local schema = { }, thinking = { type = { "string", "integer" }, - description = 'Thinking mode for ALMAS agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "max".', + description = 'Thinking mode for ALMAS agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "adaptive".', }, auto_tier = { type = "boolean", @@ -195,46 +212,23 @@ local function run_supervisor(ctx, goal, opts) if not captured then return nil, "supervisor produced no plan" end - return captured.steps, nil -end - -local function generate_learnings_digest(ctx, goal, report, opts) - local model, merr = - n00n.agent.resolve_model(ctx, { spec = opts.model, tier = not opts.model and opts.model_tier or nil }) - if merr then - return nil + local max_steps = math.min(opts.max_steps or DEFAULT_PLAN_STEPS, MAX_PLAN_STEPS) + local steps = {} + for i = 1, math.min(#captured.steps, max_steps) do + steps[i] = captured.steps[i] end - - local system = "You are a senior supervisor reviewing the execution of a multi-agent software engineering run. " - .. "Your task is to analyze the step-by-step reports and produce a concise, actionable summary of " - .. "'learnings' and 'context' for future runs. Focus on architectural facts discovered, what succeeded, " - .. "what failed and how it was resolved, and constraints to remember. Do not include raw CLI output or verbose logs. " - .. "Keep it under 250 words." - - local sess, sess_err = n00n.agent.session(ctx, { - model_spec = model.spec, - system = system, - audience = "general_sub", - name = "almas-learning-digest", - thinking = opts.thinking, - }) - if sess_err then - return nil - end - - local prompt = string.format("Original Goal:\n%s\n\nExecution Report:\n%s", goal, report) - local res, rerr = sess:prompt(prompt) - sess:close() - - if rerr or not res or not res.text or res.text == "" then - return nil + if #steps == 0 then + return nil, "supervisor produced an empty plan" end - - return res.text + return steps, nil end -local function run_step(ctx, step, goal, input, relay_k) +local function run_step(ctx, step, goal, input, relay_k, prior_results) local step_prompt = step.prompt + if prior_results and #prior_results > 0 then + local prior = table.concat(prior_results, "\n\n"):sub(-MAX_RELAY_BYTES) + step_prompt = step_prompt .. "\n\nResults from earlier plan steps:\n" .. prior + end if input.use_retrieval ~= false then local block = retrieve.retrieve(ctx, goal .. " " .. step.prompt, step.role, relay_k) if block and #block > 0 then @@ -261,16 +255,19 @@ end local function run_autonomous(ctx, goal, input, steps, relay_k) local results = {} local total_cost = 0.0 + local failures = 0 for i, step in ipairs(steps) do - local r = run_step(ctx, step, goal, input, relay_k) + local r = run_step(ctx, step, goal, input, relay_k, results) if not r.ok then + failures = failures + 1 results[#results + 1] = string.format("[%d] %s: ERROR %s", i, step.role, r.error) + break else local cost_line = string.format(" (~$%.4f, %s)", r.cost or 0, r.model or "?") results[#results + 1] = string.format("[%d] %s%s:\n%s", i, step.role, cost_line, r.text or "") total_cost = total_cost + (r.cost or 0) - if input.quorum ~= false and (step.role == "tester" or step.role == "reviewer") then + if input.quorum ~= false and step.role == "reviewer" then local verdict = quorum.validate(ctx, table.concat(results, "\n\n"), { n = 3, model = input.model, thinking = input.thinking }) if not verdict.accepted then @@ -284,7 +281,7 @@ local function run_autonomous(ctx, goal, input, steps, relay_k) end end end - return results, total_cost + return results, total_cost, failures end -- Information-bottleneck fallback: a single strong-agent pass when fanning out @@ -293,18 +290,21 @@ end local function run_single_pass(ctx, goal, input, steps, relay_k) local results = {} local total_cost = 0.0 + local failures = 0 for i, step in ipairs(steps) do - local r = run_step(ctx, step, goal, input, relay_k) + local r = run_step(ctx, step, goal, input, relay_k, results) r.model = r.model or "strong" if not r.ok then + failures = failures + 1 results[#results + 1] = string.format("[%d] %s: ERROR %s", i, step.role, r.error) + break else local cost_line = string.format(" (~$%.4f, %s)", r.cost or 0, r.model or "?") results[#results + 1] = string.format("[%d] %s%s:\n%s", i, step.role, cost_line, r.text or "") total_cost = total_cost + (r.cost or 0) end end - return results, total_cost + return results, total_cost, failures end local finish_run @@ -330,7 +330,7 @@ local function handler(input, ctx) input.auto_tier = input.model == nil end if input.thinking == nil then - input.thinking = "max" + input.thinking = "adaptive" end local goal = input.goal @@ -370,8 +370,8 @@ local function handler(input, ctx) if gate.fan_out then local out = swarm.run(ctx, goal, { relay_k = relay_k, - max_rounds = input.max_rounds or 4, - max_concurrent = input.max_concurrent or 8, + max_rounds = math.min(input.max_rounds or DEFAULT_SWARM_ROUNDS, MAX_SWARM_ROUNDS), + max_concurrent = math.min(input.max_concurrent or 8, 8), model = input.model, thinking = input.thinking, quorum = input.quorum, @@ -384,27 +384,26 @@ local function handler(input, ctx) end -- β gate says don't fan out: single strong-agent pass, log the reason. - local results, total_cost = run_single_pass(ctx, goal, input, steps, relay_k) + local results, total_cost, failures = run_single_pass(ctx, goal, input, steps, relay_k) results[1] = "[swarm] β gate: " .. gate.reason .. "\n" .. (results[1] or "") - return finish_run(ctx, input, results, total_cost, #steps, "steps", slug) + return finish_run(ctx, input, results, total_cost, #results, "steps", slug, failures) end - local results, total_cost = run_autonomous(ctx, goal, input, steps, relay_k) - return finish_run(ctx, input, results, total_cost, #steps, "steps", slug) + local results, total_cost, failures = run_autonomous(ctx, goal, input, steps, relay_k) + return finish_run(ctx, input, results, total_cost, #results, "steps", slug, failures) end -finish_run = function(ctx, input, results, total_cost, completed, unit, slug) +finish_run = function(ctx, input, results, total_cost, completed, unit, slug, failures) local report = table.concat(results, "\n\n") local summary = string.format("\n\n---\nALMAS complete: %d %s, ~$%.4f estimated cost.", completed, unit, total_cost) - local digest = generate_learnings_digest(ctx, input.goal, report, input) - if digest then - memory.save(ctx, slug, digest) - else - memory.save(ctx, slug, report .. summary) - end + memory.save(ctx, slug, report .. summary) - return { llm_output = report .. summary, format = "markdown" } + local failed = failures or 0 + if failed > 0 then + summary = summary .. string.format(" %d step(s) failed; the run is incomplete.", failed) + end + return { llm_output = report .. summary, format = "markdown", is_error = failed > 0 } end local function header(input) @@ -417,6 +416,7 @@ n00n.api.register_tool({ kind = "execute", audiences = { "main", "workflow" }, schema = schema, + timeout = ALMAS_TIMEOUT_SECS, handler = handler, header = header, }) diff --git a/plugins/task/init.lua b/plugins/task/init.lua index 91bab9050..2f624e840 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -13,6 +13,7 @@ local STRUCTURED_OUTPUT_DESCRIPTION = "Report your final result. Call it exactly local STRUCTURED_OUTPUT_ACK = "Output recorded." local STRUCTURED_OUTPUT_PROMPT_SUFFIX = "\n\nWhen finished, call the structured_output tool with your final result." local MAX_SCHEMA_ERRORS = 3 +local TASK_TIMEOUT_SECS = 3600 local MAX_STRUCTURED_RETRIES = 2 local SCHEMA_ROOT_ERROR = "output_schema must have type object" local SCHEMA_COMPILE_ERROR = "invalid output_schema" @@ -234,78 +235,76 @@ local function handler(input, ctx) local preview = make_preview(ctx, input.description or "task") - n00n.async.run(function() - local permit = semaphore:acquire() - local ok, out = pcall(function() - local sess, sess_err = n00n.agent.session(ctx, { - model_spec = model.spec, - system = system, - tools = tool_defs, - local_tools = local_tools, - audience = audience, - name = input.description, - thinking = input.thinking, - }) - if sess_err then - return { llm_output = sess_err, is_error = true } - end + local permit = semaphore:acquire() + local ok, out = pcall(function() + local sess, sess_err = n00n.agent.session(ctx, { + model_spec = model.spec, + system = system, + tools = tool_defs, + local_tools = local_tools, + audience = audience, + name = input.description, + thinking = input.thinking, + }) + if sess_err then + return { llm_output = sess_err, is_error = true } + end - local function do_prompt() - local message = input.prompt - if validator then - message = message .. STRUCTURED_OUTPUT_PROMPT_SUFFIX - end - local result, err = sess:prompt(message) - local retries = 0 - while not err and validator and not captured and retries < MAX_STRUCTURED_RETRIES do - retries = retries + 1 - result, err = sess:prompt(NUDGE_MISSING) - end - if err then - return { llm_output = "sub-agent error: " .. err, is_error = true } - end - if validator and not captured then - local msg = last_errors and (STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) or STRUCTURED_MISSING_ERROR - return { llm_output = msg, is_error = true } - end - return { llm_output = captured and n00n.json.encode(captured) or result.text, format = "markdown" } + local function do_prompt() + local message = input.prompt + if validator then + message = message .. STRUCTURED_OUTPUT_PROMPT_SUFFIX + end + local result, err = sess:prompt(message) + local retries = 0 + while not err and validator and not captured and retries < MAX_STRUCTURED_RETRIES do + retries = retries + 1 + result, err = sess:prompt(NUDGE_MISSING) + end + if err then + return { llm_output = "sub-agent error: " .. err, is_error = true } + end + if validator and not captured then + local msg = last_errors and (STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) or STRUCTURED_MISSING_ERROR + return { llm_output = msg, is_error = true } + end + if not result or not result.text or result.text == "" then + return { llm_output = "sub-agent returned no output", is_error = true } end + return { llm_output = captured and n00n.json.encode(captured) or result.text, format = "markdown" } + end - local function do_poll() - while true do - local progress, err = sess:get_progress() - if not progress then - return - end - preview:update(progress) - if progress.done then - return - end + local function do_poll() + while true do + local progress = sess:get_progress() + if not progress then + return + end + preview:update(progress) + if progress.done then + return end end + end - local results = n00n.async.gather({ do_prompt, do_poll }) - sess:close() - local prompt_res = results[1] - if not prompt_res.ok then - error(prompt_res.err, 0) - end - return prompt_res.value - end) - permit:release() - if not ok then - ctx:finish({ llm_output = "task failed: " .. tostring(out), is_error = true, body = preview.buf }) - return + local results = n00n.async.gather({ do_prompt, do_poll }) + sess:close() + local prompt_res = results[1] + if not prompt_res.ok then + error(prompt_res.err, 0) end - ctx:finish({ - llm_output = out.llm_output, - body = preview.buf, - is_error = out.is_error, - format = out.format, - }) + return prompt_res.value end) - - return nil + permit:release() + if not ok then + return { llm_output = "task failed: " .. tostring(out), is_error = true, body = preview.buf } + end + return { + llm_output = out.llm_output, + body = preview.buf, + is_error = out.is_error, + format = out.format, + } end local function header(input) @@ -338,6 +337,7 @@ n00n.api.register_tool({ audiences = { "main", "workflow" }, examples = examples, schema = schema, + timeout = TASK_TIMEOUT_SECS, handler = handler, header = header, restore = restore, diff --git a/site/docs/content/tools/_index.md b/site/docs/content/tools/_index.md index 79528354b..4bef6feb3 100644 --- a/site/docs/content/tools/_index.md +++ b/site/docs/content/tools/_index.md @@ -171,19 +171,20 @@ Launch an ALMAS team. A supervisor decomposes an SDLC goal into role agents and | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| +| `background` | boolean | no | | Start ALMAS in a separate background session and return its agent_id immediately. | | `compact` | boolean | no | | Encode retrieved context as TOON (token-saving, opt-in). | | `model_tier` | string | no | | Supervisor/model tier (weak/medium/strong). Defaults to strong when model is omitted. | -| `background` | boolean | no | | Start ALMAS in a separate background session and return its agent_id immediately. | | `quorum` | boolean | no | | Require validator quorum for autonomous validation and swarm acceptance. | +| `max_steps` | integer | no | 6, maximum 8 | Maximum supervisor plan steps to execute. | | `max_concurrent` | integer | no | 8 | Swarm mode only: max concurrent subagents per round. | | `ibn_gate` | boolean | no | | Use the information-bottleneck fan-out gate in swarm mode. | -| `max_rounds` | integer | no | 4 | Swarm mode only: max coordination rounds. | +| `max_rounds` | integer | no | 2, maximum 4 | Swarm mode only: max coordination rounds. | | `goal` | string | yes | | High-level SDLC goal, e.g. 'Add a retry helper and cover it with tests.' | | `model` | string | no | | Exact model spec for every ALMAS agent. Overrides model_tier and role tiers. | | `use_retrieval` | boolean | no | | Ground steps with repo retrieval. | | `mode` | string | no | | "supervised" (default, return the plan for review), "autonomous" (run the plan), or "swarm" (decentralized SwarmSys rounds). | | `auto_tier` | boolean | no | | Route each subagent tier from its step prompt. Defaults to true unless an exact model is set. | -| `thinking` | string/integer | no | | Thinking mode for ALMAS agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "max". | +| `thinking` | string/integer | no | | Thinking mode for ALMAS agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "adaptive". | ### `task` *(lua plugin)* From 603ab66ab31e6d1e969218515299fc7e03c8513a Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 20 Jul 2026 11:01:03 -0400 Subject: [PATCH 4/7] refactor(agents): rename ALMAS to team Signed-off-by: w0wl0lxd --- AGENTS.md | 2 +- n00n-config/src/lib.rs | 2 +- n00n-docgen/src/gen_tools.rs | 2 +- n00n-lua/src/api/agent.rs | 2 +- n00n-lua/src/loader.rs | 8 ++--- n00n-lua/tests/plugin_host.rs | 14 ++++---- n00n-lua/tests/spec.rs | 2 +- n00n-ui/src/app/mod.rs | 29 +++++++++------ n00n-ui/src/app/tests.rs | 15 ++++++++ n00n-ui/src/components/command.rs | 2 +- plugins/agent_control/init.lua | 2 +- plugins/lib/n00n/route_tier.lua | 2 +- plugins/{almas => team}/ibn.lua | 0 plugins/{almas => team}/init.lua | 48 ++++++++++++------------- plugins/{almas => team}/mem.lua | 2 +- plugins/{almas => team}/quorum.lua | 2 +- plugins/{almas => team}/retrieve.lua | 2 +- plugins/{almas => team}/roles.lua | 4 +-- plugins/{almas => team}/swarm.lua | 2 +- plugins/{almas => team}/tests/spec.lua | 0 scripts/{almas_demo.sh => team_demo.sh} | 26 +++++++------- site/docs/content/commands/_index.md | 4 +-- site/docs/content/lua-api/_index.md | 4 +-- site/docs/content/tools/_index.md | 12 +++---- 24 files changed, 106 insertions(+), 82 deletions(-) rename plugins/{almas => team}/ibn.lua (100%) rename plugins/{almas => team}/init.lua (92%) rename plugins/{almas => team}/mem.lua (94%) rename plugins/{almas => team}/quorum.lua (98%) rename plugins/{almas => team}/retrieve.lua (98%) rename plugins/{almas => team}/roles.lua (94%) rename plugins/{almas => team}/swarm.lua (99%) rename plugins/{almas => team}/tests/spec.lua (100%) rename scripts/{almas_demo.sh => team_demo.sh} (62%) diff --git a/AGENTS.md b/AGENTS.md index ca912a2c0..4de315b93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Rust workspace, key crates in root dir: - n00n-lua: Lua plugin system (API mirrored from neovim for plugin compatibility), built-in plugins in ./plugins dir - n00n-acp: ACP ndjson stdio server -Built-in lua plugins in ./plugins: index (return a compact skeleton of a source file using tree-sitter), bash, glob, question, skill, memory, webfetch, websearch, todo_write, read, write, edit, task, workflow (Lua script orchestration of subagents), code_execution (python sandbox), batch, almas. +Built-in lua plugins in ./plugins: index (return a compact skeleton of a source file using tree-sitter), bash, glob, question, skill, memory, webfetch, websearch, todo_write, read, write, edit, task, workflow (Lua script orchestration of subagents), code_execution (python sandbox), batch, team. ## Docs diff --git a/n00n-config/src/lib.rs b/n00n-config/src/lib.rs index faa79d4f6..3ec7794f1 100644 --- a/n00n-config/src/lib.rs +++ b/n00n-config/src/lib.rs @@ -54,7 +54,6 @@ pub const MIN_STREAM_TIMEOUT_SECS: u64 = 10; pub const DEFAULT_BUILTINS: &[&str] = &[ "agent_control", - "almas", "bash", "batch", "code_execution", @@ -68,6 +67,7 @@ pub const DEFAULT_BUILTINS: &[&str] = &[ "sessions", "skill", "task", + "team", "todo_write", "view_image", "webfetch", diff --git a/n00n-docgen/src/gen_tools.rs b/n00n-docgen/src/gen_tools.rs index 28155b671..146b3298d 100644 --- a/n00n-docgen/src/gen_tools.rs +++ b/n00n-docgen/src/gen_tools.rs @@ -36,7 +36,7 @@ const SECTIONS: &[(&str, &[&str])] = &[ "Agent & Knowledge", &[ "agent_control", - "almas", + "team", "task", "workflow", "todo_write", diff --git a/n00n-lua/src/api/agent.rs b/n00n-lua/src/api/agent.rs index d816ac607..4783cce33 100644 --- a/n00n-lua/src/api/agent.rs +++ b/n00n-lua/src/api/agent.rs @@ -145,7 +145,7 @@ async fn resolve_model( /// Estimate the dollar cost of a completion from its model spec and token /// counts. Uses the provider's published pricing (input/output/cache write/ -/// read), so orchestrators like ALMAS can report cost without bundling a +/// read), so orchestrators like Team can report cost without bundling a /// price table. /// /// @param spec string Model spec, e.g. `"anthropic/claude-haiku-4-5"`. diff --git a/n00n-lua/src/loader.rs b/n00n-lua/src/loader.rs index 928c84ced..a71f581dd 100644 --- a/n00n-lua/src/loader.rs +++ b/n00n-lua/src/loader.rs @@ -100,8 +100,8 @@ static BUNDLED_PLUGINS: &[BundledPlugin] = &[ dir: include_dir!("$CARGO_MANIFEST_DIR/../plugins/workflow"), }, BundledPlugin { - name: "almas", - dir: include_dir!("$CARGO_MANIFEST_DIR/../plugins/almas"), + name: "team", + dir: include_dir!("$CARGO_MANIFEST_DIR/../plugins/team"), }, BundledPlugin { name: "code_execution", @@ -665,12 +665,12 @@ mod tests { } #[test] - fn task_and_almas_builtins_coexist() { + fn task_and_team_builtins_coexist() { let reg = Arc::new(ToolRegistry::new()); let _host = PluginHost::with_all_builtins(Arc::clone(®)).unwrap(); assert!(reg.has("agent_control")); assert!(reg.has("task")); - assert!(reg.has("almas")); + assert!(reg.has("team")); } #[test] diff --git a/n00n-lua/tests/plugin_host.rs b/n00n-lua/tests/plugin_host.rs index 567c7721c..34b0a35be 100644 --- a/n00n-lua/tests/plugin_host.rs +++ b/n00n-lua/tests/plugin_host.rs @@ -3432,17 +3432,17 @@ fn bundled_todo_ctrl_t_keybind_dispatches() { } #[test] -fn almas_launcher_collects_goal_and_submits_configured_prompt() { +fn team_launcher_collects_goal_and_submits_configured_prompt() { let (_reg, host) = builtins_host(); let rx = host.ui_action_rx().unwrap(); let handle = host.event_handle().unwrap(); - handle.run_command(Arc::from("almas"), Arc::from("/almas"), String::new()); + handle.run_command(Arc::from("team"), Arc::from("/team"), String::new()); let action = rx .recv_timeout(Duration::from_secs(5)) - .expect("ALMAS launcher did not open"); + .expect("Team launcher did not open"); let n00n_lua::UiAction::OpenWin { event_tx, .. } = action else { - panic!("expected ALMAS launcher window"); + panic!("expected Team launcher window"); }; event_tx .send(n00n_lua::WinEvent::Paste { @@ -3457,9 +3457,9 @@ fn almas_launcher_collects_goal_and_submits_configured_prompt() { let action = rx .recv_timeout(Duration::from_secs(5)) - .expect("ALMAS launcher did not submit a session prompt"); + .expect("Team launcher did not submit a session prompt"); let n00n_lua::UiAction::Session { req, reply_tx } = action else { - panic!("expected ALMAS session prompt"); + panic!("expected Team session prompt"); }; let n00n_lua::SessionRequest::Prompt { id, text } = req else { panic!("expected a prompt request"); @@ -3474,7 +3474,7 @@ fn almas_launcher_collects_goal_and_submits_configured_prompt() { "submitted prompt: {text}" ); assert!( - text.contains("Use the almas tool now"), + text.contains("Use the team tool now"), "submitted prompt: {text}" ); assert!(text.contains("thinking: max"), "submitted prompt: {text}"); diff --git a/n00n-lua/tests/spec.rs b/n00n-lua/tests/spec.rs index 75178a91f..b74dda801 100644 --- a/n00n-lua/tests/spec.rs +++ b/n00n-lua/tests/spec.rs @@ -4,7 +4,7 @@ use n00n_agent::tools::ToolRegistry; use n00n_lua::PluginHost; use test_case::test_case; -#[test_case("almas", include_str!("../../plugins/almas/tests/spec.lua") ; "almas_plugin_spec")] +#[test_case("team", include_str!("../../plugins/team/tests/spec.lua") ; "team_plugin_spec")] #[test_case("edit", include_str!("../../plugins/edit/tests/spec.lua") ; "edit_plugin_spec")] #[test_case("index", include_str!("../../plugins/index/tests/spec.lua") ; "index_plugin_spec")] #[test_case("lib", include_str!("../../plugins/lib/tests/spec.lua") ; "lib_spec")] diff --git a/n00n-ui/src/app/mod.rs b/n00n-ui/src/app/mod.rs index ba0104d87..94ce0f8ec 100644 --- a/n00n-ui/src/app/mod.rs +++ b/n00n-ui/src/app/mod.rs @@ -94,7 +94,8 @@ const IMPLEMENT_PARALLEL_HINT: &str = "Use batch+task to parallelize, assign eac const TASK_DONE_DETAIL: &str = "✓ done"; const TASK_ERROR_DETAIL: &str = "✗ error"; const TASK_RUNNING_DETAIL: &str = "◈ running"; -const TASK_PANEL_FOOTER: &[(&str, &str)] = &[("enter", "attach"), ("esc", "close")]; +const TASK_PANEL_FOOTER: &[(&str, &str)] = + &[("enter", "open"), ("ctrl+x", "toggle"), ("esc", "close")]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum TaskStatus { @@ -485,15 +486,23 @@ impl App { } else { TaskStatus::Running }; - let usage = (c.token_usage.total_input() + c.token_usage.output > 0).then(|| { - format!( - "{} in / {} out", - c.token_usage.total_input(), - c.token_usage.output - ) - }); + let usage = if i == 0 { + Some("main session".to_owned()) + } else { + let model = c.model_id.as_deref().unwrap_or("model pending"); + let tokens = c.token_usage.total_input() + c.token_usage.output; + Some(if tokens > 0 { + format!("{model} · {tokens} tokens") + } else { + model.to_owned() + }) + }; TaskEntry { - name: c.name.clone(), + name: if i == 0 { + "Main chat".to_owned() + } else { + format!("Agent: {}", c.name) + }, status, usage, } @@ -501,7 +510,7 @@ impl App { .collect(); self.task_picker_original = Some(self.active_chat); self.task_picker.set_footer(TASK_PANEL_FOOTER); - self.task_picker.open(entries, " Tasks "); + self.task_picker.open(entries, " Agents & Teams "); self.task_picker.select(self.active_chat); } diff --git a/n00n-ui/src/app/tests.rs b/n00n-ui/src/app/tests.rs index adc5279f9..c4ece47e9 100644 --- a/n00n-ui/src/app/tests.rs +++ b/n00n-ui/src/app/tests.rs @@ -817,6 +817,21 @@ fn open_tasks_picker(app: &mut App) { app.update(Msg::Key(key(KeyCode::Enter))); } +#[test] +fn agent_picker_exposes_names_models_and_status() { + let mut app = app_with_subagent(); + app.chats[1].model_id = Some("openai/test-model".into()); + open_tasks_picker(&mut app); + + let main = app.task_picker.item(0).unwrap(); + assert_eq!(main.label(), "Main chat"); + assert_eq!(main.suffix(), Some("main session")); + let agent = app.task_picker.item(1).unwrap(); + assert_eq!(agent.label(), "Agent: research"); + assert_eq!(agent.suffix(), Some("openai/test-model")); + assert_eq!(agent.detail(), Some(TASK_RUNNING_DETAIL)); +} + #[test] fn ctrl_x_toggles_tasks_picker() { let mut app = test_app(); diff --git a/n00n-ui/src/components/command.rs b/n00n-ui/src/components/command.rs index 7d7fd65c4..0c2c10ef8 100644 --- a/n00n-ui/src/components/command.rs +++ b/n00n-ui/src/components/command.rs @@ -24,7 +24,7 @@ pub struct BuiltinCommand { pub const BUILTIN_COMMANDS: &[BuiltinCommand] = &[ BuiltinCommand { name: "/tasks", - description: "Browse and search tasks", + description: "Browse running and completed agents and teams", max_args: 0, }, BuiltinCommand { diff --git a/plugins/agent_control/init.lua b/plugins/agent_control/init.lua index 029d7e80b..666da1627 100644 --- a/plugins/agent_control/init.lua +++ b/plugins/agent_control/init.lua @@ -1,4 +1,4 @@ -local description = [[Control background agents started by task or ALMAS. +local description = [[Control background agents started by task or team. Actions: - list: list every live agent and its status. diff --git a/plugins/lib/n00n/route_tier.lua b/plugins/lib/n00n/route_tier.lua index 5aa774500..7c3c13a7e 100644 --- a/plugins/lib/n00n/route_tier.lua +++ b/plugins/lib/n00n/route_tier.lua @@ -1,7 +1,7 @@ -- Cost-aware model-tier router (OrchMAS-style adaptive role allocation). -- Pure lexical heuristic: no model call. Maps a subtask prompt to one of -- "weak" | "medium" | "strong" so cheap work stays cheap and hard work --- gets a bigger model. Used by the `task` tool (opt-in auto_tier) and ALMAS. +-- gets a bigger model. Used by the `task` tool (opt-in auto_tier) and Team. local M = {} -- Signals that the work needs deep reasoning -> strong/medium. diff --git a/plugins/almas/ibn.lua b/plugins/team/ibn.lua similarity index 100% rename from plugins/almas/ibn.lua rename to plugins/team/ibn.lua diff --git a/plugins/almas/init.lua b/plugins/team/init.lua similarity index 92% rename from plugins/almas/init.lua rename to plugins/team/init.lua index 4cdb49bf2..7c5c8d4a2 100644 --- a/plugins/almas/init.lua +++ b/plugins/team/init.lua @@ -1,4 +1,4 @@ --- ALMAS: Autonomous LLM-based Multi-Agent Software Engineering (Tawosi et al., +-- Team: Autonomous LLM-based Multi-Agent Software Engineering (Tawosi et al., -- ASE 2025). A supervisor decomposes an SDLC goal into role agents -- (product_manager, planner, developer, tester, reviewer); each runs as its own -- subagent on a cost-aware model tier. Built entirely on n00n.agent.* and the @@ -14,7 +14,7 @@ local MAX_PLAN_STEPS = 8 local DEFAULT_PLAN_STEPS = 6 local DEFAULT_SWARM_ROUNDS = 2 local MAX_SWARM_ROUNDS = 4 -local ALMAS_TIMEOUT_SECS = 1800 +local TEAM_TIMEOUT_SECS = 1800 local MAX_RELAY_BYTES = 12000 local PLANNER_OUTPUT = { @@ -40,7 +40,7 @@ local PLANNER_OUTPUT = { } local description = - [[Launch an ALMAS team. A supervisor decomposes an SDLC goal into role agents and runs each as its own subagent on a cost-aware model tier: + [[Launch an agent team. A supervisor decomposes an SDLC goal into role agents and runs each as its own subagent on a cost-aware model tier: - product_manager: scope & acceptance (weak) - planner: step breakdown (medium) @@ -50,7 +50,7 @@ local description = Modes: - supervised (default): return the supervisor's plan for review. -- autonomous: run the centralized ALMAS plan to completion; tester/reviewer +- autonomous: run the centralized team plan to completion; tester/reviewer steps are gated by a diversity-aware validator quorum. - swarm: decentralized SwarmSys rounds (Explorers/Workers/Validators) with a pheromone reinforcement loop, gated by an information-bottleneck β check @@ -64,7 +64,7 @@ Notes: 4. With compact (opt-in), retrieved context is TOON-encoded to save tokens (PR-C). 5. swarm mode runs bounded rounds (max_rounds); the β gate may fall back to a single strong-agent pass when coordination would not help. -6. Set background=true to start a non-blocking ALMAS run and receive an agent_id. +6. Set background=true to start a non-blocking Team run and receive an agent_id. Use agent_control to inspect, steer, or stop it. ]] @@ -103,7 +103,7 @@ local schema = { }, model = { type = "string", - description = "Exact model spec for every ALMAS agent. Overrides model_tier and role tiers.", + description = "Exact model spec for every team agent. Overrides model_tier and role tiers.", }, model_tier = { type = "string", @@ -111,7 +111,7 @@ local schema = { }, thinking = { type = { "string", "integer" }, - description = 'Thinking mode for ALMAS agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "adaptive".', + description = 'Thinking mode for team agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "adaptive".', }, auto_tier = { type = "boolean", @@ -134,7 +134,7 @@ local schema = { }, background = { type = "boolean", - description = "Start ALMAS in a separate background session and return its agent_id immediately.", + description = "Start the team in a separate background session and return its agent_id immediately.", }, compact = { type = "boolean", @@ -194,7 +194,7 @@ local function run_supervisor(ctx, goal, opts) tools = tools, local_tools = local_tools, audience = "general_sub", - name = "almas-supervisor", + name = "team-supervisor", thinking = opts.thinking, }) if sess_err then @@ -316,7 +316,7 @@ local function handler(input, ctx) forwarded[key] = value end forwarded.background = false - local prompt = "Use the almas tool now. Do not only describe this request.\n\n" .. n00n.json.encode(forwarded) + local prompt = "Use the team tool now. Do not only describe this request.\n\n" .. n00n.json.encode(forwarded) local id, err = n00n.session.new({ prompt = prompt, focus = false }) if not id then return { llm_output = err, is_error = true } @@ -352,7 +352,7 @@ local function handler(input, ctx) end return { llm_output = table.concat(plan, "\n") - .. '\n\nReview the plan, then run ALMAS again with `mode = "autonomous"` or `mode = "swarm"` to execute it.', + .. '\n\nReview the plan, then run `team` again with `mode = "autonomous"` or `mode = "swarm"` to execute it.', format = "markdown", } end @@ -395,7 +395,7 @@ end finish_run = function(ctx, input, results, total_cost, completed, unit, slug, failures) local report = table.concat(results, "\n\n") - local summary = string.format("\n\n---\nALMAS complete: %d %s, ~$%.4f estimated cost.", completed, unit, total_cost) + local summary = string.format("\n\n---\nTeam complete: %d %s, ~$%.4f estimated cost.", completed, unit, total_cost) memory.save(ctx, slug, report .. summary) @@ -407,23 +407,23 @@ finish_run = function(ctx, input, results, total_cost, completed, unit, slug, fa end local function header(input) - return "almas: " .. (input.goal or ""):sub(1, 40) + return "team: " .. (input.goal or ""):sub(1, 40) end n00n.api.register_tool({ - name = "almas", + name = "team", description = description, kind = "execute", audiences = { "main", "workflow" }, schema = schema, - timeout = ALMAS_TIMEOUT_SECS, + timeout = TEAM_TIMEOUT_SECS, handler = handler, header = header, }) local TextInput = require("n00n.text_input") -local ALMAS_MODES = { "supervised", "autonomous", "swarm" } +local TEAM_MODES = { "supervised", "autonomous", "swarm" } local MODEL_TIERS = { "weak", "medium", "strong" } local THINKING_LEVELS = { "off", "adaptive", "low", "medium", "high", "xhigh", "max" } local MENU_ROWS = 10 @@ -463,7 +463,7 @@ local function agent_prompt(goal, prefs) table.insert(config, 2, { "model", prefs.model }) end local lines = { - "Use the almas tool now. Do not only describe ALMAS or restate this request.", + "Use the team tool now. Do not only describe the team or restate this request.", "", "Goal:", goal, @@ -530,7 +530,7 @@ local function run_launcher(initial_goal) end lines[#lines + 1] = { { "", "" } } lines[#lines + 1] = { - { selected == RUN_ROW and "❯ Run ALMAS" or " Run ALMAS", selected == RUN_ROW and "selected" or "item" }, + { selected == RUN_ROW and "❯ Run Team" or " Run Team", selected == RUN_ROW and "selected" or "item" }, } buf:set_lines(lines) if win then @@ -547,7 +547,7 @@ local function run_launcher(initial_goal) end win = n00n.ui.open_win(buf, { - title = " ALMAS ", + title = " Team ", footer = { { "↑/↓", "navigate" }, { "Enter", "change/edit/run" }, @@ -566,13 +566,13 @@ local function run_launcher(initial_goal) if value == "" then selected = 10 editing = "goal" - n00n.ui.flash("Enter an ALMAS goal first") + n00n.ui.flash("Enter a team goal first") render() return false end local _, err = n00n.session.prompt(agent_prompt(value, prefs)) if err then - n00n.ui.flash("ALMAS: " .. err) + n00n.ui.flash("Team: " .. err) return false end win:close() @@ -624,7 +624,7 @@ local function run_launcher(initial_goal) render() elseif key == "enter" then if selected == 1 then - prefs.mode = cycle(ALMAS_MODES, prefs.mode) + prefs.mode = cycle(TEAM_MODES, prefs.mode) elseif selected == 2 then prefs.model_tier = cycle(MODEL_TIERS, prefs.model_tier) elseif selected == 3 then @@ -653,8 +653,8 @@ local function run_launcher(initial_goal) end n00n.api.register_command({ - name = "/almas", - description = "Configure and run an ALMAS team for a goal", + name = "/team", + description = "Configure and run an agent team for a goal", handler = function(args) run_launcher(trim(args)) end, diff --git a/plugins/almas/mem.lua b/plugins/team/mem.lua similarity index 94% rename from plugins/almas/mem.lua rename to plugins/team/mem.lua index c55158118..2ed94c464 100644 --- a/plugins/almas/mem.lua +++ b/plugins/team/mem.lua @@ -10,7 +10,7 @@ local function base_dir() local cwd = n00n.uv.cwd() local root = n00n.fs.root(cwd, ".git") or cwd local pid = helpers.project_id(root) - return n00n.fs.joinpath(state, "projects", pid, "almas") + return n00n.fs.joinpath(state, "projects", pid, "team") end function M.slug(goal) diff --git a/plugins/almas/quorum.lua b/plugins/team/quorum.lua similarity index 98% rename from plugins/almas/quorum.lua rename to plugins/team/quorum.lua index bff30736a..c4accec79 100644 --- a/plugins/almas/quorum.lua +++ b/plugins/team/quorum.lua @@ -44,7 +44,7 @@ local function run_one(ctx, v, artifact, opts) system = VALIDATOR_SYSTEM[v.prompt], tools = tools, audience = "general_sub", - name = "almas-quorum-" .. v.prompt, + name = "team-quorum-" .. v.prompt, thinking = opts.thinking, }) if serr then diff --git a/plugins/almas/retrieve.lua b/plugins/team/retrieve.lua similarity index 98% rename from plugins/almas/retrieve.lua rename to plugins/team/retrieve.lua index 1f319a7e0..3589b571f 100644 --- a/plugins/almas/retrieve.lua +++ b/plugins/team/retrieve.lua @@ -1,4 +1,4 @@ --- RAG-grounded retrieval seam (ALMAS Control Agent grounding). +-- RAG-grounded retrieval seam (team control agent grounding). -- Lexical retrieval greps the repo for goal keywords. Vector retrieval ranks -- grep-gathered chunks by cosine similarity over hashing-trick embeddings -- (offline; n00n-providers exposes no embeddings API). usearch + real diff --git a/plugins/almas/roles.lua b/plugins/team/roles.lua similarity index 94% rename from plugins/almas/roles.lua rename to plugins/team/roles.lua index 06c683a6b..5d3b4b9ee 100644 --- a/plugins/almas/roles.lua +++ b/plugins/team/roles.lua @@ -1,11 +1,11 @@ --- SDLC role catalogue and execution (ALMAS roles + PR-I reviewer/tester). +-- SDLC role catalogue and execution (team roles + PR-I reviewer/tester). -- Each role has a system framing and a default cost-aware tier. Steps run as -- their own subagent session so we get accurate token/cost telemetry (PR-B). local M = {} local route_tier = require("n00n.route_tier").route_tier --- Role -> { tier, system }. Tiers follow the ALMAS "three Cs" cost-effectiveness: +-- Role -> { tier, system }. Tiers follow the the three-Cs cost-effectiveness: -- routine clarifying work is cheap, implementation of hard parts is strong. M.ROLES = { product_manager = { diff --git a/plugins/almas/swarm.lua b/plugins/team/swarm.lua similarity index 99% rename from plugins/almas/swarm.lua rename to plugins/team/swarm.lua index bd90d949f..123c8d1a2 100644 --- a/plugins/almas/swarm.lua +++ b/plugins/team/swarm.lua @@ -36,7 +36,7 @@ local function phero_dir() local cwd = n00n.uv.cwd() local root = n00n.fs.root(cwd, ".git") or cwd local pid = helpers.project_id(root) - return n00n.fs.joinpath(state, "projects", pid, "almas") + return n00n.fs.joinpath(state, "projects", pid, "team") end local function phero_load() diff --git a/plugins/almas/tests/spec.lua b/plugins/team/tests/spec.lua similarity index 100% rename from plugins/almas/tests/spec.lua rename to plugins/team/tests/spec.lua diff --git a/scripts/almas_demo.sh b/scripts/team_demo.sh similarity index 62% rename from scripts/almas_demo.sh rename to scripts/team_demo.sh index 79bc77e73..f8be31423 100755 --- a/scripts/almas_demo.sh +++ b/scripts/team_demo.sh @@ -1,21 +1,21 @@ #!/usr/bin/env bash -# Drive the almas plugin across every mode and the new ibn/quorum/swarm toggles. +# Drive the team plugin across every mode and the new ibn/quorum/swarm toggles. # # Usage: -# just almas-demo # run the default matrix -# ./scripts/almas_demo.sh # same, directly -# ALMAS_GOAL="add a retry helper and cover it with tests" ./scripts/almas_demo.sh +# just team-demo # run the default matrix +# ./scripts/team_demo.sh # same, directly +# Team_GOAL="add a retry helper and cover it with tests" ./scripts/team_demo.sh # -# Each combo is run with `n00n -p` so the agent invokes the almas tool. Real runs -# need a configured provider (n00n auth). Set ALMAS_MODEL to pin a tier/model. +# Each combo is run with `n00n -p` so the agent invokes the team tool. Real runs +# need a configured provider (n00n auth). Set Team_MODEL to pin a tier/model. set -u cd "$(dirname "$0")/.." || exit 1 -GOAL="${ALMAS_GOAL:-Add a small retry helper with unit tests and a short doc comment.}" +GOAL="${Team_GOAL:-Add a small retry helper with unit tests and a short doc comment.}" MODEL_FLAG="" -if [ -n "${ALMAS_MODEL:-}" ]; then - MODEL_FLAG="-m ${ALMAS_MODEL}" +if [ -n "${Team_MODEL:-}" ]; then + MODEL_FLAG="-m ${Team_MODEL}" fi # (mode, extra prompt suffix, label) tuples that exercise each branch. @@ -23,7 +23,7 @@ run() { local mode="$1" local suffix="$2" local label="$3" - local prompt="Use the almas tool with mode=${mode}. ${suffix}Goal: ${GOAL}" + local prompt="Use the team tool with mode=${mode}. ${suffix}Goal: ${GOAL}" echo echo "===================================================================" @@ -31,8 +31,8 @@ run() { echo ">> prompt: ${prompt}" echo "===================================================================" # shellcheck disable=SC2086 - n00n -p ${MODEL_FLAG} "${prompt}" \ - || echo "!! [${label}] exited non-zero (see above)" + n00n -p ${MODEL_FLAG} "${prompt}" || + echo "!! [${label}] exited non-zero (see above)" } # Supervised: returns the plan for review (no execution, cheapest). @@ -55,4 +55,4 @@ run "swarm" "Set model_tier=weak, max_rounds=2. " "swarm-bounded-weak" echo echo "Done. Pheromone state (if any) lives under your state dir under" -echo " projects/*/almas/swarm_pheromone.json" +echo " projects/*/team/swarm_pheromone.json" diff --git a/site/docs/content/commands/_index.md b/site/docs/content/commands/_index.md index 12cd80410..08faa2601 100644 --- a/site/docs/content/commands/_index.md +++ b/site/docs/content/commands/_index.md @@ -13,7 +13,7 @@ Type `/` in the input box to open the command palette. | Command | Description | |---------|-------------| -| `/tasks` | Browse and search tasks | +| `/tasks` | Browse running and completed agents and teams | | `/compact` | Summarize and compact conversation history | | `/new` | Start a new session | | `/help` | Show keybindings | @@ -31,10 +31,10 @@ Type `/` in the input box to open the command palette. | `/workflow` | Toggle workflow mode (task callable inside code_execution) | | `/exit` | Exit the application | | `/reload` | Reload plugins and config | -| `/almas` | Configure and run an ALMAS team for a goal | | `/memory` | View, edit, and delete memory files | | `/rename` | Rename the current session | | `/sessions` | Browse and switch sessions | +| `/team` | Configure and run an agent team for a goal | ## Sessions diff --git a/site/docs/content/lua-api/_index.md b/site/docs/content/lua-api/_index.md index 51c6a0b98..ab7145161 100644 --- a/site/docs/content/lua-api/_index.md +++ b/site/docs/content/lua-api/_index.md @@ -823,7 +823,7 @@ n00n.agent.usage_cost({spec}, {input_tokens}, {output_tokens}) Estimate the dollar cost of a completion from its model spec and token counts. Uses the provider's published pricing (input/output/cache write/ -read), so orchestrators like ALMAS can report cost without bundling a +read), so orchestrators like Team can report cost without bundling a price table. **Parameters:** @@ -4888,7 +4888,7 @@ return M -- Cost-aware model-tier router (OrchMAS-style adaptive role allocation). -- Pure lexical heuristic: no model call. Maps a subtask prompt to one of -- "weak" | "medium" | "strong" so cheap work stays cheap and hard work --- gets a bigger model. Used by the `task` tool (opt-in auto_tier) and ALMAS. +-- gets a bigger model. Used by the `task` tool (opt-in auto_tier) and Team. -- @param prompt string Subtask description. -- @return "weak" | "medium" | "strong" diff --git a/site/docs/content/tools/_index.md b/site/docs/content/tools/_index.md index 4bef6feb3..41f213282 100644 --- a/site/docs/content/tools/_index.md +++ b/site/docs/content/tools/_index.md @@ -157,7 +157,7 @@ Use this tool when you need to ask the user questions during execution. This all ### `agent_control` *(lua plugin)* -Control background agents started by task or ALMAS. +Control background agents started by task or team. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -165,13 +165,13 @@ Control background agents started by task or ALMAS. | `action` | string | yes | Control action. | | `agent_id` | string | no | Background agent id. Required for status, message, and stop. | -### `almas` *(lua plugin)* +### `team` *(lua plugin)* -Launch an ALMAS team. A supervisor decomposes an SDLC goal into role agents and runs each as its own subagent on a cost-aware model tier: +Launch an agent team. A supervisor decomposes an SDLC goal into role agents and runs each as its own subagent on a cost-aware model tier: | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| -| `background` | boolean | no | | Start ALMAS in a separate background session and return its agent_id immediately. | +| `background` | boolean | no | | Start the team in a separate background session and return its agent_id immediately. | | `compact` | boolean | no | | Encode retrieved context as TOON (token-saving, opt-in). | | `model_tier` | string | no | | Supervisor/model tier (weak/medium/strong). Defaults to strong when model is omitted. | | `quorum` | boolean | no | | Require validator quorum for autonomous validation and swarm acceptance. | @@ -180,11 +180,11 @@ Launch an ALMAS team. A supervisor decomposes an SDLC goal into role agents and | `ibn_gate` | boolean | no | | Use the information-bottleneck fan-out gate in swarm mode. | | `max_rounds` | integer | no | 2, maximum 4 | Swarm mode only: max coordination rounds. | | `goal` | string | yes | | High-level SDLC goal, e.g. 'Add a retry helper and cover it with tests.' | -| `model` | string | no | | Exact model spec for every ALMAS agent. Overrides model_tier and role tiers. | +| `model` | string | no | | Exact model spec for every team agent. Overrides model_tier and role tiers. | | `use_retrieval` | boolean | no | | Ground steps with repo retrieval. | | `mode` | string | no | | "supervised" (default, return the plan for review), "autonomous" (run the plan), or "swarm" (decentralized SwarmSys rounds). | | `auto_tier` | boolean | no | | Route each subagent tier from its step prompt. Defaults to true unless an exact model is set. | -| `thinking` | string/integer | no | | Thinking mode for ALMAS agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "adaptive". | +| `thinking` | string/integer | no | | Thinking mode for team agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "adaptive". | ### `task` *(lua plugin)* From f9649596f260df5afaaafcec049d733b9f2121e6 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 20 Jul 2026 11:09:45 -0400 Subject: [PATCH 5/7] fix(agents): address team review feedback Signed-off-by: w0wl0lxd --- n00n-lua/tests/task_policy.rs | 2 +- plugins/task/init.lua | 2 +- plugins/team/init.lua | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/n00n-lua/tests/task_policy.rs b/n00n-lua/tests/task_policy.rs index c8fa3f4c8..5369f592f 100644 --- a/n00n-lua/tests/task_policy.rs +++ b/n00n-lua/tests/task_policy.rs @@ -86,7 +86,7 @@ end behaviors.happy = function(sess, msg) local h = sess.opts.local_tools.structured_output.handler recorder.first_ack, recorder.first_err = h({ answer = "42" }) - return { text = "raw text ignored" } + return { text = "" } end behaviors.invalid_then_valid = function(sess, msg) diff --git a/plugins/task/init.lua b/plugins/task/init.lua index 2f624e840..c760063f5 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -268,7 +268,7 @@ local function handler(input, ctx) local msg = last_errors and (STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) or STRUCTURED_MISSING_ERROR return { llm_output = msg, is_error = true } end - if not result or not result.text or result.text == "" then + if not captured and (not result or not result.text or result.text == "") then return { llm_output = "sub-agent returned no output", is_error = true } end return { llm_output = captured and n00n.json.encode(captured) or result.text, format = "markdown" } diff --git a/plugins/team/init.lua b/plugins/team/init.lua index 7c5c8d4a2..dfa623bed 100644 --- a/plugins/team/init.lua +++ b/plugins/team/init.lua @@ -395,14 +395,15 @@ end finish_run = function(ctx, input, results, total_cost, completed, unit, slug, failures) local report = table.concat(results, "\n\n") - local summary = string.format("\n\n---\nTeam complete: %d %s, ~$%.4f estimated cost.", completed, unit, total_cost) - - memory.save(ctx, slug, report .. summary) - local failed = failures or 0 + local successful = math.max(completed - failed, 0) + local summary = string.format("\n\n---\nTeam complete: %d %s, ~$%.4f estimated cost.", successful, unit, total_cost) if failed > 0 then summary = summary .. string.format(" %d step(s) failed; the run is incomplete.", failed) end + + memory.save(ctx, slug, report .. summary) + return { llm_output = report .. summary, format = "markdown", is_error = failed > 0 } end From 52bd560c416d4fa412ac18bfef66a92906ece1f2 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 20 Jul 2026 11:23:49 -0400 Subject: [PATCH 6/7] fix(team): align quorum and launcher limits Signed-off-by: w0wl0lxd --- plugins/team/init.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/team/init.lua b/plugins/team/init.lua index dfa623bed..41f5e374f 100644 --- a/plugins/team/init.lua +++ b/plugins/team/init.lua @@ -267,7 +267,7 @@ local function run_autonomous(ctx, goal, input, steps, relay_k) results[#results + 1] = string.format("[%d] %s%s:\n%s", i, step.role, cost_line, r.text or "") total_cost = total_cost + (r.cost or 0) - if input.quorum ~= false and step.role == "reviewer" then + if input.quorum ~= false and (step.role == "tester" or step.role == "reviewer") then local verdict = quorum.validate(ctx, table.concat(results, "\n\n"), { n = 3, model = input.model, thinking = input.thinking }) if not verdict.accepted then @@ -486,7 +486,7 @@ local function run_launcher(initial_goal) use_retrieval = true, ibn_gate = true, quorum = true, - max_rounds = 4, + max_rounds = DEFAULT_SWARM_ROUNDS, } local model = TextInput.new() local goal = TextInput.new() @@ -641,7 +641,7 @@ local function run_launcher(initial_goal) elseif selected == 8 then prefs.quorum = not prefs.quorum elseif selected == 9 then - prefs.max_rounds = prefs.max_rounds >= 8 and 2 or prefs.max_rounds + 1 + prefs.max_rounds = prefs.max_rounds >= MAX_SWARM_ROUNDS and 1 or prefs.max_rounds + 1 elseif selected == 10 then editing = "goal" elseif submit() then From 60479d6c2102aaab7b7b62d245d1d2c94e7ca365 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 20 Jul 2026 11:27:41 -0400 Subject: [PATCH 7/7] fix(agents): expose background session output Signed-off-by: w0wl0lxd --- n00n-lua/src/api/session.rs | 39 ++++++++++++++++++++++++++++- n00n-lua/src/api/util/command.rs | 1 + n00n-ui/src/event_loop.rs | 23 +++++++++++++++++ plugins/agent_control/init.lua | 2 +- site/docs/content/lua-api/_index.md | 16 ++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/n00n-lua/src/api/session.rs b/n00n-lua/src/api/session.rs index 44d28a45e..6216b9c51 100644 --- a/n00n-lua/src/api/session.rs +++ b/n00n-lua/src/api/session.rs @@ -57,6 +57,19 @@ async fn live(lua: Lua, #[ctx] tx: Option>) -> LuaResult roundtrip(lua, tx, SessionRequest::Live).await } +/// Returns one live session with its status and latest assistant text output. +/// +/// @param id string Live session id. +/// @return (table|nil, string|nil) `{id, title, status, updated_at, focused, output?}`, or nil and an error. +#[lua_fn] +async fn status( + lua: Lua, + #[ctx] tx: Option>, + id: String, +) -> LuaResult { + roundtrip(lua, tx, SessionRequest::Status { id }).await +} + /// Returns the id of the currently focused session. /// /// @return (string|nil, string|nil) Session id, or nil and an error. @@ -183,7 +196,7 @@ lua_table! { /// the pair `(value, err)`. Without an interactive UI attached, every /// call returns `nil, "no interactive UI attached"`. "n00n.session" => pub(crate) fn create_session_table(tx: Option>), - DOCS [list(tx), live(tx), current(tx), focus(tx), delete(tx), new(tx), prompt(tx), cancel(tx), set_title(tx)] + DOCS [list(tx), live(tx), status(tx), current(tx), focus(tx), delete(tx), new(tx), prompt(tx), cancel(tx), set_title(tx)] } #[cfg(test)] @@ -228,6 +241,30 @@ mod tests { assert_eq!(val.get::("focused").unwrap(), "abc"); } + #[test] + fn status_forwards_session_id() { + let (tx, rx) = flume::unbounded::(); + let lua = lua_with_session(Some(tx)); + let checker = std::thread::spawn(move || { + let Ok(UiAction::Session { + req: SessionRequest::Status { id }, + reply_tx, + }) = rx.recv() + else { + panic!("expected status request"); + }; + assert_eq!(id, "abc"); + reply_tx + .send(Ok(json!({ "id": id, "status": "idle", "output": "done" }))) + .unwrap(); + }); + let (val, err): (Table, Option) = + smol::block_on(lua.load("return session.status('abc')").eval_async()).unwrap(); + checker.join().unwrap(); + assert_eq!(err, None); + assert_eq!(val.get::("output").unwrap(), "done"); + } + #[test] fn cancel_forwards_session_id() { let (tx, rx) = flume::unbounded::(); diff --git a/n00n-lua/src/api/util/command.rs b/n00n-lua/src/api/util/command.rs index 3a430c180..5fbc526e1 100644 --- a/n00n-lua/src/api/util/command.rs +++ b/n00n-lua/src/api/util/command.rs @@ -390,6 +390,7 @@ pub enum WinCommand { pub enum SessionRequest { List, Live, + Status { id: String }, Current, New { prompt: Option, focus: bool }, Prompt { id: Option, text: String }, diff --git a/n00n-ui/src/event_loop.rs b/n00n-ui/src/event_loop.rs index 7dc443342..fb24f6653 100644 --- a/n00n-ui/src/event_loop.rs +++ b/n00n-ui/src/event_loop.rs @@ -704,6 +704,29 @@ impl<'t> EventLoop<'t> { .collect(); let _ = reply_tx.send(Ok(json!(list))); } + SessionRequest::Status { id } => { + let reply = parse_session_id(&id).and_then(|id| { + let idx = self + .position(id) + .ok_or_else(|| format!("{NOT_LIVE_ERR}: {id}"))?; + let rt = &self.sessions[idx]; + let history = rt.handles.history.load(); + let output = history.iter().rev().find_map(|message| { + matches!(message.role, n00n_providers::Role::Assistant) + .then(|| message.first_text_content()) + .flatten() + }); + Ok(json!({ + "id": rt.id(), + "title": rt.app.state.session.title, + "status": SessionStatus::of(&rt.app).as_str(), + "updated_at": rt.app.state.session.updated_at, + "focused": idx == self.focused, + "output": output, + })) + }); + let _ = reply_tx.send(reply); + } SessionRequest::Current => { let _ = reply_tx.send(Ok(json!(self.sessions[self.focused].id()))); } diff --git a/plugins/agent_control/init.lua b/plugins/agent_control/init.lua index 666da1627..07bbf843b 100644 --- a/plugins/agent_control/init.lua +++ b/plugins/agent_control/init.lua @@ -54,7 +54,7 @@ local function handler(input) end if input.action == "status" then - local agent, err = find_agent(input.agent_id) + local agent, err = n00n.session.status(input.agent_id) if not agent then return { llm_output = err, is_error = true } end diff --git a/site/docs/content/lua-api/_index.md b/site/docs/content/lua-api/_index.md index ab7145161..435fa29ce 100644 --- a/site/docs/content/lua-api/_index.md +++ b/site/docs/content/lua-api/_index.md @@ -2502,6 +2502,22 @@ local live, err = n00n.session.live() --- +### `n00n.session.status()` {#n00n-session-status} + +```lua +n00n.session.status({id}) +``` + +Returns one live session with its status and latest assistant text output. + +**Parameters:** + +- `{id}` (`string`) Live session id. + +**Returns:** (`table|nil`, `string|nil`) `{id, title, status, updated_at, focused, output?}`, or nil and an error. + +--- + ### `n00n.session.current()` {#n00n-session-current} ```lua