Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions n00n-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion n00n-docgen/src/gen_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
];
Expand Down
37 changes: 36 additions & 1 deletion n00n-lua/src/api/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<flume::Sender<UiAction>>,
id: String,
) -> LuaResult<Pair> {
roundtrip(lua, tx, SessionRequest::Cancel { id }).await
}

/// Renames a session, live or stored.
///
/// @param opts table Required fields: id (string) session to rename;
Expand Down Expand Up @@ -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<flume::Sender<UiAction>>),
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)]
Expand Down Expand Up @@ -215,6 +228,28 @@ mod tests {
assert_eq!(val.get::<String>("focused").unwrap(), "abc");
}

#[test]
fn cancel_forwards_session_id() {
let (tx, rx) = flume::unbounded::<UiAction>();
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<String>) =
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>) {
Expand Down
1 change: 1 addition & 0 deletions n00n-lua/src/api/util/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ pub enum SessionRequest {
Current,
New { prompt: Option<String>, focus: bool },
Prompt { id: Option<String>, text: String },
Cancel { id: String },
Focus { id: String },
Delete { id: String },
SetTitle { id: String, title: String },
Expand Down
13 changes: 13 additions & 0 deletions n00n-lua/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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(&reg)).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());
Expand Down
4 changes: 4 additions & 0 deletions n00n-ui/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,10 @@ impl App {
self.submit_or_queue(sub.into())
}

pub(crate) fn cancel_current_run(&mut self) -> Vec<Action> {
self.handle_cancel()
}

fn handle_cancel(&mut self) -> Vec<Action> {
let cancelled_run = self.run_id;
self.run_id += 1;
Expand Down
11 changes: 11 additions & 0 deletions n00n-ui/src/app/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
13 changes: 13 additions & 0 deletions n00n-ui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))));
Expand Down
4 changes: 4 additions & 0 deletions n00n-ui/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
12 changes: 12 additions & 0 deletions n00n-ui/src/components/messages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion n00n-ui/src/components/messages/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}

Expand Down Expand Up @@ -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());
Expand Down
13 changes: 3 additions & 10 deletions n00n-ui/src/components/status_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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};

Expand Down Expand Up @@ -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]
Expand Down
14 changes: 14 additions & 0 deletions n00n-ui/src/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::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))
Expand Down
89 changes: 89 additions & 0 deletions plugins/agent_control/init.lua
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
w0wl0lxd marked this conversation as resolved.
Resolved
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,
})
Loading
Loading