feat(subagents): add multi-turn subagent session runtime

This commit is contained in:
William Valentin
2026-02-26 13:07:34 -08:00
parent e887c3c964
commit 2171346116
21 changed files with 1111 additions and 12 deletions
+29
View File
@@ -793,6 +793,35 @@ Or via tools:
{"name":"council.run","args":{"task":"design a 30-day plan to cut CI flakiness by 50%"}} {"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.<name>` 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 ## Running as Service
### Ollama (Recommended) ### Ollama (Recommended)
+4
View File
@@ -297,6 +297,10 @@ agents:
# In full-access mode, sensitive operations are gated by HookEngine confirmation # In full-access mode, sensitive operations are gated by HookEngine confirmation
# (instead of requiring temporary /elevate windows). # (instead of requiring temporary /elevate windows).
sensitive_mode: confirm_without_elevation sensitive_mode: confirm_without_elevation
# Multi-turn subagent sessions (`subagent.*` tools).
subagents:
enabled: true
max_active_sessions: 6
# ── Memory / Embeddings ────────────────────────────────────────────── # ── Memory / Embeddings ──────────────────────────────────────────────
# Enable hybrid keyword + vector search using local Ollama embeddings. # Enable hybrid keyword + vector search using local Ollama embeddings.
+1
View File
@@ -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. - 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. - 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. - 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`. This is implemented via a per-lane queue (`LaneQueue`) in the gateway server, and used by `agent.send` and `agent.cancel`.
+6
View File
@@ -136,6 +136,11 @@ Tool Calls (inside NativeAgent loop)
| v | v
+---------------------------> AuditLogger (redacted) +---------------------------> AuditLogger (redacted)
Subagent sessions (multi-turn child agents)
parent AgentOrchestrator -> subagent.* tools -> SubagentManager
SubagentManager -> child AgentOrchestrator (session namespace: subagent:<parent>:<id>)
child AgentOrchestrator -> NativeAgent/tool loop (same policy engine, recursion tools removed)
Session start (when `memory.user_namespace` is set) Session start (when `memory.user_namespace` is set)
AgentOrchestrator -> MemoryStore (user/profile + user/working) AgentOrchestrator -> MemoryStore (user/profile + user/working)
AgentOrchestrator -> System prompt (session context injection) AgentOrchestrator -> System prompt (session context injection)
@@ -155,6 +160,7 @@ Gateway streaming UX signals:
Key files: Key files:
- Routing + per-session agent creation: `src/daemon/routing.ts` - 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` - Runtime preference persistence (`modelTier`, `backendMode`): `src/preferences.ts`
- Orchestration: `src/backends/native/orchestrator.ts` - Orchestration: `src/backends/native/orchestrator.ts`
- Tool loop: `src/backends/native/agent.ts` - Tool loop: `src/backends/native/agent.ts`
@@ -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. - 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. - 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. - Reaction matching is deterministic (priority + cooldown + recursion guard) before intent/agent routing.
- `subagent.*` tools create child orchestrators scoped to the parent conversation (`subagent:<parentSessionId>:<childId>`); 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. - 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. - 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. - TTS output is best-effort; synthesis failures fall back to text-only responses.
@@ -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. 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. 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. 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 ## Product Goal
@@ -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.
+34 -3
View File
@@ -6795,10 +6795,40 @@
"docs/plans/state.json" "docs/plans/state.json"
], ],
"test_status": "planning/docs update only; no runtime code changes" "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": { "overall_progress": {
"total_test_count": 2525, "total_test_count": 2531,
"all_tests_passing": true, "all_tests_passing": true,
"p0_completion": "3/3 (100%)", "p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)", "p1_completion": "4/4 (100%)",
@@ -6813,7 +6843,7 @@
"tier2_completion": "4/4 (100%) \u2014 inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "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", "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", "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", "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", "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", "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_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", "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)", "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": { "soul_md_and_cron_create": {
"date": "2026-02-11", "date": "2026-02-11",
+5
View File
@@ -6,6 +6,11 @@ export {
type SubAgentResult, type SubAgentResult,
type DelegationConfig, type DelegationConfig,
type UsageReport, type UsageReport,
SubagentManager,
type SubagentManagerConfig,
type SpawnSubagentRequest,
type SubagentSessionSummary,
type SubagentSendResult,
} from './native/index.js'; } from './native/index.js';
export { export {
COMPACTION_SYSTEM_PROMPT, COMPACTION_SYSTEM_PROMPT,
+7
View File
@@ -14,3 +14,10 @@ export {
CLASSIFICATION_PROMPT, CLASSIFICATION_PROMPT,
TOOL_SUMMARISATION_PROMPT, TOOL_SUMMARISATION_PROMPT,
} from './prompts.js'; } from './prompts.js';
export {
SubagentManager,
type SubagentManagerConfig,
type SpawnSubagentRequest,
type SubagentSessionSummary,
type SubagentSendResult,
} from './subagents.js';
+249
View File
@@ -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<Record<string, unknown>>,
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<string, unknown>) {
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<string> {
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<string, { history: Array<{ role: string; content: string }> }>();
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');
});
});
+239
View File
@@ -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<string, ManagedSubagent>();
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<SubagentSendResult> {
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,
};
}
}
+19
View File
@@ -1698,6 +1698,8 @@ describe('configSchema — agents truthfulness/autonomy', () => {
expect(result.agents.truthfulness_mode).toBe('standard'); expect(result.agents.truthfulness_mode).toBe('standard');
expect(result.agents.autonomy_level).toBe('standard'); expect(result.agents.autonomy_level).toBe('standard');
expect(result.agents.sensitive_mode).toBe('confirm_without_elevation'); 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(result.agents.immutable_denylist).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ tool: 'shell.exec', args_pattern: 'git push origin main' }), expect.objectContaining({ tool: 'shell.exec', args_pattern: 'git push origin main' }),
@@ -1713,6 +1715,10 @@ describe('configSchema — agents truthfulness/autonomy', () => {
truthfulness_mode: 'strict', truthfulness_mode: 'strict',
autonomy_level: 'conservative', autonomy_level: 'conservative',
sensitive_mode: 'confirm_without_elevation', sensitive_mode: 'confirm_without_elevation',
subagents: {
enabled: false,
max_active_sessions: 3,
},
immutable_denylist: [ immutable_denylist: [
{ tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' }, { 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.truthfulness_mode).toBe('strict');
expect(result.agents.autonomy_level).toBe('conservative'); expect(result.agents.autonomy_level).toBe('conservative');
expect(result.agents.sensitive_mode).toBe('confirm_without_elevation'); 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([ expect(result.agents.immutable_denylist).toEqual([
{ tool: 'shell.exec', args_pattern: 'rm -rf /', reason: 'too destructive' }, { 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', () => { it('rejects invalid truthfulness_mode', () => {
expect(() => configSchema.parse({ expect(() => configSchema.parse({
...minimalConfig, ...minimalConfig,
+4
View File
@@ -535,6 +535,10 @@ const agentsSchema = z.object({
fallback_tier: modelTierEnum.default('fast'), fallback_tier: modelTierEnum.default('fast'),
}).optional(), }).optional(),
}).default({}), }).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), auto_escalate: z.boolean().default(false),
max_delegation_depth: z.number().min(1).max(10).default(3), max_delegation_depth: z.number().min(1).max(10).default(3),
/** Maximum tool-loop iterations before the agent stops. */ /** Maximum tool-loop iterations before the agent stops. */
+58 -7
View File
@@ -3,13 +3,13 @@ import type { Attachment } from '../channels/types.js';
import { isSupportedAudio, transcribeAudio } from '../models/media.js'; import { isSupportedAudio, transcribeAudio } from '../models/media.js';
import { synthesizeSpeechAttachment } from '../models/tts.js'; import { synthesizeSpeechAttachment } from '../models/tts.js';
import { supportsAudioInput } from '../models/capabilities.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 { OutboundAttachmentCollector } from '../backends/native/attachments.js';
import type { ExternalBackend, ExternalBackendName } from '../backends/index.js'; import type { ExternalBackend, ExternalBackendName } from '../backends/index.js';
import type { InboundMessage, OutboundMessage } from '../channels/index.js'; import type { InboundMessage, OutboundMessage } from '../channels/index.js';
import { MemoryStore } from '../memory/index.js'; import { MemoryStore } from '../memory/index.js';
import type { Tool } from '../tools/types.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 type { AgentDelegateDeps } from '../tools/index.js';
import { createSandboxedShellTool, createSandboxedProcessStartTool, SandboxManager } from '../sandbox/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'; 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; setBackendMode?: (mode: BackendRuntimeMode) => void;
}): { }): {
handler: (msg: InboundMessage, reply: (response: OutboundMessage) => Promise<void>) => Promise<void>; handler: (msg: InboundMessage, reply: (response: OutboundMessage) => Promise<void>) => Promise<void>;
agents: Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }>; agents: Map<string, {
orchestrator: AgentOrchestrator;
collector: OutboundAttachmentCollector;
subagentManager?: SubagentManager;
}>;
} { } {
// Cache agents by session ID + agent config name to avoid recreating on every message // Cache agents by session ID + agent config name to avoid recreating on every message
const agents = new Map<string, { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector }>(); const agents = new Map<string, {
orchestrator: AgentOrchestrator;
collector: OutboundAttachmentCollector;
subagentManager?: SubagentManager;
}>();
const talkModeUntil = new Map<string, number>(); const talkModeUntil = new Map<string, number>();
const activeRuns = new Map<string, AgentOrchestrator>(); const activeRuns = new Map<string, AgentOrchestrator>();
const reactionCooldowns = new Map<string, number>(); const reactionCooldowns = new Map<string, number>();
@@ -530,7 +538,16 @@ export function createMessageRouter(deps: {
} }
} }
function getOrCreateAgent(channel: string, senderId: string, metadata?: Record<string, unknown>, agentOverride?: string): { orchestrator: AgentOrchestrator; collector: OutboundAttachmentCollector } { function getOrCreateAgent(
channel: string,
senderId: string,
metadata?: Record<string, unknown>,
agentOverride?: string,
): {
orchestrator: AgentOrchestrator;
collector: OutboundAttachmentCollector;
subagentManager?: SubagentManager;
} {
// Resolve agent config name via routing (sender → channel → default fallback) // Resolve agent config name via routing (sender → channel → default fallback)
const agentConfigName = agentOverride ?? deps.agentRouter?.resolve(channel, senderId); const agentConfigName = agentOverride ?? deps.agentRouter?.resolve(channel, senderId);
const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined; const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined;
@@ -664,6 +681,28 @@ export function createMessageRouter(deps: {
effectiveToolRegistry = effectiveToolRegistry.clone(); effectiveToolRegistry = effectiveToolRegistry.clone();
effectiveToolRegistry.register(createMediaSendTool(collector)); 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) // Register delegation tools with lazy orchestrator reference (resolved after construction)
let resolveOrchestrator: ((o: AgentOrchestrator) => void) | undefined; let resolveOrchestrator: ((o: AgentOrchestrator) => void) | undefined;
if (deps.agentConfigRegistry && deps.agentConfigRegistry.list().length > 0) { if (deps.agentConfigRegistry && deps.agentConfigRegistry.list().length > 0) {
@@ -766,7 +805,7 @@ export function createMessageRouter(deps: {
// Resolve the lazy orchestrator reference for agent.delegate // Resolve the lazy orchestrator reference for agent.delegate
resolveOrchestrator?.(orchestrator); resolveOrchestrator?.(orchestrator);
entry = { orchestrator, collector }; entry = { orchestrator, collector, subagentManager };
agents.set(sessionId, entry); agents.set(sessionId, entry);
} }
return entry; return entry;
@@ -960,7 +999,12 @@ export function createMessageRouter(deps: {
const agentConfigName = intentAgentOverride ?? deps.agentRouter?.resolve(msg.channel, msg.senderId); const agentConfigName = intentAgentOverride ?? deps.agentRouter?.resolve(msg.channel, msg.senderId);
const agentConfig = agentConfigName ? deps.agentConfigRegistry?.get(agentConfigName) : undefined; 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' const commandInput = msg.metadata?.isCommand && typeof msg.metadata.command === 'string'
? `/${msg.metadata.command}${msg.metadata.commandArgs ? ` ${msg.metadata.commandArgs}` : ''}` ? `/${msg.metadata.command}${msg.metadata.commandArgs ? ` ${msg.metadata.commandArgs}` : ''}`
@@ -999,6 +1043,13 @@ export function createMessageRouter(deps: {
names.add('council.run'); 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(); const sorted = [...names].sort();
return [ return [
`Available tools (${sorted.length}):`, `Available tools (${sorted.length}):`,
+1
View File
@@ -37,6 +37,7 @@ export { createAgentDelegateTool } from './agent-delegate.js';
export type { AgentDelegateDeps } from './agent-delegate.js'; export type { AgentDelegateDeps } from './agent-delegate.js';
export { createCouncilRunTool } from './council-run.js'; export { createCouncilRunTool } from './council-run.js';
export type { CouncilRunDeps } from './council-run.js'; export type { CouncilRunDeps } from './council-run.js';
export { createSubagentTools } from './subagents.js';
import type { Tool } from '../types.js'; import type { Tool } from '../types.js';
import type { MemoryStore } from '../../memory/store.js'; import type { MemoryStore } from '../../memory/store.js';
+136
View File
@@ -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');
});
});
+243
View File
@@ -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<ToolResult> => {
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<ToolResult> => {
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<ToolResult> => {
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<ToolResult> => {
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<ToolResult> => {
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];
}
+1 -1
View File
@@ -5,7 +5,7 @@ export { ToolExecutor } from './executor.js';
export type { ToolExecutorConfig } from './executor.js'; export type { ToolExecutorConfig } from './executor.js';
export { ToolPolicy } from './policy.js'; export { ToolPolicy } from './policy.js';
export type { ToolPolicyContext } 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 { AgentDelegateDeps } from './builtin/index.js';
export type { CouncilRunDeps } from './builtin/index.js'; export type { CouncilRunDeps } from './builtin/index.js';
export type { WebSearchConfig } from './builtin/web-search.js'; export type { WebSearchConfig } from './builtin/web-search.js';
+2
View File
@@ -102,6 +102,7 @@ describe('PROFILE_TOOLS', () => {
expect(PROFILE_TOOLS.messaging.has('memory.read')).toBe(true); 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')).toBe(true);
expect(PROFILE_TOOLS.messaging.has('web.search.news')).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', () => { 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('shell.exec')).toBe(true);
expect(PROFILE_TOOLS.coding.has('file.write')).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('process.start')).toBe(true);
expect(PROFILE_TOOLS.coding.has('subagent.send')).toBe(true);
}); });
it('full is empty (special: matches everything)', () => { it('full is empty (special: matches everything)', () => {
+20 -1
View File
@@ -50,6 +50,11 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'agent.delegate', 'agent.delegate',
'agents.list', 'agents.list',
'council.run', 'council.run',
'subagent.spawn',
'subagent.send',
'subagent.list',
'subagent.cancel',
'subagent.delete',
]), ]),
coding: new Set([ coding: new Set([
'file.read', 'file.read',
@@ -107,6 +112,11 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'agent.delegate', 'agent.delegate',
'agents.list', 'agents.list',
'council.run', 'council.run',
'subagent.spawn',
'subagent.send',
'subagent.list',
'subagent.cancel',
'subagent.delete',
]), ]),
full: new Set(), // Special: matches everything full: new Set(), // Special: matches everything
}; };
@@ -127,7 +137,16 @@ export const TOOL_GROUPS: Record<string, string[]> = {
'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'], 'group:cron': ['cron.list', 'cron.trigger', 'cron.create', 'cron.delete'],
'group:minio': ['minio.share', 'minio.ingest', 'minio.sync'], 'group:minio': ['minio.share', 'minio.ingest', 'minio.sync'],
'group:k8s': ['k8s.pods', 'k8s.deployments', 'k8s.logs'], '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. */ /** Expand group references in a list of tool names/patterns. */