fix(voice-call): load on supported 2026.9.6 hosts (#159987)

* fix(voice-call): load on supported 2026.9.6 hosts

* test(voice-call): capture the rejected context error
This commit is contained in:
Peter Steinberger 2026-09-27 19:52:02 -07:00 • committed by GitHub
parent 3b1b98223e
commit 7c346c1c65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 282 additions and 19 deletions

View file

@ -168,7 +168,7 @@ that Region. See
- `streaming.sttModel` → `streaming.providers.openai.model`
- `streaming.silenceDurationMs` → `streaming.providers.openai.silenceDurationMs`
- `streaming.vadThreshold` → `streaming.providers.openai.vadThreshold`
- `realtime.agentContext.includeSystemPrompt` is removed. Realtime sessions always include agent-context guidance; `realtime.agentContext` controls optional configured identity and profile files through the shared core resolver. See [Agent voice context](/plugins/voice-call/realtime-and-streaming#agent-voice-context).
- `realtime.agentContext.includeSystemPrompt` is removed. Hosts with the shared context resolver always include agent-context guidance; `realtime.agentContext` controls optional configured identity and profile files. Supported older hosts retain their optional, bounded context capsule. See [Agent voice context](/plugins/voice-call/realtime-and-streaming#agent-voice-context).
</Accordion>
</AccordionGroup>

View file

@ -30,7 +30,7 @@ Runtime behavior:
- On models that support function tools, Voice Call exposes the built-in `openclaw_end_call` realtime tool. It takes no arguments or call ID; the active voice bridge binds it to the current call.
- Voice Call exposes the shared `openclaw_agent_consult` realtime tool by default. GPT-Live uses native delegation to the same call-owned agent consult instead. The realtime model can delegate when the caller asks for deeper reasoning, current information, or normal OpenClaw tools.
- `realtime.consultPolicy` optionally adds guidance for when the realtime model should call `openclaw_agent_consult`.
- Voice Call always tells the realtime model that it speaks for an OpenClaw agent that may have other sessions and work. `realtime.agentContext.enabled` is default-off and controls only the additional configured identity and profile-file context.
- On hosts with the shared context resolver, Voice Call always tells the realtime model that it speaks for an OpenClaw agent that may have other sessions and work. `realtime.agentContext.enabled` is default-off and controls the additional configured identity and profile-file context. Supported older hosts retain the [legacy context behavior](/plugins/voice-call/realtime-and-streaming#agent-voice-context).
- `realtime.fastContext.enabled` is default-off. When enabled, Voice Call first searches indexed memory/session context for the consult question and returns authorized snippets to the realtime model within `realtime.fastContext.timeoutMs` before falling back to the full consult agent only if `realtime.fastContext.fallbackToConsult` is true. The active memory plugin authorizes session-transcript hits; plugins without that capability fail closed for session hits while ordinary memory hits remain available.
- If `realtime.provider` points at an unregistered provider, or no realtime voice provider is registered at all, Voice Call logs a warning and skips realtime media instead of failing the whole plugin.
- `inboundPolicy` must not be `"disabled"` when `realtime.enabled` is true; `validateProviderConfig` rejects that combination.
@ -138,7 +138,7 @@ remain errors; ending the phone session suppresses pending consult results.
### Agent voice context
Every realtime session includes an agent-context paragraph explaining that the
On hosts with the shared context resolver, every realtime session includes an agent-context paragraph explaining that the
voice model speaks for an OpenClaw agent with multiple sessions. It directs
questions about other sessions, running work, progress, or priorities to
OpenClaw. This paragraph stays present when `realtime.agentContext.enabled`
@ -154,6 +154,14 @@ unreadable files are skipped. `maxChars` bounds the profile-file block, with a
default of 6000 characters, and excludes the agent-context paragraph and
configured identity.
OpenClaw 2026.9.6 lacks that shared resolver. On this supported host, Voice Call
retains its shipped optional context capsule: `enabled: false` omits the capsule;
when enabled, identity fields and selected safe workspace-relative files follow
their respective controls. `maxChars` bounds the entire optional capsule,
including identity, headings, and the truncation marker. The newer multi-session
paragraph is unavailable on this path. This compatibility path will retire when
the supported host floor includes the shared context resolver.
Context is added when the realtime session is created, so it does not add per-turn latency.
Calls to `openclaw_agent_consult` still run the full OpenClaw agent and should
be used for tool work, current information, memory lookups, or workspace state.

View file

@ -0,0 +1,78 @@
import { resolveAgentIdentity, resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { root } from "openclaw/plugin-sdk/security-runtime";
import {
asOptionalRecord,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import type { VoiceCallConfig } from "./config.js";
function limitLegacyContextText(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
const truncated = `${truncateUtf16Safe(text, Math.max(0, maxChars - 32)).trimEnd()}\n[truncated]`;
// Count the marker against tiny positive budgets too.
return truncateUtf16Safe(truncated, maxChars);
}
// Preserve the shipped 2026.9.6 capsule until the supported host floor supplies the shared composer.
export async function buildLegacyRealtimeVoiceAgentContext(params: {
config: VoiceCallConfig["realtime"]["agentContext"];
coreConfig: OpenClawConfig;
agentId: string;
}): Promise<string | undefined> {
const { config, coreConfig, agentId } = params;
if (!config.enabled) {
return undefined;
}
const capsule = [
"OpenClaw agent voice context:",
`- Agent id: ${agentId}`,
"- Use this context to match the OpenClaw agent's personality and standing preferences on fast voice turns.",
"- Treat this as compact context only; call openclaw_agent_consult when the caller needs the full agent brain, tools, memory, or workspace state.",
];
if (config.includeIdentity) {
const identity = asOptionalRecord(resolveAgentIdentity(coreConfig, agentId));
const fields = [
["Name", identity?.name],
["Emoji", identity?.emoji],
["Vibe", identity?.vibe],
["Theme", identity?.theme],
["Creature/persona", identity?.creature],
] as const;
const lines = fields.flatMap(([label, value]) => {
const text = normalizeOptionalString(value);
return text ? [`- ${label}: ${text}`] : [];
});
if (lines.length > 0) {
capsule.push(`Configured identity:\n${lines.join("\n")}`);
}
}
if (config.includeWorkspaceFiles) {
const workspace = await root(resolveAgentWorkspaceDir(coreConfig, agentId)).catch(() => null);
if (workspace) {
const sections: string[] = [];
let remaining = config.maxChars;
for (const file of config.files) {
if (remaining <= 0) {
continue;
}
const content = await workspace.readText(file).catch(() => undefined);
const trimmed = content?.trim();
if (!trimmed) {
continue;
}
const body = limitLegacyContextText(trimmed, Math.max(0, remaining - file.length - 16));
const section = `### ${file}\n${body}`;
sections.push(section);
remaining -= section.length;
}
if (sections.length > 0) {
capsule.push(`Workspace voice context:\n${sections.join("\n\n")}`);
}
}
}
return limitLegacyContextText(capsule.join("\n\n"), config.maxChars);
}

View file

@ -1,15 +1,44 @@
// Voice Call tests cover realtime agent context plugin behavior.
import { writeFile } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { useAutoCleanupTempDirTracker } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { VoiceCallConfig } from "./config.js";
import { buildRealtimeVoiceInstructions } from "./realtime-agent-context.js";
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const host = vi.hoisted((): { modernAvailable: boolean; rejection: Error | undefined } => ({
modernAvailable: true,
rejection: undefined,
}));
vi.mock("openclaw/plugin-sdk/realtime-bootstrap-context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/realtime-bootstrap-context")>();
return {
...actual,
get resolveRealtimeVoiceAgentContextInstructions() {
if (!host.modernAvailable) {
return undefined;
}
const rejection = host.rejection;
return rejection
? async () => {
throw rejection;
}
: actual.resolveRealtimeVoiceAgentContextInstructions;
},
};
});
beforeEach(() => {
host.modernAvailable = true;
host.rejection = undefined;
});
function createCoreConfig(workspace: string): OpenClawConfig {
return {
agents: {
@ -53,6 +82,19 @@ function createConfig(overrides?: Partial<VoiceCallConfig["realtime"]>): VoiceCa
}
describe("buildRealtimeVoiceInstructions", () => {
it("propagates a present modern composer's rejection without legacy fallback", async () => {
const rejection = new Error("modern context rejected");
host.rejection = rejection;
await expect(
buildRealtimeVoiceInstructions({
baseInstructions: "Base voice instructions.",
config: createConfig(),
coreConfig: {},
agentId: "voice",
}),
).rejects.toBe(rejection);
});
it("injects bounded identity and workspace context", async () => {
const workspaceDir = tempDirs.make("openclaw-voice-context-");
await writeFile(path.join(workspaceDir, "SOUL.md"), "Stay quick, direct, and warm.\n");
@ -141,3 +183,121 @@ describe("buildRealtimeVoiceInstructions", () => {
expect(instructions.includes("Workspace persona.")).toBe(settings.profile);
});
});
describe("published 2026.9.6 voice context", () => {
beforeEach(() => {
host.modernAvailable = false;
});
it("preserves identity, selected custom files and workspace containment", async () => {
const parentDir = tempDirs.make("openclaw-legacy-voice-context-");
const workspace = path.join(parentDir, "workspace");
await mkdir(workspace);
await writeFile(path.join(parentDir, "OUTSIDE.md"), "OUTSIDE_CONTEXT");
await writeFile(path.join(workspace, "VOICE.md"), "Custom voice preferences.");
await writeFile(path.join(workspace, "SOUL.md"), "Workspace persona.");
const instructions = await buildRealtimeVoiceInstructions({
baseInstructions: "Base voice instructions.",
config: createConfig({
consultPolicy: "substantive",
agentContext: {
enabled: true,
includeIdentity: true,
includeWorkspaceFiles: true,
maxChars: 2000,
files: ["VOICE.md", "SOUL.md", "../OUTSIDE.md", "missing.md"],
},
}),
coreConfig: createCoreConfig(workspace),
agentId: "voice",
});
expect(instructions).toContain("Consult behavior:");
expect(instructions).toContain("OpenClaw agent voice context:");
expect(instructions).toContain("- Agent id: voice");
expect(instructions).toContain("- Name: Claw Voice");
expect(instructions).toContain("- Theme: bright");
expect(instructions).toContain("### VOICE.md\nCustom voice preferences.");
expect(instructions).toContain("### SOUL.md\nWorkspace persona.");
expect(instructions.indexOf("### VOICE.md")).toBeLessThan(instructions.indexOf("### SOUL.md"));
expect(instructions).not.toContain("OUTSIDE_CONTEXT");
expect(instructions).not.toContain("missing.md");
expect(instructions).not.toContain("Agent context: You speak for an OpenClaw agent");
});
it.each([
{
enabled: false,
includeIdentity: true,
includeWorkspaceFiles: true,
identity: false,
workspace: false,
},
{
enabled: true,
includeIdentity: true,
includeWorkspaceFiles: false,
identity: true,
workspace: false,
},
{
enabled: true,
includeIdentity: false,
includeWorkspaceFiles: true,
identity: false,
workspace: true,
},
])("honors shipped context controls: %j", async (settings) => {
const workspace = tempDirs.make("openclaw-legacy-voice-controls-");
await writeFile(path.join(workspace, "SOUL.md"), "Legacy workspace persona.");
const instructions = await buildRealtimeVoiceInstructions({
baseInstructions: "Base voice instructions.",
config: createConfig({
agentContext: {
enabled: settings.enabled,
includeIdentity: settings.includeIdentity,
includeWorkspaceFiles: settings.includeWorkspaceFiles,
maxChars: 2000,
files: ["SOUL.md"],
},
}),
coreConfig: createCoreConfig(workspace),
agentId: "voice",
});
expect(instructions.includes("Configured identity:")).toBe(settings.identity);
expect(instructions.includes("Legacy workspace persona.")).toBe(settings.workspace);
expect(instructions.includes("OpenClaw agent voice context:")).toBe(settings.enabled);
if (!settings.enabled) {
expect(instructions).toBe("Base voice instructions.");
}
});
it.each([200, 8])(
"bounds the entire legacy capsule to %i characters, including its marker",
async (maxChars) => {
const workspace = tempDirs.make("openclaw-legacy-voice-budget-");
await writeFile(path.join(workspace, "SOUL.md"), "Workspace persona. ".repeat(100));
const base = "Base voice instructions.";
const instructions = await buildRealtimeVoiceInstructions({
baseInstructions: base,
config: createConfig({
agentContext: {
enabled: true,
includeIdentity: true,
includeWorkspaceFiles: true,
maxChars,
files: ["SOUL.md"],
},
}),
coreConfig: createCoreConfig(workspace),
agentId: "voice",
});
expect(instructions.startsWith(`${base}\n\n`)).toBe(true);
const capsule = instructions.slice(base.length + 2);
expect(capsule.length).toBeGreaterThan(0);
expect(capsule.length).toBeLessThanOrEqual(maxChars);
if (maxChars === 200) {
expect(capsule).toMatch(/\n\[truncated\]$/);
}
},
);
});

View file

@ -1,8 +1,12 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveRealtimeVoiceAgentContextInstructions } from "openclaw/plugin-sdk/realtime-bootstrap-context";
import * as realtimeBootstrapSdk from "openclaw/plugin-sdk/realtime-bootstrap-context";
import { buildRealtimeVoiceAgentConsultPolicyInstructions } from "openclaw/plugin-sdk/realtime-voice";
import type { VoiceCallConfig } from "./config.js";
const contextSdk: Partial<
Pick<typeof realtimeBootstrapSdk, "resolveRealtimeVoiceAgentContextInstructions">
> = realtimeBootstrapSdk;
/** Build final realtime instructions from base instructions, consult policy, and agent context. */
export async function buildRealtimeVoiceInstructions(params: {
baseInstructions: string;
@ -13,19 +17,32 @@ export async function buildRealtimeVoiceInstructions(params: {
}): Promise<string> {
const { config } = params;
const contextConfig = config.realtime.agentContext;
return [
const sections = [
params.baseInstructions,
buildRealtimeVoiceAgentConsultPolicyInstructions(config.realtime),
await resolveRealtimeVoiceAgentContextInstructions({
config: params.coreConfig,
agentId: params.agentId,
files:
contextConfig.enabled && contextConfig.includeWorkspaceFiles ? contextConfig.files : [],
includeIdentity: contextConfig.enabled && contextConfig.includeIdentity,
maxChars: contextConfig.maxChars,
warn: params.warn,
}),
]
.filter(Boolean)
.join("\n\n");
];
if (contextSdk.resolveRealtimeVoiceAgentContextInstructions) {
sections.push(
await contextSdk.resolveRealtimeVoiceAgentContextInstructions({
config: params.coreConfig,
agentId: params.agentId,
files:
contextConfig.enabled && contextConfig.includeWorkspaceFiles ? contextConfig.files : [],
includeIdentity: contextConfig.enabled && contextConfig.includeIdentity,
maxChars: contextConfig.maxChars,
warn: params.warn,
}),
);
} else {
const { buildLegacyRealtimeVoiceAgentContext } =
await import("./realtime-agent-context.legacy.js");
sections.push(
await buildLegacyRealtimeVoiceAgentContext({
config: contextConfig,
coreConfig: params.coreConfig,
agentId: params.agentId,
}),
);
}
return sections.filter(Boolean).join("\n\n");
}