openclaw - ✅(Solved) Fix [Bug]: google_meet & voice_call route every call to single configured agent in multi-agent deployments [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#77753Fetched 2026-05-06 06:21:55
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
2
Timeline (top)
commented ×1cross-referenced ×1

In multi-agent OpenClaw deployments (per-user agents on Slack/Telegram/Discord), when any agent invokes the google_meet tool (action=join, create+joinAfterCreate, test_speech) or the voice_call tool (initiate_call), the outbound voice call is always routed to the single configured agent (voiceConfig.agentId ?? "main") instead of the agent that actually initiated the call. Every user hears the same main agent voice.

Root Cause

Root Causes (multiple, must all be addressed)

Fix Action

Fix / Workaround

  1. Static tool registration loses caller identityextensions/google-meet/index.ts:993 and extensions/voice-call/index.ts:609 register tools via api.registerTool({...}) static-object form. The factory form api.registerTool((ctx) => ({...}), { name }) is required to capture ctx.agentId.

  2. Cross-process gateway hop drops contextcallGoogleMeetGatewayFromTool (google-meet/index.ts:449-470) hops via callGatewayFromCli (CLI bridge → WebSocket → gateway-rpc.runtime.js). Tool-side closures cannot reach the gateway handler. agentId must travel as a serialized RPC field.

  3. Gateway handlers don't extract agentIdgooglemeet.join (line 705-720), googlemeet.create, googlemeet.test_speech build rt.join(request) without agentId.

  4. JoinMeetRequest / rt.join shape lacks agentIdextensions/google-meet/src/runtime.ts:351; same for joinMeetViaVoiceCallGateway at runtime.ts:443-468.

  5. voicecall.start RPC handler doesn't accept agentIdextensions/voice-call/index.ts:581-606 reads only to/message/dtmfSequence/mode. initiateCallAndRespond (line 335-353) calls manager.initiateCall(to, undefined, {...}) with no agentId slot.

  6. Three downstream sites read effectiveConfig.agentId, not call.agentId:

    • Webhook → response-generator: voice-call/src/webhook.ts:884 + response-generator.ts:214 (voiceConfig.agentId ?? "main").
    • Realtime consult lane #1: voice-call/src/runtime.ts:348 (effectiveConfig.agentId ?? "main").
    • Realtime consult lane #2: voice-call/src/runtime.ts:377 (same fallback).
    • Patching only response-generator silently leaves the realtime path (the dominant Google Meet voice path) broken.
  7. voice_call tool itself has the same defectextensions/voice-call/index.ts:635 initiate_call action calls rt.manager.initiateCall(to, undefined, {...}) with no agentId.

  8. Convert google_meet and voice_call tools to factory pattern with { name } opts (required by src/plugins/registry.ts:506-509 for factory-form name auto-derivation).

  9. Inject ctx.agentId into the gateway-bound RPC payload at the dispatch boundary; thread it through googlemeet.{join,create,test_speech} handlers → JoinMeetRequest/Create/TestSpeechrt.joinjoinMeetViaVoiceCallGatewayvoicecall.start RPC → manager.initiateCallCallRecord.agentId.

  10. Freeze the resolved agentId on inbound CallRecord at manager/events.ts:createWebhookCall (DID-route default, frozen at creation to prevent hot-reload drift).

  11. Add extensions/voice-call/src/util/resolve-call-agent-id.ts with a single precedence helper: call.agentId || effectiveConfig.agentId || "main". Consume from webhook, both realtime consult lanes, and response-generator.

  12. Construct per-meeting session keys (agent:${agentId}:google-meet:${session.id}) to prevent collisions on shared Twilio dial-ins (pattern reused from extensions/google-meet/src/agent-consult.ts:50-57).

PR fix notes

PR #77763: feat(google-meet,voice-call): route voice calls to calling agent

Description (problem / solution / changelog)

Closes #77753

Summary

Enables per-agent voice call routing in multi-agent OpenClaw deployments. Today, when any agent invokes google_meet (join/create+joinAfterCreate/test_speech) or voice_call (initiate_call), the call is always answered by the single configured voiceConfig.agentId ?? "main" regardless of which agent initiated it. Every user hears the same voice.

This PR threads the originating ctx.agentId from tool dispatch all the way to the Twilio audio path and consumes it through a single resolution helper, so each user hears their own agent.

Design Invariant

Resolve effective agentId exactly once at call creation, persist it on CallRecord, and consume call.agentId at every downstream site through a centralized helper. No optional-fallback chain at every hop.

ctx.agentId (tool factory)
  → params.raw.agentId (gateway-bound payload)
  → googlemeet.{join,create,test_speech} handler
  → JoinMeetRequest / CreateRequest / TestSpeechRequest
  → joinMeetViaVoiceCallGateway
  → voicecall.start RPC payload
  → manager.initiateCall opts
  → CallRecord.agentId (frozen at creation)
  → resolveCallAgentId(call, effectiveConfig) at every consumer

Changes

Production (13 files)

  • New helper extensions/voice-call/src/util/resolve-call-agent-id.ts — single precedence point: call.agentId || effectiveConfig.agentId || "main".
  • extensions/voice-call/src/types.ts — adds OutboundCallOptions.agentId/sessionKey and CallRecordSchema.agentId (Zod strip-mode → forward/back compat).
  • extensions/voice-call/src/manager/outbound.ts — freezes opts.agentId on outbound CallRecord.
  • extensions/voice-call/src/manager/events.ts — freezes resolved effectiveConfig.agentId on inbound CallRecord at createWebhookCall (prevents drift if config hot-reloads mid-call).
  • extensions/voice-call/index.tsvoicecall.start RPC accepts agentId+sessionKey; voice_call tool converted to factory (ctx) => ({...}) with { name: "voice_call" } opts so ctx.agentId is captured per-execute.
  • extensions/voice-call/src/webhook.ts — passes resolveCallAgentId(call, effectiveConfig) into generateVoiceResponse.
  • extensions/voice-call/src/runtime.ts — realtime consult lane (line 348) consumes the helper.
  • extensions/voice-call/src/response-generator.ts — accepts params.agentId with precedence over voiceConfig.agentId.
  • extensions/google-meet/index.ts — factory injects ctx.agentId into raw.agentId before the gateway hop; googlemeet.{join,create,test_speech} handlers extract params?.agentId.
  • extensions/google-meet/src/create.tscreateAndJoin threads agentId.
  • extensions/google-meet/src/runtime.ts — constructs per-meeting session key agent:${agentId}:google-meet:${session.id} (pattern from agent-consult.ts) to prevent collisions on shared Twilio dial-ins.
  • extensions/google-meet/src/transports/types.tsGoogleMeetJoinRequest.agentId.
  • extensions/google-meet/src/voice-call-gateway.tsvoicecall.start RPC payload carries agentId+sessionKey; logger surfaces both fields for multi-agent debugging.

Tests (8 files, +245 lines)

  • extensions/voice-call/src/util/resolve-call-agent-id.test.ts — 5 precedence cases.
  • extensions/voice-call/test/integration/per-agent-routing.test.ts — 2 webhook-driven integration cases (the actual user-visible boundary).
  • Extended unit coverage in voice-call/index.test.ts, voice-call/src/manager/outbound.test.ts, voice-call/src/manager/events.test.ts, voice-call/src/response-generator.test.ts, google-meet/index.test.ts, google-meet/src/voice-call-gateway.test.ts.

Backward Compatibility

  • CallRecord schema: new optional field, Zod strip-mode round-trip preserves old persisted records.
  • voicecall.start RPC: old handlers ignore unknown agentId/sessionKey params.
  • Single-agent deployments: ctx.agentId === "main" (or undefined → falls through helper to config default → "main"). Identical pre-PR behavior.
  • Mixed-version rolling upgrades: voice-call state is process-local (in-memory Map + per-pod calls.jsonl). On a multi-pod fleet without sticky session/state-affinity, old-pod voicecall.start invocations during the rolling window silently drop the field and fall back to effectiveConfig.agentId. Uniform rollout recommended for multi-pod deployments.

Test Plan

  • pnpm test extensions/voice-call extensions/google-meet518/518 pass (voice-call 379, google-meet 139)
  • pnpm check:changed — exit 0 (tsc, oxlint, import cycles, runtime sidecar guards, plugin SDK boundary, duplicate scan)
  • Helper precedence verified in unit tests (call wins, config fallback, literal default, empty-string handling, null/undefined call agentId)
  • Integration test drives webhook → response-generator with explicit agentId on the outbound CallRecord; asserts the per-call agent runs, not the config default
  • Round-trip: pre-PR JSONL records load through CallRecordSchema.parse with agentId undefined → falls back via helper

Caveats / Follow-ups

  • Workspace cleanup TTL for high-cardinality agentId (each unique agent on a voice call provisions a workspace tree) — out of scope, file follow-up.
  • Distributed CallRecord state for multi-pod rolling upgrades — pre-existing limitation, out of scope.
  • voicecall.start external callers (if any) should be added to a trust audit; the field is sourced from trusted plugin runtime context, but cross-tenant deployments may want an auth layer.
  • endMeetVoiceCallGatewayCall audit/metrics did not need agentId plumbing in this pass; verify dashboards if per-agent breakdown is desired.

Notes for Reviewers

  • The voice_call tool was a static api.registerTool({...}) registration with the same agentId-loss bug as google_meet; both are now factory-form. The { name } opts arg is required because src/plugins/registry.ts only auto-derives names from static-form tool.name (factory form leaves names empty unless opts.name/opts.names is supplied).
  • The runtime had a single realtime consult lane (line 348). The earlier draft of this plan referenced two lanes; the second site at line 377 was already passing the resolved agentId variable, not a duplicate fallback resolution.
  • Naming collision between voiceConfig.agentId (DID owner / inbound default) and the new OutboundCallOptions.agentId / CallRecord.agentId (per-call originating agent) is intentionally unified through resolveCallAgentId: outbound-frozen wins; otherwise the inbound-route default wins; literal "main" is last resort. JSDoc on the option documents this.

Filed by quangtran88 from oneclaw/oneclaw.improvement for an OneClaw multi-agent deployment.

Real behavior proof

Behavior or issue addressed: Multi-agent OpenClaw deployments routed every voice call (google_meet.join + voice_call.initiate) to the single configured voiceConfig.agentId ?? "main" regardless of which agent invoked the tool. This patch threads ctx.agentId from tool dispatch through the gateway RPC into CallRecord.agentId, frozen at creation, consumed via the new resolveCallAgentId(call, effectiveConfig) helper at every downstream site (webhook -> response-generator, realtime consult lane).

Real environment tested: Local OpenClaw checkout at HEAD 499b564d94 of quangtran88:feat/per-agent-voice-call-routing, Node 22.20.0 on macOS Darwin 25.4. Voice-call extension TypeScript sources executed live via the tsx loader (no separate build step). The Twilio provider seam was replaced with a hand-written conformant stub returning real-shaped startCall results so the manager, JSONL store, helper, and outbound RPC dispatch all run live source code without making actual phone calls.

Exact steps or command run after this patch:

git checkout feat/per-agent-voice-call-routing
pnpm install
node --import tsx scripts/proofs/per-agent-voice-call-routing.mjs

Evidence after fix: Live terminal capture from node --import tsx scripts/proofs/per-agent-voice-call-routing.mjs (stdout, verbatim):

[voice-call] Outbound call initiated: callId=e5b1eddf-8677-4f69-b4eb-007fe0f6962c providerCallId=CA00000001realstub mode=conversation preConnectDtmf=no initialMessage=no
[voice-call] Outbound call initiated: callId=3f4c6f9a-756c-4ee7-b52b-9046afde23c4 providerCallId=CA00000002realstub mode=conversation preConnectDtmf=no initialMessage=no
=== AFTER FIX (resolveCallAgentId reads CallRecord.agentId first) ===
dispatch                             callId                                 CallRecord.agentId resolveCallAgentId(call, cfg) 
google_meet.join (Slack user 1)      e5b1eddf-8677-4f69-b4eb-007fe0f6962c   slack-u123     slack-u123                    
voice_call.initiate (Slack user 2)   3f4c6f9a-756c-4ee7-b52b-9046afde23c4   slack-u456     slack-u456                    

=== LEGACY (pre-patch: effectiveConfig.agentId ?? "main") ===
dispatch                             callId                                 legacy resolution             
google_meet.join (Slack user 1)      e5b1eddf-8677-4f69-b4eb-007fe0f6962c   main                          
voice_call.initiate (Slack user 2)   3f4c6f9a-756c-4ee7-b52b-9046afde23c4   main                          

=== JSONL store inspection (calls.jsonl on disk) ===
  callId=e5b1eddf-8677-4f69-b4eb-007fe0f6962c state=initiated agentId=slack-u123 to=+18005551111
  callId=e5b1eddf-8677-4f69-b4eb-007fe0f6962c state=initiated agentId=slack-u123 to=+18005551111
  callId=3f4c6f9a-756c-4ee7-b52b-9046afde23c4 state=initiated agentId=slack-u456 to=+18005552222
  callId=3f4c6f9a-756c-4ee7-b52b-9046afde23c4 state=initiated agentId=slack-u456 to=+18005552222

=== Verification ===
  AFTER FIX  distinct agents resolved: 2 (slack-u123, slack-u456)
  LEGACY     distinct agents resolved: 1 (main)
  OK: per-call agent identity is preserved end-to-end.

Observed result after fix: Two concurrent outbound calls initiated through the live CallManager with distinct opts.agentId values (slack-u123, slack-u456) produced two CallRecord rows whose frozen agentId field matches the per-call dispatch agent. The resolveCallAgentId(call, effectiveConfig) helper returned each call's per-call agent. The pre-patch comparison column (legacy effectiveConfig.agentId ?? "main" resolution) collapsed both calls to main, confirming the original defect would have routed both Slack users to the same voice. The on-disk JSONL inspection ("calls.jsonl on disk" section) shows the same per-call agentId persisted through the store layer.

What was not tested: End-to-end live Twilio outbound + inbound webhook against a real provisioned Twilio number (provider seam stubbed to avoid real phone-call charges and DID provisioning during PR validation). Multi-pod rolling-upgrade window where old pods running pre-patch code transiently fall back to effectiveConfig.agentId -- pre-existing process-local CallRecord state limitation, documented in the implementation plan section 4.2. High-cardinality agentId workspace disk footprint (plan section 5.2 follow-up). Multi-pod OneClaw production deployment proof -- this repro runs in a single Node process.

Changed files

  • CHANGELOG.md (modified, +1/-0)
  • extensions/google-meet/index.test.ts (modified, +43/-0)
  • extensions/google-meet/index.ts (modified, +8/-1)
  • extensions/google-meet/src/create.ts (modified, +1/-0)
  • extensions/google-meet/src/runtime.ts (modified, +8/-0)
  • extensions/google-meet/src/transports/types.ts (modified, +6/-0)
  • extensions/google-meet/src/voice-call-gateway.test.ts (modified, +67/-8)
  • extensions/google-meet/src/voice-call-gateway.ts (modified, +24/-5)
  • extensions/voice-call/index.test.ts (modified, +84/-3)
  • extensions/voice-call/index.ts (modified, +172/-117)
  • extensions/voice-call/src/manager/events.test.ts (modified, +61/-0)
  • extensions/voice-call/src/manager/events.ts (modified, +3/-0)
  • extensions/voice-call/src/manager/outbound.test.ts (modified, +47/-0)
  • extensions/voice-call/src/manager/outbound.ts (modified, +1/-0)
  • extensions/voice-call/src/response-generator.test.ts (modified, +56/-0)
  • extensions/voice-call/src/response-generator.ts (modified, +12/-1)
  • extensions/voice-call/src/runtime.ts (modified, +2/-1)
  • extensions/voice-call/src/types.ts (modified, +28/-0)
  • extensions/voice-call/src/util/resolve-call-agent-id.test.ts (added, +24/-0)
  • extensions/voice-call/src/util/resolve-call-agent-id.ts (added, +20/-0)
  • extensions/voice-call/src/webhook.ts (modified, +2/-0)
  • extensions/voice-call/test/integration/per-agent-routing.test.ts (added, +167/-0)
  • scripts/proofs/per-agent-voice-call-routing.mjs (added, +192/-0)
  • src/gateway/server-plugins.ts (modified, +1/-1)
  • src/plugin-sdk/plugin-runtime.ts (modified, +4/-0)
  • src/plugins/runtime/gateway-dispatch.runtime.ts (added, +38/-0)
RAW_BUFFERClick to expand / collapse

Summary

In multi-agent OpenClaw deployments (per-user agents on Slack/Telegram/Discord), when any agent invokes the google_meet tool (action=join, create+joinAfterCreate, test_speech) or the voice_call tool (initiate_call), the outbound voice call is always routed to the single configured agent (voiceConfig.agentId ?? "main") instead of the agent that actually initiated the call. Every user hears the same main agent voice.

Reproduction

  1. Deploy OpenClaw with multiple per-user agents (e.g., slack-u123, slack-u456).
  2. User Alice (routed to slack-u123) asks the bot to join a Google Meet.
  3. User Bob (routed to slack-u456) asks the bot to join a different Google Meet concurrently.
  4. Both calls reach Twilio and respond with the same agent's voice/identity (main), not the per-user agent.

Root Causes (multiple, must all be addressed)

  1. Static tool registration loses caller identityextensions/google-meet/index.ts:993 and extensions/voice-call/index.ts:609 register tools via api.registerTool({...}) static-object form. The factory form api.registerTool((ctx) => ({...}), { name }) is required to capture ctx.agentId.
  2. Cross-process gateway hop drops contextcallGoogleMeetGatewayFromTool (google-meet/index.ts:449-470) hops via callGatewayFromCli (CLI bridge → WebSocket → gateway-rpc.runtime.js). Tool-side closures cannot reach the gateway handler. agentId must travel as a serialized RPC field.
  3. Gateway handlers don't extract agentIdgooglemeet.join (line 705-720), googlemeet.create, googlemeet.test_speech build rt.join(request) without agentId.
  4. JoinMeetRequest / rt.join shape lacks agentIdextensions/google-meet/src/runtime.ts:351; same for joinMeetViaVoiceCallGateway at runtime.ts:443-468.
  5. voicecall.start RPC handler doesn't accept agentIdextensions/voice-call/index.ts:581-606 reads only to/message/dtmfSequence/mode. initiateCallAndRespond (line 335-353) calls manager.initiateCall(to, undefined, {...}) with no agentId slot.
  6. Three downstream sites read effectiveConfig.agentId, not call.agentId:
    • Webhook → response-generator: voice-call/src/webhook.ts:884 + response-generator.ts:214 (voiceConfig.agentId ?? "main").
    • Realtime consult lane #1: voice-call/src/runtime.ts:348 (effectiveConfig.agentId ?? "main").
    • Realtime consult lane #2: voice-call/src/runtime.ts:377 (same fallback).
    • Patching only response-generator silently leaves the realtime path (the dominant Google Meet voice path) broken.
  7. voice_call tool itself has the same defectextensions/voice-call/index.ts:635 initiate_call action calls rt.manager.initiateCall(to, undefined, {...}) with no agentId.

Impact

ScenarioCurrentExpected
Multi-user Slack bot via google_meetAll users share main agent voiceEach user hears their own agent
Multi-user via direct voice_callAll users share main agent voiceEach user hears their own agent
Realtime mode (mode=realtime)Reads effectiveConfig.agentIdShould read call.agentId
Conversation mode (response-generator)Reads voiceConfig.agentIdShould read call.agentId
Google Meet transcription attributionAttributed to mainAttributed to requesting agent

Proposed Fix (high-level)

Design invariant: resolve effective agentId exactly once at call creation, persist it on CallRecord, and consume call.agentId at every downstream site through a centralized helper. No optional-fallback chain at every hop.

  1. Convert google_meet and voice_call tools to factory pattern with { name } opts (required by src/plugins/registry.ts:506-509 for factory-form name auto-derivation).
  2. Inject ctx.agentId into the gateway-bound RPC payload at the dispatch boundary; thread it through googlemeet.{join,create,test_speech} handlers → JoinMeetRequest/Create/TestSpeechrt.joinjoinMeetViaVoiceCallGatewayvoicecall.start RPC → manager.initiateCallCallRecord.agentId.
  3. Freeze the resolved agentId on inbound CallRecord at manager/events.ts:createWebhookCall (DID-route default, frozen at creation to prevent hot-reload drift).
  4. Add extensions/voice-call/src/util/resolve-call-agent-id.ts with a single precedence helper: call.agentId || effectiveConfig.agentId || "main". Consume from webhook, both realtime consult lanes, and response-generator.
  5. Construct per-meeting session keys (agent:${agentId}:google-meet:${session.id}) to prevent collisions on shared Twilio dial-ins (pattern reused from extensions/google-meet/src/agent-consult.ts:50-57).

Scope

~14 files across two extensions plus one new helper module:

  • extensions/google-meet/: index.ts, src/runtime.ts, src/voice-call-gateway.ts
  • extensions/voice-call/: index.ts, src/types.ts, src/manager/outbound.ts, src/manager/events.ts, src/webhook.ts, src/response-generator.ts, src/runtime.ts, new src/util/resolve-call-agent-id.ts

Plus unit tests (per-file) and one integration test driving the webhook → response-generator boundary.

Backward Compatibility

  • CallRecord schema: new optional field, Zod strip-mode round-trip preserves old records.
  • voicecall.start RPC: old handlers ignore unknown agentId/sessionKey params.
  • Single-agent deployments: ctx.agentId === "main" (or undefined → falls through helper to config default → "main"). Identical pre-PR behavior.
  • Mixed-version rolling upgrades: voice-call state is process-local (in-memory Map + per-pod calls.jsonl); during a rolling window, old-pod voicecall.start invocations silently drop the field and fall back to effectiveConfig.agentId. Uniform rollout recommended.

Related

  • #72440 — Google Meet tool-created realtime sessions can lose gateway ownership (related ownership/identity plumbing)
  • #72478 — Google Meet headless agent join with audio/transcription health checks (closed)
  • #76344 — google-meet realtime sessions don't survive CLI command exit (closed)

PR with full plan and detailed change set will follow shortly. Filed by quangtran88 from oneclaw/oneclaw.improvement for an OneClaw multi-agent deployment.

extent analysis

TL;DR

To fix the issue of outbound voice calls being routed to the single configured agent instead of the agent that initiated the call, update the google_meet and voice_call tools to use the factory pattern and inject ctx.agentId into the gateway-bound RPC payload.

Guidance

  1. Convert tools to factory pattern: Update google_meet and voice_call tools to use the factory pattern with { name } options to capture ctx.agentId.
  2. Inject agentId into RPC payload: Modify the gateway-bound RPC payload to include ctx.agentId at the dispatch boundary.
  3. Freeze agentId on CallRecord: Freeze the resolved agentId on inbound CallRecord at manager/events.ts:createWebhookCall to prevent hot-reload drift.
  4. Create a centralized helper: Add a helper module resolve-call-agent-id.ts to resolve the effective agentId and consume it from webhook, realtime consult lanes, and response-generator.
  5. Update downstream sites: Update all downstream sites to use the centralized helper to resolve the effective agentId.

Example

// extensions/google-meet/index.ts
api.registerTool((ctx) => ({ ... }), { name: 'google_meet' });

// extensions/voice-call/index.ts
api.registerTool((ctx) => ({ ... }), { name: 'voice_call' });

Notes

The proposed fix involves updating multiple files across two extensions and adding a new helper module. The changes should be backward compatible, and a full PR with detailed changes will follow shortly.

Recommendation

Apply the proposed fix by updating the google_meet and voice_call tools to use the factory pattern and injecting ctx.agentId into the gateway-bound RPC payload. This will ensure that outbound voice calls are routed to the correct agent.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix [Bug]: google_meet & voice_call route every call to single configured agent in multi-agent deployments [1 pull requests, 1 comments, 2 participants]