Skip to content

Commit 19f7b72

Browse files
fix(voice-call): preserve per-call agent routing (#77763)
* fix(voice-call): preserve per-call agent routing Co-authored-by: Tran Quang <[email protected]> * chore: keep release notes in PR metadata --------- Co-authored-by: Peter Steinberger <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent 8cb9263 commit 19f7b72

37 files changed

Lines changed: 950 additions & 206 deletions

docs/plugins/google-meet.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,12 @@ Twilio-only config:
805805

806806
With `voiceCall.enabled: true` (the default) and Twilio transport, Voice Call places the DTMF sequence before opening the realtime media stream, then uses the saved intro text as the initial realtime greeting. If `voice-call` is not enabled, Google Meet can still validate and record the dial plan but cannot place the Twilio call.
807807

808+
Leave `voiceCall.gatewayUrl` unset to use the local trusted Gateway runtime, which preserves the
809+
invoking agent for the full call. A configured Gateway URL remains an explicit WebSocket target and
810+
cannot authenticate plugin provenance; non-default agent joins fail closed instead of silently
811+
using another agent. Run Google Meet and Voice Call in the same Gateway process when per-agent
812+
routing is required.
813+
808814
## Tool
809815

810816
Agents use the `google_meet` tool:

docs/plugins/sdk-runtime.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ summary: "api.runtime -- the injected runtime helpers available to plugins"
33
title: "Plugin runtime helpers"
44
sidebarTitle: "Runtime helpers"
55
read_when:
6-
- You need to call core helpers from a plugin (TTS, STT, image gen, web search, subagent, nodes)
6+
- You need to call core helpers from a plugin (TTS, STT, image gen, web search, Gateway, subagent, nodes)
77
- You want to understand what api.runtime exposes
88
- You are accessing config, agent, or media helpers from plugin code
99
---
@@ -214,6 +214,27 @@ two-party event loops that do not go through the shared inbound reply runner.
214214
Model overrides require operator opt-in via `plugins.entries.<id>.llm.allowModelOverride: true` in config. Use `plugins.entries.<id>.llm.allowedModels` to restrict trusted plugins to specific canonical `provider/model` targets. Cross-agent completions require `plugins.entries.<id>.llm.allowAgentIdOverride: true`.
215215
</Warning>
216216

217+
</Accordion>
218+
<Accordion title="api.runtime.gateway">
219+
Call another Gateway method in process while preserving the current plugin's trusted runtime
220+
identity. This is intended for bundled or trusted official plugins that compose plugin-owned
221+
Gateway capabilities without opening a loopback WebSocket connection.
222+
223+
```typescript
224+
if (await api.runtime.gateway.isAvailable()) {
225+
const result = await api.runtime.gateway.request<{ callId: string }>(
226+
"voicecall.start",
227+
{ to: "+15550001234", mode: "conversation" },
228+
{ timeoutMs: 60_000 },
229+
);
230+
}
231+
```
232+
233+
Requests use `operator.write` scope and do not grant admin scope. Calls from arbitrary external
234+
plugins are rejected. Failed methods throw a `GatewayClientRequestError`, preserving structured
235+
`details`, retry metadata, and the Gateway error code for recovery flows. Use `isAvailable()`
236+
before choosing this path from tools that can also run in standalone agent processes.
237+
217238
</Accordion>
218239
<Accordion title="api.runtime.subagent">
219240
Launch and manage background subagent runs.

extensions/google-meet/index.create.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { CREATE_MEET_FROM_BROWSER_SCRIPT } from "./src/transports/chrome-create.js";
1414

1515
const voiceCallMocks = vi.hoisted(() => ({
16+
createVoiceCallGateway: vi.fn(({ runtime }: { runtime: { gateway: unknown } }) => runtime.gateway),
1617
joinMeetViaVoiceCallGateway: vi.fn(async () => ({
1718
callId: "call-1",
1819
dtmfSent: true,
@@ -46,6 +47,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
4647
});
4748

4849
vi.mock("./src/voice-call-gateway.js", () => ({
50+
createVoiceCallGateway: voiceCallMocks.createVoiceCallGateway,
4951
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
5052
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
5153
speakMeetViaVoiceCallGateway: voiceCallMocks.speakMeetViaVoiceCallGateway,

extensions/google-meet/index.test.ts

Lines changed: 106 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ type GoogleMeetManifestConfigSchema = JsonSchemaObject & {
5959
};
6060

6161
const voiceCallMocks = vi.hoisted(() => ({
62+
createVoiceCallGateway: vi.fn(
63+
({ runtime }: { runtime: { gateway: unknown } }) => runtime.gateway,
64+
),
6265
joinMeetViaVoiceCallGateway: vi.fn(async () => ({
6366
callId: "call-1",
6467
dtmfSent: true,
@@ -99,6 +102,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
99102
});
100103

101104
vi.mock("./src/voice-call-gateway.js", () => ({
105+
createVoiceCallGateway: voiceCallMocks.createVoiceCallGateway,
102106
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,
103107
endMeetVoiceCallGatewayCall: voiceCallMocks.endMeetVoiceCallGatewayCall,
104108
getMeetVoiceCallGatewayCall: voiceCallMocks.getMeetVoiceCallGatewayCall,
@@ -1451,6 +1455,104 @@ describe("google-meet plugin", () => {
14511455
expect(gatewayJoinParams.requesterSessionKey).toBe("agent:main:discord:channel:general");
14521456
});
14531457

1458+
it("keeps Twilio calls on the agent that invoked the tool", async () => {
1459+
const { tools, gatewayRequest } = setup(
1460+
{ defaultTransport: "twilio" },
1461+
{
1462+
gatewayAvailable: true,
1463+
toolContext: { agentId: "Support", sessionKey: "agent:support:main" },
1464+
},
1465+
);
1466+
const tool = tools[0] as {
1467+
execute: (id: string, params: unknown) => Promise<{ details: { session: { id: string } } }>;
1468+
};
1469+
1470+
const result = await tool.execute("id", {
1471+
action: "join",
1472+
url: "https://meet.google.com/abc-defg-hij",
1473+
dialInNumber: "+15551234567",
1474+
});
1475+
1476+
expect(gatewayRequest).toHaveBeenCalledWith(
1477+
"googlemeet.join",
1478+
expect.objectContaining({
1479+
agentId: "support",
1480+
requesterSessionKey: "agent:support:main",
1481+
}),
1482+
{ timeoutMs: 60_000 },
1483+
);
1484+
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
1485+
expect.objectContaining({
1486+
agentId: "support",
1487+
sessionKey: `agent:support:google-meet:${result.details.session.id}`,
1488+
}),
1489+
);
1490+
});
1491+
1492+
it("fails closed for standalone non-default agent routing", async () => {
1493+
const { tools } = setup(
1494+
{},
1495+
{ toolContext: { agentId: "support", sessionKey: "agent:support:main" } },
1496+
);
1497+
const tool = tools[0] as {
1498+
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
1499+
};
1500+
1501+
const result = await tool.execute("id", {
1502+
action: "join",
1503+
url: "https://meet.google.com/abc-defg-hij",
1504+
});
1505+
1506+
expect(result.details.error).toContain("requires a Gateway-hosted agent run");
1507+
});
1508+
1509+
it("preserves structured recovery details from the running Gateway", async () => {
1510+
const { tools } = setup(
1511+
{},
1512+
{ toolContext: { agentId: "main", sessionKey: "agent:main:main" } },
1513+
);
1514+
googleMeetPluginTesting.setCallGatewayFromCliForTests(async () => {
1515+
throw Object.assign(new Error("browser login required"), {
1516+
details: {
1517+
manualActionRequired: true,
1518+
reason: "not-authenticated",
1519+
browser: { profile: "openclaw" },
1520+
},
1521+
});
1522+
});
1523+
const tool = tools[0] as {
1524+
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
1525+
};
1526+
1527+
const result = await tool.execute("id", {
1528+
action: "join",
1529+
url: "https://meet.google.com/abc-defg-hij",
1530+
});
1531+
1532+
expect(result.details).toEqual({
1533+
manualActionRequired: true,
1534+
reason: "not-authenticated",
1535+
browser: { profile: "openclaw" },
1536+
});
1537+
});
1538+
1539+
it("does not accept agent routing from an external gateway caller", async () => {
1540+
const { methods } = setup({ defaultTransport: "twilio" });
1541+
1542+
await invokeGoogleMeetGatewayMethodForTest(methods, "googlemeet.join", {
1543+
url: "https://meet.google.com/abc-defg-hij",
1544+
dialInNumber: "+15551234567",
1545+
agentId: "spoofed",
1546+
});
1547+
1548+
expect(voiceCallMocks.joinMeetViaVoiceCallGateway).toHaveBeenCalledWith(
1549+
expect.objectContaining({
1550+
agentId: undefined,
1551+
sessionKey: expect.stringMatching(/^voice:google-meet:meet_/),
1552+
}),
1553+
);
1554+
});
1555+
14541556
it("explains that Twilio joins need dial-in details", async () => {
14551557
const { tools } = setup({ defaultTransport: "twilio" });
14561558
const tool = tools[0] as {
@@ -1483,12 +1585,10 @@ describe("google-meet plugin", () => {
14831585
const [endParams] = mockCall(voiceCallMocks.endMeetVoiceCallGatewayCall) as [
14841586
Record<string, unknown>,
14851587
];
1486-
expect(requireRecord(endParams.config, "voice-call end config").defaultTransport).toBe(
1487-
"twilio",
1488-
);
1588+
expect(endParams.gateway).toBeDefined();
14891589
expect(endParams.callId).toBe("call-1");
14901590
expect(voiceCallMocks.endMeetVoiceCallGatewayCall).toHaveBeenCalledWith({
1491-
config: endParams.config,
1591+
gateway: endParams.gateway,
14921592
callId: "call-1",
14931593
});
14941594
});
@@ -1543,13 +1643,11 @@ describe("google-meet plugin", () => {
15431643
const [speakParams] = voiceCallMocks.speakMeetViaVoiceCallGateway.mock.calls.at(
15441644
0,
15451645
) as unknown as [Record<string, unknown>];
1546-
expect(requireRecord(speakParams.config, "voice-call speak config").defaultTransport).toBe(
1547-
"twilio",
1548-
);
1646+
expect(speakParams.gateway).toBeDefined();
15491647
expect(speakParams.callId).toBe("call-1");
15501648
expect(speakParams.message).toBe("Say exactly: hello after joining.");
15511649
expect(voiceCallMocks.speakMeetViaVoiceCallGateway).toHaveBeenCalledWith({
1552-
config: speakParams.config,
1650+
gateway: speakParams.gateway,
15531651
callId: "call-1",
15541652
message: "Say exactly: hello after joining.",
15551653
});

extensions/google-meet/index.ts

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "openclaw/plugin-sdk/gateway-runtime";
1313
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
1414
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
15+
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
1516
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
1617
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
1718
import { Type } from "typebox";
@@ -472,8 +473,16 @@ async function callGoogleMeetGatewayFromTool(params: {
472473
config: GoogleMeetConfig;
473474
action: GoogleMeetGatewayToolAction;
474475
raw: Record<string, unknown>;
476+
runtime?: OpenClawPluginApi["runtime"];
475477
}): Promise<unknown> {
476478
try {
479+
if (params.runtime) {
480+
return await params.runtime.gateway.request(
481+
googleMeetGatewayMethodForToolAction(params.action),
482+
params.raw,
483+
{ timeoutMs: resolveGoogleMeetGatewayOperationTimeoutMs(params.config) },
484+
);
485+
}
477486
return await googleMeetToolDeps.callGatewayFromCli(
478487
googleMeetGatewayMethodForToolAction(params.action),
479488
{
@@ -492,6 +501,18 @@ async function callGoogleMeetGatewayFromTool(params: {
492501
}
493502
}
494503

504+
function keepTrustedToolAgentId(
505+
raw: Record<string, unknown>,
506+
client: GatewayRequestHandlerOptions["client"],
507+
): Record<string, unknown> {
508+
const { agentId: rawAgentId, ...rest } = raw;
509+
if (client?.internal?.pluginRuntimeOwnerId !== "google-meet") {
510+
return rest;
511+
}
512+
const agentId = normalizeOptionalString(rawAgentId);
513+
return agentId ? { ...rest, agentId } : rest;
514+
}
515+
495516
async function createMeetFromParams(params: {
496517
config: GoogleMeetConfig;
497518
runtime: OpenClawPluginApi["runtime"];
@@ -724,18 +745,20 @@ export default definePluginEntry({
724745

725746
api.registerGatewayMethod(
726747
"googlemeet.join",
727-
async ({ params, respond }: GatewayRequestHandlerOptions) => {
748+
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
728749
try {
750+
const trustedParams = keepTrustedToolAgentId(asParamRecord(params), client);
729751
const rt = await ensureRuntime();
730752
const result = await rt.join({
731-
url: resolveMeetingInput(config, params?.url),
732-
transport: normalizeTransport(params?.transport),
733-
mode: normalizeMode(params?.mode),
734-
dialInNumber: normalizeOptionalString(params?.dialInNumber),
735-
pin: normalizeOptionalString(params?.pin),
736-
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
737-
message: normalizeOptionalString(params?.message),
738-
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
753+
url: resolveMeetingInput(config, trustedParams.url),
754+
transport: normalizeTransport(trustedParams.transport),
755+
mode: normalizeMode(trustedParams.mode),
756+
dialInNumber: normalizeOptionalString(trustedParams.dialInNumber),
757+
pin: normalizeOptionalString(trustedParams.pin),
758+
dtmfSequence: normalizeOptionalString(trustedParams.dtmfSequence),
759+
message: normalizeOptionalString(trustedParams.message),
760+
requesterSessionKey: normalizeOptionalString(trustedParams.requesterSessionKey),
761+
agentId: normalizeOptionalString(trustedParams.agentId),
739762
});
740763
respond(true, result);
741764
} catch (err) {
@@ -746,9 +769,9 @@ export default definePluginEntry({
746769

747770
api.registerGatewayMethod(
748771
"googlemeet.create",
749-
async ({ params, respond }: GatewayRequestHandlerOptions) => {
772+
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
750773
try {
751-
const raw = asParamRecord(params);
774+
const raw = keepTrustedToolAgentId(asParamRecord(params), client);
752775
respond(
753776
true,
754777
shouldJoinCreatedMeet(raw)
@@ -976,18 +999,20 @@ export default definePluginEntry({
976999

9771000
api.registerGatewayMethod(
9781001
"googlemeet.testSpeech",
979-
async ({ params, respond }: GatewayRequestHandlerOptions) => {
1002+
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
9801003
try {
1004+
const trustedParams = keepTrustedToolAgentId(asParamRecord(params), client);
9811005
const rt = await ensureRuntime();
9821006
const result = await rt.testSpeech({
983-
url: resolveMeetingInput(config, params?.url),
984-
transport: normalizeTransport(params?.transport),
985-
mode: normalizeMode(params?.mode),
986-
dialInNumber: normalizeOptionalString(params?.dialInNumber),
987-
pin: normalizeOptionalString(params?.pin),
988-
dtmfSequence: normalizeOptionalString(params?.dtmfSequence),
989-
message: normalizeOptionalString(params?.message),
990-
requesterSessionKey: normalizeOptionalString(params?.requesterSessionKey),
1007+
url: resolveMeetingInput(config, trustedParams.url),
1008+
transport: normalizeTransport(trustedParams.transport),
1009+
mode: normalizeMode(trustedParams.mode),
1010+
dialInNumber: normalizeOptionalString(trustedParams.dialInNumber),
1011+
pin: normalizeOptionalString(trustedParams.pin),
1012+
dtmfSequence: normalizeOptionalString(trustedParams.dtmfSequence),
1013+
message: normalizeOptionalString(trustedParams.message),
1014+
requesterSessionKey: normalizeOptionalString(trustedParams.requesterSessionKey),
1015+
agentId: normalizeOptionalString(trustedParams.agentId),
9911016
});
9921017
respond(true, result);
9931018
} catch (err) {
@@ -1024,8 +1049,17 @@ export default definePluginEntry({
10241049
async execute(_toolCallId, params) {
10251050
const raw = asParamRecord(params);
10261051
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
1027-
const rawWithRequester = requesterSessionKey ? { ...raw, requesterSessionKey } : raw;
1052+
const agentId = toolContext.agentId ? normalizeAgentId(toolContext.agentId) : undefined;
10281053
try {
1054+
const useTrustedRuntime = agentId ? await api.runtime.gateway.isAvailable() : false;
1055+
if (agentId && agentId !== "main" && !useTrustedRuntime) {
1056+
throw new Error("Per-agent Google Meet routing requires a Gateway-hosted agent run.");
1057+
}
1058+
const rawWithRequester = {
1059+
...raw,
1060+
...(requesterSessionKey ? { requesterSessionKey } : {}),
1061+
...(useTrustedRuntime ? { agentId } : {}),
1062+
};
10291063
assertGoogleMeetAgentToolActionSupported({ config, raw });
10301064
switch (raw.action) {
10311065
case "join": {
@@ -1034,6 +1068,7 @@ export default definePluginEntry({
10341068
config,
10351069
action: "join",
10361070
raw: rawWithRequester,
1071+
runtime: useTrustedRuntime ? api.runtime : undefined,
10371072
}),
10381073
);
10391074
}
@@ -1043,6 +1078,7 @@ export default definePluginEntry({
10431078
config,
10441079
action: "create",
10451080
raw: rawWithRequester,
1081+
runtime: useTrustedRuntime ? api.runtime : undefined,
10461082
}),
10471083
);
10481084
}
@@ -1052,6 +1088,7 @@ export default definePluginEntry({
10521088
config,
10531089
action: "test_speech",
10541090
raw: rawWithRequester,
1091+
runtime: useTrustedRuntime ? api.runtime : undefined,
10551092
}),
10561093
);
10571094
}

extensions/google-meet/src/create.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export async function createAndJoinMeetFromParams(params: {
148148
dtmfSequence: normalizeOptionalString(params.raw.dtmfSequence),
149149
message: normalizeOptionalString(params.raw.message),
150150
requesterSessionKey: normalizeOptionalString(params.raw.requesterSessionKey),
151+
agentId: normalizeOptionalString(params.raw.agentId),
151152
});
152153
return {
153154
...created,

0 commit comments

Comments
 (0)