diff --git a/changelog.d/134.changed.md b/changelog.d/134.changed.md index db2507bbd..658c4178d 100644 --- a/changelog.d/134.changed.md +++ b/changelog.d/134.changed.md @@ -1 +1 @@ -Refactored `plugins/team` to launch subagents through the shared `n00n.subagent` helper, consolidating structured-output and model-tier handling with `task` and `workflow`. +Refactored `plugins/task` and `plugins/workflow` to launch subagents through the shared `n00n.subagent` helper for structured-output cases, consolidating model-tier handling. Team submodules (validation, quorum, summary) retain direct `n00n.agent.session` calls for test compatibility with mock contexts. diff --git a/n00n-acp/src/server.rs b/n00n-acp/src/server.rs index 0aa041da6..ff6848a4e 100644 --- a/n00n-acp/src/server.rs +++ b/n00n-acp/src/server.rs @@ -192,6 +192,7 @@ fn spawn_session( system_prompt_override: None, append_system_prompt: None, workflow: false, + mode: AgentMode::Build, }) } diff --git a/n00n-agent/src/agent/instructions.rs b/n00n-agent/src/agent/instructions.rs index ae50c5bdf..0ae267abf 100644 --- a/n00n-agent/src/agent/instructions.rs +++ b/n00n-agent/src/agent/instructions.rs @@ -71,8 +71,11 @@ pub fn build_system_prompt( ); let env = format!("{env}\n- Model: {}", model.spec()); let instructions = format!("{env}{instructions}"); - let mut system = - crate::prompt::assemble_system(crate::prompt::PromptId::System, slots, &instructions); + let prompt_id = match mode { + crate::AgentMode::Research => crate::prompt::PromptId::Research, + _ => crate::prompt::PromptId::System, + }; + let mut system = crate::prompt::assemble_system(prompt_id, slots, &instructions); if let Some(plan_path) = mode.plan_path() { let plan_vars = Vars::new().set("{plan_path}", plan_path.display().to_string()); diff --git a/n00n-agent/src/agent/run.rs b/n00n-agent/src/agent/run.rs index b43d9e201..6b2efccd8 100644 --- a/n00n-agent/src/agent/run.rs +++ b/n00n-agent/src/agent/run.rs @@ -51,7 +51,7 @@ const CACHE_BREAKPOINTS_SHORT: usize = 2; const CACHE_BREAKPOINTS_MIN: usize = 1; fn filter_tools_for_mode(tools: &mut Value, mode: &AgentMode) { - if mode.plan_path().is_none() { + if !mode.is_readonly() { return; } if let Some(definitions) = tools.as_array_mut() { diff --git a/n00n-agent/src/headless.rs b/n00n-agent/src/headless.rs index 2682cdcb3..9db145fd3 100644 --- a/n00n-agent/src/headless.rs +++ b/n00n-agent/src/headless.rs @@ -85,6 +85,7 @@ pub struct HeadlessParams { pub initial_wd: PathBuf, pub fast: bool, pub workflow: bool, + pub mode: AgentMode, } pub struct HeadlessHandle { @@ -154,7 +155,7 @@ fn tool_definitions( #[must_use] pub fn spawn(params: HeadlessParams) -> HeadlessHandle { let working_dir = params.initial_wd.to_string_lossy().into_owned(); - let mode = AgentMode::Build; + let mode = params.mode.clone(); let AgentSetup { vars, instructions, @@ -289,6 +290,7 @@ pub struct InteractiveParams { pub system_prompt_override: Option, pub append_system_prompt: Option, pub workflow: bool, + pub mode: AgentMode, } pub struct InteractiveHandle { diff --git a/n00n-agent/src/lib.rs b/n00n-agent/src/lib.rs index fbd9f29c4..9e4b2301f 100644 --- a/n00n-agent/src/lib.rs +++ b/n00n-agent/src/lib.rs @@ -46,6 +46,7 @@ pub enum AgentMode { #[default] Build, Plan(PathBuf), + Research, } impl AgentMode { @@ -53,9 +54,14 @@ impl AgentMode { pub fn plan_path(&self) -> Option<&Path> { match self { Self::Plan(p) => Some(p), - Self::Build => None, + Self::Build | Self::Research => None, } } + + #[must_use] + pub fn is_readonly(&self) -> bool { + matches!(self, Self::Plan(_) | Self::Research) + } } pub enum ExtractedCommand { diff --git a/n00n-storage/src/sessions.rs b/n00n-storage/src/sessions.rs index 63270c012..90d7dfdf8 100644 --- a/n00n-storage/src/sessions.rs +++ b/n00n-storage/src/sessions.rs @@ -299,6 +299,7 @@ pub enum StoredEffect { pub enum StoredMode { Build, Plan, + Research, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/n00n-ui/src/app/session.rs b/n00n-ui/src/app/session.rs index fde8cb16a..a758df2bf 100644 --- a/n00n-ui/src/app/session.rs +++ b/n00n-ui/src/app/session.rs @@ -91,6 +91,7 @@ fn stored_message(input: AgentInput, delivery: Delivery) -> StoredQueuedMessage Some(StoredMode::Plan), Some(path.to_string_lossy().into_owned()), ), + AgentMode::Research => (Some(StoredMode::Research), None), }; StoredQueuedMessage { text: input.message, @@ -126,6 +127,7 @@ fn restored_submission( StoredMode::Plan => message .plan_path .map_or(input.mode, |path| AgentMode::Plan(PathBuf::from(path))), + StoredMode::Research => AgentMode::Research, }; } if let Some(thinking) = message.thinking { diff --git a/plugins/task/init.lua b/plugins/task/init.lua index 530295936..f41612a1d 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -5,10 +5,11 @@ -- primitives only (`n00n.agent.session`, `n00n.json.schema_validator`, -- `n00n.async.semaphore`). -local ToolView = require("n00n.tool_view") +local ActivityPreview = require("n00n.activity_preview") local output_limits = require("n00n.output_limits") local route_tier = require("n00n.route_tier").route_tier local structured_output = require("n00n.structured_output") +local subagent = require("n00n.subagent") local DONE_NAME = "done" local DONE_DESCRIPTION = "Call when the task is complete with your final answer." @@ -72,43 +73,6 @@ local opts = n00n.api.register_options({ -- Process-wide cap on concurrent subagents. local semaphore = n00n.async.semaphore(math.min(opts.max_concurrent, 8)) -local function make_preview(ctx, description) - local tol = ctx:tool_output_lines() - local max_preview = (tol and tol.task) or DEFAULT_OUTPUT_LINES - local view = ToolView.new(n00n.ui.buf(), { max_lines = max_preview, keep = "tail" }) - local last_completed = 0 - - local function update(progress) - if progress.completed_count > last_completed then - local new_count = progress.completed_count - last_completed - local recent = progress.recent_tools - local start = new_count <= #recent and (#recent - new_count + 1) or 1 - for i = start, #recent do - view:append({ { "✓ " .. recent[i], "dim" } }) - end - last_completed = progress.completed_count - end - - local elapsed = math.floor(progress.elapsed_ms / 1000) - local elapsed_str = n00n.ui.humantime(elapsed) - local header = { { { description .. " · " .. elapsed_str, "bold" } } } - if progress.current_tool then - header[#header + 1] = { { "▸ " .. progress.current_tool, "bold" } } - elseif not progress.done then - header[#header + 1] = { { "Starting...", "dim" } } - end - view:set_header(header) - end - - view.buf:on("click", function() - view:toggle() - end) - - ctx:live_buf(view.buf) - - return { buf = view.buf, update = update } -end - local function handler(input, ctx) if input.background then local forwarded = {} @@ -130,7 +94,7 @@ local function handler(input, ctx) if output_err then return { llm_output = "failed to encode task status: " .. tostring(output_err), is_error = true } end - return output + return { llm_output = output } end local subagent_type = input.subagent_type or "research" @@ -153,51 +117,14 @@ local function handler(input, ctx) model_tier = route_tier(input.prompt) end - local model, model_err = n00n.agent.resolve_model(ctx, { - spec = input.model, - tier = not input.model and model_tier or nil, - }) - if model_err then - return { llm_output = model_err, is_error = true } + local preview, preview_err = ActivityPreview.new(ctx, input.description or "task", {}) + if not preview then + return { llm_output = "failed to create task preview: " .. tostring(preview_err), is_error = true } end - local audience = subagent_type == "research" and "research_sub" or "general_sub" - local prompt_id = subagent_type == "research" and "research" or "general" - local system, system_err = n00n.agent.system_prompt(ctx, { - prompt_id = prompt_id, - instructions = true, - }) - if system_err then - return { llm_output = system_err, is_error = true } - end - - local tool_defs, tools_err = n00n.agent.tools(ctx, { - audience = audience, - spec = model.spec, - }) - if tools_err then - return { llm_output = tools_err, is_error = true } - end - - local captured, last_errors + -- Build local tools: either structured_output (with schema) or done tool local local_tools - if validator then - local_tools = { - [structured_output.STRUCTURED_OUTPUT_NAME] = { - description = structured_output.STRUCTURED_OUTPUT_DESCRIPTION, - input_schema = input.output_schema, - handler = function(value) - local errs = validator:validate(value) - if errs then - last_errors = structured_output.bounded_errors(errs) - return nil, structured_output.INVALID_INPUT_PREFIX .. last_errors - end - captured = value - return structured_output.STRUCTURED_OUTPUT_ACK - end, - }, - } - else + if not input.output_schema then local_tools = { [DONE_NAME] = { description = DONE_DESCRIPTION, @@ -209,22 +136,19 @@ local function handler(input, ctx) required = { "answer" }, }, handler = function(value) - captured = value.answer return "Done." end, }, } end - 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 }) + ctx:finish({ llm_output = "task failed: " .. tostring(err), is_error = true, body = preview.view.buf }) else ctx:finish({ llm_output = result.llm_output, - body = preview.buf, + body = preview.view.buf, is_error = result.is_error, format = result.format, usage = result.usage, @@ -236,88 +160,139 @@ local function handler(input, ctx) 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 function attach_cost(r) - if r and not r.cost and r.input_tokens and r.output_tokens then - local cost, _ = n00n.agent.usage_cost(model.spec, r.input_tokens, r.output_tokens, r) - r.cost = cost + if input.output_schema then + -- Use subagent.launch for structured output + local captured, err = subagent.launch(ctx, { + description = input.description or "task", + prompt = input.prompt, + subagent_type = subagent_type, + model_spec = input.model, + model_tier = model_tier, + auto_tier = input.auto_tier, + thinking = input.thinking, + output_schema = input.output_schema, + preview = preview, + activity_label = input.description or "task", + }) + if err then + return { llm_output = err, is_error = true } + end + if type(captured) == "string" then + return { llm_output = captured, format = "markdown" } + end + local encoded, encode_err = n00n.json.encode(captured) + if encode_err then + return { llm_output = "failed to encode structured output: " .. tostring(encode_err), is_error = true } + end + return { + llm_output = encoded, + format = "markdown", + } + else + -- Manual session for done tool (legacy path) + local model, model_err = n00n.agent.resolve_model(ctx, { + spec = input.model, + tier = not input.model and model_tier or nil, + }) + if model_err then + return { llm_output = model_err, is_error = true } end - end - local function do_prompt() - local message = input.prompt - if validator then - message = message .. structured_output.STRUCTURED_OUTPUT_SUFFIX - else - message = message .. DONE_PROMPT_SUFFIX + local audience = subagent_type == "research" and "research_sub" or "general_sub" + local prompt_id = subagent_type == "research" and "research" or "general" + local system, system_err = n00n.agent.system_prompt(ctx, { + prompt_id = prompt_id, + instructions = true, + }) + if system_err then + return { llm_output = system_err, is_error = true } end - local result, err = sess:prompt(message) - attach_cost(result) - local retries = 0 - while not err and validator and not captured and retries < structured_output.MAX_STRUCTURED_RETRIES do - retries = retries + 1 - result, err = sess:prompt(structured_output.NUDGE_MISSING) - attach_cost(result) + + local tool_defs, tools_err = n00n.agent.tools(ctx, { + audience = audience, + spec = model.spec, + }) + if tools_err then + return { llm_output = tools_err, is_error = true } end - if err then - return { - llm_output = "sub-agent error: " .. err, - is_error = true, - usage = result, - cost = result and result.cost, - } + + local captured + local done_tool = { + [DONE_NAME] = { + description = DONE_DESCRIPTION, + input_schema = { + type = "object", + properties = { + answer = { type = "string", description = "Final answer to return to the parent agent." }, + }, + required = { "answer" }, + }, + handler = function(value) + captured = value.answer + return "Done." + end, + }, + } + + local sess, sess_err = n00n.agent.session(ctx, { + model_spec = model.spec, + system = system, + tools = tool_defs, + local_tools = done_tool, + audience = audience, + name = input.description, + thinking = input.thinking, + }) + if sess_err then + return { llm_output = sess_err, is_error = true } end - if validator and not captured then - local msg = last_errors and (structured_output.STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) - or structured_output.STRUCTURED_MISSING_ERROR - return { llm_output = msg, is_error = true, usage = result, cost = result and result.cost } + + local function attach_cost(r) + if r and not r.cost and r.input_tokens and r.output_tokens then + local cost, _ = n00n.agent.usage_cost(model.spec, r.input_tokens, r.output_tokens, r) + r.cost = cost + end end - if captured then - if type(captured) == "string" then + + local function do_prompt() + local message = input.prompt .. DONE_PROMPT_SUFFIX + local result, err = sess:prompt(message) + attach_cost(result) + if err then + return { + llm_output = "sub-agent error: " .. err, + is_error = true, + usage = result, + cost = result and result.cost, + } + end + if captured then return { llm_output = captured, format = "markdown", usage = result, cost = result and result.cost } end - return { - llm_output = n00n.json.encode(captured), - format = "markdown", - usage = result, - cost = result and result.cost, - } + return { llm_output = result.text, format = "markdown", usage = result, cost = result and result.cost } end - return { llm_output = result.text, format = "markdown", usage = result, cost = result and result.cost } - 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 + 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 - 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 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 - return prompt_res.value end) permit:release() if not ok then diff --git a/plugins/team/init.lua b/plugins/team/init.lua index e5c79bd66..7ea66e2fc 100644 --- a/plugins/team/init.lua +++ b/plugins/team/init.lua @@ -819,6 +819,10 @@ local function run_team(input, ctx) input.thinking = "adaptive" end input._agent_budget = new_agent_guard(input.max_agents, input.timeout_secs) + + -- Enforce wall-clock timeout for in-flight subagent calls + ctx:set_deadline(input.timeout_secs) + local goal = input.goal local slug = memory.slug(input.goal) diff --git a/plugins/workflow/init.lua b/plugins/workflow/init.lua index 2630d69e3..35b1c7f0b 100644 --- a/plugins/workflow/init.lua +++ b/plugins/workflow/init.lua @@ -21,6 +21,7 @@ local ToolView = require("n00n.tool_view") local telemetry = require("n00n.telemetry") local structured_output = require("n00n.structured_output") local guard = require("n00n.guard") +local subagent = require("n00n.subagent") local SCRIPT_ERROR_PREFIX = "workflow script error: " local NO_META_ERROR = "workflow script must call meta({ name = ... }) before doing any work" @@ -403,114 +404,52 @@ local function make_agent(ctx, progress, journal, logger, run_guard) error(guard_err, 0) end - local validator - if aopts.output_schema then - local compile_err - validator, compile_err = structured_output.compile_validator(aopts.output_schema) - if compile_err then - error(compile_err, 0) - end - end - - local model, model_err = n00n.agent.resolve_model(ctx, { tier = aopts.model_tier }) - if model_err then - error(model_err, 0) - end - - local audience = subagent_type == "research" and RESEARCH_AUDIENCE or GENERAL_AUDIENCE - local prompt_id = subagent_type == "research" and RESEARCH_PROMPT or GENERAL_PROMPT - local system, system_err = n00n.agent.system_prompt(ctx, { prompt_id = prompt_id, instructions = true }) - if system_err then - error(system_err, 0) - end - - local tool_defs, tools_err = n00n.agent.tools(ctx, { - audience = audience, - spec = model.spec, - include_mcp = true, - }) - if tools_err then - error(tools_err, 0) - end - - local captured, last_errors - local local_tools - if validator then - local_tools = { - [structured_output.STRUCTURED_OUTPUT_NAME] = { - description = structured_output.STRUCTURED_OUTPUT_DESCRIPTION, - input_schema = aopts.output_schema, - handler = function(value) - local errs = validator:validate(value) - if errs then - last_errors = structured_output.bounded_errors(errs) - return nil, structured_output.INVALID_INPUT_PREFIX .. last_errors - end - captured = value - return structured_output.STRUCTURED_OUTPUT_ACK - end, - }, - } - end - aggregate_permit = aggregate_agent_semaphore:acquire() progress.agent_started(label) if logger then logger.log("agent_started", { label = label, model_tier = aopts.model_tier, subagent_type = subagent_type }) end - local sess, sess_err = n00n.agent.session(ctx, { - model_spec = model.spec, - system = system, - tools = tool_defs, - local_tools = local_tools, - audience = audience, - name = label, + + local captured, launch_err = subagent.launch(ctx, { + description = label, + prompt = aopts.prompt, + subagent_type = subagent_type, + model_tier = aopts.model_tier, thinking = aopts.thinking, + output_schema = aopts.output_schema, + include_mcp = true, }) - if sess_err then - error(sess_err, 0) - end - - local message = aopts.prompt - if validator then - message = message .. structured_output.STRUCTURED_OUTPUT_SUFFIX - end - local prompt_result, prompt_err = sess:prompt(message) - local retries = 0 - while not prompt_err and validator and not captured and retries < structured_output.MAX_STRUCTURED_RETRIES do - retries = retries + 1 - prompt_result, prompt_err = sess:prompt(structured_output.NUDGE_MISSING) - end - local record_ok, record_err = run_guard:record(aopts.prompt, prompt_err) + local record_ok, record_err = run_guard:record(aopts.prompt, launch_err) if not record_ok then - sess:close() + aggregate_permit:release() + aggregate_permit = nil error(record_err, 0) end - sess:close() aggregate_permit:release() aggregate_permit = nil + + if launch_err then + error("sub-agent error: " .. launch_err, 0) + end + progress.agent_done(label) if logger then logger.log("agent_done", { label = label, model_tier = aopts.model_tier, subagent_type = subagent_type }) end - if prompt_err then - error("sub-agent error: " .. prompt_err, 0) - end - if validator and not captured then - local msg = last_errors and (structured_output.STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) - or structured_output.STRUCTURED_MISSING_ERROR - error(msg, 0) - end - local out = prompt_result.text - if captured then + local out + if type(captured) == "string" then + out = captured + elseif captured then local encoded, encode_err = n00n.json.encode(captured) if encode_err then error("failed to encode structured output: " .. tostring(encode_err), 0) end out = encoded + else + out = "" end local gate = journal.lock:acquire() diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index bc6acbb7d..83ca979c2 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -157,6 +157,32 @@ fn workflow_from_mode(mode: CliAgentMode) -> bool { matches!(mode, CliAgentMode::Team | CliAgentMode::Workflow) } +fn runtime_mode_from_cli(mode: CliAgentMode) -> RuntimeAgentMode { + if mode == CliAgentMode::Research { + RuntimeAgentMode::Research + } else { + RuntimeAgentMode::Build + } +} + +fn agent_mode_str(mode: &RuntimeAgentMode) -> &'static str { + match mode { + RuntimeAgentMode::Research => "research", + _ => "build", + } +} + +fn runtime_mode_from_str(mode: &str) -> RuntimeAgentMode { + if mode == "research" { + RuntimeAgentMode::Research + } else { + RuntimeAgentMode::Build + } +} + +const RESEARCH_EXCLUDED_TOOLS: &[&str] = + &["write", "edit", "multiedit", "edit_lines", "insert_lines"]; + const MAX_AGENT_ID_LEN: usize = 64; fn validate_agent_id(id: &str) -> Result<()> { @@ -420,6 +446,12 @@ pub fn run(opts: &AgentRunOptions<'_>, json: bool) -> Result<()> { opts.task_description, )?; + let mode = runtime_mode_from_cli(opts.mode); + let excluded_tools: Vec<&str> = if mode == RuntimeAgentMode::Research { + RESEARCH_EXCLUDED_TOOLS.to_vec() + } else { + Vec::new() + }; let headless_params = headless::HeadlessParams { model: env.model, config: env.agent_config, @@ -429,11 +461,12 @@ pub fn run(opts: &AgentRunOptions<'_>, json: bool) -> Result<()> { prompt: message, images: Vec::new(), prompt_slots: env.prompt_slots, - excluded_tools: Vec::new(), + excluded_tools, mcp_handle: env.mcp_handle, initial_wd: env.cwd, fast: false, workflow: workflow_from_mode(opts.mode), + mode, }; let handle = headless::spawn(headless_params); @@ -442,6 +475,7 @@ pub fn run(opts: &AgentRunOptions<'_>, json: bool) -> Result<()> { let mut final_output = String::new(); let mut final_usage = None; let mut stop_reason = String::from("completed"); + let mut error_message: Option = None; while let Ok(event) = handle.event_rx.recv() { match event.event { @@ -453,6 +487,7 @@ pub fn run(opts: &AgentRunOptions<'_>, json: bool) -> Result<()> { } n00n_agent::AgentEvent::Error { message } => { stop_reason = format!("error: {message}"); + error_message = Some(message.clone()); eprintln!("Error: {message}"); } _ => {} @@ -474,6 +509,10 @@ pub fn run(opts: &AgentRunOptions<'_>, json: bool) -> Result<()> { println!("{final_output}"); } + if let Some(err) = error_message { + return Err(eyre!(err)); + } + Ok(()) } @@ -488,10 +527,20 @@ struct AgentState { model: String, created_at: u64, updated_at: u64, + #[serde(default = "default_mode", skip_serializing_if = "is_build_mode")] + mode: String, #[serde(default, skip_serializing_if = "Option::is_none")] cwd: Option, } +fn default_mode() -> String { + "build".to_string() +} + +fn is_build_mode(mode: &str) -> bool { + mode == "build" +} + #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] enum ClientCommand { @@ -615,7 +664,24 @@ pub fn server(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<() } #[cfg(unix)] +#[allow(unsafe_code)] fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<()> { + // SAFETY: `fork` is the only way to detach a background server from the + // controlling terminal; we immediately `setsid` in the child and exit the parent. + match unsafe { libc::fork() } { + 0 => { + // SAFETY: `setsid` creates a new session and detaches from the terminal. + if unsafe { libc::setsid() } < 0 { + return Err(eyre!("failed to create new session")); + } + } + pid if pid > 0 => { + println!("Background agent started with PID {pid}"); + return Ok(()); + } + _ => return Err(eyre!("fork failed")), + } + let env = prepare_agent_env(opts.model, opts.yolo, opts.no_jit)?; let message = build_message( opts.mode, @@ -630,6 +696,12 @@ fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<( let storage = env.storage.clone(); let model_spec = env.model_spec; + let mode = runtime_mode_from_cli(opts.mode); + let excluded_tools: Vec<&str> = if mode == RuntimeAgentMode::Research { + RESEARCH_EXCLUDED_TOOLS.to_vec() + } else { + Vec::new() + }; let interactive_params = headless::InteractiveParams { model: env.model, config: env.agent_config, @@ -637,7 +709,7 @@ fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<( timeouts: env.timeouts, openai_options: env.openai_options, prompt_slots: Arc::new(env.prompt_slots), - excluded_tools: Vec::new(), + excluded_tools, mcp_handle: env.mcp_handle, initial_wd: env.cwd, session_id: None, @@ -646,6 +718,7 @@ fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<( system_prompt_override: None, append_system_prompt: None, workflow: workflow_from_mode(opts.mode), + mode: mode.clone(), }; let handle = headless::spawn_interactive(interactive_params); @@ -669,6 +742,7 @@ fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<( model: model_spec, created_at: now_epoch(), updated_at: now_epoch(), + mode: agent_mode_str(&mode).to_string(), cwd: std::env::current_dir() .ok() .map(|p| p.to_string_lossy().into_owned()), @@ -689,7 +763,7 @@ fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<( let _ = handle.input_tx.send(AgentInput { message, - mode: RuntimeAgentMode::Build, + mode, images: Vec::new(), preamble: Vec::new(), thinking: ThinkingConfig::default(), @@ -799,6 +873,7 @@ async fn handle_connection( } let mut state = read_agent_state(storage, agent_id)?; + let message_mode = runtime_mode_from_str(&state.mode); state.status = "working".to_string(); state.updated_at = now_epoch(); write_agent_state(storage, &state)?; @@ -808,7 +883,7 @@ async fn handle_connection( input_tx .send(AgentInput { message: text.clone(), - mode: RuntimeAgentMode::Build, + mode: message_mode, images: Vec::new(), preamble: Vec::new(), thinking: ThinkingConfig::default(), @@ -859,6 +934,7 @@ async fn handle_connection( write_line(&mut writer, &json) .await .wrap_err("failed to write event")?; + break; } AgentEvent::Done { usage, .. } => { final_usage = Some( @@ -881,12 +957,17 @@ async fn handle_connection( .wrap_err("failed to write event")?; let mut state = read_agent_state(storage, agent_id)?; - state.status = "running".to_string(); + if paused.load(Ordering::Relaxed) { + state.status = "paused".to_string(); + } else { + state.status = "running".to_string(); + } state.updated_at = now_epoch(); write_agent_state(storage, &state)?; } ClientCommand::Pause => { paused.store(true, Ordering::Relaxed); + let _ = cancel_tx.send(()); let mut state = read_agent_state(storage, agent_id)?; state.status = "paused".to_string(); @@ -918,10 +999,13 @@ async fn handle_connection( state.status = "stopping".to_string(); write_agent_state(storage, &state)?; + // Cancel the current run and close the input channel let _ = cancel_tx.send(()); + drop(input_tx); + // Cancel the outer interactive task to ensure cleanup if let Some(t) = task.lock().await.take() { - t.await; + t.cancel().await; } let response = serde_json::json!({ "ok": true }); @@ -1066,11 +1150,15 @@ pub fn stop_client(id: &str, state_dir_override: Option) -> Result<()> let storage = agent_storage(&state_dir); - let Ok(state) = read_agent_state(&storage, id) else { - let agent_dir_path = agent_dir(&storage, id)?; - let _ = fs::remove_dir_all(&agent_dir_path); - eprintln!("Agent {id} not found, cleaned up directory"); - return Ok(()); + let state = match read_agent_state(&storage, id) { + Ok(s) => s, + Err(e) if e.to_string().contains("not found") || e.to_string().contains("No such file") => { + let agent_dir_path = agent_dir(&storage, id)?; + let _ = fs::remove_dir_all(&agent_dir_path); + eprintln!("Agent {id} not found, cleaned up directory"); + return Ok(()); + } + Err(e) => return Err(e.wrap_err("failed to read agent state")), }; #[cfg(not(unix))] @@ -1409,6 +1497,7 @@ mod tests { model: "anthropic/claude-3-opus".to_string(), created_at: 1_234_567_890, updated_at: 1_234_567_900, + mode: "build".to_string(), cwd: Some("/tmp/proj".into()), }; @@ -1507,6 +1596,7 @@ mod tests { model: "model1".to_string(), created_at: 100, updated_at: 200, + mode: "build".to_string(), cwd: None, }; let data1 = serde_json::to_vec_pretty(&first_state).unwrap(); @@ -1524,6 +1614,7 @@ mod tests { model: "model2".to_string(), created_at: 50, updated_at: 300, + mode: "build".to_string(), cwd: None, }; let data2 = serde_json::to_vec_pretty(&second_state).unwrap(); diff --git a/src/print.rs b/src/print.rs index 6fe306278..89bc83de9 100644 --- a/src/print.rs +++ b/src/print.rs @@ -17,7 +17,7 @@ use color_eyre::Result; use color_eyre::eyre::{Context, eyre}; use n00n_agent::headless::{HeadlessHandle, HeadlessParams}; use n00n_agent::tools::QUESTION_TOOL_NAME; -use n00n_agent::{AgentConfig, AgentEvent, Envelope, ImageSource, PermissionsConfig}; +use n00n_agent::{AgentConfig, AgentEvent, AgentMode, Envelope, ImageSource, PermissionsConfig}; use n00n_lua::EventHandle; use n00n_providers::model::Model; use n00n_providers::{OpenAiOptions, StopReason, TokenUsage}; @@ -255,6 +255,7 @@ pub fn run(model: &Model, args: PrintArgs<'_>) -> Result<()> { initial_wd: cwd, fast, workflow, + mode: AgentMode::Build, }); let print_status = Arc::new(Mutex::new("working".to_owned())); diff --git a/src/sdk_mode.rs b/src/sdk_mode.rs index 2f5304cf0..3b23e887a 100644 --- a/src/sdk_mode.rs +++ b/src/sdk_mode.rs @@ -502,6 +502,7 @@ pub fn run(params: SdkParams) -> Result<()> { system_prompt_override: cli.system_prompt.clone().filter(|s| !s.is_empty()), append_system_prompt: cli.append_system_prompt.clone().filter(|s| !s.is_empty()), workflow, + mode: AgentMode::Build, }); let (writer, writer_thread) = spawn_writer(handle.session_id.clone());