426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
228 lines
9.3 KiB
Markdown
228 lines
9.3 KiB
Markdown
# Sub-agent routing
|
||
|
||
> Canonical orchestration path for ACP sub-agents. ACP-spawned sessions route
|
||
> through `AcpService` and `SubAgentRouter`.
|
||
|
||
## Goals
|
||
|
||
1. **Origin tracking.** When the main agent spawns a sub-agent in response to
|
||
a user message in room R, the sub-agent's terminal output (`task_complete`,
|
||
`error`, `blocked`) lands back in room R, addressed appropriately.
|
||
2. **Main-agent-in-the-loop.** When a sub-agent reports done, the **main
|
||
agent** — not a separate coordinator — decides whether to:
|
||
- reply to the user (sub-agent finished, here's the result),
|
||
- reply to the sub-agent via `SEND_TO_AGENT` (proof not satisfying; keep
|
||
going),
|
||
- or both in one turn.
|
||
3. **Cache friendliness.** Sub-agent updates should not invalidate the
|
||
stable provider/system prefix on every event.
|
||
|
||
## Components
|
||
|
||
### `AcpService`
|
||
|
||
Spawn surface. TASKS op=create records origin context in
|
||
`session.metadata` at spawn time:
|
||
|
||
```ts
|
||
{
|
||
messageId: message.id, // parent message UUID
|
||
roomId: taskRoomId, // task-owner room, defaults to message.roomId
|
||
originRoomId: message.roomId,
|
||
taskRoomId,
|
||
worktreeRoomId,
|
||
swarmRooms: [
|
||
{ roomId: taskRoomId, roles: ["task"] },
|
||
{ roomId: worktreeRoomId, roles: ["worktree"] },
|
||
],
|
||
worldId: message.worldId,
|
||
userId: message.entityId,
|
||
label,
|
||
source: content.source,
|
||
}
|
||
```
|
||
|
||
### `SubAgentRouter` (`services/sub-agent-router.ts`)
|
||
|
||
Subscribes to `AcpService.onSessionEvent`. On `task_complete`, `error`, or
|
||
`blocked` (boundary events only — not streaming chunks), it:
|
||
|
||
1. Reads `session.metadata` for origin and swarm-room keys.
|
||
2. Constructs a synthetic `Memory` with:
|
||
- `entityId` = a deterministic per-session sub-agent UUID derived locally
|
||
via SHA1 of `<runtime.agentId>:acpx:sub-agent:<sessionId>` (no runtime
|
||
dependency on `@elizaos/core`'s `createUniqueUuid` so the router stays
|
||
type-only on core),
|
||
- `agentId` = `runtime.agentId`,
|
||
- `roomId` = the selected swarm target room,
|
||
- `content.source` = `"sub_agent"`,
|
||
- `content.inReplyTo` = origin `messageId`,
|
||
- `content.metadata.subAgent*` carries the structured event
|
||
(`subAgentSessionId`, `subAgentLabel`, `subAgentEvent`,
|
||
`subAgentStatus`, `subAgentAgentType`, `subAgentRoundTrip`,
|
||
`subAgentRoundTripCap`, `subAgentCapExceeded`, `originUserId`,
|
||
`originMessageId`, `originSource`).
|
||
- `content.metadata.subAgentRoutingKind`,
|
||
`subAgentTargetRoomId`, `subAgentTargetRoomRole`,
|
||
`subAgentTargetRoomRoles`, `taskRoomId`, `worktreeRoomId`, and
|
||
`subAgentSwarmRooms` tell the main agent why this memory landed in
|
||
this room and which other swarm room(s) exist for the task.
|
||
3. Delivers via `runtime.messageService.handleMessage(runtime, memory, callback)`,
|
||
which also persists the memory. If `messageService` is unavailable, it
|
||
falls back to `runtime.createMemory(..., "messages")` plus
|
||
`MESSAGE_RECEIVED`.
|
||
|
||
For platform-originated tasks, the router builds a short-lived callback from
|
||
`runtime.sendMessageToTarget` so the planner's answer can return to the same
|
||
selected swarm room.
|
||
|
||
#### Why only boundary events
|
||
|
||
Streaming events (`agent_message_chunk`, `tool_running`, `ready`) would
|
||
re-fire the planner constantly and burn the prompt cache. Live status is
|
||
exposed via the provider instead. The router is the channel for events
|
||
that warrant an action decision.
|
||
|
||
#### Dedup / idempotency
|
||
|
||
Events are deduped in-memory by
|
||
`<sessionId>|<event>|<status>|<short hash of payload>`. Same sub-agent
|
||
re-emitting the same `task_complete` payload posts once. A different
|
||
response payload posts again — that's "the sub-agent did more work and
|
||
reported a new state".
|
||
|
||
Swarm target rooms are also normalized before posting. `taskRoomId` is first,
|
||
`worktreeRoomId` is second, duplicate room IDs collapse into one target, and
|
||
the collapsed target keeps both roles (`["task", "worktree"]`). That means a
|
||
task room that is also the worktree coordination room gets one useful message
|
||
rather than two identical messages with ambiguous purpose.
|
||
|
||
#### Routing kinds
|
||
|
||
Most terminal events use `subAgentRoutingKind: "TASK_STATUS"` and fan out to
|
||
the normalized task/worktree swarm rooms. Two explicit coordination events are
|
||
targeted:
|
||
|
||
- `QUESTION_FOR_TASK_CREATOR` routes only to the task room and carries
|
||
`subAgentTargetRoomRole: "task"`.
|
||
- `AGENT_COORDINATION` routes to the worktree room when present, otherwise the
|
||
task room, and carries the selected target role.
|
||
|
||
`blocked` events default to `QUESTION_FOR_TASK_CREATOR` because the sub-agent
|
||
is waiting on human or parent-agent input.
|
||
|
||
#### Disable switch
|
||
|
||
`ACPX_SUB_AGENT_ROUTER_DISABLED=1` keeps the service registered but unbound
|
||
(useful for tests, headless backfills, or staging where you want spawning
|
||
without runtime injection).
|
||
|
||
#### Round-trip cap
|
||
|
||
To prevent ping-pong loops where the main agent and a sub-agent endlessly
|
||
ask each other to keep going, the router tracks per-session inject count.
|
||
When the count exceeds `ACPX_SUB_AGENT_ROUND_TRIP_CAP` (default 32) the
|
||
router force-stops the session and emits a single
|
||
`round_trip_cap_exceeded` memory carrying `subAgentRoundTrip`,
|
||
`subAgentRoundTripCap`, and `subAgentCapExceeded: true`. Subsequent events
|
||
from the same capped session are suppressed.
|
||
|
||
Set `ACPX_SUB_AGENT_ROUND_TRIP_CAP=N` in the runtime config to override.
|
||
The default of 32 is generous; a typical sub-agent task hits 1–5
|
||
round-trips before terminal completion.
|
||
|
||
### `activeSubAgentsProvider` (`providers/active-sub-agents.ts`)
|
||
|
||
Cache-friendly view of live sub-agent sessions. Filters to:
|
||
|
||
- sessions whose `metadata.roomId` is set (i.e. routed by `createTaskAction`),
|
||
- sessions not in a terminal status (`stopped`, `completed`, `error`,
|
||
`errored`, `cancelled`).
|
||
|
||
The text is **structural only** — id, label, agentType, bucketed status,
|
||
last two workdir segments. No timestamps, no message excerpts. Sorted by
|
||
`sessionId` so the rendered text is byte-stable across turns when the
|
||
active set is unchanged.
|
||
|
||
Status bucketing: `ready`, `running`, `busy`, `tool_running`, and
|
||
`authenticating` all collapse to the literal string `"active"` in the
|
||
provider text. `blocked` is preserved as a distinct value (the planner
|
||
needs to know a session is waiting for input). Terminal statuses
|
||
(`stopped`, `completed`, `error`, `errored`, `cancelled`) cause the
|
||
session to be filtered out entirely. This keeps the cached provider
|
||
segment byte-identical across transient status flips like
|
||
`ready → tool_running → ready`, which would otherwise invalidate the
|
||
prefix cache on every tool call.
|
||
|
||
This is the live status channel. The synthetic Memory posted by the router
|
||
is the per-event channel.
|
||
|
||
### Action set
|
||
|
||
The main agent's planner sees:
|
||
|
||
- **`REPLY`** (from the bootstrap action set) — replies to the user in
|
||
current room.
|
||
- **`SEND_TO_AGENT { sessionId, text }`** — pushes a follow-up to a
|
||
live sub-agent. Use when the sub-agent's proof is unsatisfying or it
|
||
asked a clarifying question.
|
||
- **`STOP_AGENT { sessionId }`** — terminates. Use when the sub-agent's
|
||
output is clearly final and you don't want it idling.
|
||
- **`ACPX_CREATE_TASK`** — spawn additional sub-agents.
|
||
|
||
Multi-action plans (e.g. `[REPLY, SEND_TO_AGENT]`) are supported by the
|
||
planner and execute sequentially in one turn.
|
||
|
||
## Cache discipline
|
||
|
||
Anthropic prompt caching breaks at segment boundaries (see
|
||
`plugin-anthropic/models/text.ts`). The plugin marks providers and action
|
||
examples as `stable: true`. Sub-agent flow is designed around this:
|
||
|
||
- **Stable prefix (cached):** system prompt, character bio, action examples,
|
||
active-sub-agents provider text (structural only, sorted, deterministic).
|
||
- **Volatile suffix (re-tokenized):** the sub-agent's synthetic message
|
||
text (the per-event narration).
|
||
|
||
Each new sub-agent event invalidates only the message tail. The provider
|
||
text changes only when a session enters or leaves the active set, not on
|
||
every chunk.
|
||
|
||
A per-session router invocation is one cache-miss tail; everything before
|
||
the most recent turn stays warm.
|
||
|
||
## Loop safety
|
||
|
||
- The router emits **inbound** memories with `entityId` set to the
|
||
sub-agent's pseudo-UUID (not `runtime.agentId`), so the runtime processes
|
||
them as messages from another entity, not as the agent's own outputs.
|
||
- The main agent's reply via `SEND_TO_AGENT` does not directly trigger a
|
||
new `task_complete`. The sub-agent has to actually do work first, which
|
||
bounds re-entry.
|
||
- Dedup prevents accidental double-injection from event re-emission.
|
||
- The round-trip cap (above) is the hard ceiling for ping-pong loops.
|
||
|
||
## ACP Boundary
|
||
|
||
`plugin-agent-orchestrator` has a single task-agent transport: `AcpService`.
|
||
There is no PTY/coordinator fallback path.
|
||
|
||
Autonomous follow-up decisions are handled by the main agent's normal action
|
||
selection over the synthetic Memory emitted by `SubAgentRouter`.
|
||
|
||
## Testing
|
||
|
||
- `__tests__/unit/sub-agent-router.test.ts` — origin tracking, dedup,
|
||
streaming-event filtering, disable switch, error narration, fallback
|
||
emit, unsubscribe.
|
||
- `__tests__/unit/active-sub-agents.test.ts` — origin filtering, terminal
|
||
exclusion, deterministic sort, no volatile fields, action-hint text.
|
||
|
||
## Related files
|
||
|
||
- [src/services/sub-agent-router.ts](../src/services/sub-agent-router.ts)
|
||
- [src/providers/active-sub-agents.ts](../src/providers/active-sub-agents.ts)
|
||
- [src/services/acp-service.ts](../src/services/acp-service.ts)
|
||
- [src/actions/create-task.ts](../src/actions/create-task.ts)
|
||
- [src/actions/send-to-agent.ts](../src/actions/send-to-agent.ts)
|