Skip to content

Commit 0416619

Browse files
authored
fix(agents): guard delivery-evidence attachment recursion against cycles
1 parent d3e19ab commit 0416619

46 files changed

Lines changed: 3820 additions & 284 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/plugins/voice-call.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,18 @@ each carrier call should start with fresh context, for example reception,
211211
booking, IVR, or Google Meet bridge flows where the same phone number may
212212
represent different meetings.
213213

214+
Voice Call stores generated session keys under the configured agent namespace
215+
(`agent:<agentId>:voice:*`) so call memory survives Gateway session-key
216+
canonicalization after restarts. Raw explicit integration keys use the same
217+
agent namespace. A canonical `agent:<configuredAgentId>:*` key keeps that owner,
218+
and its main aliases honor core `session.mainKey` and global scope. Foreign or
219+
malformed `agent:*` input is scoped as an opaque key under the configured agent;
220+
`global` and `unknown` remain global sentinels. Gateway startup promotes older
221+
raw keys in default or `{agentId}`-templated stores where the path proves one
222+
owner. In fixed custom stores, ambiguous legacy rows remain untouched because
223+
they do not contain enough information to choose an owner; new calls use
224+
canonical agent-scoped history.
225+
214226
## Realtime voice conversations
215227

216228
`realtime` selects a full-duplex realtime voice provider for live call

extensions/voice-call/doctor-contract-api.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
PluginDoctorStateMigrationContext,
1313
} from "openclaw/plugin-sdk/runtime-doctor";
1414
import { afterEach, beforeEach, describe, expect, it } from "vitest";
15-
import { stateMigrations } from "./doctor-contract-api.js";
15+
import { resolveSessionStoreAgentIds, stateMigrations } from "./doctor-contract-api.js";
1616
import {
1717
createTestStorePath,
1818
makePersistedCall,
@@ -68,6 +68,42 @@ describe("voice-call doctor state migration", () => {
6868
await fs.rm(storePath, { recursive: true, force: true });
6969
});
7070

71+
it("reports top-level and per-number session-store agents", () => {
72+
expect(
73+
resolveSessionStoreAgentIds({
74+
cfg: {
75+
plugins: {
76+
entries: {
77+
"voice-call": {
78+
config: {
79+
agentId: "Voice",
80+
numbers: {
81+
"+15550001111": { agentId: "Cards" },
82+
"+15550002222": {},
83+
},
84+
},
85+
},
86+
},
87+
},
88+
},
89+
}),
90+
).toEqual(["cards", "voice"]);
91+
expect(
92+
resolveSessionStoreAgentIds({
93+
cfg: {
94+
plugins: { entries: { "@openclaw/voice-call": { config: {} } } },
95+
},
96+
}),
97+
).toEqual(["main"]);
98+
expect(
99+
resolveSessionStoreAgentIds({
100+
cfg: {
101+
plugins: { entries: { "voice-call": { enabled: true } } },
102+
},
103+
}),
104+
).toEqual(["main"]);
105+
});
106+
71107
it("imports legacy calls.jsonl into plugin state", async () => {
72108
const sourcePath = path.join(storePath, "calls.jsonl");
73109
const call = makePersistedCall({

extensions/voice-call/doctor-contract-api.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import fs from "node:fs/promises";
33
import os from "node:os";
44
import path from "node:path";
5+
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
6+
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
57
import type {
68
PluginDoctorStateMigration,
79
PluginStateKeyedStore,
@@ -81,6 +83,36 @@ type PluginDoctorStateMigrationParams = Parameters<
8183
PluginDoctorStateMigration["detectLegacyState"]
8284
>[0];
8385

86+
function asRecord(value: unknown): Record<string, unknown> | undefined {
87+
return value && typeof value === "object" && !Array.isArray(value)
88+
? (value as Record<string, unknown>)
89+
: undefined;
90+
}
91+
92+
/** Return Voice Call agents whose templated core session stores need migration. */
93+
export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): string[] {
94+
const agentIds = new Set<string>();
95+
for (const pluginId of ["voice-call", "@openclaw/voice-call"]) {
96+
const entry = params.cfg.plugins?.entries?.[pluginId];
97+
if (!entry) {
98+
continue;
99+
}
100+
const config = entry.config === undefined ? {} : asRecord(entry.config);
101+
if (!config) {
102+
continue;
103+
}
104+
agentIds.add(normalizeAgentId(typeof config.agentId === "string" ? config.agentId : undefined));
105+
const numbers = asRecord(config.numbers);
106+
for (const route of Object.values(numbers ?? {})) {
107+
const agentId = asRecord(route)?.agentId;
108+
if (typeof agentId === "string") {
109+
agentIds.add(normalizeAgentId(agentId));
110+
}
111+
}
112+
}
113+
return [...agentIds].toSorted();
114+
}
115+
84116
/** Resolve the voice-call store path used by legacy and plugin-state call records. */
85117
function resolveVoiceCallStorePath(params: {
86118
config: PluginDoctorStateMigrationParams["config"];

extensions/voice-call/src/config.test.ts

Lines changed: 178 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import { afterEach, beforeEach, describe, expect, it } from "vitest";
33
import {
44
VoiceCallConfigSchema,
5+
resolveVoiceCallAgentSessionKey,
56
resolveTwilioAuthToken,
67
resolveVoiceCallEffectiveConfig,
78
resolveVoiceCallNumberRouteKey,
9+
resolveVoiceCallNumberRouteKeyForCall,
810
resolveVoiceCallSessionKey,
911
validateProviderConfig,
1012
normalizeVoiceCallConfig,
@@ -296,7 +298,23 @@ describe("resolveVoiceCallConfig session routing", () => {
296298
callId: "call-123",
297299
phone: "+1 (555) 000-1111",
298300
}),
299-
).toBe("voice:15550001111");
301+
).toBe("agent:main:voice:15550001111");
302+
});
303+
304+
it("scopes generated voice session keys by configured agent", () => {
305+
const config = resolveVoiceCallConfig({
306+
enabled: true,
307+
provider: "mock",
308+
agentId: "Voice",
309+
});
310+
311+
expect(
312+
resolveVoiceCallSessionKey({
313+
config,
314+
callId: "CALL-123",
315+
phone: "+1 (555) 000-1111",
316+
}),
317+
).toBe("agent:voice:voice:15550001111");
300318
});
301319

302320
it("can scope voice sessions to each call", () => {
@@ -313,10 +331,10 @@ describe("resolveVoiceCallConfig session routing", () => {
313331
callId: "call-123",
314332
phone: "+1 (555) 000-1111",
315333
}),
316-
).toBe("voice:call:call-123");
334+
).toBe("agent:main:voice:call:call-123");
317335
});
318336

319-
it("preserves explicit voice session keys", () => {
337+
it("scopes explicit voice session keys by configured agent", () => {
320338
const config = resolveVoiceCallConfig({
321339
enabled: true,
322340
provider: "mock",
@@ -328,9 +346,135 @@ describe("resolveVoiceCallConfig session routing", () => {
328346
config,
329347
callId: "call-123",
330348
phone: "+1 (555) 000-1111",
331-
explicitSessionKey: "meet-room-1",
349+
explicitSessionKey: "Meet-Room-1",
350+
}),
351+
).toBe("agent:main:meet-room-1");
352+
});
353+
354+
it("scopes persisted and explicit keys at the agent session boundary", () => {
355+
const config = resolveVoiceCallConfig({
356+
enabled: true,
357+
provider: "mock",
358+
agentId: "Voice",
359+
});
360+
361+
expect(
362+
resolveVoiceCallAgentSessionKey({
363+
config,
364+
sessionKey: "voice:call:legacy-call",
365+
}),
366+
).toBe("agent:voice:voice:call:legacy-call");
367+
expect(
368+
resolveVoiceCallAgentSessionKey({
369+
config,
370+
sessionKey: "meet-room-1",
371+
}),
372+
).toBe("agent:voice:meet-room-1");
373+
expect(
374+
resolveVoiceCallAgentSessionKey({
375+
config,
376+
sessionKey: "agent:main:shared-room",
377+
}),
378+
).toBe("agent:voice:agent:main:shared-room");
379+
expect(
380+
resolveVoiceCallAgentSessionKey({
381+
config,
382+
sessionKey: "agent:other:Matrix:Channel:!RoomAbC:example.org",
383+
}),
384+
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
385+
expect(
386+
resolveVoiceCallAgentSessionKey({
387+
config,
388+
sessionKey: "agent:voice:agent:other:matrix:channel:!RoomAbC:example.org",
389+
}),
390+
).toBe("agent:voice:agent:other:matrix:channel:!RoomAbC:example.org");
391+
expect(
392+
resolveVoiceCallAgentSessionKey({
393+
config,
394+
sessionKey: "Signal:Group:AbC123=",
395+
}),
396+
).toBe("agent:voice:signal:group:AbC123=");
397+
expect(
398+
resolveVoiceCallAgentSessionKey({
399+
config,
400+
sessionKey: "agent:broken",
401+
}),
402+
).toBe("agent:voice:agent:broken");
403+
expect(
404+
resolveVoiceCallAgentSessionKey({
405+
config,
406+
sessionKey: "agent::broken",
407+
}),
408+
).toBe("agent:voice:agent::broken");
409+
expect(
410+
resolveVoiceCallAgentSessionKey({
411+
config,
412+
sessionKey: "agent::Matrix:Channel:!RoomAbC:example.org",
413+
}),
414+
).toBe("agent:voice:agent::matrix:channel:!RoomAbC:example.org");
415+
expect(
416+
resolveVoiceCallAgentSessionKey({
417+
config,
418+
sessionKey: "agent:other:room::part",
419+
}),
420+
).toBe("agent:voice:agent:other:room::part");
421+
expect(
422+
resolveVoiceCallAgentSessionKey({
423+
config,
424+
sessionKey: "agent:voice:room::part",
425+
}),
426+
).toBe("agent:voice:room::part");
427+
expect(
428+
resolveVoiceCallAgentSessionKey({
429+
config,
430+
sessionKey: "agent:voice::Matrix:Channel:!RoomAbC:example.org",
431+
}),
432+
).toBe("agent:voice:agent:voice::matrix:channel:!RoomAbC:example.org");
433+
expect(
434+
resolveVoiceCallAgentSessionKey({
435+
config,
436+
sessionKey: "agent:bad/id:room",
437+
}),
438+
).toBe("agent:voice:agent:bad/id:room");
439+
});
440+
441+
it("canonicalizes raw and scoped main aliases with the core session config", () => {
442+
const config = resolveVoiceCallConfig({
443+
enabled: true,
444+
provider: "mock",
445+
agentId: "Voice",
446+
});
447+
448+
for (const sessionKey of ["main", "agent:voice:main"]) {
449+
expect(
450+
resolveVoiceCallAgentSessionKey({
451+
config,
452+
sessionKey,
453+
coreSession: { mainKey: "work" },
454+
}),
455+
).toBe("agent:voice:work");
456+
}
457+
expect(
458+
resolveVoiceCallAgentSessionKey({
459+
config,
460+
sessionKey: "main",
461+
coreSession: { scope: "global" },
462+
}),
463+
).toBe("global");
464+
expect(
465+
resolveVoiceCallAgentSessionKey({
466+
config,
467+
sessionKey: "agent:main:main",
468+
coreSession: { mainKey: "work" },
469+
}),
470+
).toBe("agent:voice:agent:main:main");
471+
expect(
472+
resolveVoiceCallAgentSessionKey({
473+
config,
474+
sessionKey: "agent:main:main",
475+
coreSession: { scope: "global" },
332476
}),
333-
).toBe("meet-room-1");
477+
).toBe("agent:voice:agent:main:main");
334478
});
335479

336480
it("resolves per-number inbound route overrides over global voice settings", () => {
@@ -395,6 +539,35 @@ describe("resolveVoiceCallConfig session routing", () => {
395539
expect(effective.config).toBe(config);
396540
expect(effective.config.inboundGreeting).toBe("Hello from global.");
397541
});
542+
543+
it("uses dialed-number fallback only for inbound calls", () => {
544+
expect(
545+
resolveVoiceCallNumberRouteKeyForCall({
546+
direction: "inbound",
547+
to: "+15550001111",
548+
}),
549+
).toBe("+15550001111");
550+
expect(
551+
resolveVoiceCallNumberRouteKeyForCall({
552+
direction: "outbound",
553+
to: "+15550001111",
554+
}),
555+
).toBeUndefined();
556+
expect(
557+
resolveVoiceCallNumberRouteKeyForCall({
558+
direction: "inbound",
559+
to: "+15550001111",
560+
metadata: { numberRouteKey: "+15550002222" },
561+
}),
562+
).toBe("+15550002222");
563+
expect(
564+
resolveVoiceCallNumberRouteKeyForCall({
565+
direction: "outbound",
566+
to: "+15550001111",
567+
metadata: { numberRouteKey: "+15550002222" },
568+
}),
569+
).toBeUndefined();
570+
});
398571
});
399572

400573
describe("normalizeVoiceCallConfig", () => {

0 commit comments

Comments
 (0)