Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

228 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 15
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)