feat(subagents): add multi-turn subagent session runtime
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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}):`,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user