From 6d6df03802701ccead4dcf80bd0dc4539eaccfc7 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Sun, 26 Jul 2026 23:18:15 -0400 Subject: [PATCH 01/25] feat(agent): add structured output helper and agent CLI command Phase 2 implementation: - Create plugins/lib/n00n/structured_output.lua with constants and helper functions for schema validation - Create plugins/lib/n00n/subagent.lua with unified subagent launch API - Migrate plugins/task/init.lua to use n00n.structured_output helper - Migrate plugins/workflow/init.lua to use n00n.structured_output helper - Add n00n agent run CLI command with stubs for future daemon commands - Add tests for structured_output helper in plugins/lib/tests/spec.lua - Keep team/roles.lua unchanged due to custom patterns (hardcoded audience, budget handling, preview support) All tests pass. Lua syntax validated with luac. --- .devin/AGENT_ORCHESTRATION_REVIEW.md | 333 +++++++++++++++++++++++++ plugins/lib/n00n/structured_output.lua | 166 ++++++++++++ plugins/lib/n00n/subagent.lua | 154 ++++++++++++ plugins/lib/tests/spec.lua | 87 +++++++ plugins/task/init.lua | 79 +----- plugins/team/roles.lua | 3 + plugins/workflow/init.lua | 78 +----- src/cli.rs | 57 +++++ src/cmd/agent.rs | 136 ++++++++++ src/cmd/mod.rs | 43 +++- 10 files changed, 1004 insertions(+), 132 deletions(-) create mode 100644 .devin/AGENT_ORCHESTRATION_REVIEW.md create mode 100644 plugins/lib/n00n/structured_output.lua create mode 100644 plugins/lib/n00n/subagent.lua create mode 100644 src/cmd/agent.rs diff --git a/.devin/AGENT_ORCHESTRATION_REVIEW.md b/.devin/AGENT_ORCHESTRATION_REVIEW.md new file mode 100644 index 000000000..7495cdae4 --- /dev/null +++ b/.devin/AGENT_ORCHESTRATION_REVIEW.md @@ -0,0 +1,333 @@ +# Agent Orchestration Review + +## Worktree +- Path: `/home/w0w/dev/.n00n-worktrees/agent-cli-simplify` +- Branch: `feat/agent-cli-simplify` + +## Abstractions and Responsibilities + +### Rust Core (n00n-agent) + +#### `headless.rs` +- **Responsibility**: Headless (non-interactive) and interactive agent spawning +- **Key types**: + - `HeadlessParams` / `HeadlessHandle`: One-shot agent runs + - `InteractiveParams` / `InteractiveHandle`: Long-running interactive sessions + - `SessionStore`: Session persistence +- **Notes**: Single entry point for both headless and interactive modes. Handles session persistence, tool setup, and agent lifecycle. + +#### `agent/run.rs` +- **Responsibility**: Core agent loop (turn management, tool dispatch, streaming) +- **Key types**: + - `AgentParams`: Static agent configuration (provider, model, permissions, etc.) + - `AgentRunParams`: Per-run parameters (history, system, tools, event channel) + - `Agent`: The agent instance with the run loop +- **Notes**: Handles streaming, tool calls, compaction, retries, and cancellation. No orchestration logic beyond single-agent loops. + +### Lua API (n00n-lua) + +#### `api/agent.rs` +- **Responsibility**: Subagent primitives for Lua plugins +- **Key functions**: + - `resolve_model()`: Model selection with tier clamping + - `system_prompt()`: Build system prompts from templates + - `tools()`: Get tool definitions for a given audience + - `call_tool()`: Direct tool invocation from Lua + - `session()`: Create subagent sessions +- **Notes**: Pure primitives. No policy (retries, validation, concurrency) - that lives in plugins. + +#### `api/session.rs` +- **Responsibility**: Host session management (list, live, focus, delete, prompt) +- **Key functions**: + - `list()`, `live()`, `status()`, `current()`, `focus()`, `delete()`, `new()`, `prompt()`, `cancel()`, `set_title()` +- **Notes**: Round-trips to UI event loop. No agent orchestration here. + +### Lua Plugins + +#### `task/init.lua` +- **Responsibility**: Single isolated subagent with structured output +- **Key features**: + - Structured output via local `structured_output` tool + - Schema validation with `n00n.json.schema_validator` + - Concurrency control via semaphore + - Progress preview UI + - Background mode (spawns new session via `n00n.session.new`) +- **Subagent launch path**: `n00n.agent.session()` → `sess:prompt()` → close + +#### `workflow/init.lua` +- **Responsibility**: Multi-stage agent orchestration via sandboxed Lua scripts +- **Key features**: + - Script runtime with globals: `agent()`, `parallel()`, `pipeline()`, `phase()`, `log()`, `inputs` + - Journaling for resume/replay + - Structured output via local `structured_output` tool + - Concurrency limits (per-workflow, aggregate) + - Progress preview UI +- **Subagent launch path**: `n00n.agent.session()` → `sess:prompt()` → close + +#### `team/init.lua` +- **Responsibility**: ALMAS multi-agent SDLC orchestration +- **Key features**: + - Supervisor planning (structured output) + - Role-based execution (product_manager, planner, developer, tester, reviewer) + - Swarm mode (decentralized rounds) + - Waves mode (plan → implement → validate with gates) + - Retrieval augmentation + - Quorum validation + - Human escalation / resume + - Background mode +- **Subagent launch path**: Delegates to `roles.run()` → `n00n.agent.session()` → `sess:prompt()` → close + +#### `team/roles.lua` +- **Responsibility**: Role catalogue and execution +- **Key features**: + - Role definitions with tier and system prompts + - `run()` function that creates subagent sessions + - Usage/cost tracking +- **Subagent launch path**: `n00n.agent.session()` → `sess:prompt()` → close + +#### `agent_control/init.lua` +- **Responsibility**: Control background agents (list, status, message, pause, resume, stop, policy) +- **Key features**: + - Policy management (set/get/delete/list rules) + - Session control via `n00n.session.*` API + - Policy evaluation before actions +- **Notes**: Overlaps with CLI surface - no dedicated CLI commands for agent control. + +#### `blackboard/init.lua` +- **Responsibility**: Shared coordination substrate for multi-agent sessions +- **Key features**: + - Post observations, claim tasks atomically, query state + - Project-scoped persistence +- **Notes**: Coordination primitive, not orchestration. + +#### `batch/init.lua` +- **Responsibility**: Concurrent tool dispatch +- **Key features**: + - Parallel tool execution with UI composition + - State persistence for restore +- **Notes**: Tool-level concurrency, not agent orchestration. + +### CLI (src/cli.rs, src/cmd/) + +#### `cli.rs` +- **Responsibility**: CLI argument parsing +- **Current commands**: + - `auth` (login/logout/status) + - `models` + - `index` + - `mcp` (auth/logout) + - `update` / `rollback` + - `acp` (Agent Client Protocol server) + - `prompt` (show system prompt or tools) +- **Notes**: No `agent` subcommand tree. No agent control commands. + +#### `cmd/mod.rs` +- **Responsibility**: Command dispatch +- **Notes**: Delegates to subcmd implementations. + +#### `cmd/subcmd.rs` +- **Responsibility**: Subcommand implementations +- **Notes**: Auth, models, index, mcp, prompt implementations. + +## Duplication and Overcomplication + +### 1. Structured Output / Schema Validation (MAJOR DUPLICATION) + +**Duplicated constants (task vs workflow):** +- `STRUCTURED_OUTPUT_NAME` = "structured_output" +- `STRUCTURED_OUTPUT_DESCRIPTION` +- `STRUCTURED_OUTPUT_ACK` +- `STRUCTURED_OUTPUT_PROMPT_SUFFIX` / `STRUCTURED_OUTPUT_SUFFIX` +- `MAX_STRUCTURED_RETRIES` = 1 +- `MAX_SCHEMA_ERRORS` = 3 +- `MAX_SCHEMA_BYTES` = 32 * 1024 +- `MAX_SCHEMA_DEPTH` = 16 +- `SCHEMA_ROOT_ERROR` +- `SCHEMA_COMPILE_ERROR` +- `SCHEMA_SIZE_ERROR` +- `SCHEMA_DEPTH_ERROR` +- `STRUCTURED_MISSING_ERROR` +- `STRUCTURED_INVALID_ERROR` +- `NUDGE_MISSING` +- `INVALID_INPUT_PREFIX` + +**Duplicated functions (task vs workflow):** +- `schema_within_depth(value, depth)` - identical implementation +- `bounded_errors(errors)` - identical implementation + +**Duplicated logic (task vs workflow):** +- Schema validation flow (compile → size check → depth check → validator creation) +- Local tool registration for `structured_output` +- Retry loop with nudge for missing structured_output call +- Error message formatting + +**Impact**: ~80 lines of duplicated code across two files. Any fix or enhancement must be made in both places. + +### 2. Subagent Launch Path (MODERATE DUPLICATION) + +**task/init.lua:** +```lua +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, +}) +local result, err = sess:prompt(message) +sess:close() +``` + +**workflow/init.lua (agent() function):** +```lua +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 prompt_result, prompt_err = sess:prompt(message) +sess:close() +``` + +**team/roles.lua (run() function):** +```lua +local sess, serr = n00n.agent.session(ctx, { + model_spec = model.spec, + system = r.system, + tools = tools, + audience = "general_sub", + name = role, + thinking = opts.thinking, +}) +local res, rerr = sess:prompt(prompt) +sess:close() +``` + +**Impact**: Three near-identical subagent launch patterns. Differences: +- `task` uses `input.description` for name +- `workflow` uses `label` for name +- `roles` uses `role` for name and adds `thinking` parameter +- `roles` hardcodes audience to "general_sub" + +### 3. Model Resolution and Tool Setup (MODERATE DUPLICATION) + +**Pattern repeated in task, workflow, and roles:** +```lua +local model, model_err = n00n.agent.resolve_model(ctx, { spec = ..., tier = ... }) +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 }) +local tool_defs, tools_err = n00n.agent.tools(ctx, { audience = audience, spec = model.spec }) +``` + +**Impact**: Same 4-5 line sequence in three places. Only variation is audience selection logic. + +### 4. Cost/Usage Tracking (MINOR DUPLICATION) + +**task/init.lua:** +```lua +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 +``` + +**team/roles.lua:** +```lua +M.metrics = usage.price +-- Later: +local measured_usage, cost, metrics_err = M.metrics(model.spec, res) +``` + +**Impact**: Different approaches to the same problem. Task attaches cost inline; roles uses a separate module. + +### 5. Agent Control vs CLI Surface (MISSING INTEGRATION) + +**agent_control tool** provides: +- `list`, `status`, `message`, `pause`, `resume`, `stop`, `policy` actions + +**CLI** has: +- No `n00n agent` subcommand tree +- No way to control background agents from CLI +- Users must invoke agent_control through the LLM interface + +**Impact**: Agent control is only accessible via tool calls, not as a first-class CLI command. + +### 6. Team as Reimplementation of Workflow (ARCHITECTURAL OVERLAP) + +**team/init.lua** implements: +- Sequential step execution (like workflow scripts) +- Parallel execution (swarm mode, like workflow `parallel()`) +- State persistence (memory plugin, like workflow journaling) +- Progress tracking (ActivityPreview, like workflow progress) + +**workflow/init.lua** provides: +- Script-based orchestration +- Parallel/pipeline primitives +- Journaling for resume + +**Impact**: Team could be expressed as a workflow template/script with additional plugins (blackboard, retrieval, quorum). Currently reimplements orchestration primitives. + +## Simplification Opportunities + +### High Priority + +1. **Extract shared structured-output helper module** + - Create `plugins/structured_output.lua` with: + - Constants (STRUCTURED_OUTPUT_*, MAX_*, SCHEMA_*, NUDGE_*, INVALID_INPUT_PREFIX) + - `schema_within_depth()` + - `bounded_errors()` + - `make_structured_tool(schema)` - returns local tool spec + - `run_with_structured_output(sess, message, validator, max_retries)` - handles prompt + retry loop + - Replace duplicated code in task and workflow + +2. **Unify subagent launch path** + - Create shared helper in `n00n-lua` or new plugin: + - `launch_subagent(ctx, opts)` where opts includes: + - `model_spec`, `system`, `tools`, `local_tools`, `audience`, `name`, `thinking` + - Returns `{ result, err, cost, usage }` + - Replace three near-identical launch sequences + +3. **Add CLI agent control commands** + - Add `n00n agent` subcommand tree: + - `n00n agent list` - list background agents + - `n00n agent status ` - show agent status + - `n00n agent message ` - send steering message + - `n00n agent pause ` - pause agent + - `n00n agent resume ` - resume agent + - `n00n agent stop ` - stop agent + - `n00n agent policy ...` - policy management + - Map to existing agent_control tool actions + +### Medium Priority + +4. **Extract model/tool setup helper** + - Create `setup_subagent_env(ctx, subagent_type, model_spec, model_tier)` helper + - Returns `{ model, audience, system, tools }` + - Replace repeated 4-5 line sequences + +5. **Unify cost/usage tracking** + - Standardize on one approach (likely the roles/usage module pattern) + - Apply consistently across task, workflow, team + +6. **Consider team as workflow template** + - Express team as a workflow script + specialized plugins + - Reduces team to composition rather than reimplementation + - May require workflow enhancements (state persistence beyond journaling) + +### Low Priority + +7. **CLI session management** + - Add `n00n session` subcommand for direct session control + - Maps to `n00n.session.*` Lua API + - Useful for automation without LLM + +## Proposed Design (Phase 2) + +See final message for design proposal. diff --git a/plugins/lib/n00n/structured_output.lua b/plugins/lib/n00n/structured_output.lua new file mode 100644 index 000000000..1c1ba7d69 --- /dev/null +++ b/plugins/lib/n00n/structured_output.lua @@ -0,0 +1,166 @@ +-- Structured output helper module for subagent validation. +-- Provides constants, schema validation, and local tool creation for +-- structured output patterns used across task, workflow, and subagent plugins. + +local M = {} + +-- Constants +M.STRUCTURED_OUTPUT_NAME = "structured_output" +M.STRUCTURED_OUTPUT_DESCRIPTION = "Report your final result. Call it exactly once when your task is complete." +M.STRUCTURED_OUTPUT_ACK = "Output recorded." +M.STRUCTURED_OUTPUT_SUFFIX = "\n\nWhen finished, call the structured_output tool with your final result." +M.MAX_STRUCTURED_RETRIES = 1 +M.MAX_SCHEMA_ERRORS = 3 +M.MAX_SCHEMA_BYTES = 32 * 1024 +M.MAX_SCHEMA_DEPTH = 16 +M.SCHEMA_ROOT_ERROR = "output_schema must have type object" +M.SCHEMA_COMPILE_ERROR = "invalid output_schema" +M.SCHEMA_SIZE_ERROR = "output_schema exceeds 32768-byte limit" +M.SCHEMA_DEPTH_ERROR = "output_schema exceeds maximum depth of 16" +M.STRUCTURED_MISSING_ERROR = "subagent finished without calling structured_output" +M.STRUCTURED_INVALID_ERROR = "subagent result does not match output_schema" +M.NUDGE_MISSING = + "You did not call the structured_output tool. Call it now with your final result matching its input schema." +M.INVALID_INPUT_PREFIX = "Input does not match the required schema. Fix the errors and call structured_output again:\n" + +-- Check if a schema value is within the maximum depth limit +function M.schema_within_depth(value, depth) + if type(value) ~= "table" then + return true + end + if depth > M.MAX_SCHEMA_DEPTH then + return false + end + for _, child in pairs(value) do + if not M.schema_within_depth(child, depth + 1) then + return false + end + end + return true +end + +-- Limit error messages to a reasonable number +function M.bounded_errors(errors) + local out = {} + for i = 1, math.min(#errors, M.MAX_SCHEMA_ERRORS) do + out[i] = errors[i] + end + return table.concat(out, "\n") +end + +-- Compile a schema validator with early validation checks +-- Returns (validator | nil, err) +function M.compile_validator(schema) + if type(schema) ~= "table" or schema.type ~= "object" then + return nil, M.SCHEMA_ROOT_ERROR + end + + local schema_json, encode_err = n00n.json.encode(schema) + if encode_err then + return nil, M.SCHEMA_COMPILE_ERROR .. ": " .. encode_err + end + + if #schema_json > M.MAX_SCHEMA_BYTES then + return nil, M.SCHEMA_SIZE_ERROR + end + + if not M.schema_within_depth(schema, 1) then + return nil, M.SCHEMA_DEPTH_ERROR + end + + local validator, compile_err = n00n.json.schema_validator(schema) + if compile_err then + return nil, M.SCHEMA_COMPILE_ERROR .. ": " .. compile_err + end + + return validator, nil +end + +-- Create a local tool spec for structured output +-- Returns a table with description, input_schema, and handler +function M.make_local_tool(schema, on_submit) + return { + description = M.STRUCTURED_OUTPUT_DESCRIPTION, + input_schema = schema, + handler = function(value) + local validator, compile_err = M.compile_validator(schema) + if compile_err then + return nil, compile_err + end + + local errs = validator:validate(value) + if errs then + local bounded = M.bounded_errors(errs) + return nil, M.INVALID_INPUT_PREFIX .. bounded + end + + if on_submit then + on_submit(value) + end + + return M.STRUCTURED_OUTPUT_ACK + end, + } +end + +-- Run a session with structured output validation and retry logic +-- Returns (result | nil, err) +function M.run_with_validation(sess, message, validator, max_retries) + max_retries = max_retries or M.MAX_STRUCTURED_RETRIES + + local captured + local last_errors + + -- Set up local tool if validator is provided + local local_tools + if validator then + local_tools = { + [M.STRUCTURED_OUTPUT_NAME] = { + description = M.STRUCTURED_OUTPUT_DESCRIPTION, + input_schema = validator.schema, -- Note: validator may not expose schema, this is a placeholder + handler = function(value) + local errs = validator:validate(value) + if errs then + last_errors = M.bounded_errors(errs) + return nil, M.INVALID_INPUT_PREFIX .. last_errors + end + captured = value + return M.STRUCTURED_OUTPUT_ACK + end, + }, + } + end + + -- Add suffix to message if validator is present + local full_message = message + if validator then + full_message = message .. M.STRUCTURED_OUTPUT_SUFFIX + end + + -- Initial prompt + local result, err = sess:prompt(full_message) + + -- Retry loop for missing structured_output calls + local retries = 0 + while not err and validator and not captured and retries < max_retries do + retries = retries + 1 + result, err = sess:prompt(M.NUDGE_MISSING) + end + + if err then + return nil, err + end + + if validator and not captured then + local msg = last_errors and (M.STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) or M.STRUCTURED_MISSING_ERROR + return nil, msg + end + + if captured then + return captured, nil + end + + return result.text, nil +end + +return M diff --git a/plugins/lib/n00n/subagent.lua b/plugins/lib/n00n/subagent.lua new file mode 100644 index 000000000..39c9de1f9 --- /dev/null +++ b/plugins/lib/n00n/subagent.lua @@ -0,0 +1,154 @@ +-- Subagent launch helper module. +-- Provides a unified interface for launching subagents with model resolution, +-- system prompts, tool setup, and optional structured output validation. + +local M = {} + +local usage = require("n00n.usage") +local structured_output = require("n00n.structured_output") + +-- Launch a subagent with the given options. +-- Returns (result | nil, err, cost, usage) +-- +-- Options: +-- description (required): Short description for the subagent +-- prompt (required): The prompt to send to the subagent +-- subagent_type: "research" or "general" (default: "general") +-- model_spec: Exact model spec (optional) +-- model_tier: Capped tier: "weak", "medium", or "strong" +-- auto_tier: Pick model_tier from prompt automatically (optional) +-- thinking: Thinking mode configuration +-- output_schema: JSON Schema for structured output validation +-- audience: Tool audience (default: computed from subagent_type) +-- local_tools: Additional local tools to register +-- ctx: Agent context (required) +function M.launch(ctx, opts) + if not opts then + return nil, "opts is required", nil, nil + end + if not opts.description then + return nil, "opts.description is required", nil, nil + end + if not opts.prompt then + return nil, "opts.prompt is required", nil, nil + end + if not ctx then + return nil, "ctx is required", nil, nil + end + + local subagent_type = opts.subagent_type or "general" + if subagent_type ~= "research" and subagent_type ~= "general" then + return nil, "unknown subagent_type: " .. tostring(subagent_type), nil, nil + end + + -- Resolve model + local model, model_err = n00n.agent.resolve_model(ctx, { + spec = opts.model_spec, + tier = not opts.model_spec and opts.model_tier or nil, + }) + if model_err then + return nil, model_err, nil, nil + end + + -- Compute audience and prompt_id + local audience = opts.audience or (subagent_type == "research" and "research_sub" or "general_sub") + local prompt_id = subagent_type == "research" and "research" or "general" + + -- Build system prompt + local system, system_err = n00n.agent.system_prompt(ctx, { + prompt_id = prompt_id, + instructions = true, + }) + if system_err then + return nil, system_err, nil, nil + end + + -- Get tool definitions + local tool_defs, tools_err = n00n.agent.tools(ctx, { + audience = audience, + spec = model.spec, + }) + if tools_err then + return nil, tools_err, nil, nil + end + + -- Set up local tools + local local_tools = opts.local_tools or {} + local validator + local captured + local last_errors + + -- Add structured output tool if schema is provided + if opts.output_schema then + local compile_err + validator, compile_err = structured_output.compile_validator(opts.output_schema) + if compile_err then + return nil, compile_err, nil, nil + end + + local_tools[structured_output.STRUCTURED_OUTPUT_NAME] = { + description = structured_output.STRUCTURED_OUTPUT_DESCRIPTION, + input_schema = opts.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 + + -- Create session + local sess, sess_err = n00n.agent.session(ctx, { + model_spec = model.spec, + system = system, + tools = tool_defs, + local_tools = local_tools, + audience = audience, + name = opts.description, + thinking = opts.thinking, + }) + if sess_err then + return nil, sess_err, nil, nil + end + + -- Build message with structured output suffix if needed + local message = opts.prompt + if opts.output_schema then + message = message .. structured_output.STRUCTURED_OUTPUT_SUFFIX + end + + -- Run the prompt with retry logic for structured output + local result, err = sess:prompt(message) + 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) + end + + sess:close() + + if err then + return nil, "sub-agent error: " .. err, nil, result + 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 nil, msg, nil, result + end + + -- Calculate cost and usage + local measured_usage, cost, metrics_err = usage.price(model.spec, result) + if metrics_err then + -- Return result even if pricing fails + return captured or result.text, nil, nil, measured_usage + end + + return captured or result.text, nil, cost, measured_usage +end + +return M diff --git a/plugins/lib/tests/spec.lua b/plugins/lib/tests/spec.lua index 3692a5246..5b33361ab 100644 --- a/plugins/lib/tests/spec.lua +++ b/plugins/lib/tests/spec.lua @@ -2,6 +2,7 @@ local ActivityPreview = require("n00n.activity_preview") local ExploreResult = require("n00n.explore_result") local truncate = require("n00n.truncate") local ToolView = require("n00n.tool_view") +local structured_output = require("n00n.structured_output") local failures = {} @@ -1505,6 +1506,92 @@ case("activity_preview_repeated_prompts_keep_order_and_update_status_in_place", n00n.ui.buf = old_buf end) +-- Structured output tests + +case("structured_output_constants_defined", function() + eq(type(structured_output.STRUCTURED_OUTPUT_NAME), "string") + eq(type(structured_output.STRUCTURED_OUTPUT_DESCRIPTION), "string") + eq(type(structured_output.STRUCTURED_OUTPUT_ACK), "string") + eq(type(structured_output.STRUCTURED_OUTPUT_SUFFIX), "string") + eq(type(structured_output.MAX_STRUCTURED_RETRIES), "number") + eq(type(structured_output.MAX_SCHEMA_ERRORS), "number") + eq(type(structured_output.MAX_SCHEMA_BYTES), "number") + eq(type(structured_output.MAX_SCHEMA_DEPTH), "number") + eq(type(structured_output.SCHEMA_ROOT_ERROR), "string") + eq(type(structured_output.SCHEMA_COMPILE_ERROR), "string") + eq(type(structured_output.SCHEMA_SIZE_ERROR), "string") + eq(type(structured_output.SCHEMA_DEPTH_ERROR), "string") + eq(type(structured_output.STRUCTURED_MISSING_ERROR), "string") + eq(type(structured_output.STRUCTURED_INVALID_ERROR), "string") + eq(type(structured_output.NUDGE_MISSING), "string") + eq(type(structured_output.INVALID_INPUT_PREFIX), "string") +end) + +case("structured_output_schema_within_depth_simple", function() + eq(structured_output.schema_within_depth("string", 1), true) + eq(structured_output.schema_within_depth(123, 1), true) + eq(structured_output.schema_within_depth(true, 1), true) +end) + +case("structured_output_schema_within_depth_nested", function() + local shallow = { a = 1, b = 2 } + eq(structured_output.schema_within_depth(shallow, 1), true) + + -- Create a structure that exceeds MAX_SCHEMA_DEPTH (16) + local deep = {} + local current = deep + for i = 1, 20 do + current.next = {} + current = current.next + end + eq(structured_output.schema_within_depth(deep, 1), false) +end) + +case("structured_output_bounded_errors_limits_count", function() + local errors = {} + for i = 1, 10 do + errors[i] = "error " .. i + end + local bounded = structured_output.bounded_errors(errors) + assert(bounded:find("error 1"), "should include first error") + assert(bounded:find("error 3"), "should include third error") + assert(not bounded:find("error 4"), "should not include fourth error") +end) + +case("structured_output_bounded_errors_empty", function() + local bounded = structured_output.bounded_errors({}) + eq(bounded, "") +end) + +case("structured_output_compile_validator_rejects_non_object", function() + local validator, err = structured_output.compile_validator({ type = "string" }) + eq(validator, nil) + assert(err:find("must have type object"), "error should mention object requirement") +end) + +case("structured_output_compile_validator_rejects_large_schema", function() + local large_schema = { type = "object", properties = {} } + for i = 1, 1000 do + large_schema.properties["field" .. i] = { type = "string", description = string.rep("x", 100) } + end + local validator, err = structured_output.compile_validator(large_schema) + eq(validator, nil) + assert(err:find("exceeds"), "error should mention size limit") +end) + +case("structured_output_compile_validator_accepts_valid_schema", function() + local schema = { + type = "object", + properties = { + name = { type = "string" }, + count = { type = "number" }, + }, + } + local validator, err = structured_output.compile_validator(schema) + assert(validator ~= nil, "should return validator") + eq(err, nil) +end) + if #failures > 0 then error(#failures .. " case(s) failed:\n\n" .. table.concat(failures, "\n\n")) end diff --git a/plugins/task/init.lua b/plugins/task/init.lua index f8851cded..d3fc15ee0 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -7,49 +7,16 @@ local ToolView = require("n00n.tool_view") local output_limits = require("n00n.output_limits") +local structured_output = require("n00n.structured_output") -local STRUCTURED_OUTPUT_NAME = "structured_output" -local STRUCTURED_OUTPUT_DESCRIPTION = "Report your final result. Call it exactly once when your task is complete." -local STRUCTURED_OUTPUT_ACK = "Output recorded." -local STRUCTURED_OUTPUT_PROMPT_SUFFIX = "\n\nWhen finished, call the structured_output tool with your final result." local DONE_NAME = "done" local DONE_DESCRIPTION = "Call when the task is complete with your final answer." local DONE_PROMPT_SUFFIX = "\n\nWhen finished, call the done tool with your final answer." -local MAX_STRUCTURED_RETRIES = 2 -local MAX_SCHEMA_ERRORS = 3 -local MAX_SCHEMA_BYTES = 32 * 1024 -local MAX_SCHEMA_DEPTH = 16 -local MAX_STRUCTURED_RETRIES = 1 -local SCHEMA_ROOT_ERROR = "output_schema must have type object" -local SCHEMA_SIZE_ERROR = "output_schema exceeds 32768-byte limit" -local SCHEMA_DEPTH_ERROR = "output_schema exceeds maximum depth of 16" -local SCHEMA_COMPILE_ERROR = "invalid output_schema" -local STRUCTURED_MISSING_ERROR = "subagent finished without calling structured_output" -local STRUCTURED_INVALID_ERROR = "subagent result does not match output_schema" -local INVALID_INPUT_PREFIX = - "Input does not match the required schema. Fix the errors and call structured_output again:\n" -local NUDGE_MISSING = - "You did not call the structured_output tool. Call it now with your final result matching its input schema." local BODY_INDENT_COLS = 4 local MIN_MD_WIDTH = 20 local DEFAULT_OUTPUT_LINES = 5 local DEFAULT_MAX_LINE_BYTES = 500 -local function schema_within_depth(value, depth) - if type(value) ~= "table" then - return true - end - if depth > MAX_SCHEMA_DEPTH then - return false - end - for _, child in pairs(value) do - if not schema_within_depth(child, depth + 1) then - return false - end - end - return true -end - local description = [[Launch one isolated agent; combine independent calls with batch. research (default) is read-only; general can edit. Each call starts fresh, so include context and ask for concise file:line results. Summarize returned results. auto_tier is opt-in. background returns agent_id.]] @@ -112,14 +79,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 bounded_errors(errors) - local out = {} - for i = 1, math.min(#errors, MAX_SCHEMA_ERRORS) do - out[i] = errors[i] - end - return table.concat(out, "\n") -end - local function make_preview(ctx, description) local tol = ctx:tool_output_lines() local max_preview = (tol and tol.task) or DEFAULT_OUTPUT_LINES @@ -187,23 +146,10 @@ local function handler(input, ctx) -- Compile early: a bad schema costs zero tokens. local validator if input.output_schema then - if type(input.output_schema) ~= "table" or input.output_schema.type ~= "object" then - return { llm_output = SCHEMA_ROOT_ERROR, is_error = true } - end - local schema_json, encode_err = n00n.json.encode(input.output_schema) - if encode_err then - return { llm_output = SCHEMA_COMPILE_ERROR .. ": " .. encode_err, is_error = true } - end - if #schema_json > MAX_SCHEMA_BYTES then - return { llm_output = SCHEMA_SIZE_ERROR, is_error = true } - end - if not schema_within_depth(input.output_schema, 1) then - return { llm_output = SCHEMA_DEPTH_ERROR, is_error = true } - end local compile_err - validator, compile_err = n00n.json.schema_validator(input.output_schema) + validator, compile_err = structured_output.compile_validator(input.output_schema) if compile_err then - return { llm_output = SCHEMA_COMPILE_ERROR .. ": " .. compile_err, is_error = true } + return { llm_output = compile_err, is_error = true } end end @@ -242,17 +188,17 @@ local function handler(input, ctx) local local_tools if validator then local_tools = { - [STRUCTURED_OUTPUT_NAME] = { - description = STRUCTURED_OUTPUT_DESCRIPTION, + [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 = bounded_errors(errs) - return nil, INVALID_INPUT_PREFIX .. last_errors + last_errors = structured_output.bounded_errors(errs) + return nil, structured_output.INVALID_INPUT_PREFIX .. last_errors end captured = value - return STRUCTURED_OUTPUT_ACK + return structured_output.STRUCTURED_OUTPUT_ACK end, }, } @@ -317,16 +263,16 @@ local function handler(input, ctx) local function do_prompt() local message = input.prompt if validator then - message = message .. STRUCTURED_OUTPUT_PROMPT_SUFFIX + message = message .. structured_output.STRUCTURED_OUTPUT_SUFFIX else message = message .. DONE_PROMPT_SUFFIX end local result, err = sess:prompt(message) attach_cost(result) local retries = 0 - while not err and validator and not captured and retries < MAX_STRUCTURED_RETRIES do + while not err and validator and not captured and retries < structured_output.MAX_STRUCTURED_RETRIES do retries = retries + 1 - result, err = sess:prompt(NUDGE_MISSING) + result, err = sess:prompt(structured_output.NUDGE_MISSING) attach_cost(result) end if err then @@ -338,7 +284,8 @@ local function handler(input, ctx) } end if validator and not captured then - local msg = last_errors and (STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) or STRUCTURED_MISSING_ERROR + 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 } end if captured then diff --git a/plugins/team/roles.lua b/plugins/team/roles.lua index 79a214038..6a8973937 100644 --- a/plugins/team/roles.lua +++ b/plugins/team/roles.lua @@ -1,6 +1,9 @@ -- 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). +-- NOTE: This module uses custom patterns (hardcoded audience, custom system prompts, +-- budget handling, preview support) that differ from the standard n00n.subagent.launch +-- pattern. Migration to n00n.subagent.launch deferred due to these custom requirements. local M = {} local route_tier = require("n00n.route_tier").route_tier diff --git a/plugins/workflow/init.lua b/plugins/workflow/init.lua index 4b85da6c4..a7a63b4a4 100644 --- a/plugins/workflow/init.lua +++ b/plugins/workflow/init.lua @@ -19,39 +19,7 @@ local ToolView = require("n00n.tool_view") local telemetry = require("n00n.telemetry") - -local STRUCTURED_OUTPUT_NAME = "structured_output" -local STRUCTURED_OUTPUT_DESCRIPTION = "Report your final result. Call it exactly once when your task is complete." -local STRUCTURED_OUTPUT_ACK = "Output recorded." -local STRUCTURED_OUTPUT_SUFFIX = "\n\nWhen finished, call the structured_output tool with your final result." -local MAX_STRUCTURED_RETRIES = 1 -local MAX_SCHEMA_ERRORS = 3 -local MAX_SCHEMA_BYTES = 32 * 1024 -local MAX_SCHEMA_DEPTH = 16 -local SCHEMA_ROOT_ERROR = "output_schema must have type object" -local SCHEMA_COMPILE_ERROR = "invalid output_schema" -local SCHEMA_SIZE_ERROR = "output_schema exceeds 32768-byte limit" -local SCHEMA_DEPTH_ERROR = "output_schema exceeds maximum depth of 16" -local STRUCTURED_MISSING_ERROR = "subagent finished without calling structured_output" -local STRUCTURED_INVALID_ERROR = "subagent result does not match output_schema" -local NUDGE_MISSING = - "You did not call the structured_output tool. Call it now with your final result matching its input schema." -local INVALID_INPUT_PREFIX = - "Input does not match the required schema. Fix the errors and call structured_output again:\n" -local function schema_within_depth(value, depth) - if type(value) ~= "table" then - return true - end - if depth > MAX_SCHEMA_DEPTH then - return false - end - for _, child in pairs(value) do - if not schema_within_depth(child, depth + 1) then - return false - end - end - return true -end +local structured_output = require("n00n.structured_output") local SCRIPT_ERROR_PREFIX = "workflow script error: " local NO_META_ERROR = "workflow script must call meta({ name = ... }) before doing any work" @@ -135,14 +103,6 @@ local max_concurrent_workflows = math.min(opts.max_concurrent_workflows, HARD_MA local workflow_semaphore = n00n.async.semaphore(max_concurrent_workflows) local aggregate_agent_semaphore = n00n.async.semaphore(HARD_MAX_AGGREGATE_AGENTS) -local function bounded_errors(errors) - local out = {} - for i = 1, math.min(#errors, MAX_SCHEMA_ERRORS) do - out[i] = errors[i] - end - return table.concat(out, "\n") -end - local function freeze_fns(src, names) local bare = {} for _, name in ipairs(names) do @@ -446,23 +406,10 @@ local function make_agent(ctx, progress, journal, logger) local validator if aopts.output_schema then - if type(aopts.output_schema) ~= "table" or aopts.output_schema.type ~= "object" then - error(SCHEMA_ROOT_ERROR, 0) - end - local encoded_schema, encode_err = n00n.json.encode(aopts.output_schema) - if encode_err then - error(SCHEMA_COMPILE_ERROR .. ": " .. encode_err, 0) - end - if #encoded_schema > MAX_SCHEMA_BYTES then - error(SCHEMA_SIZE_ERROR, 0) - end - if not schema_within_depth(aopts.output_schema, 1) then - error(SCHEMA_DEPTH_ERROR, 0) - end local compile_err - validator, compile_err = n00n.json.schema_validator(aopts.output_schema) + validator, compile_err = structured_output.compile_validator(aopts.output_schema) if compile_err then - error(SCHEMA_COMPILE_ERROR .. ": " .. compile_err, 0) + error(compile_err, 0) end end @@ -491,17 +438,17 @@ local function make_agent(ctx, progress, journal, logger) local local_tools if validator then local_tools = { - [STRUCTURED_OUTPUT_NAME] = { - description = STRUCTURED_OUTPUT_DESCRIPTION, + [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 = bounded_errors(errs) - return nil, INVALID_INPUT_PREFIX .. last_errors + last_errors = structured_output.bounded_errors(errs) + return nil, structured_output.INVALID_INPUT_PREFIX .. last_errors end captured = value - return STRUCTURED_OUTPUT_ACK + return structured_output.STRUCTURED_OUTPUT_ACK end, }, } @@ -526,13 +473,13 @@ local function make_agent(ctx, progress, journal, logger) local message = aopts.prompt if validator then - message = message .. STRUCTURED_OUTPUT_SUFFIX + 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 < MAX_STRUCTURED_RETRIES do + 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(NUDGE_MISSING) + prompt_result, prompt_err = sess:prompt(structured_output.NUDGE_MISSING) end sess:close() aggregate_permit:release() @@ -546,7 +493,8 @@ local function make_agent(ctx, progress, journal, logger) error("sub-agent error: " .. prompt_err, 0) end if validator and not captured then - local msg = last_errors and (STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) or STRUCTURED_MISSING_ERROR + 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 diff --git a/src/cli.rs b/src/cli.rs index 0b79940e6..65a62c94a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,6 +6,7 @@ use color_eyre::eyre::bail; use n00n_agent::tools::{all_builtin_tool_names, is_builtin_tool}; +use crate::cmd::agent::AgentMode; use crate::print::OutputFormat; #[derive(Clone, ValueEnum, Default)] @@ -273,6 +274,62 @@ pub enum Command { #[arg(long, requires = "tools")] names: bool, }, + /// Run agent commands + Agent { + #[command(subcommand)] + action: AgentCommand, + }, +} + +#[derive(Subcommand)] +pub enum AgentCommand { + /// Run a one-shot agent prompt + Run { + /// Prompt to send to the agent + #[arg(short, long)] + prompt: String, + /// Model spec (provider/model-id) + #[arg(short, long)] + model: Option, + /// Agent mode + #[arg(long, value_enum, default_value_t = AgentMode::General)] + mode: AgentMode, + /// Goal for team mode + #[arg(long)] + goal: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// List background agents (stub for Phase 3) + List, + /// Show agent status (stub for Phase 3) + Status { id: String }, + /// Send message to agent (stub for Phase 3) + Message { id: String, text: String }, + /// Pause agent (stub for Phase 3) + Pause { id: String }, + /// Resume agent (stub for Phase 3) + Resume { id: String }, + /// Stop agent (stub for Phase 3) + Stop { id: String }, + /// Agent policy management (stub for Phase 3) + Policy { + #[command(subcommand)] + action: PolicyAction, + }, +} + +#[derive(Subcommand)] +pub enum PolicyAction { + /// Set policy rule (stub) + Set, + /// Get policy rule (stub) + Get, + /// Delete policy rule (stub) + Delete, + /// List policy rules (stub) + List, } #[derive(Subcommand)] diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs new file mode 100644 index 000000000..52035fa5e --- /dev/null +++ b/src/cmd/agent.rs @@ -0,0 +1,136 @@ +use std::env; +use std::sync::Arc; + +use color_eyre::Result; +use color_eyre::eyre::Context; + +use n00n_agent::headless; +use n00n_agent::tools::ToolRegistry; +use n00n_config::{load_env_files, load_permissions}; +use n00n_lua::PluginHost; +use n00n_storage::StateDir; + +use crate::setup; + +pub fn run( + prompt: &str, + model_arg: Option<&str>, + mode: AgentMode, + _goal: Option<&str>, + json: bool, + yolo: bool, + no_jit: bool, +) -> Result<()> { + let storage = StateDir::resolve().context("resolve data directory")?; + n00n_providers::model_registry::load_from_storage(&storage); + + let cwd = env::current_dir().unwrap_or_else(|_| ".".into()); + load_env_files(&cwd); + + let mut plugin_host = PluginHost::with_jit(Arc::clone(ToolRegistry::global_arc()), !no_jit) + .context("initialize lua plugin host")?; + + let raw_config = plugin_host + .load_init_files(&cwd) + .context("load init.lua files")?; + + let mut config = raw_config + .unwrap_or_else(Default::default) + .into_config(false) + .context("invalid config")?; + config.permissions = load_permissions(&cwd); + + setup::init_logging(&config.storage); + + if yolo || config.always_yolo { + config.permissions.yolo = true; + } + config.validate()?; + + plugin_host + .load_builtins(&config.plugins) + .context("load builtin plugins")?; + + let timeouts = n00n_providers::Timeouts { + connect: config.provider.connect_timeout, + low_speed: config.provider.low_speed_timeout, + stream: config.provider.stream_timeout, + }; + + let model = setup::resolve_model(model_arg, &config.provider, &storage)?; + setup::install_panic_log_hook(); + + let (mcp_handle, _mcp_config_errors) = smol::block_on(n00n_agent::mcp::start( + &cwd, + config.agent.mcp_tool_desc_max_chars, + )); + + let prompt_slots = plugin_host + .event_handle() + .map_or_else(Default::default, |h| h.collect_prompt_slots()); + + let headless_params = headless::HeadlessParams { + model, + config: config.agent, + permissions_config: config.permissions, + timeouts, + openai_options: n00n_providers::OpenAiOptions::from(&config.provider), + prompt: prompt.to_string(), + images: Vec::new(), + prompt_slots, + excluded_tools: Vec::new(), + mcp_handle, + initial_wd: cwd, + fast: false, + workflow: matches!(mode, AgentMode::Team | AgentMode::Workflow), + }; + + let handle = headless::spawn(headless_params); + + // Collect events and final result + let mut final_output = String::new(); + let mut final_usage = None; + let mut stop_reason = String::from("completed"); + + while let Ok(event) = handle.event_rx.recv() { + match event.event { + n00n_agent::AgentEvent::TextDelta { text } => { + final_output.push_str(&text); + } + n00n_agent::AgentEvent::Done { usage, .. } => { + final_usage = Some(usage); + } + n00n_agent::AgentEvent::Error { message } => { + stop_reason = format!("error: {message}"); + eprintln!("Error: {message}"); + } + _ => {} + } + } + + smol::block_on(handle.task); + + if json { + let output = serde_json::json!({ + "session_id": handle.session_id, + "output": final_output, + "usage": final_usage, + "stop_reason": stop_reason, + }); + let json_output = serde_json::to_string_pretty(&output)?; + println!("{json_output}"); + } else { + println!("{final_output}"); + } + + Ok(()) +} + +#[derive(clap::ValueEnum, Clone, Copy, Debug)] +pub enum AgentMode { + Research, + General, + Task, + Team, + Workflow, +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index ba6bf6b07..bf687c80c 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,4 +1,5 @@ mod acp; +pub mod agent; mod subcmd; mod tui; @@ -7,7 +8,7 @@ use color_eyre::eyre::Context; use n00n_storage::StateDir; -use crate::cli::{AuthAction, Cli, Command, McpAction}; +use crate::cli::{AgentCommand, AuthAction, Cli, Command, McpAction}; use crate::update; pub fn dispatch(cli: Cli) -> Result<()> { @@ -60,6 +61,46 @@ pub fn dispatch(cli: Cli) -> Result<()> { }, )?; } + Some(Command::Agent { action }) => match action { + AgentCommand::Run { + prompt, + model, + mode, + goal: _, + json, + } => { + agent::run( + &prompt, + model.as_deref(), + mode, + None, + json, + cli.permission_flags.yolo, + cli.plugin_flags.no_jit, + )?; + } + AgentCommand::List => { + eprintln!("Agent list command is not yet implemented (Phase 3)"); + } + AgentCommand::Status { id: _ } => { + eprintln!("Agent status command is not yet implemented (Phase 3)"); + } + AgentCommand::Message { id: _, text: _ } => { + eprintln!("Agent message command is not yet implemented (Phase 3)"); + } + AgentCommand::Pause { id: _ } => { + eprintln!("Agent pause command is not yet implemented (Phase 3)"); + } + AgentCommand::Resume { id: _ } => { + eprintln!("Agent resume command is not yet implemented (Phase 3)"); + } + AgentCommand::Stop { id: _ } => { + eprintln!("Agent stop command is not yet implemented (Phase 3)"); + } + AgentCommand::Policy { action: _ } => { + eprintln!("Agent policy command is not yet implemented (Phase 3)"); + } + }, None => { tui::run(cli)?; } From 7b7adeac3d348572c73f65511905ba07da37ff1a Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Sun, 26 Jul 2026 23:25:09 -0400 Subject: [PATCH 02/25] fix(agent-orchestration): repair route_tier and structured_output helpers --- plugins/lib/n00n/structured_output.lua | 60 -------------------------- plugins/lib/n00n/subagent.lua | 9 +++- plugins/task/init.lua | 1 + 3 files changed, 9 insertions(+), 61 deletions(-) diff --git a/plugins/lib/n00n/structured_output.lua b/plugins/lib/n00n/structured_output.lua index 1c1ba7d69..97b7d76c2 100644 --- a/plugins/lib/n00n/structured_output.lua +++ b/plugins/lib/n00n/structured_output.lua @@ -103,64 +103,4 @@ function M.make_local_tool(schema, on_submit) } end --- Run a session with structured output validation and retry logic --- Returns (result | nil, err) -function M.run_with_validation(sess, message, validator, max_retries) - max_retries = max_retries or M.MAX_STRUCTURED_RETRIES - - local captured - local last_errors - - -- Set up local tool if validator is provided - local local_tools - if validator then - local_tools = { - [M.STRUCTURED_OUTPUT_NAME] = { - description = M.STRUCTURED_OUTPUT_DESCRIPTION, - input_schema = validator.schema, -- Note: validator may not expose schema, this is a placeholder - handler = function(value) - local errs = validator:validate(value) - if errs then - last_errors = M.bounded_errors(errs) - return nil, M.INVALID_INPUT_PREFIX .. last_errors - end - captured = value - return M.STRUCTURED_OUTPUT_ACK - end, - }, - } - end - - -- Add suffix to message if validator is present - local full_message = message - if validator then - full_message = message .. M.STRUCTURED_OUTPUT_SUFFIX - end - - -- Initial prompt - local result, err = sess:prompt(full_message) - - -- Retry loop for missing structured_output calls - local retries = 0 - while not err and validator and not captured and retries < max_retries do - retries = retries + 1 - result, err = sess:prompt(M.NUDGE_MISSING) - end - - if err then - return nil, err - end - - if validator and not captured then - local msg = last_errors and (M.STRUCTURED_INVALID_ERROR .. ":\n" .. last_errors) or M.STRUCTURED_MISSING_ERROR - return nil, msg - end - - if captured then - return captured, nil - end - - return result.text, nil -end - return M diff --git a/plugins/lib/n00n/subagent.lua b/plugins/lib/n00n/subagent.lua index 39c9de1f9..7c7dedfef 100644 --- a/plugins/lib/n00n/subagent.lua +++ b/plugins/lib/n00n/subagent.lua @@ -4,6 +4,7 @@ local M = {} +local route_tier = require("n00n.route_tier").route_tier local usage = require("n00n.usage") local structured_output = require("n00n.structured_output") @@ -41,10 +42,16 @@ function M.launch(ctx, opts) return nil, "unknown subagent_type: " .. tostring(subagent_type), nil, nil end + -- Resolve model tier (auto_tier overrides model_tier when no explicit spec) + local model_tier = opts.model_tier + if not opts.model_spec and opts.auto_tier == true then + model_tier = route_tier(opts.prompt) + end + -- Resolve model local model, model_err = n00n.agent.resolve_model(ctx, { spec = opts.model_spec, - tier = not opts.model_spec and opts.model_tier or nil, + tier = not opts.model_spec and model_tier or nil, }) if model_err then return nil, model_err, nil, nil diff --git a/plugins/task/init.lua b/plugins/task/init.lua index d3fc15ee0..c4dbb240d 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -7,6 +7,7 @@ local ToolView = require("n00n.tool_view") local output_limits = require("n00n.output_limits") +local route_tier = require("n00n.route_tier").route_tier local structured_output = require("n00n.structured_output") local DONE_NAME = "done" From ba816801eee6eec9d2be4cd60ddf983f08e4961f Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Sun, 26 Jul 2026 23:46:45 -0400 Subject: [PATCH 03/25] feat(agent): add background agent server and management CLI Implement `n00n agent run --background` Unix-socket server plus `message`, `stop`, and `list` client commands. Background agents keep state in `state_dir/agents//agent.json` and a `control.sock`. Server streams `TextDelta`/`ToolOutput`/`Done` events as NDJSON, and supports an initial `--prompt` before entering the command loop. The `--mode` flag selects the same workflow/team/etc modes used by the one-shot runner. - Add `AgentMode` CLI enum (General, Research, Task, Team, Workflow) - Add `async-lock` for per-agent message serialization - Add unit tests for CLI parsing, state serde, and state listing Phase 3 stubs (status, pause, resume, policy) remain unimplemented. --- Cargo.lock | 1 + Cargo.toml | 1 + src/cli.rs | 97 ++++++- src/cmd/agent.rs | 723 ++++++++++++++++++++++++++++++++++++++++++++++- src/cmd/mod.rs | 48 +++- 5 files changed, 838 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3051ff4a..dd408cde9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2865,6 +2865,7 @@ dependencies = [ name = "n00n" version = "0.4.2" dependencies = [ + "async-lock", "clap", "color-eyre", "flume 0.11.1", diff --git a/Cargo.toml b/Cargo.toml index ac08ac33b..bb08cbb92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ libc = { workspace = true } uuid = { workspace = true } flume = { workspace = true } open = { workspace = true } +async-lock = { workspace = true } [dev-dependencies] test-case = { workspace = true } diff --git a/src/cli.rs b/src/cli.rs index 65a62c94a..507f227a1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,6 @@ use color_eyre::eyre::bail; use n00n_agent::tools::{all_builtin_tool_names, is_builtin_tool}; -use crate::cmd::agent::AgentMode; use crate::print::OutputFormat; #[derive(Clone, ValueEnum, Default)] @@ -17,6 +16,16 @@ pub enum PromptVariant { General, } +#[derive(Clone, Copy, ValueEnum, Default, Debug, PartialEq, Eq)] +pub enum AgentMode { + #[default] + General, + Research, + Task, + Team, + Workflow, +} + #[derive(Clone, ValueEnum, Default)] pub enum InputFormat { #[default] @@ -300,20 +309,26 @@ pub enum AgentCommand { /// Output as JSON #[arg(long)] json: bool, + /// Run in background mode + #[arg(long)] + background: bool, + /// Agent ID (for background mode) + #[arg(long)] + id: Option, }, - /// List background agents (stub for Phase 3) + /// List background agents List, - /// Show agent status (stub for Phase 3) + /// Show agent status Status { id: String }, - /// Send message to agent (stub for Phase 3) + /// Send message to agent Message { id: String, text: String }, - /// Pause agent (stub for Phase 3) + /// Pause agent Pause { id: String }, - /// Resume agent (stub for Phase 3) + /// Resume agent Resume { id: String }, - /// Stop agent (stub for Phase 3) + /// Stop agent Stop { id: String }, - /// Agent policy management (stub for Phase 3) + /// Agent policy management Policy { #[command(subcommand)] action: PolicyAction, @@ -408,4 +423,70 @@ mod tests { fn normalize_tool_name_multi_edit_rejects_snake_variant() { assert!(normalize_tool_name("MultiEdit").is_err()); } + + #[test] + fn agent_run_parse_background_flag() { + let cli = Cli::parse_from(["n00n", "agent", "run", "--prompt", "test", "--background"]); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::Run { + background: true, + .. + } + }) + )); + } + + #[test] + fn agent_run_parse_id_flag() { + let cli = Cli::parse_from([ + "n00n", "agent", "run", "--prompt", "test", "--id", "my-agent", + ]); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::Run { + id: Some(id), + .. + } + }) if id == "my-agent" + )); + } + + #[test] + fn agent_message_parse() { + let cli = Cli::parse_from(["n00n", "agent", "message", "agent-id", "hello"]); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::Message { + id, + text, + } + }) if id == "agent-id" && text == "hello" + )); + } + + #[test] + fn agent_stop_parse() { + let cli = Cli::parse_from(["n00n", "agent", "stop", "agent-id"]); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::Stop { id } + }) if id == "agent-id" + )); + } + + #[test] + fn agent_list_parse() { + let cli = Cli::parse_from(["n00n", "agent", "list"]); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::List + }) + )); + } } diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 52035fa5e..d041e9d6c 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -1,21 +1,48 @@ use std::env; +use std::fs; +use std::path::PathBuf; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use async_lock::Mutex; use color_eyre::Result; use color_eyre::eyre::Context; - +use flume::Sender; +use futures_lite::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, split}; use n00n_agent::headless; use n00n_agent::tools::ToolRegistry; +use n00n_agent::{AgentEvent, AgentInput, AgentMode as RuntimeAgentMode, Envelope}; use n00n_config::{load_env_files, load_permissions}; use n00n_lua::PluginHost; +use n00n_providers::ThinkingConfig; use n00n_storage::StateDir; +use serde::{Deserialize, Serialize}; +use smol::net::unix::{UnixListener, UnixStream}; +use crate::cli::AgentMode as CliAgentMode; use crate::setup; +fn workflow_from_mode(mode: CliAgentMode) -> bool { + matches!(mode, CliAgentMode::Team | CliAgentMode::Workflow) +} + +async fn write_line(writer: &mut W, line: &str) -> Result<()> { + writer + .write_all(line.as_bytes()) + .await + .wrap_err("failed to write line")?; + writer + .write_all(b"\n") + .await + .wrap_err("failed to write newline")?; + writer.flush().await.wrap_err("failed to flush writer")?; + Ok(()) +} + pub fn run( prompt: &str, model_arg: Option<&str>, - mode: AgentMode, + mode: CliAgentMode, _goal: Option<&str>, json: bool, yolo: bool, @@ -82,7 +109,7 @@ pub fn run( mcp_handle, initial_wd: cwd, fast: false, - workflow: matches!(mode, AgentMode::Team | AgentMode::Workflow), + workflow: workflow_from_mode(mode), }; let handle = headless::spawn(headless_params); @@ -126,11 +153,687 @@ pub fn run( Ok(()) } -#[derive(clap::ValueEnum, Clone, Copy, Debug)] -pub enum AgentMode { - Research, - General, - Task, - Team, - Workflow, +#[derive(Debug, Serialize, Deserialize)] +struct AgentState { + id: String, + session_id: String, + socket_path: String, + pid: u32, + status: String, + prompt: String, + model: String, + created_at: u64, + updated_at: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +enum ClientCommand { + Message { text: String }, + Stop, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ServerEvent { + TextDelta { + text: String, + }, + ToolOutput { + id: String, + content: String, + }, + Error { + message: String, + }, + Done { + text: String, + usage: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + }, +} + +const AGENTS_SUBDIR: &str = "agents"; +const STATE_FILE: &str = "agent.json"; +const SOCKET_FILE: &str = "control.sock"; + +fn agent_dir(state_dir: &StateDir, agent_id: &str) -> Result { + let agents_dir = state_dir.ensure_subdir(AGENTS_SUBDIR)?; + Ok(agents_dir.join(agent_id)) +} + +fn socket_path(state_dir: &StateDir, agent_id: &str) -> Result { + Ok(agent_dir(state_dir, agent_id)?.join(SOCKET_FILE)) +} + +fn state_file_path(state_dir: &StateDir, agent_id: &str) -> Result { + Ok(agent_dir(state_dir, agent_id)?.join(STATE_FILE)) +} + +fn write_agent_state(state_dir: &StateDir, state: &AgentState) -> Result<()> { + let path = state_file_path(state_dir, &state.id)?; + let data = serde_json::to_vec_pretty(state).wrap_err("failed to serialize agent state")?; + n00n_storage::atomic_write(&path, &data).wrap_err("failed to write agent state")?; + Ok(()) +} + +fn read_agent_state(state_dir: &StateDir, agent_id: &str) -> Result { + let path = state_file_path(state_dir, agent_id)?; + let data = fs::read_to_string(&path).wrap_err("failed to read agent state")?; + let state: AgentState = serde_json::from_str(&data).wrap_err("failed to parse agent state")?; + Ok(state) +} + +fn list_agent_states(state_dir: &StateDir) -> Result> { + let agents_dir = state_dir.ensure_subdir(AGENTS_SUBDIR)?; + let mut states = Vec::new(); + + for entry in fs::read_dir(&agents_dir).wrap_err("failed to read agents directory")? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let state_path = path.join(STATE_FILE); + if state_path.exists() { + let data = + fs::read_to_string(&state_path).wrap_err("failed to read agent state")?; + let state: AgentState = + serde_json::from_str(&data).wrap_err("failed to parse agent state")?; + states.push(state); + } + } + } + + states.sort_by_key(|a| std::cmp::Reverse(a.updated_at)); + Ok(states) +} + +fn now_epoch() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +pub fn server( + prompt: &str, + model_arg: Option<&str>, + mode: CliAgentMode, + agent_id: Option, + yolo: bool, + no_jit: bool, +) -> Result<()> { + let storage = StateDir::resolve().context("resolve data directory")?; + n00n_providers::model_registry::load_from_storage(&storage); + + let cwd = env::current_dir().unwrap_or_else(|_| ".".into()); + load_env_files(&cwd); + + let mut plugin_host = PluginHost::with_jit(Arc::clone(ToolRegistry::global_arc()), !no_jit) + .context("initialize lua plugin host")?; + + let raw_config = plugin_host + .load_init_files(&cwd) + .context("load init.lua files")?; + + let mut config = raw_config + .unwrap_or_else(Default::default) + .into_config(false) + .context("invalid config")?; + config.permissions = load_permissions(&cwd); + + setup::init_logging(&config.storage); + + if yolo || config.always_yolo { + config.permissions.yolo = true; + } + config.validate()?; + + plugin_host + .load_builtins(&config.plugins) + .context("load builtin plugins")?; + + let timeouts = n00n_providers::Timeouts { + connect: config.provider.connect_timeout, + low_speed: config.provider.low_speed_timeout, + stream: config.provider.stream_timeout, + }; + + let model = setup::resolve_model(model_arg, &config.provider, &storage)?; + let model_spec = model.spec(); + setup::install_panic_log_hook(); + + let (mcp_handle, _mcp_config_errors) = smol::block_on(n00n_agent::mcp::start( + &cwd, + config.agent.mcp_tool_desc_max_chars, + )); + + let prompt_slots = plugin_host + .event_handle() + .map_or_else(Default::default, |h| h.collect_prompt_slots()); + + let interactive_params = headless::InteractiveParams { + model, + config: config.agent, + permissions_config: config.permissions, + timeouts, + openai_options: n00n_providers::OpenAiOptions::from(&config.provider), + prompt_slots: Arc::new(prompt_slots), + excluded_tools: Vec::new(), + mcp_handle, + initial_wd: cwd, + session_id: None, + initial_history: Vec::new(), + yolo, + system_prompt_override: None, + append_system_prompt: None, + workflow: workflow_from_mode(mode), + }; + + let handle = headless::spawn_interactive(interactive_params); + + let agent_id = + agent_id.unwrap_or_else(|| handle.session_id.to_string().chars().take(12).collect()); + + let agent_dir_path = agent_dir(&storage, &agent_id)?; + fs::create_dir_all(&agent_dir_path).wrap_err("failed to create agent directory")?; + + let socket_path_value = socket_path(&storage, &agent_id)?; + + let mut state = AgentState { + id: agent_id.clone(), + session_id: handle.session_id.to_string(), + socket_path: socket_path_value.to_string_lossy().into_owned(), + pid: std::process::id(), + status: "running".to_string(), + prompt: prompt.to_string(), + model: model_spec, + created_at: now_epoch(), + updated_at: now_epoch(), + }; + + write_agent_state(&storage, &state)?; + + let listener = UnixListener::bind(&socket_path_value).wrap_err("failed to bind socket")?; + + let message_lock = Arc::new(Mutex::new(())); + + if !prompt.is_empty() { + let _lock = smol::block_on(message_lock.lock()); + state.status = "working".to_string(); + write_agent_state(&storage, &state)?; + + let _ = handle.input_tx.send(AgentInput { + message: prompt.to_string(), + mode: RuntimeAgentMode::Build, + images: Vec::new(), + preamble: Vec::new(), + thinking: ThinkingConfig::default(), + fast: false, + workflow: workflow_from_mode(mode), + prompt: None, + }); + + // Wait for the initial run to complete. + wait_for_run_done(&handle.event_rx); + + state.status = "running".to_string(); + state.updated_at = now_epoch(); + write_agent_state(&storage, &state)?; + } + + smol::block_on(async { + while let Ok(stream) = listener.accept().await { + let stream = stream.0; + let input_tx = handle.input_tx.clone(); + let event_rx = handle.event_rx.clone(); + let cancel_tx = handle.cancel_tx.clone(); + let message_lock = Arc::clone(&message_lock); + let storage_clone = storage.clone(); + let agent_id_clone = agent_id.clone(); + let mode_clone = mode; + + smol::spawn(async move { + if let Err(e) = handle_connection( + stream, + input_tx, + event_rx, + cancel_tx, + message_lock, + &storage_clone, + &agent_id_clone, + mode_clone, + ) + .await + { + eprintln!("Connection error: {e}"); + } + }) + .detach(); + } + }); + + Ok(()) +} + +fn wait_for_run_done(event_rx: &flume::Receiver) { + while let Ok(envelope) = event_rx.recv() { + if matches!( + envelope.event, + AgentEvent::Done { .. } | AgentEvent::Error { .. } + ) { + break; + } + } +} + +async fn handle_connection( + stream: UnixStream, + input_tx: Sender, + event_rx: flume::Receiver, + cancel_tx: Sender<()>, + message_lock: Arc>, + storage: &StateDir, + agent_id: &str, + mode: CliAgentMode, +) -> Result<()> { + let (reader, mut writer) = split(stream); + let mut reader = BufReader::new(reader); + + let mut line = String::new(); + reader + .read_line(&mut line) + .await + .wrap_err("failed to read command")?; + if line.is_empty() { + return Ok(()); + } + + let cmd: ClientCommand = serde_json::from_str(&line).wrap_err("failed to parse command")?; + + match cmd { + ClientCommand::Message { text } => { + let _lock = message_lock.lock().await; + + let mut state = read_agent_state(storage, agent_id)?; + state.status = "working".to_string(); + state.updated_at = now_epoch(); + write_agent_state(storage, &state)?; + + while event_rx.try_recv().is_ok() {} + + input_tx + .send(AgentInput { + message: text.clone(), + mode: RuntimeAgentMode::Build, + images: Vec::new(), + preamble: Vec::new(), + thinking: ThinkingConfig::default(), + fast: false, + workflow: workflow_from_mode(mode), + prompt: None, + }) + .wrap_err("failed to send input")?; + + let mut current_run_id: Option = None; + let mut accumulated_text = String::new(); + let mut final_usage: Option = None; + let mut error_message: Option = None; + + while let Ok(envelope) = event_rx.recv_async().await { + if current_run_id.is_none() { + current_run_id = Some(envelope.run_id); + } + + if current_run_id != Some(envelope.run_id) { + continue; + } + + match envelope.event { + AgentEvent::TextDelta { text } => { + accumulated_text.push_str(&text); + let event = ServerEvent::TextDelta { text }; + let json = + serde_json::to_string(&event).wrap_err("failed to serialize event")?; + write_line(&mut writer, &json) + .await + .wrap_err("failed to write event")?; + } + AgentEvent::ToolOutput { id, content } => { + let event = ServerEvent::ToolOutput { id, content }; + let json = + serde_json::to_string(&event).wrap_err("failed to serialize event")?; + write_line(&mut writer, &json) + .await + .wrap_err("failed to write event")?; + } + AgentEvent::Error { message } => { + error_message = Some(message.clone()); + let event = ServerEvent::Error { message }; + let json = + serde_json::to_string(&event).wrap_err("failed to serialize event")?; + write_line(&mut writer, &json) + .await + .wrap_err("failed to write event")?; + } + AgentEvent::Done { usage, .. } => { + final_usage = Some( + serde_json::to_value(usage).unwrap_or_else(|_| serde_json::Value::Null), + ); + break; + } + _ => {} + } + } + + let event = ServerEvent::Done { + text: accumulated_text, + usage: final_usage.unwrap_or_else(|| serde_json::Value::Null), + error: error_message, + }; + let json = serde_json::to_string(&event).wrap_err("failed to serialize event")?; + write_line(&mut writer, &json) + .await + .wrap_err("failed to write event")?; + + let mut state = read_agent_state(storage, agent_id)?; + state.status = "running".to_string(); + state.updated_at = now_epoch(); + write_agent_state(storage, &state)?; + } + ClientCommand::Stop => { + let mut state = read_agent_state(storage, agent_id)?; + state.status = "stopping".to_string(); + write_agent_state(storage, &state)?; + + let _ = cancel_tx.send(()); + + let response = serde_json::json!({ "ok": true }); + let response_json = serde_json::to_string(&response)?; + write_line(&mut writer, &response_json) + .await + .wrap_err("failed to write response")?; + + let agent_dir_path = agent_dir(storage, agent_id)?; + let _ = fs::remove_file(&state.socket_path); + let _ = fs::remove_dir_all(&agent_dir_path); + + std::process::exit(0); + } + } + + Ok(()) +} + +pub fn message_client(id: &str, text: &str, json: bool) -> Result<()> { + let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; + let state = read_agent_state(&storage, id).wrap_err("failed to read agent state")?; + + let stream = smol::block_on(UnixStream::connect(&state.socket_path)) + .wrap_err("failed to connect to agent socket")?; + + let cmd = ClientCommand::Message { + text: text.to_string(), + }; + let cmd_json = serde_json::to_string(&cmd).wrap_err("failed to serialize command")?; + + smol::block_on(async { + let (reader, mut writer) = split(stream); + let mut reader = BufReader::new(reader); + + write_line(&mut writer, &cmd_json) + .await + .wrap_err("failed to send command")?; + + let mut line = String::new(); + + while let Ok(n) = reader.read_line(&mut line).await { + if n == 0 { + break; + } + + if json { + print!("{line}"); + } else if let Ok(event) = serde_json::from_str::(&line) { + match event { + ServerEvent::TextDelta { text } => { + print!("{text}"); + } + ServerEvent::Done { + text, + error: Some(message), + .. + } => { + print!("{text}"); + eprintln!("\nError: {message}"); + return Err(color_eyre::eyre::eyre!(message)); + } + ServerEvent::Done { .. } => { + println!(); + } + ServerEvent::Error { message } => { + eprintln!("Error: {message}"); + return Err(color_eyre::eyre::eyre!(message)); + } + ServerEvent::ToolOutput { .. } => {} + } + } + line.clear(); + } + + Ok(()) + }) +} + +pub fn stop_client(id: &str) -> Result<()> { + let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; + + 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 stream = smol::block_on(UnixStream::connect(&state.socket_path)) + .wrap_err("failed to connect to agent socket")?; + + let cmd = ClientCommand::Stop; + let cmd_json = serde_json::to_string(&cmd).wrap_err("failed to serialize command")?; + + smol::block_on(async { + let (reader, mut writer) = split(stream); + let mut reader = BufReader::new(reader); + + write_line(&mut writer, &cmd_json) + .await + .wrap_err("failed to send command")?; + + let mut line = String::new(); + let _ = reader + .read_line(&mut line) + .await + .wrap_err("failed to read response")?; + + let response: serde_json::Value = + serde_json::from_str(&line).wrap_err("failed to parse response")?; + + match response.get("ok").and_then(serde_json::Value::as_bool) { + Some(true) => println!("Agent {id} stopped"), + _ => eprintln!("Failed to stop agent {id}"), + } + + Ok(()) + }) +} + +pub fn list_client(json: bool) -> Result<()> { + let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; + let states = list_agent_states(&storage)?; + + if json { + let output = + serde_json::to_string_pretty(&states).wrap_err("failed to serialize agent list")?; + println!("{output}"); + } else { + if states.is_empty() { + println!("No background agents running"); + return Ok(()); + } + + println!("Background agents:"); + for state in &states { + let prompt_preview = if state.prompt.len() > 50 { + format!("{}...", &state.prompt[..50]) + } else { + state.prompt.clone() + }; + println!( + " {} - {} - {} - {} - {}", + state.id, state.status, state.model, prompt_preview, state.updated_at + ); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn agent_state_serialization_roundtrip() { + let state = AgentState { + id: "test-agent".to_string(), + session_id: "session-123".to_string(), + socket_path: "/tmp/test.sock".to_string(), + pid: 12345, + status: "running".to_string(), + prompt: "test prompt".to_string(), + model: "anthropic/claude-3-opus".to_string(), + created_at: 1_234_567_890, + updated_at: 1_234_567_900, + }; + + let json = serde_json::to_string(&state).unwrap(); + let decoded: AgentState = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.id, state.id); + assert_eq!(decoded.session_id, state.session_id); + assert_eq!(decoded.socket_path, state.socket_path); + assert_eq!(decoded.pid, state.pid); + assert_eq!(decoded.status, state.status); + assert_eq!(decoded.prompt, state.prompt); + assert_eq!(decoded.model, state.model); + assert_eq!(decoded.created_at, state.created_at); + assert_eq!(decoded.updated_at, state.updated_at); + } + + #[test] + fn client_command_message_serialization() { + let cmd = ClientCommand::Message { + text: "hello".to_string(), + }; + let json = serde_json::to_string(&cmd).unwrap(); + let decoded: ClientCommand = serde_json::from_str(&json).unwrap(); + + assert!(matches!( + decoded, + ClientCommand::Message { text } if text == "hello" + )); + } + + #[test] + fn client_command_stop_serialization() { + let cmd = ClientCommand::Stop; + let json = serde_json::to_string(&cmd).unwrap(); + let decoded: ClientCommand = serde_json::from_str(&json).unwrap(); + + assert!(matches!(decoded, ClientCommand::Stop)); + } + + #[test] + fn server_event_text_delta_serialization() { + let event = ServerEvent::TextDelta { + text: "hello".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let decoded: ServerEvent = serde_json::from_str(&json).unwrap(); + + assert!(matches!( + decoded, + ServerEvent::TextDelta { text } if text == "hello" + )); + } + + #[test] + fn server_event_done_serialization() { + let event = ServerEvent::Done { + text: "result".to_string(), + usage: serde_json::json!({ "total_tokens": 100 }), + error: None, + }; + let json = serde_json::to_string(&event).unwrap(); + let decoded: ServerEvent = serde_json::from_str(&json).unwrap(); + + assert!(matches!( + decoded, + ServerEvent::Done { text, .. } if text == "result" + )); + } + + #[test] + fn list_agent_states_empty_directory() { + let tmp = TempDir::new().unwrap(); + let state_dir = StateDir::from_path(tmp.path().to_path_buf()); + let _agents_dir = state_dir.ensure_subdir(AGENTS_SUBDIR).unwrap(); + + let states = list_agent_states(&state_dir).unwrap(); + assert!(states.is_empty()); + } + + #[test] + fn list_agent_states_with_entries() { + let tmp = TempDir::new().unwrap(); + let state_dir = StateDir::from_path(tmp.path().to_path_buf()); + let agents_root = state_dir.ensure_subdir(AGENTS_SUBDIR).unwrap(); + + let agent1_path = agents_root.join("agent1"); + fs::create_dir_all(&agent1_path).unwrap(); + let first_state = AgentState { + id: "agent1".to_string(), + session_id: "session1".to_string(), + socket_path: "/tmp/agent1.sock".to_string(), + pid: 1, + status: "running".to_string(), + prompt: "prompt1".to_string(), + model: "model1".to_string(), + created_at: 100, + updated_at: 200, + }; + let data1 = serde_json::to_vec_pretty(&first_state).unwrap(); + n00n_storage::atomic_write(&agent1_path.join(STATE_FILE), &data1).unwrap(); + + let agent2_path = agents_root.join("agent2"); + fs::create_dir_all(&agent2_path).unwrap(); + let second_state = AgentState { + id: "agent2".to_string(), + session_id: "session2".to_string(), + socket_path: "/tmp/agent2.sock".to_string(), + pid: 2, + status: "running".to_string(), + prompt: "prompt2".to_string(), + model: "model2".to_string(), + created_at: 50, + updated_at: 300, + }; + let data2 = serde_json::to_vec_pretty(&second_state).unwrap(); + n00n_storage::atomic_write(&agent2_path.join(STATE_FILE), &data2).unwrap(); + + let all = list_agent_states(&state_dir).unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all[0].id, "agent2"); + assert_eq!(all[1].id, "agent1"); + } } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index bf687c80c..3ae506c3c 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -68,25 +68,45 @@ pub fn dispatch(cli: Cli) -> Result<()> { mode, goal: _, json, + background, + id, } => { - agent::run( - &prompt, - model.as_deref(), - mode, - None, - json, - cli.permission_flags.yolo, - cli.plugin_flags.no_jit, - )?; + if background { + agent::server( + &prompt, + model.as_deref(), + mode, + id, + cli.permission_flags.yolo, + cli.plugin_flags.no_jit, + )?; + } else { + agent::run( + &prompt, + model.as_deref(), + mode, + None, + json, + cli.permission_flags.yolo, + cli.plugin_flags.no_jit, + )?; + } } AgentCommand::List => { - eprintln!("Agent list command is not yet implemented (Phase 3)"); + agent::list_client(matches!( + cli.output_format, + crate::print::OutputFormat::Json + ))?; } AgentCommand::Status { id: _ } => { eprintln!("Agent status command is not yet implemented (Phase 3)"); } - AgentCommand::Message { id: _, text: _ } => { - eprintln!("Agent message command is not yet implemented (Phase 3)"); + AgentCommand::Message { id, text } => { + agent::message_client( + &id, + &text, + matches!(cli.output_format, crate::print::OutputFormat::Json), + )?; } AgentCommand::Pause { id: _ } => { eprintln!("Agent pause command is not yet implemented (Phase 3)"); @@ -94,8 +114,8 @@ pub fn dispatch(cli: Cli) -> Result<()> { AgentCommand::Resume { id: _ } => { eprintln!("Agent resume command is not yet implemented (Phase 3)"); } - AgentCommand::Stop { id: _ } => { - eprintln!("Agent stop command is not yet implemented (Phase 3)"); + AgentCommand::Stop { id } => { + agent::stop_client(&id)?; } AgentCommand::Policy { action: _ } => { eprintln!("Agent policy command is not yet implemented (Phase 3)"); From 6d6400599382476e1ee388744a4f831f493e05f3 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Sun, 26 Jul 2026 23:49:35 -0400 Subject: [PATCH 04/25] feat(agent): implement status, pause, and resume commands Add `ClientCommand::Pause` and `Resume` to the background agent protocol. The server stores a shared `paused` flag and rejects new messages while paused, updating `agent.json` status accordingly. Implement `n00n agent status`, `pause`, and `resume` client commands. `status` reads the persisted agent state; `pause`/`resume` send a control command over the Unix socket. The `policy` subcommand remains a stub. --- src/cmd/agent.rs | 109 +++++++++++++++++++++++++++++++++++++++++++++++ src/cmd/mod.rs | 17 +++++--- 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index d041e9d6c..23968884a 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -2,6 +2,7 @@ use std::env; use std::fs; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use async_lock::Mutex; @@ -170,6 +171,8 @@ struct AgentState { #[serde(tag = "cmd", rename_all = "snake_case")] enum ClientCommand { Message { text: String }, + Pause, + Resume, Stop, } @@ -356,6 +359,7 @@ pub fn server( let listener = UnixListener::bind(&socket_path_value).wrap_err("failed to bind socket")?; let message_lock = Arc::new(Mutex::new(())); + let paused = Arc::new(AtomicBool::new(false)); if !prompt.is_empty() { let _lock = smol::block_on(message_lock.lock()); @@ -388,6 +392,7 @@ pub fn server( let event_rx = handle.event_rx.clone(); let cancel_tx = handle.cancel_tx.clone(); let message_lock = Arc::clone(&message_lock); + let paused = Arc::clone(&paused); let storage_clone = storage.clone(); let agent_id_clone = agent_id.clone(); let mode_clone = mode; @@ -399,6 +404,7 @@ pub fn server( event_rx, cancel_tx, message_lock, + paused, &storage_clone, &agent_id_clone, mode_clone, @@ -432,6 +438,7 @@ async fn handle_connection( event_rx: flume::Receiver, cancel_tx: Sender<()>, message_lock: Arc>, + paused: Arc, storage: &StateDir, agent_id: &str, mode: CliAgentMode, @@ -454,6 +461,15 @@ async fn handle_connection( ClientCommand::Message { text } => { let _lock = message_lock.lock().await; + if paused.load(Ordering::Relaxed) { + let response = serde_json::json!({"ok": false, "error": "agent is paused"}); + let response_json = serde_json::to_string(&response)?; + write_line(&mut writer, &response_json) + .await + .wrap_err("failed to write response")?; + return Ok(()); + } + let mut state = read_agent_state(storage, agent_id)?; state.status = "working".to_string(); state.updated_at = now_epoch(); @@ -540,6 +556,34 @@ async fn handle_connection( state.updated_at = now_epoch(); write_agent_state(storage, &state)?; } + ClientCommand::Pause => { + paused.store(true, Ordering::Relaxed); + + let mut state = read_agent_state(storage, agent_id)?; + state.status = "paused".to_string(); + state.updated_at = now_epoch(); + write_agent_state(storage, &state)?; + + let response = serde_json::json!({ "ok": true }); + let response_json = serde_json::to_string(&response)?; + write_line(&mut writer, &response_json) + .await + .wrap_err("failed to write response")?; + } + ClientCommand::Resume => { + paused.store(false, Ordering::Relaxed); + + let mut state = read_agent_state(storage, agent_id)?; + state.status = "running".to_string(); + state.updated_at = now_epoch(); + write_agent_state(storage, &state)?; + + let response = serde_json::json!({ "ok": true }); + let response_json = serde_json::to_string(&response)?; + write_line(&mut writer, &response_json) + .await + .wrap_err("failed to write response")?; + } ClientCommand::Stop => { let mut state = read_agent_state(storage, agent_id)?; state.status = "stopping".to_string(); @@ -697,6 +741,71 @@ pub fn list_client(json: bool) -> Result<()> { Ok(()) } +pub fn status_client(id: &str, json: bool) -> Result<()> { + let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; + let state = read_agent_state(&storage, id).wrap_err("failed to read agent state")?; + + if json { + let output = + serde_json::to_string_pretty(&state).wrap_err("failed to serialize agent status")?; + println!("{output}"); + } else { + println!("Agent: {}", state.id); + println!(" session: {}", state.session_id); + println!(" status: {}", state.status); + println!(" model: {}", state.model); + println!(" prompt: {}", state.prompt); + println!(" socket: {}", state.socket_path); + println!(" pid: {}", state.pid); + println!(" created: {}", state.created_at); + println!(" updated: {}", state.updated_at); + } + + Ok(()) +} + +pub fn pause_client(id: &str) -> Result<()> { + control_command_client(id, &ClientCommand::Pause, "paused") +} + +pub fn resume_client(id: &str) -> Result<()> { + control_command_client(id, &ClientCommand::Resume, "resumed") +} + +fn control_command_client(id: &str, command: &ClientCommand, success_label: &str) -> Result<()> { + let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; + let state = read_agent_state(&storage, id).wrap_err("failed to read agent state")?; + let stream = smol::block_on(UnixStream::connect(&state.socket_path)) + .wrap_err("failed to connect to agent socket")?; + + let cmd_json = serde_json::to_string(&command).wrap_err("failed to serialize command")?; + + smol::block_on(async { + let (reader, mut writer) = split(stream); + let mut reader = BufReader::new(reader); + + write_line(&mut writer, &cmd_json) + .await + .wrap_err("failed to send command")?; + + let mut line = String::new(); + let _ = reader + .read_line(&mut line) + .await + .wrap_err("failed to read response")?; + + let response: serde_json::Value = + serde_json::from_str(&line).wrap_err("failed to parse response")?; + + match response.get("ok").and_then(serde_json::Value::as_bool) { + Some(true) => println!("Agent {id} {success_label}"), + _ => eprintln!("Failed to update agent {id}"), + } + + Ok(()) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 3ae506c3c..d939a3c40 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -98,8 +98,11 @@ pub fn dispatch(cli: Cli) -> Result<()> { crate::print::OutputFormat::Json ))?; } - AgentCommand::Status { id: _ } => { - eprintln!("Agent status command is not yet implemented (Phase 3)"); + AgentCommand::Status { id } => { + agent::status_client( + &id, + matches!(cli.output_format, crate::print::OutputFormat::Json), + )?; } AgentCommand::Message { id, text } => { agent::message_client( @@ -108,17 +111,17 @@ pub fn dispatch(cli: Cli) -> Result<()> { matches!(cli.output_format, crate::print::OutputFormat::Json), )?; } - AgentCommand::Pause { id: _ } => { - eprintln!("Agent pause command is not yet implemented (Phase 3)"); + AgentCommand::Pause { id } => { + agent::pause_client(&id)?; } - AgentCommand::Resume { id: _ } => { - eprintln!("Agent resume command is not yet implemented (Phase 3)"); + AgentCommand::Resume { id } => { + agent::resume_client(&id)?; } AgentCommand::Stop { id } => { agent::stop_client(&id)?; } AgentCommand::Policy { action: _ } => { - eprintln!("Agent policy command is not yet implemented (Phase 3)"); + eprintln!("Agent policy command is not yet implemented"); } }, None => { From 4a19b4559534398685ee751cebb97d736329ebc2 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Sun, 26 Jul 2026 23:51:14 -0400 Subject: [PATCH 05/25] fix(agent): avoid duplicate text when message run ends with error The text-mode client was streaming TextDelta chunks and then printing the full Done.text again when the run ended with an error. Only print the error in that case; the streamed text is already on the terminal. --- src/cmd/agent.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 23968884a..c460dc1a6 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -643,11 +643,9 @@ pub fn message_client(id: &str, text: &str, json: bool) -> Result<()> { print!("{text}"); } ServerEvent::Done { - text, error: Some(message), .. } => { - print!("{text}"); eprintln!("\nError: {message}"); return Err(color_eyre::eyre::eyre!(message)); } From 3fe1fcf6db0aab1cc980f851a8b165c9634b6f94 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 00:24:59 -0400 Subject: [PATCH 06/25] refactor(team): migrate roles and supervisor to n00n.subagent Extend `plugins/lib/n00n/subagent.lua` with options the team plugin needs: - `system` override for role-specific prompts - `preview` and `activity_label` for ActivityPreview integration - `budget` with `:consume()` for agent-call budgets - `fail_on_pricing_error` to keep the existing team semantics - return the resolved `model_spec` as a fifth value Migrate `plugins/team/roles.lua` to launch subagents through the shared helper, preserving its `{ok, text, cost, model, usage, error}` return shape and all existing tests. Simplify `plugins/team/init.lua` `run_supervisor` by delegating the structured-output plan generation to `n00n.subagent.launch` with `output_schema = PLANNER_OUTPUT`. --- plugins/lib/n00n/subagent.lua | 90 +++++++++++++++++++++++++---------- plugins/team/init.lua | 87 +++++++-------------------------- plugins/team/roles.lua | 70 ++++++--------------------- 3 files changed, 98 insertions(+), 149 deletions(-) diff --git a/plugins/lib/n00n/subagent.lua b/plugins/lib/n00n/subagent.lua index 7c7dedfef..6978673a5 100644 --- a/plugins/lib/n00n/subagent.lua +++ b/plugins/lib/n00n/subagent.lua @@ -9,7 +9,7 @@ local usage = require("n00n.usage") local structured_output = require("n00n.structured_output") -- Launch a subagent with the given options. --- Returns (result | nil, err, cost, usage) +-- Returns (result | nil, err, cost, usage, model_spec) -- -- Options: -- description (required): Short description for the subagent @@ -19,27 +19,41 @@ local structured_output = require("n00n.structured_output") -- model_tier: Capped tier: "weak", "medium", or "strong" -- auto_tier: Pick model_tier from prompt automatically (optional) -- thinking: Thinking mode configuration +-- system: Override the default system prompt (optional) -- output_schema: JSON Schema for structured output validation -- audience: Tool audience (default: computed from subagent_type) +-- include_mcp: Include MCP tools (default: true) -- local_tools: Additional local tools to register +-- preview: ActivityPreview object wrapping sess:prompt (optional) +-- activity_label: Label used with preview (default: description) +-- budget: Budget object with :consume() method (optional) +-- fail_on_pricing_error: Return an error if usage pricing fails (default: false) -- ctx: Agent context (required) function M.launch(ctx, opts) if not opts then - return nil, "opts is required", nil, nil + return nil, "opts is required", nil, nil, nil end if not opts.description then - return nil, "opts.description is required", nil, nil + return nil, "opts.description is required", nil, nil, nil end if not opts.prompt then - return nil, "opts.prompt is required", nil, nil + return nil, "opts.prompt is required", nil, nil, nil end if not ctx then - return nil, "ctx is required", nil, nil + return nil, "ctx is required", nil, nil, nil + end + + -- Budget check + if opts.budget then + local budget_ok, budget_err = opts.budget:consume() + if not budget_ok then + return nil, budget_err or "budget exhausted", nil, nil, nil + end end local subagent_type = opts.subagent_type or "general" if subagent_type ~= "research" and subagent_type ~= "general" then - return nil, "unknown subagent_type: " .. tostring(subagent_type), nil, nil + return nil, "unknown subagent_type: " .. tostring(subagent_type), nil, nil, nil end -- Resolve model tier (auto_tier overrides model_tier when no explicit spec) @@ -54,29 +68,37 @@ function M.launch(ctx, opts) tier = not opts.model_spec and model_tier or nil, }) if model_err then - return nil, model_err, nil, nil + return nil, model_err, nil, nil, nil end + local model_spec = model.spec -- Compute audience and prompt_id local audience = opts.audience or (subagent_type == "research" and "research_sub" or "general_sub") local prompt_id = subagent_type == "research" and "research" or "general" -- Build system prompt - local system, system_err = n00n.agent.system_prompt(ctx, { - prompt_id = prompt_id, - instructions = true, - }) - if system_err then - return nil, system_err, nil, nil + local system + if opts.system then + system = opts.system + else + local system_err + system, system_err = n00n.agent.system_prompt(ctx, { + prompt_id = prompt_id, + instructions = true, + }) + if system_err then + return nil, system_err, nil, nil, model_spec + end end -- Get tool definitions local tool_defs, tools_err = n00n.agent.tools(ctx, { audience = audience, - spec = model.spec, + spec = model_spec, + include_mcp = opts.include_mcp, }) if tools_err then - return nil, tools_err, nil, nil + return nil, tools_err, nil, nil, model_spec end -- Set up local tools @@ -90,7 +112,7 @@ function M.launch(ctx, opts) local compile_err validator, compile_err = structured_output.compile_validator(opts.output_schema) if compile_err then - return nil, compile_err, nil, nil + return nil, compile_err, nil, nil, model_spec end local_tools[structured_output.STRUCTURED_OUTPUT_NAME] = { @@ -110,7 +132,7 @@ function M.launch(ctx, opts) -- Create session local sess, sess_err = n00n.agent.session(ctx, { - model_spec = model.spec, + model_spec = model_spec, system = system, tools = tool_defs, local_tools = local_tools, @@ -119,7 +141,7 @@ function M.launch(ctx, opts) thinking = opts.thinking, }) if sess_err then - return nil, sess_err, nil, nil + return nil, sess_err, nil, nil, model_spec end -- Build message with structured output suffix if needed @@ -128,34 +150,52 @@ function M.launch(ctx, opts) message = message .. structured_output.STRUCTURED_OUTPUT_SUFFIX end + local preview = opts.preview + local label = opts.activity_label or opts.description + -- Run the prompt with retry logic for structured output - local result, err = sess:prompt(message) + local result, err + if preview then + result, err = preview:prompt(sess, message, label) + else + result, err = sess:prompt(message) + end 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) + if preview then + result, err = preview:prompt(sess, structured_output.NUDGE_MISSING, label) + else + result, err = sess:prompt(structured_output.NUDGE_MISSING) + end end sess:close() + -- Normalize usage from raw result for error paths + local measured_usage, _ = usage.normalize(result) + if err then - return nil, "sub-agent error: " .. err, nil, result + return nil, "sub-agent error: " .. err, nil, measured_usage, model_spec 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 nil, msg, nil, result + return nil, msg, nil, measured_usage, model_spec end -- Calculate cost and usage - local measured_usage, cost, metrics_err = usage.price(model.spec, result) + local priced_usage, cost, metrics_err = usage.price(model_spec, result) if metrics_err then + if opts.fail_on_pricing_error then + return nil, "pricing failed: " .. metrics_err, nil, priced_usage, model_spec + end -- Return result even if pricing fails - return captured or result.text, nil, nil, measured_usage + return captured or result.text, nil, nil, priced_usage, model_spec end - return captured or result.text, nil, cost, measured_usage + return captured or result.text, nil, cost, priced_usage, model_spec end return M diff --git a/plugins/team/init.lua b/plugins/team/init.lua index 5c8443565..a01cecd77 100644 --- a/plugins/team/init.lua +++ b/plugins/team/init.lua @@ -7,6 +7,7 @@ local ActivityPreview = require("n00n.activity_preview") local memory = require("mem") local retrieve = require("retrieve") local roles = require("roles") +local subagent = require("n00n.subagent") local ibn = require("ibn") local quorum = require("quorum") local swarm = require("swarm") @@ -227,8 +228,6 @@ local schema = { }, } -local NUDGE = "You have not called structured_output. Call it now with the plan object." - local function new_agent_budget(requested) local limit = math.min(requested or DEFAULT_TEAM_AGENTS, MAX_TEAM_AGENTS) return { @@ -254,85 +253,35 @@ local function plan_prompt(goal) end local function run_supervisor(ctx, goal, opts) - local budget_ok, budget_err = opts._agent_budget:consume() - if not budget_ok then - return nil, budget_err - end - local validator, verr = n00n.json.schema_validator(PLANNER_OUTPUT) - if verr then - return nil, "planner schema invalid: " .. verr - end - 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, merr - end - local system, serr = n00n.agent.system_prompt(ctx, { prompt_id = "general", instructions = true }) - if serr then - return nil, serr - end - local tools, terr = n00n.agent.tools(ctx, { spec = model.spec, audience = "general_sub", include_mcp = true }) - if terr then - return nil, terr - end - - local captured - local local_tools = { - structured_output = { - description = "Output the plan as {steps:[{role, prompt, tier?}]}.", - input_schema = PLANNER_OUTPUT, - handler = function(value) - local e = validator:validate(value) - if e then - return nil, "invalid plan: " .. table.concat(e, "; ") - end - captured = value - return "Plan recorded." - end, - }, - } - - local sess, sess_err = n00n.agent.session(ctx, { - model_spec = model.spec, - system = system, - tools = tools, - local_tools = local_tools, - audience = "general_sub", - name = "team-supervisor", + local captured, err, cost, usage_val = subagent.launch(ctx, { + description = "team-supervisor", + prompt = plan_prompt(goal), + output_schema = PLANNER_OUTPUT, + model_spec = opts.model, + model_tier = opts.model_tier, thinking = opts.thinking, + preview = opts._preview, + activity_label = "supervisor", + budget = opts._agent_budget, + fail_on_pricing_error = true, }) - if sess_err then - return nil, sess_err - end - local res, rerr = opts._preview:prompt(sess, plan_prompt(goal), "supervisor") - if not rerr and not captured then - local nudged - nudged, rerr = opts._preview:prompt(sess, NUDGE, "supervisor") - if nudged then - res = nudged - end - end - sess:close() - local usage, cost, metrics_err = roles.metrics(model.spec, res) - if metrics_err then - return nil, "supervisor usage pricing failed: " .. metrics_err, nil, usage + if err then + return nil, "supervisor failed: " .. err, cost, usage_val end - if rerr then - return nil, "supervisor failed: " .. rerr, cost, usage - end - if not captured then - return nil, "supervisor produced no plan", cost, usage + if type(captured) ~= "table" or not captured.steps then + return nil, "supervisor produced no plan", cost, usage_val end + 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 if #steps == 0 then - return nil, "supervisor produced an empty plan", cost, usage + return nil, "supervisor produced an empty plan", cost, usage_val end - return steps, nil, cost, usage + return steps, nil, cost, usage_val end local function run_step(ctx, step, goal, input, relay_k, prior_results) diff --git a/plugins/team/roles.lua b/plugins/team/roles.lua index 6a8973937..8dcfbac01 100644 --- a/plugins/team/roles.lua +++ b/plugins/team/roles.lua @@ -1,12 +1,9 @@ -- 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). --- NOTE: This module uses custom patterns (hardcoded audience, custom system prompts, --- budget handling, preview support) that differ from the standard n00n.subagent.launch --- pattern. Migration to n00n.subagent.launch deferred due to these custom requirements. local M = {} -local route_tier = require("n00n.route_tier").route_tier +local subagent = require("n00n.subagent") local usage = require("n00n.usage") -- Role -> { tier, system }. Tiers follow the the three-Cs cost-effectiveness: @@ -60,67 +57,30 @@ M.metrics = usage.price -- @param ctx AgentContext -- @param role string Key into M.ROLES. -- @param prompt string Step prompt (already retrieval-augmented by caller). --- @param opts table { model?, model_tier?, auto_tier?, thinking? } +-- @param opts table { model?, model_tier?, auto_tier?, thinking?, preview?, activity_label?, budget? } function M.run(ctx, role, prompt, opts) opts = opts or {} - if opts.budget then - local budget_ok, budget_err = opts.budget:consume() - if not budget_ok then - return { ok = false, error = budget_err } - end - end local r = M.ROLES[role] or M.ROLES.developer - local tier = (opts.auto_tier and route_tier(prompt)) or opts.model_tier or r.tier - local spec = opts.model - - local model, merr = n00n.agent.resolve_model(ctx, { spec = spec, tier = not spec and tier or nil }) - if merr then - return { ok = false, error = merr } - end - local tools, terr = n00n.agent.tools(ctx, { spec = model.spec, audience = "general_sub", include_mcp = true }) - if terr then - return { ok = false, error = terr } - end - local sess, serr = n00n.agent.session(ctx, { - model_spec = model.spec, + local text, err, cost, usage_val, model_spec = subagent.launch(ctx, { + description = role, + prompt = prompt, system = r.system, - tools = tools, - audience = "general_sub", - name = role, + model_spec = opts.model, + model_tier = opts.model_tier or r.tier, + auto_tier = opts.auto_tier, thinking = opts.thinking, + preview = opts.preview, + activity_label = opts.activity_label or role, + budget = opts.budget, + fail_on_pricing_error = true, }) - if serr then - return { ok = false, error = serr } - end - local res, rerr - if opts.preview then - res, rerr = opts.preview:prompt(sess, prompt, opts.activity_label or role) - else - res, rerr = sess:prompt(prompt) - end - sess:close() - local measured_usage, cost, metrics_err = M.metrics(model.spec, res) - if metrics_err then - return { - ok = false, - error = "usage pricing failed: " .. metrics_err, - model = model.spec, - usage = measured_usage, - } - end - if rerr then - return { ok = false, error = rerr, cost = cost, model = model.spec, usage = measured_usage } + if err then + return { ok = false, error = err, cost = cost, model = model_spec, usage = usage_val } end - return { - ok = true, - text = res and res.text, - cost = cost, - model = model.spec, - usage = measured_usage, - } + return { ok = true, text = text, cost = cost, model = model_spec, usage = usage_val } end return M From cdeab82c78eb0a6504ee5bfe04b5ea296c7750ff Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 00:28:33 -0400 Subject: [PATCH 07/25] fix(agent): log cleanup warnings in stop handler Surface failed socket/directory removals in the background agent stop handler instead of silently ignoring them. The process still exits, but the warning gives operators a signal that stale state may remain. --- src/cmd/agent.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index c460dc1a6..711dbc0ad 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -598,8 +598,12 @@ async fn handle_connection( .wrap_err("failed to write response")?; let agent_dir_path = agent_dir(storage, agent_id)?; - let _ = fs::remove_file(&state.socket_path); - let _ = fs::remove_dir_all(&agent_dir_path); + if let Err(e) = fs::remove_file(&state.socket_path) { + tracing::warn!(error = %e, path = %state.socket_path, "failed to remove agent socket"); + } + if let Err(e) = fs::remove_dir_all(&agent_dir_path) { + tracing::warn!(error = %e, path = %agent_dir_path.display(), "failed to remove agent state directory"); + } std::process::exit(0); } From 3cb443a1a5f9b272d1a390454d711925ef9457bc Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 00:33:17 -0400 Subject: [PATCH 08/25] fix(agent): validate agent ids and lock down socket permissions Prevent path traversal from user-supplied agent ids by validating them before any filesystem operation, and reject ids that contain path separators, control characters, or other unsafe content. Limit ids to 64 ASCII alphanumeric/hyphen/underscore characters. Set the agent state directory to 0o700 and the Unix control socket to 0o600 so only the owning user can read or connect to background agent state. --- src/cmd/agent.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 711dbc0ad..d9cd9cfaf 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -1,5 +1,6 @@ use std::env; use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -27,6 +28,28 @@ fn workflow_from_mode(mode: CliAgentMode) -> bool { matches!(mode, CliAgentMode::Team | CliAgentMode::Workflow) } +const MAX_AGENT_ID_LEN: usize = 64; + +fn validate_agent_id(id: &str) -> Result<()> { + if id.is_empty() { + return Err(color_eyre::eyre::eyre!("agent id cannot be empty")); + } + if id.len() > MAX_AGENT_ID_LEN { + return Err(color_eyre::eyre::eyre!( + "agent id must be {MAX_AGENT_ID_LEN} characters or fewer" + )); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(color_eyre::eyre::eyre!( + "agent id must contain only ASCII letters, digits, hyphens, and underscores" + )); + } + Ok(()) +} + async fn write_line(writer: &mut W, line: &str) -> Result<()> { writer .write_all(line.as_bytes()) @@ -202,6 +225,7 @@ const STATE_FILE: &str = "agent.json"; const SOCKET_FILE: &str = "control.sock"; fn agent_dir(state_dir: &StateDir, agent_id: &str) -> Result { + validate_agent_id(agent_id)?; let agents_dir = state_dir.ensure_subdir(AGENTS_SUBDIR)?; Ok(agents_dir.join(agent_id)) } @@ -339,6 +363,8 @@ pub fn server( let agent_dir_path = agent_dir(&storage, &agent_id)?; fs::create_dir_all(&agent_dir_path).wrap_err("failed to create agent directory")?; + fs::set_permissions(&agent_dir_path, fs::Permissions::from_mode(0o700)) + .wrap_err("failed to set agent directory permissions")?; let socket_path_value = socket_path(&storage, &agent_id)?; @@ -357,6 +383,8 @@ pub fn server( write_agent_state(&storage, &state)?; let listener = UnixListener::bind(&socket_path_value).wrap_err("failed to bind socket")?; + fs::set_permissions(&socket_path_value, fs::Permissions::from_mode(0o600)) + .wrap_err("failed to set socket permissions")?; let message_lock = Arc::new(Mutex::new(())); let paused = Arc::new(AtomicBool::new(false)); @@ -947,4 +975,28 @@ mod tests { assert_eq!(all[0].id, "agent2"); assert_eq!(all[1].id, "agent1"); } + + #[test] + fn validate_agent_id_accepts_safe_ids() { + assert!(validate_agent_id("my-agent_1").is_ok()); + assert!(validate_agent_id("a").is_ok()); + assert!(validate_agent_id(&"x".repeat(64)).is_ok()); + } + + #[test] + fn validate_agent_id_rejects_unsafe_ids() { + assert!(validate_agent_id("").is_err()); + assert!(validate_agent_id("a/b").is_err()); + assert!(validate_agent_id("..").is_err()); + assert!(validate_agent_id("../../../etc/passwd").is_err()); + assert!(validate_agent_id("a b").is_err()); + assert!(validate_agent_id(&"x".repeat(65)).is_err()); + } + + #[test] + fn agent_dir_rejects_path_traversal_id() { + let tmp = TempDir::new().unwrap(); + let state_dir = StateDir::from_path(tmp.path().to_path_buf()); + assert!(agent_dir(&state_dir, "../escape").is_err()); + } } From d4c10994b4237a555e8199f0ecdf4a89ea7a3c01 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 00:36:41 -0400 Subject: [PATCH 09/25] refactor(agent): share one-shot and background agent setup Extract `prepare_agent_env` and `PreparedEnv` to remove the duplicated plugin/model/MCP initialization between `run` and `server`. Both paths now build their `HeadlessParams`/`InteractiveParams` from the same prepared environment, making the two entry points easier to keep in sync. --- src/cmd/agent.rs | 161 ++++++++++++++++++++++------------------------- 1 file changed, 75 insertions(+), 86 deletions(-) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index d9cd9cfaf..6bb2e9725 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -13,10 +13,13 @@ use flume::Sender; use futures_lite::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, split}; use n00n_agent::headless; use n00n_agent::tools::ToolRegistry; -use n00n_agent::{AgentEvent, AgentInput, AgentMode as RuntimeAgentMode, Envelope}; +use n00n_agent::{ + AgentConfig, AgentEvent, AgentInput, AgentMode as RuntimeAgentMode, Envelope, McpHandle, + PermissionsConfig, prompt::ResolvedSlots, +}; use n00n_config::{load_env_files, load_permissions}; use n00n_lua::PluginHost; -use n00n_providers::ThinkingConfig; +use n00n_providers::{Model, OpenAiOptions, ThinkingConfig, Timeouts}; use n00n_storage::StateDir; use serde::{Deserialize, Serialize}; use smol::net::unix::{UnixListener, UnixStream}; @@ -50,28 +53,20 @@ fn validate_agent_id(id: &str) -> Result<()> { Ok(()) } -async fn write_line(writer: &mut W, line: &str) -> Result<()> { - writer - .write_all(line.as_bytes()) - .await - .wrap_err("failed to write line")?; - writer - .write_all(b"\n") - .await - .wrap_err("failed to write newline")?; - writer.flush().await.wrap_err("failed to flush writer")?; - Ok(()) +struct PreparedEnv { + storage: StateDir, + cwd: PathBuf, + model: Model, + model_spec: String, + agent_config: AgentConfig, + permissions: PermissionsConfig, + timeouts: Timeouts, + openai_options: OpenAiOptions, + mcp_handle: Option, + prompt_slots: ResolvedSlots, } -pub fn run( - prompt: &str, - model_arg: Option<&str>, - mode: CliAgentMode, - _goal: Option<&str>, - json: bool, - yolo: bool, - no_jit: bool, -) -> Result<()> { +fn prepare_agent_env(model_arg: Option<&str>, yolo: bool, no_jit: bool) -> Result { let storage = StateDir::resolve().context("resolve data directory")?; n00n_providers::model_registry::load_from_storage(&storage); @@ -102,13 +97,14 @@ pub fn run( .load_builtins(&config.plugins) .context("load builtin plugins")?; - let timeouts = n00n_providers::Timeouts { + let timeouts = Timeouts { connect: config.provider.connect_timeout, low_speed: config.provider.low_speed_timeout, stream: config.provider.stream_timeout, }; let model = setup::resolve_model(model_arg, &config.provider, &storage)?; + let model_spec = model.spec(); setup::install_panic_log_hook(); let (mcp_handle, _mcp_config_errors) = smol::block_on(n00n_agent::mcp::start( @@ -120,18 +116,56 @@ pub fn run( .event_handle() .map_or_else(Default::default, |h| h.collect_prompt_slots()); - let headless_params = headless::HeadlessParams { + Ok(PreparedEnv { + storage, + cwd, model, - config: config.agent, - permissions_config: config.permissions, + model_spec, + agent_config: config.agent, + permissions: config.permissions, timeouts, - openai_options: n00n_providers::OpenAiOptions::from(&config.provider), + openai_options: OpenAiOptions::from(&config.provider), + mcp_handle, + prompt_slots, + }) +} + +async fn write_line(writer: &mut W, line: &str) -> Result<()> { + writer + .write_all(line.as_bytes()) + .await + .wrap_err("failed to write line")?; + writer + .write_all(b"\n") + .await + .wrap_err("failed to write newline")?; + writer.flush().await.wrap_err("failed to flush writer")?; + Ok(()) +} + +pub fn run( + prompt: &str, + model_arg: Option<&str>, + mode: CliAgentMode, + _goal: Option<&str>, + json: bool, + yolo: bool, + no_jit: bool, +) -> Result<()> { + let env = prepare_agent_env(model_arg, yolo, no_jit)?; + + let headless_params = headless::HeadlessParams { + model: env.model, + config: env.agent_config, + permissions_config: env.permissions, + timeouts: env.timeouts, + openai_options: env.openai_options, prompt: prompt.to_string(), images: Vec::new(), - prompt_slots, + prompt_slots: env.prompt_slots, excluded_tools: Vec::new(), - mcp_handle, - initial_wd: cwd, + mcp_handle: env.mcp_handle, + initial_wd: env.cwd, fast: false, workflow: workflow_from_mode(mode), }; @@ -289,65 +323,20 @@ pub fn server( yolo: bool, no_jit: bool, ) -> Result<()> { - let storage = StateDir::resolve().context("resolve data directory")?; - n00n_providers::model_registry::load_from_storage(&storage); - - let cwd = env::current_dir().unwrap_or_else(|_| ".".into()); - load_env_files(&cwd); - - let mut plugin_host = PluginHost::with_jit(Arc::clone(ToolRegistry::global_arc()), !no_jit) - .context("initialize lua plugin host")?; - - let raw_config = plugin_host - .load_init_files(&cwd) - .context("load init.lua files")?; - - let mut config = raw_config - .unwrap_or_else(Default::default) - .into_config(false) - .context("invalid config")?; - config.permissions = load_permissions(&cwd); - - setup::init_logging(&config.storage); - - if yolo || config.always_yolo { - config.permissions.yolo = true; - } - config.validate()?; - - plugin_host - .load_builtins(&config.plugins) - .context("load builtin plugins")?; - - let timeouts = n00n_providers::Timeouts { - connect: config.provider.connect_timeout, - low_speed: config.provider.low_speed_timeout, - stream: config.provider.stream_timeout, - }; - - let model = setup::resolve_model(model_arg, &config.provider, &storage)?; - let model_spec = model.spec(); - setup::install_panic_log_hook(); - - let (mcp_handle, _mcp_config_errors) = smol::block_on(n00n_agent::mcp::start( - &cwd, - config.agent.mcp_tool_desc_max_chars, - )); - - let prompt_slots = plugin_host - .event_handle() - .map_or_else(Default::default, |h| h.collect_prompt_slots()); + let env = prepare_agent_env(model_arg, yolo, no_jit)?; + let storage = env.storage.clone(); + let model_spec = env.model_spec; let interactive_params = headless::InteractiveParams { - model, - config: config.agent, - permissions_config: config.permissions, - timeouts, - openai_options: n00n_providers::OpenAiOptions::from(&config.provider), - prompt_slots: Arc::new(prompt_slots), + model: env.model, + config: env.agent_config, + permissions_config: env.permissions, + timeouts: env.timeouts, + openai_options: env.openai_options, + prompt_slots: Arc::new(env.prompt_slots), excluded_tools: Vec::new(), - mcp_handle, - initial_wd: cwd, + mcp_handle: env.mcp_handle, + initial_wd: env.cwd, session_id: None, initial_history: Vec::new(), yolo, From 2e6aace6925b6bccc96469032395425295ed31cf Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 00:37:32 -0400 Subject: [PATCH 10/25] docs(changelog): add fragments for agent CLI and team refactor Record the user-facing additions, refactor, and security hardening from PR #134 in changelog.d. --- changelog.d/134.added.md | 1 + changelog.d/134.changed.md | 1 + changelog.d/134.security.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 changelog.d/134.added.md create mode 100644 changelog.d/134.changed.md create mode 100644 changelog.d/134.security.md diff --git a/changelog.d/134.added.md b/changelog.d/134.added.md new file mode 100644 index 000000000..0b449a05f --- /dev/null +++ b/changelog.d/134.added.md @@ -0,0 +1 @@ +Added the `n00n agent` CLI for running one-shot prompts and managing long-lived background agents via Unix sockets (`run`, `message`, `list`, `status`, `pause`, `resume`, `stop`). diff --git a/changelog.d/134.changed.md b/changelog.d/134.changed.md new file mode 100644 index 000000000..db2507bbd --- /dev/null +++ b/changelog.d/134.changed.md @@ -0,0 +1 @@ +Refactored `plugins/team` to launch subagents through the shared `n00n.subagent` helper, consolidating structured-output and model-tier handling with `task` and `workflow`. diff --git a/changelog.d/134.security.md b/changelog.d/134.security.md new file mode 100644 index 000000000..1bf5905df --- /dev/null +++ b/changelog.d/134.security.md @@ -0,0 +1 @@ +Hardened background agent state management by validating `agent_id` against path traversal and setting state directory and Unix socket permissions to owner-only. From 858f517d9c4d2c777d4115467d5676cc7955d124 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 00:55:51 -0400 Subject: [PATCH 11/25] fix(agent): await task completion before stop exits The background agent Stop command now waits for the agent task to finish after sending the cancel signal, instead of exiting immediately. This prevents dropping active tool calls and unfinished MCP sessions and ensures state is persisted before cleanup. --- src/cmd/agent.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 6bb2e9725..a12002d3a 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -147,7 +147,6 @@ pub fn run( prompt: &str, model_arg: Option<&str>, mode: CliAgentMode, - _goal: Option<&str>, json: bool, yolo: bool, no_jit: bool, @@ -402,6 +401,8 @@ pub fn server( write_agent_state(&storage, &state)?; } + let task = Arc::new(Mutex::new(Some(handle.task))); + smol::block_on(async { while let Ok(stream) = listener.accept().await { let stream = stream.0; @@ -413,6 +414,7 @@ pub fn server( let storage_clone = storage.clone(); let agent_id_clone = agent_id.clone(); let mode_clone = mode; + let task = Arc::clone(&task); smol::spawn(async move { if let Err(e) = handle_connection( @@ -425,6 +427,7 @@ pub fn server( &storage_clone, &agent_id_clone, mode_clone, + task, ) .await { @@ -459,6 +462,7 @@ async fn handle_connection( storage: &StateDir, agent_id: &str, mode: CliAgentMode, + task: Arc>>>, ) -> Result<()> { let (reader, mut writer) = split(stream); let mut reader = BufReader::new(reader); @@ -608,6 +612,10 @@ async fn handle_connection( let _ = cancel_tx.send(()); + if let Some(t) = task.lock().await.take() { + t.await; + } + let response = serde_json::json!({ "ok": true }); let response_json = serde_json::to_string(&response)?; write_line(&mut writer, &response_json) From 8f72bb4b7f6ccc7c766a13b4180e6a8ea9964d72 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 00:56:02 -0400 Subject: [PATCH 12/25] refactor(cli): remove unused goal arg and stub policy commands The `goal` flag on `n00n agent run` was parsed but never used, and the `policy` subcommands were stubs. Remove both until they are wired to actual behavior. --- src/cli.rs | 20 -------------------- src/cmd/mod.rs | 5 ----- 2 files changed, 25 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 507f227a1..0069cb4be 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -303,9 +303,6 @@ pub enum AgentCommand { /// Agent mode #[arg(long, value_enum, default_value_t = AgentMode::General)] mode: AgentMode, - /// Goal for team mode - #[arg(long)] - goal: Option, /// Output as JSON #[arg(long)] json: bool, @@ -328,23 +325,6 @@ pub enum AgentCommand { Resume { id: String }, /// Stop agent Stop { id: String }, - /// Agent policy management - Policy { - #[command(subcommand)] - action: PolicyAction, - }, -} - -#[derive(Subcommand)] -pub enum PolicyAction { - /// Set policy rule (stub) - Set, - /// Get policy rule (stub) - Get, - /// Delete policy rule (stub) - Delete, - /// List policy rules (stub) - List, } #[derive(Subcommand)] diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index d939a3c40..bc9a60638 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -66,7 +66,6 @@ pub fn dispatch(cli: Cli) -> Result<()> { prompt, model, mode, - goal: _, json, background, id, @@ -85,7 +84,6 @@ pub fn dispatch(cli: Cli) -> Result<()> { &prompt, model.as_deref(), mode, - None, json, cli.permission_flags.yolo, cli.plugin_flags.no_jit, @@ -120,9 +118,6 @@ pub fn dispatch(cli: Cli) -> Result<()> { AgentCommand::Stop { id } => { agent::stop_client(&id)?; } - AgentCommand::Policy { action: _ } => { - eprintln!("Agent policy command is not yet implemented"); - } }, None => { tui::run(cli)?; From 698a7b3a2e12bd9eab81c2cfb363948fe268d7e4 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 00:56:06 -0400 Subject: [PATCH 13/25] fix(task,workflow): pass thinking config to subagent sessions Thread the `thinking` option through `n00n.agent.session` in both the task and workflow plugins, and include it in the workflow journal key so cached results honor the thinking setting. --- plugins/task/init.lua | 1 + plugins/workflow/init.lua | 2 ++ 2 files changed, 3 insertions(+) diff --git a/plugins/task/init.lua b/plugins/task/init.lua index c4dbb240d..7e8101101 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -249,6 +249,7 @@ local function handler(input, ctx) local_tools = local_tools, audience = audience, name = input.description, + thinking = input.thinking, }) if sess_err then return { llm_output = sess_err, is_error = true } diff --git a/plugins/workflow/init.lua b/plugins/workflow/init.lua index a7a63b4a4..7921a4347 100644 --- a/plugins/workflow/init.lua +++ b/plugins/workflow/init.lua @@ -215,6 +215,7 @@ local function journal_key(aopts) model_tier = aopts.model_tier, label = aopts.label, output_schema = aopts.output_schema, + thinking = aopts.thinking, })) end @@ -466,6 +467,7 @@ local function make_agent(ctx, progress, journal, logger) local_tools = local_tools, audience = audience, name = label, + thinking = aopts.thinking, }) if sess_err then error(sess_err, 0) From 286c30cd0bd059f0f3bb2d38120b9660db029d7b Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 01:21:53 -0400 Subject: [PATCH 14/25] fix(n00n-agent): make yolo enable rather than toggle in spawn_interactive `headless::spawn_interactive` was calling `permissions.toggle_yolo()` when `params.yolo` was true, which flipped an already-true yolo state back to false. This broke `--yolo` in background agent mode (and TUI/ACP when always_yolo was set). Add `PermissionManager::set_yolo` and use it to reliably enable yolo when requested, leaving the config-derived default otherwise. --- n00n-agent/src/headless.rs | 2 +- n00n-agent/src/permissions.rs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/n00n-agent/src/headless.rs b/n00n-agent/src/headless.rs index 7619e7e0b..e8d631a09 100644 --- a/n00n-agent/src/headless.rs +++ b/n00n-agent/src/headless.rs @@ -336,7 +336,7 @@ pub fn spawn_interactive(params: InteractiveParams) -> InteractiveHandle { params.initial_wd.clone(), )); if params.yolo { - permissions.toggle_yolo(); + permissions.set_yolo(true); } let answer_rx = Arc::new(Mutex::new(answer_rx)); diff --git a/n00n-agent/src/permissions.rs b/n00n-agent/src/permissions.rs index dae3666e3..0361603cd 100644 --- a/n00n-agent/src/permissions.rs +++ b/n00n-agent/src/permissions.rs @@ -378,6 +378,10 @@ impl PermissionManager { !prev } + pub fn set_yolo(&self, yolo: bool) { + self.yolo.store(yolo, Ordering::Relaxed); + } + pub fn is_yolo(&self) -> bool { self.yolo.load(Ordering::Relaxed) } From 3943670163b32ee0de8cb13d412266610a883539 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 01:42:04 -0400 Subject: [PATCH 15/25] feat(agent): add --goal and mode-aware team/workflow/task prompts Re-add --goal to n00n agent run and wire it into mode-specific prompts. Team mode now emits a team tool call with goal, mode, max_agents, waves, and auto-tier flags. Workflow mode emits a workflow tool call with the prompt as the script and goal merged into inputs. Task mode emits a task tool call with description/prompt. General/Research modes prepend the goal to the prompt. Also introduce AgentRunOptions to keep run/server signatures tidy and avoid excessive boolean parameters. --- changelog.d/134.added.md | 2 +- src/cli.rs | 109 ++++++++++++++++- src/cmd/agent.rs | 258 +++++++++++++++++++++++++++++++++++---- src/cmd/mod.rs | 37 +++--- 4 files changed, 361 insertions(+), 45 deletions(-) diff --git a/changelog.d/134.added.md b/changelog.d/134.added.md index 0b449a05f..990c8f07b 100644 --- a/changelog.d/134.added.md +++ b/changelog.d/134.added.md @@ -1 +1 @@ -Added the `n00n agent` CLI for running one-shot prompts and managing long-lived background agents via Unix sockets (`run`, `message`, `list`, `status`, `pause`, `resume`, `stop`). +Added the `n00n agent` CLI for running one-shot prompts and managing long-lived background agents via Unix sockets (`run`, `message`, `list`, `status`, `pause`, `resume`, `stop`). `n00n agent run` now supports `--goal`, `--team-mode`, `--max-agents`, `--waves`, `--workflow-inputs`, and `--task-description` to drive team, workflow, and task mode directly. diff --git a/src/cli.rs b/src/cli.rs index 0069cb4be..be5be5e62 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -294,7 +294,7 @@ pub enum Command { pub enum AgentCommand { /// Run a one-shot agent prompt Run { - /// Prompt to send to the agent + /// Prompt to send to the agent; for workflow mode this is the Lua script #[arg(short, long)] prompt: String, /// Model spec (provider/model-id) @@ -303,6 +303,24 @@ pub enum AgentCommand { /// Agent mode #[arg(long, value_enum, default_value_t = AgentMode::General)] mode: AgentMode, + /// High-level goal for team/workflow/task mode; prepended in general/research mode + #[arg(long)] + goal: Option, + /// Team execution mode (supervised, autonomous, swarm); only used with --mode team + #[arg(long)] + team_mode: Option, + /// Maximum agent calls for team mode + #[arg(long)] + max_agents: Option, + /// Run team in wave-based checkpoints + #[arg(long)] + waves: bool, + /// JSON object passed as workflow inputs; only used with --mode workflow + #[arg(long)] + workflow_inputs: Option, + /// Task description; defaults to --goal in task mode + #[arg(long)] + task_description: Option, /// Output as JSON #[arg(long)] json: bool, @@ -434,6 +452,95 @@ mod tests { )); } + #[test] + fn agent_run_parse_goal_and_team_mode() { + let cli = Cli::parse_from([ + "n00n", + "agent", + "run", + "--prompt", + "test", + "--goal", + "ship feature", + "--mode", + "team", + "--team-mode", + "autonomous", + "--max-agents", + "8", + "--waves", + ]); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::Run { + goal: Some(g), + team_mode: Some(t), + max_agents: Some(8), + waves: true, + mode: AgentMode::Team, + .. + } + }) if g == "ship feature" && t == "autonomous" + )); + } + + #[test] + fn agent_run_parse_workflow_inputs() { + let cli = Cli::parse_from([ + "n00n", + "agent", + "run", + "--prompt", + "return agent({prompt = inputs.goal})", + "--mode", + "workflow", + "--goal", + "find bugs", + "--workflow-inputs", + r#"{"foo":"bar"}"#, + ]); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::Run { + goal: Some(g), + workflow_inputs: Some(w), + mode: AgentMode::Workflow, + .. + } + }) if g == "find bugs" && w == r#"{"foo":"bar"}"# + )); + } + + #[test] + fn agent_run_parse_task_description() { + let cli = Cli::parse_from([ + "n00n", + "agent", + "run", + "--prompt", + "refactor this module", + "--mode", + "task", + "--goal", + "cleanup", + "--task-description", + "refactor", + ]); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::Run { + goal: Some(g), + task_description: Some(d), + mode: AgentMode::Task, + .. + } + }) if g == "cleanup" && d == "refactor" + )); + } + #[test] fn agent_message_parse() { let cli = Cli::parse_from(["n00n", "agent", "message", "agent-id", "hello"]); diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index a12002d3a..0305f717c 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -22,6 +22,7 @@ use n00n_lua::PluginHost; use n00n_providers::{Model, OpenAiOptions, ThinkingConfig, Timeouts}; use n00n_storage::StateDir; use serde::{Deserialize, Serialize}; +use serde_json::json; use smol::net::unix::{UnixListener, UnixStream}; use crate::cli::AgentMode as CliAgentMode; @@ -53,6 +54,144 @@ fn validate_agent_id(id: &str) -> Result<()> { Ok(()) } +fn build_message( + mode: CliAgentMode, + prompt: &str, + goal: Option<&str>, + team_mode: Option<&str>, + max_agents: Option, + waves: bool, + workflow_inputs: Option<&str>, + task_description: Option<&str>, +) -> Result { + match mode { + CliAgentMode::Team => build_team_message(prompt, goal, team_mode, max_agents, waves), + CliAgentMode::Workflow => build_workflow_message(prompt, goal, workflow_inputs), + CliAgentMode::Task => build_task_message(prompt, goal, task_description), + _ => Ok(build_generic_message(prompt, goal)), + } +} + +fn build_team_message( + prompt: &str, + goal: Option<&str>, + team_mode: Option<&str>, + max_agents: Option, + waves: bool, +) -> Result { + let mode = team_mode.map_or_else(|| "autonomous", |m| m); + if !matches!(mode, "supervised" | "autonomous" | "swarm") { + return Err(color_eyre::eyre::eyre!( + "team mode must be 'supervised', 'autonomous', or 'swarm'" + )); + } + + let mut goal_text = goal.map_or_else(|| prompt.to_string(), ToString::to_string); + if let Some(g) = goal + && !prompt.is_empty() + && prompt != g + { + goal_text.push_str("\n\nAdditional context:\n"); + goal_text.push_str(prompt); + } + + let mut input = serde_json::Map::new(); + input.insert("goal".to_string(), json!(goal_text)); + input.insert("mode".to_string(), json!(mode)); + input.insert("auto_tier".to_string(), json!(true)); + input.insert("compact".to_string(), json!(true)); + input.insert("use_retrieval".to_string(), json!(true)); + if let Some(n) = max_agents { + input.insert("max_agents".to_string(), json!(n)); + } + if waves { + input.insert("waves".to_string(), json!(true)); + } + + let payload = serde_json::Value::Object(input); + Ok(format!( + "Use the team tool now. Do not only describe this request.\n\n{}", + serde_json::to_string(&payload)? + )) +} + +fn build_workflow_message( + prompt: &str, + goal: Option<&str>, + workflow_inputs: Option<&str>, +) -> Result { + let mut inputs: serde_json::Map = if let Some(s) = workflow_inputs { + serde_json::from_str(s).context("invalid --workflow-inputs JSON: expected an object")? + } else { + serde_json::Map::new() + }; + if let Some(g) = goal { + inputs.insert("goal".to_string(), json!(g)); + } + + let mut input = serde_json::Map::new(); + input.insert("script".to_string(), json!(prompt)); + if !inputs.is_empty() { + input.insert("inputs".to_string(), serde_json::Value::Object(inputs)); + } + + let payload = serde_json::Value::Object(input); + Ok(format!( + "Use the workflow tool now. Do not only describe this request.\n\n{}", + serde_json::to_string(&payload)? + )) +} + +fn build_task_message( + prompt: &str, + goal: Option<&str>, + task_description: Option<&str>, +) -> Result { + let description = task_description.or(goal).map_or_else( + || { + prompt + .split_whitespace() + .take(5) + .collect::>() + .join(" ") + }, + ToString::to_string, + ); + + let mut input = serde_json::Map::new(); + input.insert("description".to_string(), json!(description)); + input.insert("prompt".to_string(), json!(prompt)); + input.insert("auto_tier".to_string(), json!(true)); + + let payload = serde_json::Value::Object(input); + Ok(format!( + "Use the task tool now. Do not only describe this request.\n\n{}", + serde_json::to_string(&payload)? + )) +} + +fn build_generic_message(prompt: &str, goal: Option<&str>) -> String { + if let Some(g) = goal { + format!("Goal: {g}\n\n{prompt}") + } else { + prompt.to_string() + } +} + +pub struct AgentRunOptions<'a> { + pub prompt: &'a str, + pub model: Option<&'a str>, + pub mode: CliAgentMode, + pub goal: Option<&'a str>, + pub team_mode: Option<&'a str>, + pub max_agents: Option, + pub waves: bool, + pub workflow_inputs: Option<&'a str>, + pub task_description: Option<&'a str>, + pub yolo: bool, + pub no_jit: bool, +} + struct PreparedEnv { storage: StateDir, cwd: PathBuf, @@ -143,15 +282,18 @@ async fn write_line(writer: &mut W, line: &str) -> Res Ok(()) } -pub fn run( - prompt: &str, - model_arg: Option<&str>, - mode: CliAgentMode, - json: bool, - yolo: bool, - no_jit: bool, -) -> Result<()> { - let env = prepare_agent_env(model_arg, yolo, no_jit)?; +pub fn run(opts: &AgentRunOptions<'_>, json: bool) -> Result<()> { + let env = prepare_agent_env(opts.model, opts.yolo, opts.no_jit)?; + let message = build_message( + opts.mode, + opts.prompt, + opts.goal, + opts.team_mode, + opts.max_agents, + opts.waves, + opts.workflow_inputs, + opts.task_description, + )?; let headless_params = headless::HeadlessParams { model: env.model, @@ -159,14 +301,14 @@ pub fn run( permissions_config: env.permissions, timeouts: env.timeouts, openai_options: env.openai_options, - prompt: prompt.to_string(), + prompt: message, images: Vec::new(), prompt_slots: env.prompt_slots, excluded_tools: Vec::new(), mcp_handle: env.mcp_handle, initial_wd: env.cwd, fast: false, - workflow: workflow_from_mode(mode), + workflow: workflow_from_mode(opts.mode), }; let handle = headless::spawn(headless_params); @@ -314,15 +456,18 @@ fn now_epoch() -> u64 { .map_or(0, |d| d.as_secs()) } -pub fn server( - prompt: &str, - model_arg: Option<&str>, - mode: CliAgentMode, - agent_id: Option, - yolo: bool, - no_jit: bool, -) -> Result<()> { - let env = prepare_agent_env(model_arg, yolo, no_jit)?; +pub fn server(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<()> { + let env = prepare_agent_env(opts.model, opts.yolo, opts.no_jit)?; + let message = build_message( + opts.mode, + opts.prompt, + opts.goal, + opts.team_mode, + opts.max_agents, + opts.waves, + opts.workflow_inputs, + opts.task_description, + )?; let storage = env.storage.clone(); let model_spec = env.model_spec; @@ -338,10 +483,10 @@ pub fn server( initial_wd: env.cwd, session_id: None, initial_history: Vec::new(), - yolo, + yolo: opts.yolo, system_prompt_override: None, append_system_prompt: None, - workflow: workflow_from_mode(mode), + workflow: workflow_from_mode(opts.mode), }; let handle = headless::spawn_interactive(interactive_params); @@ -362,7 +507,7 @@ pub fn server( socket_path: socket_path_value.to_string_lossy().into_owned(), pid: std::process::id(), status: "running".to_string(), - prompt: prompt.to_string(), + prompt: opts.prompt.to_string(), model: model_spec, created_at: now_epoch(), updated_at: now_epoch(), @@ -377,19 +522,19 @@ pub fn server( let message_lock = Arc::new(Mutex::new(())); let paused = Arc::new(AtomicBool::new(false)); - if !prompt.is_empty() { + if !opts.prompt.is_empty() { let _lock = smol::block_on(message_lock.lock()); state.status = "working".to_string(); write_agent_state(&storage, &state)?; let _ = handle.input_tx.send(AgentInput { - message: prompt.to_string(), + message, mode: RuntimeAgentMode::Build, images: Vec::new(), preamble: Vec::new(), thinking: ThinkingConfig::default(), fast: false, - workflow: workflow_from_mode(mode), + workflow: workflow_from_mode(opts.mode), prompt: None, }); @@ -413,7 +558,7 @@ pub fn server( let paused = Arc::clone(&paused); let storage_clone = storage.clone(); let agent_id_clone = agent_id.clone(); - let mode_clone = mode; + let mode_clone = opts.mode; let task = Arc::clone(&task); smol::spawn(async move { @@ -996,4 +1141,63 @@ mod tests { let state_dir = StateDir::from_path(tmp.path().to_path_buf()); assert!(agent_dir(&state_dir, "../escape").is_err()); } + + #[test] + fn build_team_message_uses_goal_and_appends_prompt_context() { + let msg = build_team_message( + "use rust", + Some("ship feature"), + Some("autonomous"), + Some(8), + true, + ) + .unwrap(); + assert!(msg.starts_with("Use the team tool now.")); + assert!(msg.contains("ship feature")); + assert!(msg.contains("use rust")); + assert!(msg.contains("\"mode\":\"autonomous\"")); + assert!(msg.contains("\"max_agents\":8")); + assert!(msg.contains("\"waves\":true")); + } + + #[test] + fn build_team_message_defaults_prompt_to_goal() { + let msg = build_team_message("ship feature", None, None, None, false).unwrap(); + assert!(msg.contains("\"goal\":\"ship feature\"")); + assert!(msg.contains("\"mode\":\"autonomous\"")); + } + + #[test] + fn build_team_message_rejects_invalid_mode() { + assert!(build_team_message("g", None, Some("fast"), None, false).is_err()); + } + + #[test] + fn build_workflow_message_includes_script_inputs_and_goal() { + let msg = build_workflow_message( + "return agent({prompt = inputs.goal})", + Some("find bugs"), + Some(r#"{"foo":"bar"}"#), + ) + .unwrap(); + assert!(msg.starts_with("Use the workflow tool now.")); + assert!(msg.contains("find bugs")); + assert!(msg.contains("bar")); + assert!(msg.contains("return agent({prompt = inputs.goal})")); + } + + #[test] + fn build_task_message_uses_description_and_prompt() { + let msg = + build_task_message("refactor this module", Some("cleanup"), Some("refactor")).unwrap(); + assert!(msg.starts_with("Use the task tool now.")); + assert!(msg.contains("\"description\":\"refactor\"")); + assert!(msg.contains("\"prompt\":\"refactor this module\"")); + } + + #[test] + fn build_generic_message_prepends_goal() { + let msg = build_generic_message("do it", Some("win")); + assert_eq!(msg, "Goal: win\n\ndo it"); + } } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index bc9a60638..7fd565e63 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -66,28 +66,33 @@ pub fn dispatch(cli: Cli) -> Result<()> { prompt, model, mode, + goal, + team_mode, + max_agents, + waves, + workflow_inputs, + task_description, json, background, id, } => { + let run_opts = agent::AgentRunOptions { + prompt: &prompt, + model: model.as_deref(), + mode, + goal: goal.as_deref(), + team_mode: team_mode.as_deref(), + max_agents, + waves, + workflow_inputs: workflow_inputs.as_deref(), + task_description: task_description.as_deref(), + yolo: cli.permission_flags.yolo, + no_jit: cli.plugin_flags.no_jit, + }; if background { - agent::server( - &prompt, - model.as_deref(), - mode, - id, - cli.permission_flags.yolo, - cli.plugin_flags.no_jit, - )?; + agent::server(&run_opts, id)?; } else { - agent::run( - &prompt, - model.as_deref(), - mode, - json, - cli.permission_flags.yolo, - cli.plugin_flags.no_jit, - )?; + agent::run(&run_opts, json)?; } } AgentCommand::List => { From 847a251b36bd469e8f1aa1cc23e5f5e48907328a Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 02:10:42 -0400 Subject: [PATCH 16/25] feat(agent): configurable agent-call limits and runaway guard Remove the hard-coded 24/32 agent-call ceilings in team and workflow. - team `max_agents` is now configurable with no hard maximum and an optional `timeout_secs`. - workflow `max_agents_per_run` is configurable with no hard maximum. - Add `plugins/lib/n00n/guard.lua` to enforce call budgets, wall-clock timeouts, repeated-prompt loops, and consecutive subagent errors. - Wire the guard through `n00n.subagent.launch`, `team`, and `workflow`. --- changelog.d/134.changed.2.md | 1 + plugins/lib/n00n/guard.lua | 96 +++++++++++++++++++++++++++++++++++ plugins/lib/n00n/subagent.lua | 41 +++++++++++++-- plugins/team/init.lua | 32 ++++++------ plugins/workflow/init.lua | 50 ++++++++++++------ 5 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 changelog.d/134.changed.2.md create mode 100644 plugins/lib/n00n/guard.lua diff --git a/changelog.d/134.changed.2.md b/changelog.d/134.changed.2.md new file mode 100644 index 000000000..3d5c06588 --- /dev/null +++ b/changelog.d/134.changed.2.md @@ -0,0 +1 @@ +Removed the hard-coded 24/32 agent-call ceilings in `team` and `workflow`; `max_agents`/`max_agents_per_run` are now user-configurable with no hard maximum. Added a shared `n00n.guard` runaway detector that enforces call budgets, wall-clock timeouts, repeated-prompt loops, and consecutive subagent errors. diff --git a/plugins/lib/n00n/guard.lua b/plugins/lib/n00n/guard.lua new file mode 100644 index 000000000..ed94c694b --- /dev/null +++ b/plugins/lib/n00n/guard.lua @@ -0,0 +1,96 @@ +-- Runaway guard for subagent budgets. +-- +-- Combines a user-configurable call limit with heuristic runaway detection: +-- repeated identical prompts, consecutive subagent errors, and wall-clock timeouts. +-- +-- Use as a drop-in replacement for a simple { consume = ... } budget table: +-- guard.consume() is still called before a call. +-- guard.observe(prompt, err) is called after a call if available. +-- +-- For richer control, subagent.launch also supports guard:check(prompt) before a +-- call and guard:record(prompt, err) after a call, which lets the guard see the +-- prompt and the result. + +local M = {} + +local DEFAULT_MAX_REPEATED_PROMPT = 3 +local DEFAULT_MAX_CONSECUTIVE_ERRORS = 3 + +local function guard_error(kind, detail) + return kind .. " runaway guard triggered" .. (detail and ": " .. detail or "") +end + +function M.new(opts) + opts = opts or {} + + local start_time = os.time() + + local self = { + limit = opts.max_calls, + timeout_secs = opts.timeout_secs, + max_repeated = opts.max_repeated_prompt or DEFAULT_MAX_REPEATED_PROMPT, + max_consecutive_errors = opts.max_consecutive_errors or DEFAULT_MAX_CONSECUTIVE_ERRORS, + + used = 0, + prompts = {}, + consecutive_errors = 0, + start_time = start_time, + } + + -- Check before a subagent call. Returns (ok, err). + function self.check(_, prompt) + if self.timeout_secs then + local elapsed = os.time() - self.start_time + if elapsed > self.timeout_secs then + return nil, guard_error("timeout", "elapsed " .. elapsed .. "s > " .. self.timeout_secs .. "s") + end + end + + if self.limit and self.used >= self.limit then + return nil, guard_error("budget", "agent-call limit " .. self.limit .. " reached") + end + + if prompt then + local count = self.prompts[prompt] or 0 + if count >= self.max_repeated then + return nil, guard_error("repeated prompt", "prompt seen " .. count .. " times") + end + end + + return true + end + + -- Record a subagent result. Returns (ok, err); on error the caller should + -- treat the run as failed even if the subagent returned a result. + function self.record(_, prompt, err) + if prompt then + self.prompts[prompt] = (self.prompts[prompt] or 0) + 1 + end + + if err then + self.consecutive_errors = self.consecutive_errors + 1 + if self.consecutive_errors >= self.max_consecutive_errors then + return nil, guard_error("consecutive errors", self.consecutive_errors .. " in a row") + end + else + self.consecutive_errors = 0 + end + + self.used = self.used + 1 + return true + end + + -- Backwards-compatible API for callers that already use a consume/observe + -- budget table. + function self.consume(_) + return self:check(nil) + end + + function self.observe(_, prompt, err) + return self:record(prompt, err) + end + + return self +end + +return M diff --git a/plugins/lib/n00n/subagent.lua b/plugins/lib/n00n/subagent.lua index 6978673a5..11e1f847c 100644 --- a/plugins/lib/n00n/subagent.lua +++ b/plugins/lib/n00n/subagent.lua @@ -43,12 +43,36 @@ function M.launch(ctx, opts) return nil, "ctx is required", nil, nil, nil end - -- Budget check - if opts.budget then - local budget_ok, budget_err = opts.budget:consume() - if not budget_ok then - return nil, budget_err or "budget exhausted", nil, nil, nil + local function guard_check(prompt) + if not opts.budget then + return true end + if opts.budget.check then + return opts.budget:check(prompt) + end + if opts.budget.consume then + return opts.budget:consume() + end + return true + end + + local function guard_record(prompt, err) + if not opts.budget then + return true + end + if opts.budget.record then + return opts.budget:record(prompt, err) + end + if opts.budget.observe then + return opts.budget:observe(prompt, err) + end + return true + end + + -- Runaway-guard check before any expensive setup work (model resolution, etc.) + local guard_ok, guard_err = guard_check(opts.prompt) + if not guard_ok then + return nil, guard_err, nil, nil, nil end local subagent_type = opts.subagent_type or "general" @@ -160,6 +184,13 @@ function M.launch(ctx, opts) else result, err = sess:prompt(message) end + + -- Record result for runaway heuristics + local record_ok, record_err = guard_record(opts.prompt, err) + if not record_ok then + sess:close() + return nil, record_err, nil, usage.normalize(result), model_spec + end local retries = 0 while not err and validator and not captured and retries < structured_output.MAX_STRUCTURED_RETRIES do retries = retries + 1 diff --git a/plugins/team/init.lua b/plugins/team/init.lua index a01cecd77..5b172a72d 100644 --- a/plugins/team/init.lua +++ b/plugins/team/init.lua @@ -8,6 +8,7 @@ local memory = require("mem") local retrieve = require("retrieve") local roles = require("roles") local subagent = require("n00n.subagent") +local guard = require("n00n.guard") local ibn = require("ibn") local quorum = require("quorum") local swarm = require("swarm") @@ -63,7 +64,6 @@ local DEFAULT_PLAN_STEPS = 6 local DEFAULT_SWARM_ROUNDS = 2 local MAX_SWARM_ROUNDS = 4 local DEFAULT_TEAM_AGENTS = 16 -local MAX_TEAM_AGENTS = 24 local MAX_TEAM_CONCURRENT = 4 local TEAM_TIMEOUT_SECS = 1800 local MAX_RELAY_BYTES = 12000 @@ -141,8 +141,14 @@ local schema = { max_agents = { type = "integer", minimum = 1, - maximum = MAX_TEAM_AGENTS, - description = "Team agent budget (default 16, max 24).", + default = DEFAULT_TEAM_AGENTS, + description = "Team agent-call budget (default 16, no hard maximum).", + }, + timeout_secs = { + type = "integer", + minimum = 1, + default = TEAM_TIMEOUT_SECS, + description = "Wall-clock timeout before the team run is aborted (default 1800s).", }, max_steps = { type = "integer", @@ -228,19 +234,11 @@ local schema = { }, } -local function new_agent_budget(requested) - local limit = math.min(requested or DEFAULT_TEAM_AGENTS, MAX_TEAM_AGENTS) - return { - limit = limit, - used = 0, - consume = function(self) - if self.used >= self.limit then - return nil, "team agent-call budget exhausted (" .. self.limit .. "; hard maximum " .. MAX_TEAM_AGENTS .. ")" - end - self.used = self.used + 1 - return true - end, - } +local function new_agent_guard(requested, timeout_secs) + return guard.new({ + max_calls = requested or DEFAULT_TEAM_AGENTS, + timeout_secs = timeout_secs or TEAM_TIMEOUT_SECS, + }) end local function plan_prompt(goal) @@ -801,7 +799,7 @@ local function run_team(input, ctx) if input.thinking == nil then input.thinking = "adaptive" end - input._agent_budget = new_agent_budget(input.max_agents) + input._agent_budget = new_agent_guard(input.max_agents, 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 7921a4347..e0cad37b7 100644 --- a/plugins/workflow/init.lua +++ b/plugins/workflow/init.lua @@ -20,6 +20,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 SCRIPT_ERROR_PREFIX = "workflow script error: " local NO_META_ERROR = "workflow script must call meta({ name = ... }) before doing any work" @@ -37,7 +38,8 @@ local JOURNAL_DIRNAME = "workflows" local JOURNAL_FILENAME = "journal.jsonl" local META_FILENAME = "meta.json" local DEFAULT_AGENTS_PER_RUN = 24 -local HARD_MAX_AGENTS_PER_RUN = 32 +local DEFAULT_CONCURRENT_AGENTS = 4 +local DEFAULT_CONCURRENT_WORKFLOWS = 2 local HARD_MAX_CONCURRENT_AGENTS = 8 local HARD_MAX_CONCURRENT_WORKFLOWS = 4 local HARD_MAX_AGGREGATE_AGENTS = 12 @@ -49,7 +51,7 @@ local description = [[Run a bounded, sandboxed Lua workflow for multi-stage agen Start with meta({ name = ..., description = ..., phases = {...} }). Globals: agent({ prompt, subagent_type?, model_tier?, label?, output_schema? }) returns isolated agent result (research=read-only, general=default; tiers weak/medium/strong; output_schema returns validated JSON). parallel(fns, { concurrency? }) runs branches in order; any failure fails the call. pipeline(items, stages, { concurrency? }) runs each item through all stages. phase(name, fn), log(...), and inputs. -No n00n, os, io, require, print, or load. Scripts must be deterministic for resume replay, must return the final string, and are limited to 24 agent calls by default (32 hard maximum). Use task for one agent.]] +No n00n, os, io, require, print, or load. Scripts must be deterministic for resume replay, must return the final string, and are capped by max_agents_per_run (default 24, no hard maximum) with a runaway guard for repeated prompts and consecutive errors. Use task for one agent.]] local schema = { type = "object", @@ -86,10 +88,18 @@ local opts = n00n.api.register_options({ max_agents_per_run = { default = DEFAULT_AGENTS_PER_RUN, min = 1, - desc = "Agent-call budget per workflow (hard max 32).", + desc = "Agent-call budget per workflow (default 24, no hard maximum).", + }, + max_concurrent_agents = { + default = DEFAULT_CONCURRENT_AGENTS, + min = 1, + desc = "Concurrency per parallel()/pipeline() (default 4, hard max 8).", + }, + max_concurrent_workflows = { + default = DEFAULT_CONCURRENT_WORKFLOWS, + min = 1, + desc = "Concurrent workflows (default 2, hard max 4).", }, - max_concurrent_agents = { default = 4, min = 1, desc = "Concurrency per parallel()/pipeline() (hard max 8)." }, - max_concurrent_workflows = { default = 2, min = 1, desc = "Concurrent workflows (hard max 4)." }, timeout_secs = { default = DEFAULT_TIMEOUT_SECS, min = 1, @@ -97,7 +107,7 @@ local opts = n00n.api.register_options({ }, }) -local max_agents_per_run = math.min(opts.max_agents_per_run, HARD_MAX_AGENTS_PER_RUN) +local max_agents_per_run = opts.max_agents_per_run or DEFAULT_AGENTS_PER_RUN local max_concurrent_agents = math.min(opts.max_concurrent_agents, HARD_MAX_CONCURRENT_AGENTS) local max_concurrent_workflows = math.min(opts.max_concurrent_workflows, HARD_MAX_CONCURRENT_WORKFLOWS) local workflow_semaphore = n00n.async.semaphore(max_concurrent_workflows) @@ -346,7 +356,7 @@ local function pipeline(items, stages, popts) return parallel(fns, popts) end -local function make_agent(ctx, progress, journal, logger) +local function make_agent(ctx, progress, journal, logger, run_guard) return function(aopts) aopts = aopts or {} if type(aopts.prompt) ~= "string" then @@ -397,14 +407,14 @@ local function make_agent(ctx, progress, journal, logger) progress.agent_cached(label) return hit end - journal.agent_count = journal.agent_count + 1 - if journal.agent_count > max_agents_per_run then - gate:release() - error("workflow exceeded agent-call budget (" .. max_agents_per_run .. ", hard max 32)", 0) - end gate:release() end + local guard_ok, guard_err = run_guard:check(aopts.prompt) + if not guard_ok then + error(guard_err, 0) + end + local validator if aopts.output_schema then local compile_err @@ -483,6 +493,13 @@ local function make_agent(ctx, progress, journal, logger) 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) + if not record_ok then + sess:close() + error(record_err, 0) + end + sess:close() aggregate_permit:release() aggregate_permit = nil @@ -627,10 +644,10 @@ local function make_progress(ctx) } end -local function build_env(ctx, progress, inputs, journal, captured, saga, logger) +local function build_env(ctx, progress, inputs, journal, captured, saga, logger, run_guard) local env = { inputs = inputs, - agent = make_agent(ctx, progress, journal, logger), + agent = make_agent(ctx, progress, journal, logger, run_guard), parallel = parallel, pipeline = pipeline, tostring = tostring, @@ -753,7 +770,6 @@ local function handler(input, ctx) lock = n00n.async.semaphore(1), in_flight = {}, meta_ready = false, - agent_count = 0, } local progress = make_progress(ctx) @@ -814,11 +830,13 @@ local function handler(input, ctx) -- Bound pure-Lua runaway loops (while true) via the VM watchdog deadline. ctx:set_deadline(opts.timeout_secs) + local run_guard = guard.new({ max_calls = max_agents_per_run, timeout_secs = opts.timeout_secs }) + n00n.async.run(function() local permit local ok, result = pcall(function() permit = workflow_semaphore:acquire() - local env = build_env(ctx, progress, input.inputs or {}, journal, captured, saga, logger) + local env = build_env(ctx, progress, input.inputs or {}, journal, captured, saga, logger, run_guard) local run_fn, load_err = n00n.workflow.compile(input.script, env) if not run_fn then error(tostring(load_err), 0) From 22d4ff09f0cb24d05da6bbb5897b8493d1da2d4b Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 02:20:55 -0400 Subject: [PATCH 17/25] fix(guard): reserve call budget atomically in check and guard consecutive errors - Move the used counter increment from record() into check() so the budget is reserved before any async yield; this prevents concurrent subagent calls from overshooting max_calls. - Check consecutive_errors in check() and use > in record() so the guard blocks the next call after the threshold instead of wasting a token on the failing call. - Add lib spec coverage for budget, repeated-prompt, consecutive-error, and legacy consume() behavior. --- plugins/lib/n00n/guard.lua | 12 ++++++---- plugins/lib/tests/spec.lua | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/plugins/lib/n00n/guard.lua b/plugins/lib/n00n/guard.lua index ed94c694b..f86fb7799 100644 --- a/plugins/lib/n00n/guard.lua +++ b/plugins/lib/n00n/guard.lua @@ -50,6 +50,10 @@ function M.new(opts) return nil, guard_error("budget", "agent-call limit " .. self.limit .. " reached") end + if self.consecutive_errors >= self.max_consecutive_errors then + return nil, guard_error("consecutive errors", self.consecutive_errors .. " in a row") + end + if prompt then local count = self.prompts[prompt] or 0 if count >= self.max_repeated then @@ -57,11 +61,12 @@ function M.new(opts) end end + self.used = self.used + 1 return true end - -- Record a subagent result. Returns (ok, err); on error the caller should - -- treat the run as failed even if the subagent returned a result. + -- Record a subagent result. Returns (ok, err); updates prompt-frequency and + -- consecutive-error heuristics. function self.record(_, prompt, err) if prompt then self.prompts[prompt] = (self.prompts[prompt] or 0) + 1 @@ -69,14 +74,13 @@ function M.new(opts) if err then self.consecutive_errors = self.consecutive_errors + 1 - if self.consecutive_errors >= self.max_consecutive_errors then + if self.consecutive_errors > self.max_consecutive_errors then return nil, guard_error("consecutive errors", self.consecutive_errors .. " in a row") end else self.consecutive_errors = 0 end - self.used = self.used + 1 return true end diff --git a/plugins/lib/tests/spec.lua b/plugins/lib/tests/spec.lua index 5b33361ab..139506bae 100644 --- a/plugins/lib/tests/spec.lua +++ b/plugins/lib/tests/spec.lua @@ -3,6 +3,7 @@ local ExploreResult = require("n00n.explore_result") local truncate = require("n00n.truncate") local ToolView = require("n00n.tool_view") local structured_output = require("n00n.structured_output") +local guard = require("n00n.guard") local failures = {} @@ -19,6 +20,51 @@ local function eq(actual, expected, msg) end end +case("guard_increments_used_on_check_not_record", function() + local g = guard.new({ max_calls = 2 }) + eq(g.used, 0) + eq(g:check("p1"), true) + eq(g.used, 1) + eq(g:record("p1", nil), true) + eq(g.used, 1) + eq(g:check("p2"), true) + eq(g.used, 2) + local ok, err = g:check("p3") + eq(ok, nil) + assert(err:find("budget"), "third call must hit budget limit: " .. tostring(err)) +end) + +case("guard_blocks_repeated_prompts", function() + local g = guard.new({ max_calls = 10, max_repeated_prompt = 2 }) + eq(g:check("x"), true) + eq(g:record("x", nil), true) + eq(g:check("x"), true) + eq(g:record("x", nil), true) + local ok, err = g:check("x") + eq(ok, nil) + assert(err:find("repeated"), "third identical prompt must be blocked: " .. tostring(err)) +end) + +case("guard_tracks_consecutive_errors", function() + local g = guard.new({ max_calls = 10, max_consecutive_errors = 2 }) + eq(g:check("a"), true) + eq(g:record("a", "boom"), true) + eq(g:check("b"), true) + eq(g:record("b", "boom"), true) + local ok, err = g:check("c") + eq(ok, nil) + assert(err:find("consecutive errors"), "third consecutive error must abort: " .. tostring(err)) +end) + +case("guard_consume_is_backwards_compatible", function() + local g = guard.new({ max_calls = 1 }) + eq(g:consume(), true) + eq(g.used, 1) + local ok, err = g:consume() + eq(ok, nil) + assert(err:find("budget"), "legacy consume must enforce limit: " .. tostring(err)) +end) + -- Mock buf that records set_lines calls local function mock_buf() local b = { lines = nil, call_count = 0, handlers = {} } From 7173e835751bd5c68b90abfaeecbad85c8a134e8 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 05:09:20 -0400 Subject: [PATCH 18/25] fix(agent): gate background agent IPC behind Unix-only cfg Background agents use Unix domain sockets and permission modes that are not available on Windows. Return a clear error on unsupported platforms so the crate builds on all CI targets. --- src/cmd/agent.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 10a223a06..b80003e6e 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -1,15 +1,16 @@ use std::env; use std::fs; -use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(unix)] use async_lock::Mutex; use color_eyre::Result; use color_eyre::eyre::Context; +#[cfg(unix)] use flume::Sender; +#[cfg(unix)] use futures_lite::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, split}; use n00n_agent::headless; use n00n_agent::tools::ToolRegistry; @@ -23,11 +24,20 @@ use n00n_providers::{Model, OpenAiOptions, ThinkingConfig, Timeouts}; use n00n_storage::StateDir; use serde::{Deserialize, Serialize}; use serde_json::json; +#[cfg(unix)] use smol::net::unix::{UnixListener, UnixStream}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::sync::atomic::{AtomicBool, Ordering}; use crate::cli::AgentMode as CliAgentMode; use crate::setup; +#[cfg(not(unix))] +const BACKGROUND_AGENTS_UNSUPPORTED: &str = + "background agents require Unix domain sockets and are not supported on this platform"; + fn workflow_from_mode(mode: CliAgentMode) -> bool { matches!(mode, CliAgentMode::Team | CliAgentMode::Workflow) } @@ -269,6 +279,7 @@ fn prepare_agent_env(model_arg: Option<&str>, yolo: bool, no_jit: bool) -> Resul }) } +#[cfg(unix)] async fn write_line(writer: &mut W, line: &str) -> Result<()> { writer .write_all(line.as_bytes()) @@ -456,6 +467,7 @@ fn now_epoch() -> u64 { .map_or(0, |d| d.as_secs()) } +#[cfg(unix)] pub fn server(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<()> { let env = prepare_agent_env(opts.model, opts.yolo, opts.no_jit)?; let message = build_message( @@ -587,6 +599,12 @@ pub fn server(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<() Ok(()) } +#[cfg(not(unix))] +pub fn server(_opts: &AgentRunOptions<'_>, _agent_id: Option) -> Result<()> { + Err(color_eyre::eyre::eyre!(BACKGROUND_AGENTS_UNSUPPORTED)) +} + +#[cfg(unix)] fn wait_for_run_done(event_rx: &flume::Receiver) { while let Ok(envelope) = event_rx.recv() { if matches!( @@ -598,6 +616,7 @@ fn wait_for_run_done(event_rx: &flume::Receiver) { } } +#[cfg(unix)] async fn handle_connection( stream: UnixStream, input_tx: Sender, @@ -784,6 +803,7 @@ async fn handle_connection( Ok(()) } +#[cfg(unix)] pub fn message_client(id: &str, text: &str, json: bool) -> Result<()> { let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; let state = read_agent_state(&storage, id).wrap_err("failed to read agent state")?; @@ -842,6 +862,12 @@ pub fn message_client(id: &str, text: &str, json: bool) -> Result<()> { }) } +#[cfg(not(unix))] +pub fn message_client(_id: &str, _text: &str, _json: bool) -> Result<()> { + Err(color_eyre::eyre::eyre!(BACKGROUND_AGENTS_UNSUPPORTED)) +} + +#[cfg(unix)] pub fn stop_client(id: &str) -> Result<()> { let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; @@ -884,6 +910,11 @@ pub fn stop_client(id: &str) -> Result<()> { }) } +#[cfg(not(unix))] +pub fn stop_client(_id: &str) -> Result<()> { + Err(color_eyre::eyre::eyre!(BACKGROUND_AGENTS_UNSUPPORTED)) +} + pub fn list_client(json: bool) -> Result<()> { let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; let states = list_agent_states(&storage)?; @@ -938,14 +969,27 @@ pub fn status_client(id: &str, json: bool) -> Result<()> { Ok(()) } +#[cfg(unix)] pub fn pause_client(id: &str) -> Result<()> { control_command_client(id, &ClientCommand::Pause, "paused") } +#[cfg(not(unix))] +pub fn pause_client(_id: &str) -> Result<()> { + Err(color_eyre::eyre::eyre!(BACKGROUND_AGENTS_UNSUPPORTED)) +} + +#[cfg(unix)] pub fn resume_client(id: &str) -> Result<()> { control_command_client(id, &ClientCommand::Resume, "resumed") } +#[cfg(not(unix))] +pub fn resume_client(_id: &str) -> Result<()> { + Err(color_eyre::eyre::eyre!(BACKGROUND_AGENTS_UNSUPPORTED)) +} + +#[cfg(unix)] fn control_command_client(id: &str, command: &ClientCommand, success_label: &str) -> Result<()> { let storage = StateDir::resolve().wrap_err("failed to resolve state directory")?; let state = read_agent_state(&storage, id).wrap_err("failed to read agent state")?; From 6a8f2dc327d64b3c0604ac83d49f0d0049d22d19 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 06:35:04 -0400 Subject: [PATCH 19/25] fix(nix): use platform-correct loader env var in wrappers Use DYLD_LIBRARY_PATH on Darwin and LD_LIBRARY_PATH elsewhere, and wrap the computed package binary path to avoid runtime loader failures on non-Linux builds. --- changelog.d/nix-darwin-loader-path.fixed.md | 1 + flake.nix | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 changelog.d/nix-darwin-loader-path.fixed.md diff --git a/changelog.d/nix-darwin-loader-path.fixed.md b/changelog.d/nix-darwin-loader-path.fixed.md new file mode 100644 index 000000000..2719a6be8 --- /dev/null +++ b/changelog.d/nix-darwin-loader-path.fixed.md @@ -0,0 +1 @@ +Fix Nix binary wrapping on macOS by using `DYLD_LIBRARY_PATH` instead of `LD_LIBRARY_PATH`, and scope wrapping to the computed package binary path. diff --git a/flake.nix b/flake.nix index f330cb90d..26a51394b 100644 --- a/flake.nix +++ b/flake.nix @@ -50,6 +50,8 @@ stdenv.cc.cc.lib zlib ]; + runtimeLibraryPath = lib.makeLibraryPath runtimeLibs; + loaderLibraryPathVar = if pkgs.stdenv.isDarwin then "DYLD_LIBRARY_PATH" else "LD_LIBRARY_PATH"; n00n = rustPlatform.buildRustPackage { pname = packageName; inherit version; @@ -103,12 +105,12 @@ postFixup = lib.optionalString pkgs.stdenv.isLinux '' old_rpath="$(patchelf --print-rpath "$out/bin/${packageName}")" - new_rpath="${lib.makeLibraryPath runtimeLibs}''${old_rpath:+:$old_rpath}" + new_rpath="${runtimeLibraryPath}''${old_rpath:+:$old_rpath}" patchelf --set-rpath "$new_rpath" "$out/bin/${packageName}" '' + lib.optionalString (!pkgs.stdenv.isLinux) '' - wrapProgram $out/bin/n00n \ - --prefix LD_LIBRARY_PATH : "${lib.makeLibraryPath runtimeLibs}" + wrapProgram "$out/bin/${packageName}" \ + --prefix ${loaderLibraryPathVar} : "${runtimeLibraryPath}" ''; }; in From 483bdcdf75fafbf72ab1aa49bf5feb7401c5f2f1 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Mon, 27 Jul 2026 21:10:45 -0400 Subject: [PATCH 20/25] refactor(plugins): migrate task and workflow to n00n.subagent.launch for structured output --- changelog.d/134.changed.md | 2 +- plugins/task/init.lua | 235 +++++++++++++++++++------------------ plugins/workflow/init.lua | 100 +++------------- 3 files changed, 140 insertions(+), 197 deletions(-) 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/plugins/task/init.lua b/plugins/task/init.lua index 530295936..39afe6564 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -9,6 +9,7 @@ local ToolView = require("n00n.tool_view") 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." @@ -153,51 +154,11 @@ 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 } - 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 preview = make_preview(ctx, input.description or "task") - 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,15 +170,12 @@ 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 }) @@ -236,88 +194,137 @@ 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, cost, usage_val = 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 = "task failed: " .. err, is_error = true } + end + if type(captured) == "string" then + return { llm_output = captured, format = "markdown", usage = usage_val, cost = cost } + end + return { + llm_output = n00n.json.encode(captured), + format = "markdown", + usage = usage_val, + cost = cost, + } + 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, err = 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/workflow/init.lua b/plugins/workflow/init.lua index 2630d69e3..428950b57 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,92 +404,29 @@ 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, cost, usage_val = 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 progress.agent_done(label) @@ -496,21 +434,19 @@ local function make_agent(ctx, progress, journal, logger, run_guard) 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) + if launch_err then + error("sub-agent error: " .. launch_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 + + local out if 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 = captured or "" end local gate = journal.lock:acquire() From 40cb65482955bc85d4419140461c1e67e6356e41 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Tue, 28 Jul 2026 01:36:19 -0400 Subject: [PATCH 21/25] fix(agent): resolve P1 issues in background server and error handling - Fix background detach: fork and setsid to daemonize server process - Fix stop command: close input_tx and await outer task for proper cleanup - Fix error handling: break event loop on Error to prevent state hang - Fix task plugin: remove unused error from get_progress, fix encode error handling - Fix workflow plugin: emit agent_done only on success, remove unused cost/usage returns --- plugins/task/init.lua | 10 ++++------ plugins/workflow/init.lua | 11 ++++++----- src/cmd/agent.rs | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/plugins/task/init.lua b/plugins/task/init.lua index 39afe6564..cda539fbf 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -131,7 +131,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" @@ -196,7 +196,7 @@ local function handler(input, ctx) local ok, out = pcall(function() if input.output_schema then -- Use subagent.launch for structured output - local captured, err, cost, usage_val = subagent.launch(ctx, { + local captured, err = subagent.launch(ctx, { description = input.description or "task", prompt = input.prompt, subagent_type = subagent_type, @@ -212,13 +212,11 @@ local function handler(input, ctx) return { llm_output = "task failed: " .. err, is_error = true } end if type(captured) == "string" then - return { llm_output = captured, format = "markdown", usage = usage_val, cost = cost } + return { llm_output = captured, format = "markdown" } end return { llm_output = n00n.json.encode(captured), format = "markdown", - usage = usage_val, - cost = cost, } else -- Manual session for done tool (legacy path) @@ -306,7 +304,7 @@ local function handler(input, ctx) local function do_poll() while true do - local progress, err = sess:get_progress() + local progress = sess:get_progress() if not progress then return end diff --git a/plugins/workflow/init.lua b/plugins/workflow/init.lua index 428950b57..16b790255 100644 --- a/plugins/workflow/init.lua +++ b/plugins/workflow/init.lua @@ -410,7 +410,7 @@ local function make_agent(ctx, progress, journal, logger, run_guard) logger.log("agent_started", { label = label, model_tier = aopts.model_tier, subagent_type = subagent_type }) end - local captured, launch_err, cost, usage_val = subagent.launch(ctx, { + local captured, launch_err = subagent.launch(ctx, { description = label, prompt = aopts.prompt, subagent_type = subagent_type, @@ -429,15 +429,16 @@ local function make_agent(ctx, progress, journal, logger, run_guard) aggregate_permit:release() aggregate_permit = nil - progress.agent_done(label) - if logger then - logger.log("agent_done", { label = label, model_tier = aopts.model_tier, subagent_type = subagent_type }) - end 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 + local out if captured then local encoded, encode_err = n00n.json.encode(captured) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index be984ef6c..69159869f 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -615,7 +615,25 @@ pub fn server(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<() } #[cfg(unix)] +#[allow(unsafe_code)] fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<()> { + // Fork to detach from terminal for background mode + match unsafe { libc::fork() } { + 0 => { + // Child process: continue with server + // Detach from controlling terminal + if unsafe { libc::setsid() } < 0 { + return Err(eyre!("failed to create new session")); + } + } + pid if pid > 0 => { + // Parent process: exit after child is spawned + 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, @@ -859,6 +877,7 @@ async fn handle_connection( write_line(&mut writer, &json) .await .wrap_err("failed to write event")?; + break; } AgentEvent::Done { usage, .. } => { final_usage = Some( @@ -918,8 +937,11 @@ 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); + // Await the outer interactive task to ensure cleanup if let Some(t) = task.lock().await.take() { t.await; } From 55d3583c9cbf62e090ffaa18dee4b1cc3cde05d3 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Tue, 28 Jul 2026 01:49:10 -0400 Subject: [PATCH 22/25] fix(agent): address P2 review feedback in agent.rs - Return error on AgentEvent::Error in one-shot run for proper exit code - Preserve agent state on read failures (only delete on not found) - Avoid overwriting paused status when active run completes - UTF-8 truncation already uses character-aware .chars().take() --- src/cmd/agent.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 69159869f..b2ab23cc2 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -442,6 +442,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 +454,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 +476,10 @@ pub fn run(opts: &AgentRunOptions<'_>, json: bool) -> Result<()> { println!("{final_output}"); } + if let Some(err) = error_message { + return Err(eyre!(err)); + } + Ok(()) } @@ -900,7 +906,10 @@ async fn handle_connection( .wrap_err("failed to write event")?; let mut state = read_agent_state(storage, agent_id)?; - state.status = "running".to_string(); + // Only update status to running if not paused + if !paused.load(Ordering::Relaxed) { + state.status = "running".to_string(); + } state.updated_at = now_epoch(); write_agent_state(storage, &state)?; } @@ -1097,11 +1106,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))] From c9bcd03c419fc0f90803be528f193aec61bf3947 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Tue, 28 Jul 2026 01:56:34 -0400 Subject: [PATCH 23/25] fix(team): enforce wall-clock timeout during subagent calls --- plugins/team/init.lua | 4 ++++ 1 file changed, 4 insertions(+) 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) From eb34c823e4d417700968d657be8500b38dd1678b Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Tue, 28 Jul 2026 05:15:43 -0400 Subject: [PATCH 24/25] feat(agent): wire research mode through system prompt and tool restrictions - Add AgentMode::Research and is_readonly() so build_system_prompt picks the research prompt and filter_tools_for_mode strips execute-kind tools. - Plumb runtime AgentMode through HeadlessParams/InteractiveParams and persist it in background AgentState so follow-up messages keep research mode. - Exclude write/edit/multiedit/edit_lines/insert_lines for research runs. - Fix task plugin preview to use ActivityPreview (required by n00n.subagent) and return raw structured-output validation errors for tests. --- n00n-acp/src/server.rs | 1 + n00n-agent/src/agent/instructions.rs | 7 ++- n00n-agent/src/agent/run.rs | 2 +- n00n-agent/src/headless.rs | 4 +- n00n-agent/src/lib.rs | 8 ++- n00n-storage/src/sessions.rs | 1 + n00n-ui/src/app/session.rs | 2 + plugins/task/init.lua | 56 +++++-------------- src/cmd/agent.rs | 80 +++++++++++++++++++++++----- src/print.rs | 3 +- src/sdk_mode.rs | 1 + 11 files changed, 104 insertions(+), 61 deletions(-) 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 cda539fbf..f41612a1d 100644 --- a/plugins/task/init.lua +++ b/plugins/task/init.lua @@ -5,7 +5,7 @@ -- 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") @@ -73,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 = {} @@ -154,7 +117,10 @@ local function handler(input, ctx) model_tier = route_tier(input.prompt) end - local preview = make_preview(ctx, input.description or "task") + 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 -- Build local tools: either structured_output (with schema) or done tool local local_tools @@ -178,11 +144,11 @@ local function handler(input, ctx) 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, @@ -209,13 +175,17 @@ local function handler(input, ctx) activity_label = input.description or "task", }) if err then - return { llm_output = "task failed: " .. err, is_error = true } + 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 = n00n.json.encode(captured), + llm_output = encoded, format = "markdown", } else diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 3857c20ba..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); @@ -494,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 { @@ -623,17 +666,16 @@ pub fn server(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<() #[cfg(unix)] #[allow(unsafe_code)] fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<()> { - // Fork to detach from terminal for background mode + // 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 => { - // Child process: continue with server - // Detach from controlling terminal + // 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 => { - // Parent process: exit after child is spawned println!("Background agent started with PID {pid}"); return Ok(()); } @@ -654,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, @@ -661,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, @@ -670,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); @@ -693,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()), @@ -713,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(), @@ -823,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)?; @@ -832,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(), @@ -906,8 +957,9 @@ async fn handle_connection( .wrap_err("failed to write event")?; let mut state = read_agent_state(storage, agent_id)?; - // Only update status to running if not paused - if !paused.load(Ordering::Relaxed) { + if paused.load(Ordering::Relaxed) { + state.status = "paused".to_string(); + } else { state.status = "running".to_string(); } state.updated_at = now_epoch(); @@ -915,6 +967,7 @@ async fn handle_connection( } 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(); @@ -950,9 +1003,9 @@ async fn handle_connection( let _ = cancel_tx.send(()); drop(input_tx); - // Await the outer interactive task to ensure cleanup + // 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 }); @@ -1444,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()), }; @@ -1542,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(); @@ -1559,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()); From cf2092b3566864ab1bc8c5f383cc087541e37e38 Mon Sep 17 00:00:00 2001 From: w0wl0lxd Date: Tue, 28 Jul 2026 05:40:59 -0400 Subject: [PATCH 25/25] fix(workflow): preserve string subagent output and encode structured tables Subagent.launch may return either a plain string or a structured table. Store strings unchanged, JSON-encode tables with error handling, and use an empty string for nil output instead of encoding nil. --- plugins/workflow/init.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/workflow/init.lua b/plugins/workflow/init.lua index 16b790255..35b1c7f0b 100644 --- a/plugins/workflow/init.lua +++ b/plugins/workflow/init.lua @@ -440,14 +440,16 @@ local function make_agent(ctx, progress, journal, logger, run_guard) end local out - if captured then + 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 = captured or "" + out = "" end local gate = journal.lock:acquire()