diff --git a/README.md b/README.md index 8d18582..0d4e42a 100644 --- a/README.md +++ b/README.md @@ -793,6 +793,35 @@ Or via tools: {"name":"council.run","args":{"task":"design a 30-day plan to cut CI flakiness by 50%"}} ``` +### Subagent Sessions (`subagent.*` tools) + +Flynn now supports multi-turn child agent sessions scoped to the current parent session. + +Available tools: + +- `subagent.spawn` — create a child session from an `agent_configs.` profile +- `subagent.send` — send follow-up work to an existing child session +- `subagent.list` — list active child sessions +- `subagent.cancel` — request cancellation for a running child turn +- `subagent.delete` — remove a child session and clear its history + +Example flow: + +```json +{"name":"subagent.spawn","args":{"agent":"research","subagent_id":"plan-research","task":"Survey backup strategies for a 3-node homelab."}} +{"name":"subagent.send","args":{"subagent_id":"plan-research","message":"Now compare operational risk and recovery-time tradeoffs."}} +{"name":"subagent.list","args":{}} +``` + +Config controls: + +```yaml +agents: + subagents: + enabled: true + max_active_sessions: 6 +``` + ## Running as Service ### Ollama (Recommended) diff --git a/config/default.yaml b/config/default.yaml index 61215da..5a29575 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -297,6 +297,10 @@ agents: # In full-access mode, sensitive operations are gated by HookEngine confirmation # (instead of requiring temporary /elevate windows). sensitive_mode: confirm_without_elevation + # Multi-turn subagent sessions (`subagent.*` tools). + subagents: + enabled: true + max_active_sessions: 6 # ── Memory / Embeddings ────────────────────────────────────────────── # Enable hybrid keyword + vector search using local Ollama embeddings. diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index 8bca964..faaba60 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -41,6 +41,7 @@ The gateway serialises agent work **per session**, not per WebSocket connection: - The gateway `agent.send` command path and channel-router path use the same runtime backend-mode command service; `flynn tui` forwards `/runtime ...` through this gateway path for parity. - Backend routing and fallback outcomes are emitted to audit logs (`backend.route`, `backend.success`, `backend.fallback`) for rollout evaluation; this telemetry is outside JSON-RPC response payloads. - Session-start memory injection (`user/profile` + `user/working`) is server-side and controlled by `memory.user_namespace`; it does not affect protocol payloads. +- Multi-turn child agents are exposed through tool calls (`subagent.spawn/send/list/cancel/delete`) inside the agent loop; they do not add new JSON-RPC methods. This is implemented via a per-lane queue (`LaneQueue`) in the gateway server, and used by `agent.send` and `agent.cancel`. diff --git a/docs/architecture/AGENT_DIAGRAM.md b/docs/architecture/AGENT_DIAGRAM.md index 7fe4718..0b9c3ea 100644 --- a/docs/architecture/AGENT_DIAGRAM.md +++ b/docs/architecture/AGENT_DIAGRAM.md @@ -136,6 +136,11 @@ Tool Calls (inside NativeAgent loop) | v +---------------------------> AuditLogger (redacted) +Subagent sessions (multi-turn child agents) + parent AgentOrchestrator -> subagent.* tools -> SubagentManager + SubagentManager -> child AgentOrchestrator (session namespace: subagent::) + child AgentOrchestrator -> NativeAgent/tool loop (same policy engine, recursion tools removed) + Session start (when `memory.user_namespace` is set) AgentOrchestrator -> MemoryStore (user/profile + user/working) AgentOrchestrator -> System prompt (session context injection) @@ -155,6 +160,7 @@ Gateway streaming UX signals: Key files: - Routing + per-session agent creation: `src/daemon/routing.ts` +- Subagent session manager (child orchestrators): `src/backends/native/subagents.ts` - Runtime preference persistence (`modelTier`, `backendMode`): `src/preferences.ts` - Orchestration: `src/backends/native/orchestrator.ts` - Tool loop: `src/backends/native/agent.ts` diff --git a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md index 47bc56b..3e23a42 100644 --- a/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md +++ b/docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md @@ -17,6 +17,7 @@ If you only want the protocol surface, see `docs/api/PROTOCOL.md`. - Backend routing outcomes are auditable via `backend.route` / `backend.success` / `backend.fallback`, which enables offline canary evaluation without changing gateway protocol methods. - Run lifecycle/cancel intent and reaction decisions are emitted to audit logs, and aggregated into `system.metrics` counters (runStates, cancelLatencyMs, reactions) for dashboards. - Reaction matching is deterministic (priority + cooldown + recursion guard) before intent/agent routing. +- `subagent.*` tools create child orchestrators scoped to the parent conversation (`subagent::`); this is tool-loop behavior, not a separate gateway RPC session lane. - Companion `node.*` registration is per WebSocket connection; reconnects must re-register capabilities before invoking node RPC methods. - Canvas artifacts are persisted per session under the gateway data directory for UI recovery across restarts. - TTS output is best-effort; synthesis failures fall back to text-only responses. diff --git a/docs/plans/2026-02-26-personal-assistant-productization-plan.md b/docs/plans/2026-02-26-personal-assistant-productization-plan.md index 266bcb7..87c0cd2 100644 --- a/docs/plans/2026-02-26-personal-assistant-productization-plan.md +++ b/docs/plans/2026-02-26-personal-assistant-productization-plan.md @@ -20,6 +20,7 @@ The following were previously treated as gaps but are already implemented in Fly 2. Voice UX is functional but not yet a polished, end-to-end daily-driver experience across surfaces. 3. Browser tools exist but lack task-level reliability primitives (checkpoints/retries/guardrails) for autonomous workflows. 4. Onboarding lacks a "first success" guided path that validates real integrations live during setup. +5. Subagent sessions are now available (`subagent.*`) but need lifecycle hardening (TTL/budgeting/UI visibility) for larger autonomous workflows. ## Product Goal diff --git a/docs/plans/2026-02-26-subagents-support-plan.md b/docs/plans/2026-02-26-subagents-support-plan.md new file mode 100644 index 0000000..bfad5e8 --- /dev/null +++ b/docs/plans/2026-02-26-subagents-support-plan.md @@ -0,0 +1,51 @@ +# Subagents Support Plan (Flynn) + +Date: 2026-02-26 +Status: phase 1 implemented +Scope: add OpenClaw-style multi-turn subagent session support in Flynn without changing channel surface scope (Telegram-first) + +## Constraints + +1. Keep channel scope unchanged (Telegram remains default for now). +2. Deliver subagent capability through the existing native tool loop. +3. Keep gateway protocol additive-only (no new JSON-RPC methods required). + +## Phase 1 (Implemented in this change) + +1. Added subagent runtime manager (`src/backends/native/subagents.ts`) that can: + - spawn child sessions, + - send follow-up turns, + - list active child sessions, + - cancel in-flight child runs, + - delete child sessions. +2. Added new tools: + - `subagent.spawn` + - `subagent.send` + - `subagent.list` + - `subagent.cancel` + - `subagent.delete` +3. Wired tools into per-session router orchestration (`src/daemon/routing.ts`). +4. Added config guardrails under `agents.subagents`: + - `enabled` + - `max_active_sessions` +5. Added policy/profile support so `subagent.*` is controlled through `group:agents` and tool profiles. + +## Phase 2 (Next) + +1. Add per-subagent TTL/idle eviction and auto-cleanup metrics. +2. Add optional transcript export/summarization (`subagent.summary`). +3. Add per-subagent tool-profile override (read-only by default for risky workloads). +4. Add parent-child trace IDs in audit events for easier debugging. + +## Phase 3 (Stretch) + +1. Add queue semantics for child sessions (`followup` vs `interrupt` per subagent). +2. Add explicit resource budgets (token/time) per child session. +3. Add UI affordances in gateway chat for subagent session inspection. + +## Acceptance Criteria (Phase 1) + +1. Parent agent can spawn and continue a child subagent across multiple turns. +2. Child session state is isolated and delete clears history. +3. Recursion tooling (`agent.delegate`, `council.run`, `subagent.*`) is removed from child registries. +4. Tests cover manager lifecycle, tool behavior, config parsing, and policy profile inclusion. diff --git a/docs/plans/state.json b/docs/plans/state.json index a445ee6..f65b6ea 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -6795,10 +6795,40 @@ "docs/plans/state.json" ], "test_status": "planning/docs update only; no runtime code changes" + }, + "subagents-support-phase1": { + "status": "completed", + "date": "2026-02-26", + "updated": "2026-02-26", + "summary": "Implemented Phase 1 subagent support: added a SubagentManager with multi-turn child sessions, new `subagent.*` tools (spawn/send/list/cancel/delete), routing wiring, config guardrails, policy/profile integration, docs/diagram updates, and focused test coverage.", + "files_modified": [ + "src/backends/native/subagents.ts", + "src/backends/native/subagents.test.ts", + "src/backends/native/index.ts", + "src/backends/index.ts", + "src/tools/builtin/subagents.ts", + "src/tools/builtin/subagents.test.ts", + "src/tools/builtin/index.ts", + "src/tools/index.ts", + "src/tools/policy.ts", + "src/tools/policy.test.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/daemon/routing.ts", + "config/default.yaml", + "README.md", + "docs/api/PROTOCOL.md", + "docs/architecture/AGENT_DIAGRAM.md", + "docs/architecture/GATEWAY_SESSIONS_AND_QUEUE.md", + "docs/plans/2026-02-26-subagents-support-plan.md", + "docs/plans/2026-02-26-personal-assistant-productization-plan.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/backends/native/subagents.test.ts src/tools/builtin/subagents.test.ts src/tools/policy.test.ts src/config/schema.test.ts passing" } }, "overall_progress": { - "total_test_count": 2525, + "total_test_count": 2531, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -6813,7 +6843,7 @@ "tier2_completion": "4/4 (100%) \u2014 inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) \u2014 lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) \u2014 gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "rebaselined 2026-02-26 — channel breadth, setup wizard, and baseline browser automation are implemented; remaining high-impact personal-assistant gaps center on shipped companion apps (desktop/mobile), voice UX polish, browser workflow reliability primitives, and first-success onboarding funnel optimization.", + "feature_gap_scorecard": "rebaselined 2026-02-26 — channel breadth, setup wizard, baseline browser automation, and phase-1 multi-turn subagent sessions (`subagent.*`) are implemented; remaining high-impact personal-assistant gaps center on shipped companion apps (desktop/mobile), voice UX polish, browser workflow reliability primitives, and first-success onboarding funnel optimization.", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete \u2014 milestone done", "dashboard_observability": "completed \u2014 service health graphs + core service log viewer added to web UI via observability RPCs and bounded backend sampling", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", @@ -6846,7 +6876,8 @@ "deeper_surfaces_phase3_companion_canvas_voice": "completed \u2014 companion reconnect resilience (auto-reconnect with backoff, pending-wait cancellation on disconnect), canvas artifact persistence (SQLite-backed store, daemon-restart durability), voice TTS fallback coverage (text-only reply on TTS failure, no dropped responses)", "deeper_surfaces_phase4_rollout": "completed \u2014 phase 4 rollout and operator readiness plan documented: canary rollout plan by feature flag/surface, explicit rollback playbook, operator docs and architecture/protocol docs synchronized", "post_phase_test_fixes": "completed \u2014 fixed 4 test failures introduced by phases 1-3: iOS/Android push listNodes (missing publishHeartbeat before platform-filtered query), server.test agent.send (run_state events now precede done; added sendAndWaitForDone helper), httpBody 413 (req.destroy() closed socket before response could be sent; replaced with Connection: close header on 413 responses)", - "personal_assistant_productization_plan": "proposed \u2014 8-10 week phased roadmap defined (companion MVP surfaces, voice reliability hardening, browser workflow reliability layer, onboarding 2.0 first-success funnel) with measurable exit gates." + "personal_assistant_productization_plan": "proposed \u2014 8-10 week phased roadmap defined (companion MVP surfaces, voice reliability hardening, browser workflow reliability layer, onboarding 2.0 first-success funnel) with measurable exit gates.", + "subagents_support": "completed \u2014 phase-1 subagent runtime support added with `subagent.spawn/send/list/cancel/delete`, per-parent child-session orchestration, config guardrails (`agents.subagents.*`), and focused regression tests." }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/backends/index.ts b/src/backends/index.ts index b603dac..53ea3b0 100644 --- a/src/backends/index.ts +++ b/src/backends/index.ts @@ -6,6 +6,11 @@ export { type SubAgentResult, type DelegationConfig, type UsageReport, + SubagentManager, + type SubagentManagerConfig, + type SpawnSubagentRequest, + type SubagentSessionSummary, + type SubagentSendResult, } from './native/index.js'; export { COMPACTION_SYSTEM_PROMPT, diff --git a/src/backends/native/index.ts b/src/backends/native/index.ts index 71c5be5..0c66b3e 100644 --- a/src/backends/native/index.ts +++ b/src/backends/native/index.ts @@ -14,3 +14,10 @@ export { CLASSIFICATION_PROMPT, TOOL_SUMMARISATION_PROMPT, } from './prompts.js'; +export { + SubagentManager, + type SubagentManagerConfig, + type SpawnSubagentRequest, + type SubagentSessionSummary, + type SubagentSendResult, +} from './subagents.js'; diff --git a/src/backends/native/subagents.test.ts b/src/backends/native/subagents.test.ts new file mode 100644 index 0000000..bdfc757 --- /dev/null +++ b/src/backends/native/subagents.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ToolRegistry } from '../../tools/registry.js'; + +const mocks = vi.hoisted(() => { + const processCalls: string[] = []; + return { + ctorConfigs: [] as Array>, + processCalls, + cancellable: true, + cancelCalls: 0, + }; +}); + +vi.mock('./orchestrator.js', () => { + class AgentOrchestrator { + private readonly session: { + addMessage(message: { role: string; content: string }): void; + getHistory(): Array<{ role: string; content: string }>; + }; + + constructor(config: Record) { + mocks.ctorConfigs.push(config); + this.session = config.session as { + addMessage(message: { role: string; content: string }): void; + getHistory(): Array<{ role: string; content: string }>; + }; + } + + async process(message: string): Promise { + mocks.processCalls.push(message); + const output = `subagent:${message}`; + this.session.addMessage({ role: 'user', content: message }); + this.session.addMessage({ role: 'assistant', content: output }); + return output; + } + + isCancellable(): boolean { + return mocks.cancellable; + } + + cancel(): void { + mocks.cancelCalls += 1; + } + } + + return { AgentOrchestrator }; +}); + +import { SubagentManager } from './subagents.js'; + +type TestSession = { + id: string; + addMessage: (message: { role: string; content: string }) => void; + getHistory: () => Array<{ role: string; content: string }>; + clear: () => void; + replaceHistory: (messages: Array<{ role: string; content: string }>) => void; + getConfig: (_key: string) => string | undefined; + setConfig: (_key: string, _value: string) => void; + deleteConfig: (_key: string) => void; +}; + +function createSessionManagerMock() { + const sessions = new Map }>(); + const closed: string[] = []; + + const getSession = (frontend: string, userId: string): TestSession => { + const id = `${frontend}:${userId}`; + let state = sessions.get(id); + if (!state) { + state = { history: [] }; + sessions.set(id, state); + } + + return { + id, + addMessage: (message) => { + state?.history.push(message); + }, + getHistory: () => [...(state?.history ?? [])], + clear: () => { + if (state) { + state.history = []; + } + }, + replaceHistory: (messages) => { + if (state) { + state.history = [...messages]; + } + }, + getConfig: () => undefined, + setConfig: () => {}, + deleteConfig: () => {}, + }; + }; + + return { + sessions, + closed, + api: { + getSession, + closeSession: (frontend: string, userId: string) => { + closed.push(`${frontend}:${userId}`); + }, + }, + }; +} + +function createAgentRegistryMock() { + const entries = [ + { name: 'research', modelTier: 'complex', systemPrompt: 'You are research.' }, + { name: 'helper', modelTier: 'default', systemPrompt: 'You are helper.' }, + ]; + return { + get: (name: string) => entries.find((entry) => entry.name === name), + list: () => entries, + }; +} + +describe('SubagentManager', () => { + beforeEach(() => { + mocks.ctorConfigs = []; + mocks.processCalls.length = 0; + mocks.cancellable = true; + mocks.cancelCalls = 0; + }); + + it('spawns, sends, lists, cancels, and deletes subagent sessions', async () => { + const sessionManager = createSessionManagerMock(); + const tools = new ToolRegistry(); + for (const name of [ + 'file.read', + 'agent.delegate', + 'council.run', + 'subagent.spawn', + 'subagent.send', + 'subagent.list', + 'subagent.cancel', + 'subagent.delete', + ]) { + tools.register({ + name, + description: name, + inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ success: true, output: 'ok' }), + }); + } + + const manager = new SubagentManager({ + parentSessionId: 'telegram:alice', + modelRouter: {} as never, + sessionManager: sessionManager.api as never, + toolRegistry: tools, + toolExecutor: {} as never, + agentConfigRegistry: createAgentRegistryMock() as never, + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + maxDelegationDepth: 3, + defaultPrimaryTier: 'default', + maxIterations: 12, + maxActiveSessions: 2, + }); + + const spawned = manager.spawn({ agent: 'research', subagentId: 'planner' }); + expect(spawned.id).toBe('planner'); + expect(spawned.agent).toBe('research'); + expect(spawned.tier).toBe('complex'); + + // verify blocked orchestration tools are not passed to child subagents + const ctorConfig = mocks.ctorConfigs[0] as { toolRegistry: ToolRegistry }; + const childToolNames = ctorConfig.toolRegistry.list().map((tool) => tool.name); + expect(childToolNames).toContain('file.read'); + expect(childToolNames).not.toContain('agent.delegate'); + expect(childToolNames).not.toContain('council.run'); + expect(childToolNames).not.toContain('subagent.spawn'); + + const firstSend = await manager.send('planner', 'Draft a rollout plan'); + expect(firstSend.content).toBe('subagent:Draft a rollout plan'); + expect(firstSend.session.messageCount).toBe(2); + + const listed = manager.list(); + expect(listed).toHaveLength(1); + expect(listed[0].id).toBe('planner'); + expect(listed[0].messageCount).toBe(2); + + expect(manager.cancel('planner')).toBe(true); + expect(mocks.cancelCalls).toBe(1); + + expect(manager.delete('planner')).toBe(true); + expect(manager.list()).toHaveLength(0); + expect(sessionManager.closed).toContain('subagent:telegram:alice:planner'); + }); + + it('enforces active-session limits and unknown-session errors', async () => { + const sessionManager = createSessionManagerMock(); + const manager = new SubagentManager({ + parentSessionId: 'telegram:bob', + modelRouter: {} as never, + sessionManager: sessionManager.api as never, + toolRegistry: new ToolRegistry(), + toolExecutor: {} as never, + agentConfigRegistry: createAgentRegistryMock() as never, + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + maxDelegationDepth: 3, + defaultPrimaryTier: 'default', + maxActiveSessions: 1, + }); + + manager.spawn({ agent: 'helper', subagentId: 'one' }); + expect(() => manager.spawn({ agent: 'helper', subagentId: 'two' })).toThrow('Subagent session limit reached'); + + await expect(manager.send('missing', 'hello')).rejects.toThrow('not found'); + expect(manager.delete('missing')).toBe(false); + }); + + it('rejects unknown agent names on spawn', () => { + const sessionManager = createSessionManagerMock(); + const manager = new SubagentManager({ + parentSessionId: 'telegram:carol', + modelRouter: {} as never, + sessionManager: sessionManager.api as never, + toolRegistry: new ToolRegistry(), + toolExecutor: {} as never, + agentConfigRegistry: createAgentRegistryMock() as never, + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + maxDelegationDepth: 3, + defaultPrimaryTier: 'default', + maxActiveSessions: 3, + }); + + expect(() => manager.spawn({ agent: 'unknown' })).toThrow('not found'); + }); +}); diff --git a/src/backends/native/subagents.ts b/src/backends/native/subagents.ts new file mode 100644 index 0000000..5ff483e --- /dev/null +++ b/src/backends/native/subagents.ts @@ -0,0 +1,239 @@ +import { randomUUID } from 'node:crypto'; +import type { AgentConfigRegistry } from '../../agents/registry.js'; +import type { ToolPolicyContext } from '../../tools/policy.js'; +import type { ModelRouter, ModelTier } from '../../models/router.js'; +import type { SessionManager } from '../../session/manager.js'; +import type { ToolRegistry } from '../../tools/registry.js'; +import type { ToolExecutor } from '../../tools/executor.js'; +import { AgentOrchestrator, type DelegationConfig } from './orchestrator.js'; + +const SUBAGENT_FRONTEND = 'subagent'; + +const BLOCKED_SUBAGENT_TOOL_NAMES = [ + 'agent.delegate', + 'council.run', + 'subagent.spawn', + 'subagent.send', + 'subagent.list', + 'subagent.cancel', + 'subagent.delete', +]; + +export interface SubagentManagerConfig { + parentSessionId: string; + modelRouter: ModelRouter; + sessionManager: SessionManager; + toolRegistry: ToolRegistry; + toolExecutor: ToolExecutor; + agentConfigRegistry: AgentConfigRegistry; + delegation: DelegationConfig; + maxDelegationDepth: number; + defaultPrimaryTier: ModelTier; + maxIterations?: number; + maxActiveSessions: number; + toolPolicyContext?: ToolPolicyContext; +} + +export interface SpawnSubagentRequest { + agent: string; + subagentId?: string; + tier?: ModelTier; + systemPrompt?: string; +} + +interface ManagedSubagent { + id: string; + agent: string; + tier: ModelTier; + sessionUserId: string; + createdAt: number; + updatedAt: number; + busy: boolean; + orchestrator: AgentOrchestrator; +} + +export interface SubagentSessionSummary { + id: string; + agent: string; + tier: ModelTier; + messageCount: number; + createdAt: number; + updatedAt: number; + busy: boolean; +} + +export interface SubagentSendResult { + content: string; + session: SubagentSessionSummary; +} + +/** + * Manages multi-turn child subagent sessions scoped to a parent session. + */ +export class SubagentManager { + private readonly sessions = new Map(); + + constructor(private readonly config: SubagentManagerConfig) {} + + spawn(request: SpawnSubagentRequest): SubagentSessionSummary { + const agentName = request.agent.trim(); + if (!agentName) { + throw new Error('agent is required'); + } + + const agentConfig = this.config.agentConfigRegistry.get(agentName); + if (!agentConfig) { + const available = this.config.agentConfigRegistry.list().map((entry) => entry.name); + throw new Error( + `Agent \"${agentName}\" not found. Available agents: ${available.length > 0 ? available.join(', ') : 'none'}`, + ); + } + + const id = this.resolveSubagentId(request.subagentId); + if (this.sessions.has(id)) { + throw new Error(`Subagent session \"${id}\" already exists.`); + } + if (this.sessions.size >= this.config.maxActiveSessions) { + throw new Error( + `Subagent session limit reached (${this.config.maxActiveSessions}). Delete an existing subagent session first.`, + ); + } + + const tier = request.tier ?? agentConfig.modelTier ?? this.config.defaultPrimaryTier; + const systemPrompt = request.systemPrompt + ?? agentConfig.systemPrompt + ?? `You are subagent \"${agentName}\". Complete assigned tasks clearly and concisely.`; + const now = Date.now(); + const sessionUserId = `${this.config.parentSessionId}:${id}`; + const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, sessionUserId); + + const subagentToolRegistry = this.config.toolRegistry.clone(); + for (const toolName of BLOCKED_SUBAGENT_TOOL_NAMES) { + subagentToolRegistry.unregister(toolName); + } + + const policyContext: ToolPolicyContext | undefined = this.config.toolPolicyContext + ? { + ...this.config.toolPolicyContext, + sessionId: session.id, + tier, + agent: tier, + } + : undefined; + + const subagent = new AgentOrchestrator({ + modelRouter: this.config.modelRouter, + systemPrompt, + session, + toolRegistry: subagentToolRegistry, + toolExecutor: this.config.toolExecutor, + primaryTier: tier, + delegation: this.config.delegation, + maxDelegationDepth: this.config.maxDelegationDepth, + maxIterations: this.config.maxIterations, + toolPolicyContext: policyContext, + }); + + this.sessions.set(id, { + id, + agent: agentName, + tier, + sessionUserId, + createdAt: now, + updatedAt: now, + busy: false, + orchestrator: subagent, + }); + + return this.getSummaryById(id); + } + + async send(subagentId: string, message: string): Promise { + const subagent = this.requireSubagent(subagentId); + const trimmed = message.trim(); + if (!trimmed) { + throw new Error('message is required'); + } + + subagent.busy = true; + subagent.updatedAt = Date.now(); + try { + const content = await subagent.orchestrator.process(trimmed); + subagent.updatedAt = Date.now(); + return { + content, + session: this.getSummary(subagent), + }; + } finally { + subagent.busy = false; + } + } + + cancel(subagentId: string): boolean { + const subagent = this.requireSubagent(subagentId); + if (!subagent.orchestrator.isCancellable()) { + return false; + } + subagent.orchestrator.cancel(); + subagent.updatedAt = Date.now(); + return true; + } + + delete(subagentId: string): boolean { + const subagent = this.sessions.get(subagentId); + if (!subagent) { + return false; + } + + if (subagent.orchestrator.isCancellable()) { + subagent.orchestrator.cancel(); + } + + const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, subagent.sessionUserId); + session.clear(); + this.config.sessionManager.closeSession(SUBAGENT_FRONTEND, subagent.sessionUserId); + this.sessions.delete(subagentId); + return true; + } + + list(): SubagentSessionSummary[] { + return [...this.sessions.values()] + .map((entry) => this.getSummary(entry)) + .sort((a, b) => a.id.localeCompare(b.id)); + } + + private resolveSubagentId(rawId: string | undefined): string { + const explicit = rawId?.trim(); + if (explicit) { + return explicit; + } + return `sa-${randomUUID().slice(0, 8)}`; + } + + private requireSubagent(id: string): ManagedSubagent { + const normalized = id.trim(); + const subagent = this.sessions.get(normalized); + if (!subagent) { + throw new Error(`Subagent session \"${normalized}\" not found.`); + } + return subagent; + } + + private getSummaryById(id: string): SubagentSessionSummary { + const subagent = this.requireSubagent(id); + return this.getSummary(subagent); + } + + private getSummary(subagent: ManagedSubagent): SubagentSessionSummary { + const session = this.config.sessionManager.getSession(SUBAGENT_FRONTEND, subagent.sessionUserId); + return { + id: subagent.id, + agent: subagent.agent, + tier: subagent.tier, + messageCount: session.getHistory().length, + createdAt: subagent.createdAt, + updatedAt: subagent.updatedAt, + busy: subagent.busy, + }; + } +} diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 5c64659..f37ec7f 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1698,6 +1698,8 @@ describe('configSchema — agents truthfulness/autonomy', () => { expect(result.agents.truthfulness_mode).toBe('standard'); expect(result.agents.autonomy_level).toBe('standard'); expect(result.agents.sensitive_mode).toBe('confirm_without_elevation'); + expect(result.agents.subagents.enabled).toBe(true); + expect(result.agents.subagents.max_active_sessions).toBe(6); expect(result.agents.immutable_denylist).toEqual( expect.arrayContaining([ expect.objectContaining({ tool: 'shell.exec', args_pattern: 'git push origin main' }), @@ -1713,6 +1715,10 @@ describe('configSchema — agents truthfulness/autonomy', () => { truthfulness_mode: 'strict', autonomy_level: 'conservative', sensitive_mode: 'confirm_without_elevation', + subagents: { + enabled: false, + max_active_sessions: 3, + }, immutable_denylist: [ { tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' }, ], @@ -1722,11 +1728,24 @@ describe('configSchema — agents truthfulness/autonomy', () => { expect(result.agents.truthfulness_mode).toBe('strict'); expect(result.agents.autonomy_level).toBe('conservative'); expect(result.agents.sensitive_mode).toBe('confirm_without_elevation'); + expect(result.agents.subagents.enabled).toBe(false); + expect(result.agents.subagents.max_active_sessions).toBe(3); expect(result.agents.immutable_denylist).toEqual([ { tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' }, ]); }); + it('rejects invalid subagent session limits', () => { + expect(() => configSchema.parse({ + ...minimalConfig, + agents: { + subagents: { + max_active_sessions: 0, + }, + }, + })).toThrow(); + }); + it('rejects invalid truthfulness_mode', () => { expect(() => configSchema.parse({ ...minimalConfig, diff --git a/src/config/schema.ts b/src/config/schema.ts index e921db2..15d6701 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -535,6 +535,10 @@ const agentsSchema = z.object({ fallback_tier: modelTierEnum.default('fast'), }).optional(), }).default({}), + subagents: z.object({ + enabled: z.boolean().default(true), + max_active_sessions: z.number().min(1).max(32).default(6), + }).default({}), auto_escalate: z.boolean().default(false), max_delegation_depth: z.number().min(1).max(10).default(3), /** Maximum tool-loop iterations before the agent stops. */ diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index c20b49a..98a3a50 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -3,13 +3,13 @@ import type { Attachment } from '../channels/types.js'; import { isSupportedAudio, transcribeAudio } from '../models/media.js'; import { synthesizeSpeechAttachment } from '../models/tts.js'; import { supportsAudioInput } from '../models/capabilities.js'; -import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js'; +import { AgentOrchestrator, SubagentManager, type DelegationConfig } from '../backends/index.js'; import { OutboundAttachmentCollector } from '../backends/native/attachments.js'; import type { ExternalBackend, ExternalBackendName } from '../backends/index.js'; import type { InboundMessage, OutboundMessage } from '../channels/index.js'; import { MemoryStore } from '../memory/index.js'; import type { Tool } from '../tools/types.js'; -import { createMediaSendTool, createAgentDelegateTool, createCouncilRunTool } from '../tools/index.js'; +import { createMediaSendTool, createAgentDelegateTool, createCouncilRunTool, createSubagentTools } from '../tools/index.js'; import type { AgentDelegateDeps } from '../tools/index.js'; import { createSandboxedShellTool, createSandboxedProcessStartTool, SandboxManager } from '../sandbox/index.js'; import { MODEL_PROVIDERS, type Config, type CouncilsConfig, type ModelConfig, type ModelProvider } from '../config/index.js'; @@ -382,10 +382,18 @@ export function createMessageRouter(deps: { setBackendMode?: (mode: BackendRuntimeMode) => void; }): { handler: (msg: InboundMessage, reply: (response: OutboundMessage) => Promise) => Promise; - agents: Map; + agents: Map; } { // Cache agents by session ID + agent config name to avoid recreating on every message - const agents = new Map(); + const agents = new Map(); const talkModeUntil = new Map(); const activeRuns = new Map(); const reactionCooldowns = new Map(); @@ -530,7 +538,16 @@ export function createMessageRouter(deps: { } } - function getOrCreateAgent(channel: string, senderId: string, metadata?: Record, agentOverride?: string): { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector } { + function getOrCreateAgent( + channel: string, + senderId: string, + metadata?: Record, + agentOverride?: string, + ): { + orchestrator: AgentOrchestrator; + collector: OutboundAttachmentCollector; + subagentManager?: SubagentManager; + } { // Resolve agent config name via routing (sender → channel → default fallback) const agentConfigName = agentOverride ?? deps.agentRouter?.resolve(channel, senderId); const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined; @@ -664,6 +681,28 @@ export function createMessageRouter(deps: { effectiveToolRegistry = effectiveToolRegistry.clone(); effectiveToolRegistry.register(createMediaSendTool(collector)); + let subagentManager: SubagentManager | undefined; + const subagentsEnabled = deps.config.agents.subagents?.enabled ?? true; + const maxSubagentSessions = deps.config.agents.subagents?.max_active_sessions ?? 6; + if (subagentsEnabled && deps.agentConfigRegistry && deps.agentConfigRegistry.list().length > 0) { + subagentManager = new SubagentManager({ + parentSessionId: session.id, + modelRouter: deps.modelRouter, + sessionManager: deps.sessionManager, + toolRegistry: effectiveToolRegistry, + toolExecutor: deps.toolExecutor, + agentConfigRegistry: deps.agentConfigRegistry, + delegation: delegationConfig, + maxDelegationDepth: deps.config.agents.max_delegation_depth ?? 3, + defaultPrimaryTier: effectiveTier, + maxIterations: deps.config.agents.max_iterations, + maxActiveSessions: maxSubagentSessions, + }); + for (const tool of createSubagentTools(subagentManager)) { + effectiveToolRegistry.register(tool); + } + } + // Register delegation tools with lazy orchestrator reference (resolved after construction) let resolveOrchestrator: ((o: AgentOrchestrator) => void) | undefined; if (deps.agentConfigRegistry && deps.agentConfigRegistry.list().length > 0) { @@ -766,7 +805,7 @@ export function createMessageRouter(deps: { // Resolve the lazy orchestrator reference for agent.delegate resolveOrchestrator?.(orchestrator); - entry = { orchestrator, collector }; + entry = { orchestrator, collector, subagentManager }; agents.set(sessionId, entry); } return entry; @@ -960,7 +999,12 @@ export function createMessageRouter(deps: { const agentConfigName = intentAgentOverride ?? deps.agentRouter?.resolve(msg.channel, msg.senderId); const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined; - const { orchestrator: agent, collector } = getOrCreateAgent(msg.channel, msg.senderId, effectiveMetadata, agentConfigName); + const { orchestrator: agent, collector, subagentManager } = getOrCreateAgent( + msg.channel, + msg.senderId, + effectiveMetadata, + agentConfigName, + ); const commandInput = msg.metadata?.isCommand && typeof msg.metadata.command === 'string' ? `/${msg.metadata.command}${msg.metadata.commandArgs ? ` ${msg.metadata.commandArgs}` : ''}` @@ -999,6 +1043,13 @@ export function createMessageRouter(deps: { names.add('council.run'); } } + if (subagentManager) { + names.add('subagent.spawn'); + names.add('subagent.send'); + names.add('subagent.list'); + names.add('subagent.cancel'); + names.add('subagent.delete'); + } const sorted = [...names].sort(); return [ `Available tools (${sorted.length}):`, diff --git a/src/tools/builtin/index.ts b/src/tools/builtin/index.ts index cf087d4..64b82e5 100644 --- a/src/tools/builtin/index.ts +++ b/src/tools/builtin/index.ts @@ -37,6 +37,7 @@ export { createAgentDelegateTool } from './agent-delegate.js'; export type { AgentDelegateDeps } from './agent-delegate.js'; export { createCouncilRunTool } from './council-run.js'; export type { CouncilRunDeps } from './council-run.js'; +export { createSubagentTools } from './subagents.js'; import type { Tool } from '../types.js'; import type { MemoryStore } from '../../memory/store.js'; diff --git a/src/tools/builtin/subagents.test.ts b/src/tools/builtin/subagents.test.ts new file mode 100644 index 0000000..55f3e54 --- /dev/null +++ b/src/tools/builtin/subagents.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSubagentTools } from './subagents.js'; + +const mockController = { + spawn: vi.fn(), + send: vi.fn(), + list: vi.fn(), + cancel: vi.fn(), + delete: vi.fn(), +}; + +describe('subagent tools', () => { + beforeEach(() => { + mockController.spawn.mockReset(); + mockController.send.mockReset(); + mockController.list.mockReset(); + mockController.cancel.mockReset(); + mockController.delete.mockReset(); + }); + + it('spawns a subagent and optionally runs initial task', async () => { + mockController.spawn.mockReturnValue({ + id: 'planner', + agent: 'research', + tier: 'complex', + messageCount: 0, + createdAt: 1, + updatedAt: 1, + busy: false, + }); + mockController.send.mockResolvedValue({ + content: 'Initial answer', + session: { + id: 'planner', + agent: 'research', + tier: 'complex', + messageCount: 2, + createdAt: 1, + updatedAt: 2, + busy: false, + }, + }); + + const tools = createSubagentTools(mockController); + const spawn = tools.find((tool) => tool.name === 'subagent.spawn'); + expect(spawn).toBeDefined(); + + const result = await spawn!.execute({ + agent: 'research', + subagent_id: 'planner', + task: 'Create a checklist', + }); + + expect(result.success).toBe(true); + expect(result.output).toContain('Spawned subagent'); + expect(result.output).toContain('Initial answer'); + expect(mockController.spawn).toHaveBeenCalledWith({ + agent: 'research', + subagentId: 'planner', + tier: undefined, + systemPrompt: undefined, + }); + expect(mockController.send).toHaveBeenCalledWith('planner', 'Create a checklist'); + }); + + it('sends to, lists, cancels, and deletes subagent sessions', async () => { + mockController.send.mockResolvedValue({ + content: 'Follow-up answer', + session: { + id: 'planner', + agent: 'research', + tier: 'complex', + messageCount: 4, + createdAt: 1, + updatedAt: 3, + busy: false, + }, + }); + mockController.list.mockReturnValue([ + { + id: 'planner', + agent: 'research', + tier: 'complex', + messageCount: 4, + createdAt: 1, + updatedAt: 3, + busy: false, + }, + ]); + mockController.cancel.mockReturnValue(true); + mockController.delete.mockReturnValue(true); + + const tools = createSubagentTools(mockController); + + const send = tools.find((tool) => tool.name === 'subagent.send'); + const list = tools.find((tool) => tool.name === 'subagent.list'); + const cancel = tools.find((tool) => tool.name === 'subagent.cancel'); + const del = tools.find((tool) => tool.name === 'subagent.delete'); + + const sendResult = await send!.execute({ subagent_id: 'planner', message: 'Refine the plan' }); + expect(sendResult.success).toBe(true); + expect(sendResult.output).toContain('Follow-up answer'); + + const listResult = await list!.execute({}); + expect(listResult.success).toBe(true); + expect(listResult.output).toContain('Active subagents (1)'); + + const cancelResult = await cancel!.execute({ subagent_id: 'planner' }); + expect(cancelResult.success).toBe(true); + expect(cancelResult.output).toContain('Cancellation requested'); + + const deleteResult = await del!.execute({ subagent_id: 'planner' }); + expect(deleteResult.success).toBe(true); + expect(deleteResult.output).toContain('Deleted subagent session'); + }); + + it('returns structured failures when controller operations fail', async () => { + mockController.spawn.mockImplementation(() => { + throw new Error('spawn failed'); + }); + mockController.delete.mockReturnValue(false); + + const tools = createSubagentTools(mockController); + + const spawn = tools.find((tool) => tool.name === 'subagent.spawn'); + const del = tools.find((tool) => tool.name === 'subagent.delete'); + + const spawnResult = await spawn!.execute({ agent: 'research' }); + expect(spawnResult.success).toBe(false); + expect(spawnResult.error).toBe('spawn failed'); + + const deleteResult = await del!.execute({ subagent_id: 'missing' }); + expect(deleteResult.success).toBe(false); + expect(deleteResult.error).toContain('not found'); + }); +}); diff --git a/src/tools/builtin/subagents.ts b/src/tools/builtin/subagents.ts new file mode 100644 index 0000000..494488e --- /dev/null +++ b/src/tools/builtin/subagents.ts @@ -0,0 +1,243 @@ +import type { Tool, ToolResult } from '../types.js'; +import type { ModelTier } from '../../models/router.js'; + +interface SubagentSessionSummary { + id: string; + agent: string; + tier: ModelTier; + messageCount: number; + createdAt: number; + updatedAt: number; + busy: boolean; +} + +interface SubagentController { + spawn(request: { + agent: string; + subagentId?: string; + tier?: ModelTier; + systemPrompt?: string; + }): SubagentSessionSummary; + send(subagentId: string, message: string): Promise<{ + content: string; + session: SubagentSessionSummary; + }>; + list(): SubagentSessionSummary[]; + cancel(subagentId: string): boolean; + delete(subagentId: string): boolean; +} + +interface SpawnArgs { + agent: string; + subagent_id?: string; + tier?: ModelTier; + system_prompt?: string; + task?: string; +} + +interface SendArgs { + subagent_id: string; + message: string; +} + +interface SessionArgs { + subagent_id: string; +} + +function formatSummary(summary: SubagentSessionSummary): string { + return [ + `id=${summary.id}`, + `agent=${summary.agent}`, + `tier=${summary.tier}`, + `messages=${summary.messageCount}`, + `busy=${summary.busy ? 'yes' : 'no'}`, + ].join(' '); +} + +/** + * Creates subagent session tools for multi-turn child-agent workflows. + */ +export function createSubagentTools(controller: SubagentController): Tool[] { + const spawnTool: Tool = { + name: 'subagent.spawn', + description: + 'Create a new subagent session bound to a configured agent profile. Optionally run an initial task immediately.', + inputSchema: { + type: 'object', + properties: { + agent: { type: 'string', description: 'Agent profile name from agent_configs (e.g. research, coder).' }, + subagent_id: { type: 'string', description: 'Optional custom subagent session ID.' }, + tier: { type: 'string', description: 'Optional model tier override (fast|default|complex|local).' }, + system_prompt: { type: 'string', description: 'Optional system prompt override for this subagent session.' }, + task: { type: 'string', description: 'Optional initial task to run right after spawn.' }, + }, + required: ['agent'], + }, + execute: async (rawArgs: unknown): Promise => { + try { + const args = rawArgs as SpawnArgs; + const summary = controller.spawn({ + agent: args.agent, + subagentId: args.subagent_id, + tier: args.tier, + systemPrompt: args.system_prompt, + }); + + if (typeof args.task === 'string' && args.task.trim().length > 0) { + const first = await controller.send(summary.id, args.task); + return { + success: true, + output: [ + `Spawned subagent: ${formatSummary(first.session)}`, + '', + first.content, + ].join('\n'), + }; + } + + return { + success: true, + output: `Spawned subagent: ${formatSummary(summary)}`, + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + const sendTool: Tool = { + name: 'subagent.send', + description: 'Send a message/task to a spawned subagent session and return the response.', + inputSchema: { + type: 'object', + properties: { + subagent_id: { type: 'string', description: 'Subagent session ID returned by subagent.spawn.' }, + message: { type: 'string', description: 'Task/message for the subagent session.' }, + }, + required: ['subagent_id', 'message'], + }, + execute: async (rawArgs: unknown): Promise => { + try { + const args = rawArgs as SendArgs; + const result = await controller.send(args.subagent_id, args.message); + return { + success: true, + output: [ + `Subagent response (${formatSummary(result.session)}):`, + '', + result.content, + ].join('\n'), + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + const listTool: Tool = { + name: 'subagent.list', + description: 'List active spawned subagent sessions for this parent session.', + inputSchema: { + type: 'object', + properties: {}, + }, + execute: async (): Promise => { + try { + const sessions = controller.list(); + if (sessions.length === 0) { + return { + success: true, + output: 'No active subagent sessions.', + }; + } + + const lines = sessions.map((session) => `- ${formatSummary(session)}`); + return { + success: true, + output: `Active subagents (${sessions.length}):\n${lines.join('\n')}`, + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + const cancelTool: Tool = { + name: 'subagent.cancel', + description: 'Request cancellation for a running subagent session turn.', + inputSchema: { + type: 'object', + properties: { + subagent_id: { type: 'string', description: 'Subagent session ID to cancel.' }, + }, + required: ['subagent_id'], + }, + execute: async (rawArgs: unknown): Promise => { + try { + const args = rawArgs as SessionArgs; + const cancelled = controller.cancel(args.subagent_id); + return { + success: true, + output: cancelled + ? `Cancellation requested for subagent \"${args.subagent_id}\".` + : `No active operation to cancel for subagent \"${args.subagent_id}\".`, + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + const deleteTool: Tool = { + name: 'subagent.delete', + description: 'Delete a subagent session and clear its conversation state.', + inputSchema: { + type: 'object', + properties: { + subagent_id: { type: 'string', description: 'Subagent session ID to delete.' }, + }, + required: ['subagent_id'], + }, + execute: async (rawArgs: unknown): Promise => { + try { + const args = rawArgs as SessionArgs; + const deleted = controller.delete(args.subagent_id); + if (!deleted) { + return { + success: false, + output: '', + error: `Subagent session \"${args.subagent_id}\" not found.`, + }; + } + return { + success: true, + output: `Deleted subagent session \"${args.subagent_id}\".`, + }; + } catch (error) { + return { + success: false, + output: '', + error: error instanceof Error ? error.message : String(error), + }; + } + }, + }; + + return [spawnTool, sendTool, listTool, cancelTool, deleteTool]; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 0115c65..8a8defd 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,7 +5,7 @@ export { ToolExecutor } from './executor.js'; export type { ToolExecutorConfig } from './executor.js'; export { ToolPolicy } from './policy.js'; export type { ToolPolicyContext } from './policy.js'; -export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createAudioTranscribeTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool, createMinioSyncTool, createK8sTools, createAgentDelegateTool, createCouncilRunTool } from './builtin/index.js'; +export { allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createAudioTranscribeTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools, createMinioShareTool, createMinioIngestTool, createMinioSyncTool, createK8sTools, createAgentDelegateTool, createCouncilRunTool, createSubagentTools } from './builtin/index.js'; export type { AgentDelegateDeps } from './builtin/index.js'; export type { CouncilRunDeps } from './builtin/index.js'; export type { WebSearchConfig } from './builtin/web-search.js'; diff --git a/src/tools/policy.test.ts b/src/tools/policy.test.ts index 543df01..276a43d 100644 --- a/src/tools/policy.test.ts +++ b/src/tools/policy.test.ts @@ -102,6 +102,7 @@ describe('PROFILE_TOOLS', () => { expect(PROFILE_TOOLS.messaging.has('memory.read')).toBe(true); expect(PROFILE_TOOLS.messaging.has('web.search')).toBe(true); expect(PROFILE_TOOLS.messaging.has('web.search.news')).toBe(true); + expect(PROFILE_TOOLS.messaging.has('subagent.spawn')).toBe(true); }); it('coding is a superset of messaging', () => { @@ -111,6 +112,7 @@ describe('PROFILE_TOOLS', () => { expect(PROFILE_TOOLS.coding.has('shell.exec')).toBe(true); expect(PROFILE_TOOLS.coding.has('file.write')).toBe(true); expect(PROFILE_TOOLS.coding.has('process.start')).toBe(true); + expect(PROFILE_TOOLS.coding.has('subagent.send')).toBe(true); }); it('full is empty (special: matches everything)', () => { diff --git a/src/tools/policy.ts b/src/tools/policy.ts index ea49e99..bf475b8 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -50,6 +50,11 @@ const PROFILE_TOOLS: Record> = { 'agent.delegate', 'agents.list', 'council.run', + 'subagent.spawn', + 'subagent.send', + 'subagent.list', + 'subagent.cancel', + 'subagent.delete', ]), coding: new Set([ 'file.read', @@ -107,6 +112,11 @@ const PROFILE_TOOLS: Record> = { 'agent.delegate', 'agents.list', 'council.run', + 'subagent.spawn', + 'subagent.send', + 'subagent.list', + 'subagent.cancel', + 'subagent.delete', ]), full: new Set(), // Special: matches everything }; @@ -127,7 +137,16 @@ export const TOOL_GROUPS: Record = { 'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'], 'group:minio': ['minio.share', 'minio.ingest', 'minio.sync'], 'group:k8s': ['k8s.pods', 'k8s.deployments', 'k8s.logs'], - 'group:agents': ['agent.delegate', 'agents.list', 'council.run'], + 'group:agents': [ + 'agent.delegate', + 'agents.list', + 'council.run', + 'subagent.spawn', + 'subagent.send', + 'subagent.list', + 'subagent.cancel', + 'subagent.delete', + ], }; /** Expand group references in a list of tool names/patterns. */