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 4ceb5996d..3ec7794f1 100644 --- a/n00n-config/src/lib.rs +++ b/n00n-config/src/lib.rs @@ -53,7 +53,7 @@ pub const MIN_LOW_SPEED_TIMEOUT_SECS: u64 = 1; pub const MIN_STREAM_TIMEOUT_SECS: u64 = 10; pub const DEFAULT_BUILTINS: &[&str] = &[ - "almas", + "agent_control", "bash", "batch", "code_execution", @@ -67,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 7cf4f52fe..146b3298d 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", + "team", + "task", + "workflow", + "todo_write", + "memory", + "skill", + ], ), ("Web", &["webfetch", "websearch"]), ]; 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/api/session.rs b/n00n-lua/src/api/session.rs index da11c4431..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. @@ -143,6 +156,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 +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), 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)] @@ -215,6 +241,52 @@ 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::(); + 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-lua/src/api/util/command.rs b/n00n-lua/src/api/util/command.rs index a02c32fc0..5fbc526e1 100644 --- a/n00n-lua/src/api/util/command.rs +++ b/n00n-lua/src/api/util/command.rs @@ -390,9 +390,11 @@ pub enum WinCommand { pub enum SessionRequest { List, Live, + Status { id: String }, 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..a71f581dd 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"), @@ -96,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", @@ -660,6 +664,15 @@ mod tests { assert!(contents(&slots, PromptId::System, Slot::ToolUsage).is_empty()); } + #[test] + 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("team")); + } + #[test] fn pipelined_load_registers_every_builtin() { let reg = Arc::new(ToolRegistry::new()); 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-lua/tests/task_policy.rs b/n00n-lua/tests/task_policy.rs index b4e79d284..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) @@ -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/n00n-ui/src/app/mod.rs b/n00n-ui/src/app/mod.rs index 3e8da8183..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); } @@ -962,6 +971,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..96ad0bd15 100644 --- a/n00n-ui/src/app/mouse.rs +++ b/n00n-ui/src/app/mouse.rs @@ -72,6 +72,17 @@ 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.chats.iter().position(|chat| { + chat.tool_use_id.as_deref() == Some(id) + }) + }); + 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/app/tests.rs b/n00n-ui/src/app/tests.rs index 3056c2471..c4ece47e9 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)))); @@ -804,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/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/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/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..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()))); } @@ -733,6 +756,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::Idle { + return Err(format!("session is idle: {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..07bbf843b --- /dev/null +++ b/plugins/agent_control/init.lua @@ -0,0 +1,89 @@ +local description = [[Control background agents started by task or team. + +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 = n00n.session.status(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/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/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/task/init.lua b/plugins/task/init.lua index 57d27c689..c760063f5 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" @@ -40,6 +41,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 +78,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 +148,21 @@ local function make_preview(ctx, description) 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(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 + 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,85 +235,76 @@ 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, - }) + 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 - end - 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 } + local function do_prompt() + local message = input.prompt + if validator then + message = message .. STRUCTURED_OUTPUT_PROMPT_SUFFIX 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 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 - - 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 - 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 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" } + 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) + 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 - return prompt_res.value - end) - permit:release() - if not ok then - error(out, 0) end - return out - end, on_finish) - return nil + 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 + 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) @@ -324,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/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 79% rename from plugins/almas/init.lua rename to plugins/team/init.lua index 0781672f2..41f5e374f 100644 --- a/plugins/almas/init.lua +++ b/plugins/team/init.lua @@ -1,16 +1,22 @@ --- 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 -- 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") 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 TEAM_TIMEOUT_SECS = 1800 +local MAX_RELAY_BYTES = 12000 + local PLANNER_OUTPUT = { type = "object", required = { "steps" }, @@ -34,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) @@ -44,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 @@ -58,6 +64,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 Team run and receive an agent_id. + Use agent_control to inspect, steer, or stop it. ]] local schema = { @@ -77,15 +85,25 @@ 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.", + description = "Exact model spec for every team agent. Overrides model_tier and role tiers.", }, model_tier = { type = "string", @@ -93,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 team agents: "off", "adaptive", an effort level through "max", or a token budget. Defaults to "adaptive".', }, auto_tier = { type = "boolean", @@ -114,6 +132,10 @@ local schema = { default = true, description = "Require validator quorum for autonomous validation and swarm acceptance.", }, + background = { + type = "boolean", + description = "Start the team in a separate background session and return its agent_id immediately.", + }, compact = { type = "boolean", default = false, @@ -172,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 @@ -190,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 @@ -256,10 +255,13 @@ 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 "") @@ -279,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 @@ -288,32 +290,49 @@ 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 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 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 } + 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 input.auto_tier = input.model == nil end if input.thinking == nil then - input.thinking = "max" + input.thinking = "adaptive" 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) @@ -333,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 @@ -351,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, @@ -365,46 +384,47 @@ 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) + 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 - return { llm_output = report .. summary, format = "markdown" } + memory.save(ctx, slug, report .. summary) + + return { llm_output = report .. summary, format = "markdown", is_error = failed > 0 } 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 = 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 @@ -444,7 +464,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, @@ -466,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() @@ -511,7 +531,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 @@ -528,7 +548,7 @@ local function run_launcher(initial_goal) end win = n00n.ui.open_win(buf, { - title = " ALMAS ", + title = " Team ", footer = { { "↑/↓", "navigate" }, { "Enter", "change/edit/run" }, @@ -547,13 +567,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() @@ -605,7 +625,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 @@ -621,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 @@ -634,8 +654,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 94% rename from plugins/almas/tests/spec.lua rename to plugins/team/tests/spec.lua index f5059f510..193aee1d4 100644 --- a/plugins/almas/tests/spec.lua +++ b/plugins/team/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/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/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..435fa29ce 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:** @@ -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 @@ -2619,6 +2635,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 @@ -4872,7 +4904,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 271533862..41f213282 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 @@ -155,24 +155,36 @@ Use this tool when you need to ask the user questions during execution. This all ## Agent & Knowledge -### `almas` *(lua plugin)* +### `agent_control` *(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: +Control background agents started by task or team. + +| 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. | + +### `team` *(lua plugin)* + +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 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. | +| `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. | +| `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 "max". | +| `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)* @@ -180,15 +192,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.