fix(voice-call): preserve tool invocation context (#116856)

* fix(voice-call): preserve tool invocation context

* fix(voice-call): trust requester tool context

* style(voice-call): format invocation context test
This commit is contained in:
Dallin Romney
2026-08-01 13:23:24 +08:00
committed by GitHub
parent 4fb19fb8c5
commit 8b1a9ccaa4
3 changed files with 63 additions and 39 deletions
+33 -15
View File
@@ -758,23 +758,41 @@ describe("voice-call plugin", () => {
expect(error?.message).not.toContain("endedAt=");
});
it("freezes the invoking agent on tool-created calls", async () => {
const { tools } = setup({ provider: "mock" }, { agentId: "support" });
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<unknown>;
};
it.each([{ action: "initiate_call", message: "Hello" }, { message: "Hello" }])(
"freezes invocation context for tool-created calls ($action)",
async (params) => {
const { tools } = setup(
{ provider: "mock" },
{ agentId: "support", sessionKey: "agent:support:discord:channel:general" },
);
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<unknown>;
};
await tool.execute("id", {
action: "initiate_call",
to: "+15550001234",
message: "Hello",
});
await tool.execute("id", {
...params,
to: "+15550001234",
requesterSessionKey: "agent:spoofed:requester",
sessionKey: "agent:support:voice:call-1",
});
expect(runtimeStub.manager["initiateCall"]).toHaveBeenCalledWith(
"+15550001234",
undefined,
expect.objectContaining({ agentId: "support", message: "Hello" }),
);
expect(runtimeStub.manager["initiateCall"]).toHaveBeenCalledWith(
"+15550001234",
"agent:support:voice:call-1",
expect.objectContaining({
agentId: "support",
message: "Hello",
requesterSessionKey: "agent:support:discord:channel:general",
}),
);
},
);
it("does not expose requester session identity to the model", () => {
const { tools } = setup({ provider: "mock" });
const tool = tools[0] as { parameters: unknown };
expect(JSON.stringify(tool.parameters)).not.toContain("requesterSessionKey");
});
it("tool get_status returns json payload", async () => {
+23 -20
View File
@@ -2,7 +2,7 @@
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ErrorCodes, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing";
import {
asOptionalRecord,
normalizeOptionalString,
@@ -189,9 +189,6 @@ const VoiceCallToolSchema = Type.Union([
message: Type.String({ description: "Intro message" }),
mode: Type.Optional(Type.Union([Type.Literal("notify"), Type.Literal("conversation")])),
sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
requesterSessionKey: Type.Optional(
Type.String({ description: "OpenClaw session key that initiated the call" }),
),
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })),
}),
Type.Object({
@@ -223,9 +220,6 @@ const VoiceCallToolSchema = Type.Union([
sid: Type.Optional(Type.String({ description: "Call SID" })),
message: Type.Optional(Type.String({ description: "Optional intro message" })),
sessionKey: Type.Optional(Type.String({ description: "OpenClaw session key for the call" })),
requesterSessionKey: Type.Optional(
Type.String({ description: "OpenClaw session key that initiated the call" }),
),
dtmfSequence: Type.Optional(Type.String({ description: "DTMF digits to play before connect" })),
}),
]);
@@ -723,7 +717,13 @@ export default definePluginEntry({
parameters: VoiceCallToolSchema,
async execute(_toolCallId, params) {
const rawParams = asParamRecord(params);
const agentId = normalizeOptionalString(toolContext.agentId);
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
// Agent ownership and requester lineage come from trusted tool context.
// Some harnesses omit agentId but retain its canonical session key.
const contextAgentId =
normalizeOptionalString(toolContext.agentId) ??
parseAgentSessionKey(requesterSessionKey)?.agentId;
const agentId = contextAgentId ? normalizeAgentId(contextAgentId) : undefined;
try {
const rt = await ensureRuntime();
@@ -738,15 +738,20 @@ export default definePluginEntry({
if (!to) {
throw new Error("to required");
}
const result = await rt.manager.initiateCall(to, undefined, {
message,
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
mode:
rawParams.mode === "notify" || rawParams.mode === "conversation"
? rawParams.mode
: undefined,
...(agentId ? { agentId } : {}),
});
const result = await rt.manager.initiateCall(
to,
normalizeOptionalString(rawParams.sessionKey),
{
message,
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
mode:
rawParams.mode === "notify" || rawParams.mode === "conversation"
? rawParams.mode
: undefined,
...(agentId ? { agentId } : {}),
...(requesterSessionKey ? { requesterSessionKey } : {}),
},
);
if (!result.success) {
throw new Error(result.error || "initiate failed");
}
@@ -833,9 +838,7 @@ export default definePluginEntry({
dtmfSequence: normalizeOptionalString(rawParams.dtmfSequence),
message: normalizeOptionalString(rawParams.message),
...(agentId ? { agentId } : {}),
...(normalizeOptionalString(rawParams.requesterSessionKey)
? { requesterSessionKey: normalizeOptionalString(rawParams.requesterSessionKey) }
: {}),
...(requesterSessionKey ? { requesterSessionKey } : {}),
},
);
if (!result.success) {
@@ -207,7 +207,7 @@ async function runVoiceCallProof(options: ProducerOptions): Promise<string> {
to: "+15550002222",
message: "Gateway RPC fixture",
mode: "conversation",
sessionKey: "agent:main:voice-rpc",
sessionKey: "agent:qa:voice-rpc",
});
const rpcCallId = findStringByKey(rpc, "callId");
if (!rpcCallId) {
@@ -215,14 +215,13 @@ async function runVoiceCallProof(options: ProducerOptions): Promise<string> {
}
const tool = await gateway.call("tools.invoke", {
name: "voice_call",
sessionKey: "agent:main:requester",
sessionKey: "agent:qa:requester",
args: {
action: "initiate_call",
to: "+15550003333",
message: "Agent tool fixture",
mode: "conversation",
sessionKey: "agent:main:voice-consult",
requesterSessionKey: "agent:main:requester",
sessionKey: "agent:qa:voice-consult",
},
});
const toolCallId = findStringByKey(tool, "callId");
@@ -255,6 +254,10 @@ async function runVoiceCallProof(options: ProducerOptions): Promise<string> {
streamUrl: stream.streamUrl,
});
const toolResults = await waitForFinalToolResult(fixture.toolResultsPath);
const finalToolResult = toolResults.final.result as Record<string, unknown>;
if (typeof finalToolResult.error === "string") {
throw new Error(`embedded consult failed: ${finalToolResult.error}`);
}
const bridgeCalls = (await fs.readFile(fixture.bridgeCallsPath, "utf8"))
.split("\n")
.map((line) => line.trim())