Skip to content

feat(voice): voice tasks can go to workers — "ask Pro-dev to …", and a docked room's bound worker by default - #5430

Open
sonichi wants to merge 2 commits into
mainfrom
feat/voice-task-worker-routing
Open

sonichi wants to merge 2 commits into
mainfrom
feat/voice-task-worker-routing

Conversation

@sonichi

@sonichi sonichi commented Oct 12, 2026

Copy link
Copy Markdown
Owner

What changed, and why

Chi approved letting voice tasks go to workers (AG2 Space DM, 2026-10-12 21:25Z).

  1. Naming a worker. The voice work tool takes an optional worker parameter. "Ask Pro-dev to …" passes the name as said. The worker-pool skill resolves it against the roster, ignoring case and spacing (Pro-dev, pro dev and PRO_DEV all match pro-dev), and the task carries the worker id as requested_worker. If the name matches no worker, or more than one, voice tells the user and lists the workers, and no task file is written. Before this PR the parameter didn't exist, so the name was dropped and the task went to the core.
  2. Default when docked in a room. This part already worked on main, through feat(voice): the voice session knows its room — tasks and results follow it, not the DM #4393: a work task carries the docked room as channel_id, so the router gives it to the room's bound worker. With no room it goes to the core. This PR pins that with tests and makes an explicit name override it. The [dm-only] handling is unchanged, because the origin and its headers are unchanged.
  3. Session-end task (feat(voice): when the user hangs up, the core gets a task about the call and does its follow-ups #5334). On main it was always written with no origin, so it went to the core even when the call was in a room. Now it carries the docked room's channel_id, and nothing else. The adapter keys (source_room_id, channel_kind) and the room_context line are left out, so no room origin is rebuilt for its result. The result still goes the owner-DM way that feat(voice): the session-end task files voice-agent problems as pending questions; its result reaches the owner's DM, not a later call #5429 sets up. The prompt text is not touched.
    The client disconnect releases the binding about 60s before the hang-up grace runs out. So createCallOriginLatch records the origin at disconnect and clears it on reconnect. A call still running at shutdown uses the live origin.

How roster labels are reached without core importing the skill

  • Core (src/task-bridge.ts) only has an injected seam: setVoiceWorkerResolver(fn), added to SkillSetupCtx the same way setVoiceTaskOriginResolver was. With no resolver installed, a named worker is refused ("No workers are set up here…"). It is never sent to the core. The core contains no roster code and no worker-pool path; tests/core-never-names-the-worker-pool-skill.test.py still passes.
  • skills/worker-pool/manifest.json and tools.ts are a thin setup(ctx) hook with no tools. Its resolver runs scripts/resolve_worker_name.py, which calls the new pool_roster.resolve_spoken_name. That function sits next to resolve_label, so the matching rules live in one place, in the pool. The script resolves the workspace the same way the router does.
  • The value written to requested_worker is the worker id. The router accepts an id as-is, so a later display-label change can't move the task to someone else.

Review first

src/task-bridge.ts: resolveVoiceWorker, buildVoiceTaskHeader, _routingOnlyOrigin and submitVoiceSessionEndTask, createCallOriginLatch, and the worker parameter in workTool. Then skills/worker-pool/scripts/pool_roster.py (resolve_spoken_name) and skills/worker-pool/tools.ts.

Feature/documentation consistency

docs/worker-pool-design.md → Routing gets a "Voice" paragraph, and its last_verified in docs/catalog.json is updated. skills/MANIFEST.md lists the new ctx member.

Before / after evidence

Demo script (demo-voice-route.mts, not committed). It calls the real workTool.execute and submitVoiceSessionEndTask, then sends each task file through the real pool_route_handler.read_task and pool_router.route. The fixture roster has pro-dev = w-dev, pro-comm = w-comm, pro-fast = w-fast, and the room is bound to w-comm.

Parent 4e22807db (origin/main):

"ask Pro dev to ..." docked in room bound to pro-comm:
   status=pending  channel_id: !room:ag2.space  (no requested_worker)  -> route ["w-comm"]
no name, docked in room bound to pro-comm:
   status=pending  channel_id: !room:ag2.space  (no requested_worker)  -> route ["w-comm"]
no name, no room:
   status=pending  channel_id: local-voice  (no requested_worker)  -> route ["core"]
"ask pro-design to ..." (not on roster):
   status=pending  channel_id: local-voice  (no requested_worker)  -> route ["core"]
session end, call docked in room:
   channel_id: local-voice -> route ["core"]
session end, no room:
   channel_id: local-voice -> route ["core"]

HEAD:

"ask Pro dev to ..." docked in room bound to pro-comm:
   status=pending  channel_id: !room:ag2.space  requested_worker: w-dev  -> route ["w-dev"]
no name, docked in room bound to pro-comm:
   status=pending  channel_id: !room:ag2.space  (no requested_worker)  -> route ["w-comm"]
no name, no room:
   status=pending  channel_id: local-voice  (no requested_worker)  -> route ["core"]
"ask pro-design to ..." (not on roster):
   status=rejected  message=There is no worker called "pro-design". The workers are: pro-comm, pro-dev, pro-fast. Nothing was sent: tell the user and ask which one they meant.
session end, call docked in room:
   channel_id: !room:ag2.space -> route ["w-comm"]
session end, no room:
   channel_id: local-voice -> route ["core"]

New tests, run at the parent (the test files copied into an origin/main worktree):

$ python3 tests/pool-roster-spoken-name.test.py
AttributeError: module 'pool_roster' has no attribute 'normalize_spoken_name'
$ npx tsx --test --test-force-exit tests/voice-worker-routing.test.ts
  ✖ an explicit worker becomes requested_worker, with or without a room     (actual: undefined, expected: 'w-dev')
  ✖ is refused with a line for the user, and nothing is written             (setVoiceWorkerResolver is not a function)
  ✖ docked: channel_id is the room, with no adapter keys, ...               (actual: 'local-voice', expected: '!room:ag2.space')
  ✖ the latch keeps the room a call was docked in ...                       (createCallOriginLatch is not a function)
  ﹣ worker-pool skill not installed # SKIP
ℹ tests 10  ℹ pass 4  ℹ fail 5  ℹ skipped 1

At HEAD:

$ python3 tests/pool-roster-spoken-name.test.py
  ok   normalise 'Pro-dev' / 'pro dev' / ' PRO__DEV ' / 'Pro - Fast'
  ok   'Pro-dev' / 'pro dev' / 'PRO_DEV' / 'w-dev' -> w-dev
  ok   display label matches normalised ... unknown is refused with the names ... shared name is ambiguous
  ok   script prints the verdict ... no roster, dash-led name
  ok   router delivers the resolved id, over the room binding
  ok   room-docked default goes to the bound worker
  ok   no room goes to the core
all passed
$ npx tsx --test --test-force-exit tests/voice-worker-routing.test.ts
▶ buildVoiceTaskHeader (explicit name with/without room; room-docked default; no room -> local-voice; non-token value not written)
▶ work with a named worker and no resolver installed (refused, no task file; throwing resolver refused)
▶ session end follows the docked room, routing only (+ latch)
▶ the worker-pool resolver (real script, fixture roster) ("Pro-dev", "pro dev", "PRO_DEV", "  pro  -  dev ", id; unknown; ambiguous; no roster)
▶ end to end: work tool -> task file -> pool_router.route (explicit -> w-dev over binding; room -> w-comm; none -> core; unknown refused; session end room -> w-comm; none -> core)
ℹ tests 23  ℹ pass 23  ℹ fail 0

Tests run

  • npx tsx --test --test-force-exit tests/voice-worker-routing.test.ts tests/voice-session-end.test.ts tests/ag2space-voice-room.test.ts tests/skill-setup-dedup.test.ts tests/skill-setup-isolation.test.ts tests/voice-plugin-seam.test.ts tests/task-bridge-*.test.ts tests/relay-agent*.test.ts: 293 pass, 0 fail
  • python3 on tests/requested-worker-header.test.py, core-never-names-the-worker-pool-skill.test.py, lint-skill-supervised-worker.test.py, worker-pool-design-retractions.test.py, marketplace.test.py, health-check-dangling-symlinks.test.py, pool-router-replay-idempotence.test.py and pool-router-non-exclusive-folder.test.py: all PASS. bash tests/start-cli-skill-manifest-config.test.sh: PASS.
  • npx tsc --noEmit and npx tsc -p tsconfig.skills.json: no errors in the changed files. eslint on the changed files: 0 errors; the warnings are existing no-explicit-any ones in task-bridge.ts. git diff --cached | bash scripts/review-checks.sh: PASS.
  • tests/voice-session-end.test.ts: the source-shape assertions for the voice-agent.ts wiring were updated to the new lines (callOrigin.clientConnected()/clientLeaving()/take()).

Live path

This touches the voice task writer. No live voice round trip has been done yet. Evidence so far is the real tool, task-file and router path against a fixture roster. Before merge, a live witness should do two things on a host with the pool:

  • docked in a room, say "ask Pro-dev to …" and check that deliveries/<pro-dev id>/ gets the task;
  • hang up while docked, and check that the session-end task routes to the room's worker and its result reaches the DM.

Coordination

🤖 Generated with Claude Code

@sonichi

sonichi commented Oct 12, 2026

Copy link
Copy Markdown
Owner Author

@yixuan-ag2 Chi asked for your review on this one. Summary: the voice work tool takes an optional worker name ("ask Pro-dev to …"). If the name is unknown or matches more than one worker, the task is refused and the user hears the worker names. A call docked in a room routes like a message typed in that room, so the bound worker gets it. The end-of-call task follows the call's room. Core never imports the worker-pool skill; the pool fills a resolver hook through its manifest. No live voice test yet.

🤖 Generated with Claude Code

…a docked room's bound worker by default

The voice `work` tool takes an optional `worker` name. The worker-pool skill's
voice hook resolves it against the roster (case and spacing ignored) and the
task carries the id as `requested_worker`; an unknown or ambiguous name is told
to the user and nothing is written. The core reaches the roster only through an
injected resolver (SkillSetupCtx.setVoiceWorkerResolver), so it names no skill.

A work task docked in a room already carries the room as `channel_id`, so the
room's bound worker gets it; the session-end task now does the same, with the
routing header only (no adapter keys), latched across the disconnect that
releases the binding before the hang-up grace runs out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonichi
sonichi force-pushed the feat/voice-task-worker-routing branch from a415c43 to 857e704 Compare October 12, 2026 21:40
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 94%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • skills/worker-pool/scripts/pool_roster.py (100%)

Summary

  • Total: 19 lines
  • Missing: 0 lines
  • Coverage: 100%

⚠️ Not measured

Changed, but absent from coverage.xml — outside [run] source in
.coveragerc, so diff-cover never examined these lines:

  • skills/worker-pool/scripts/resolve_worker_name.py

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for one blocker: the PR's own CI is red, and the red is caused by this diff.

Blocker: the tool-table anchor fixture was not regenerated

  • CI job "node + out-of-tree python tests" (run 38312145854, job 115020232128) has 1 failure out of 1703: not ok 450 - tool-table anchor matches the committed fixture in tests/voice-behavior-anchors.test.ts.
  • The diff shown is the new worker parameter on workTool (src/task-bridge.ts, the worker: z.string().optional().describe(...) hunk). The fixture tests/fixtures/voice-behavior-anchors.json does not have it.
  • "tsc + tests (clean install)" fails only because it gathers that job's result (node-and-oot-tests FAILED).
  • The last three CI runs on main are green (head 3208059 and the two before it), so this is not inherited from main.
  • The anchor is there because tool descriptions are the tuned prompt surface. So this needs a deliberate regeneration, not a quiet fix.

Asks:

  1. Regenerate on purpose (ANCHOR_UPDATE=1 npx tsx tests/voice-behavior-anchors.test.ts) and commit the fixture change.
  2. Say in the PR body that the work tool's parameter table, which voice reads, gains worker. Quote the new description string. The body's "Tests run" list leaves this suite out, so it was not run before push.
  3. Re-run CI and get it green.

Not blocking, named: the live voice round trip has not been done, and the body says so. That covers the docked "ask Pro-dev to …" landing in deliveries/<id>/, and a docked hang-up whose session-end task routes to the room's worker and replies in the DM. Please paste it when you have it.

What I checked and found fine

  • New tests exercise real behavior. They call the real workTool.execute, the real resolve_worker_name.py against a fixture roster, and pool_router.route. They do not grep source. The changed voice-session-end.test.ts source regexes are an existing pattern, updated in place, not new.
  • resolve_spoken_name sits next to resolve_label in pool_roster.py, so the policy has one owner. Core reaches it only through the injected setVoiceWorkerResolver seam.
  • requested_worker is checked against REQUESTED_WORKER_RE before it is written. A named worker with no resolver installed is refused, not sent to the core.
  • Hardcoded-path scan over the added lines found no ~/.sutando, $HOME/.sutando or /Users/<name> literals. SUTANDO_WORKSPACE appears only as a test-scoped env set in tests/voice-worker-routing.test.ts, not as a live default.
  • license/cla shows success, with 1 context at 857e704.

Not verified: I did not run any test locally. Everything above comes from the diff and the CI logs. I did not check whether #5429 or #5035 conflict at this head.

— Sutando (susans-MacBook-Pro)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@liususan091219 liususan091219 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes still requested at 921e903c. The only commit since my review at 857e704b is 921e903c, a merge of main. It does not address the blocker, and CI is red at this head for the same reason.

Blocker (unchanged): the tool-table anchor fixture was not regenerated

  • CI run 40232216012, job "node + out-of-tree python tests": 1961 pass, 1 fail, not ok 506 - tool-table anchor matches the committed fixture (tests/voice-behavior-anchors.test.ts).
  • The diff in the failure is exactly this PR's new param: worker: 'Only when the user names a worker to do it ("ask Pro-dev to …"): …' on the work tool (src/task-bridge.ts, the worker: z.string().optional() hunk in workTool.parameters). tests/fixtures/voice-behavior-anchors.json still lacks it.
  • "tsc + tests (clean install)" fails only because it gathers that job's result.

Asks (same as before):

  1. Regenerate on purpose (ANCHOR_UPDATE=1 npx tsx tests/voice-behavior-anchors.test.ts) and commit the fixture.
  2. Say in the PR body that the work tool's parameter table gains worker, and quote the description string.
  3. Get CI green.

The merge itself checks out. git show --remerge-diff shows conflicts in src/task-bridge.ts, src/voice-agent.ts, src/skill-setup-runner.ts and docs/catalog.json, and every one keeps both sides:

  • buildVoiceTaskHeader takes main's lane and then this PR's requestedWorker. It writes both header lines. The caller and tests/voice-worker-routing.test.ts were updated to the new argument order.
  • The worker lookup in workTool.execute now runs after captureWorkOrigin(). That matches main's "read before the first await" rule, since resolveVoiceWorker is awaited.
  • SkillSetupCtx and voice-agent.ts keep both setVoiceWorkerResolver and main's onUserSpoke. catalog.json takes main's newer last_verified.

Gates: license/cla success (1 context). No unresolved threads.

Not blocking, named: the live voice round trip is still owed (the docked "ask Pro-dev to …" landing in deliveries/<id>/, and a docked hang-up whose session-end task routes to the room's worker).

Not verified: I read the merge resolution and the CI log; I did not run the node suite locally.

— Sutando (susans-MacBook-Pro)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants