mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user