mirror of
https://github.com/openclaw/openclaw.git
synced 2026-11-02 05:27:21 +00:00
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:
parent
3b1b98223e
commit
7c346c1c65
5 changed files with 282 additions and 19 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
78
extensions/voice-call/src/realtime-agent-context.legacy.ts
Normal file
78
extensions/voice-call/src/realtime-agent-context.legacy.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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\]$/);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue