diff --git a/extensions/discord/src/voice/realtime-session.runtime.ts b/extensions/discord/src/voice/realtime-session.runtime.ts index 6946e305e39a..1a455fde214f 100644 --- a/extensions/discord/src/voice/realtime-session.runtime.ts +++ b/extensions/discord/src/voice/realtime-session.runtime.ts @@ -227,6 +227,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { providerConfigs: buildProviderConfigs(this.realtimeConfig), providerConfigOverrides: buildProviderConfigOverrides(this.realtimeConfig), cfg: this.params.cfg, + agentId: this.params.entry.route.agentId, defaultModel: this.realtimeConfig?.model, noRegisteredProviderMessage: "No configured realtime voice provider registered", }); @@ -284,6 +285,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession { this.bridge = this.harness.createBridge({ provider: resolved.provider, cfg: this.params.cfg, + agentId: this.params.entry.route.agentId, providerConfig: resolved.providerConfig, audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, instructions, diff --git a/extensions/discord/src/voice/voice-session.lifecycle.test.ts b/extensions/discord/src/voice/voice-session.lifecycle.test.ts index b7982704ea38..b4462650406d 100644 --- a/extensions/discord/src/voice/voice-session.lifecycle.test.ts +++ b/extensions/discord/src/voice/voice-session.lifecycle.test.ts @@ -15,7 +15,9 @@ defineDiscordVoiceTests( joinVoiceChannelMock, entersStateMock, createAudioPlayerMock, + resolveAgentRouteMock, resolveRealtimeBootstrapContextInstructionsMock, + resolveConfiguredRealtimeVoiceProviderMock, createRealtimeVoiceBridgeSessionMock, realtimeSessionMock, managerModule, @@ -420,6 +422,44 @@ defineDiscordVoiceTests( expectConnectedStatus(manager, "1002"); }); + it("preserves the routed agent through realtime autoJoin startup", async () => { + resolveAgentRouteMock.mockReturnValue({ + agentId: "molty", + sessionKey: "agent:molty:discord:channel:g1:1001", + }); + resolveConfiguredRealtimeVoiceProviderMock.mockImplementation((params?: unknown) => { + if (requireRecord(params, "provider resolution params").agentId !== "molty") { + throw new Error("AGENT_SELECTION_REQUIRED: expected routed agent molty"); + } + return { + provider: { id: "openai", capabilities: { supportsActivationNameGating: true } }, + providerConfig: { model: "gpt-realtime-2", voice: "cedar" }, + }; + }); + createRealtimeVoiceBridgeSessionMock.mockImplementation((params?: unknown) => { + if (requireRecord(params, "bridge session params").agentId !== "molty") { + throw new Error("AGENT_SELECTION_REQUIRED: expected routed agent molty"); + } + return realtimeSessionMock; + }); + const manager = createManager( + makeVoiceConfig({ + mode: "agent-proxy", + autoJoin: [{ guildId: "g1", channelId: "1001" }], + realtime: { provider: "openai" }, + }), + undefined, + { agents: { list: [{ id: "helper" }, { id: "molty" }] } }, + ); + + await manager.autoJoin(); + + expect(resolveConfiguredRealtimeVoiceProviderMock).toHaveBeenCalledTimes(1); + expect(createRealtimeVoiceBridgeSessionMock).toHaveBeenCalledTimes(1); + expect(realtimeSessionMock.connect).toHaveBeenCalledTimes(1); + expectConnectedStatus(manager, "1001"); + }); + it("suppresses repeated autoJoin attempts after fatal realtime startup failures", async () => { realtimeSessionMock.connect.mockRejectedValueOnce(new Error("Incorrect API key provided")); const manager = createManager( diff --git a/extensions/google-meet/src/realtime.process.test.ts b/extensions/google-meet/src/realtime.process.test.ts index 13caf25d327a..b0969f045331 100644 --- a/extensions/google-meet/src/realtime.process.test.ts +++ b/extensions/google-meet/src/realtime.process.test.ts @@ -446,6 +446,60 @@ describe("local Meet realtime transport process stream errors", () => { }); describe("Google Meet bidi realtime engine cleanup", () => { + it("preserves the configured realtime agent through provider startup", async () => { + let bridgeRequest: Parameters[0] | undefined; + const isConfigured = vi.fn(({ agentId }) => agentId === "molty"); + const bridge = { + connect: vi.fn(async () => {}), + sendAudio: vi.fn(), + sendUserMessage: vi.fn(), + setMediaTimestamp: vi.fn(), + submitToolResult: vi.fn(), + acknowledgeMark: vi.fn(), + close: vi.fn(), + triggerGreeting: vi.fn(), + isConnected: vi.fn(() => true), + }; + const provider: RealtimeVoiceProviderPlugin = { + id: "openai", + label: "OpenAI", + isConfigured, + createBridge: (request) => { + bridgeRequest = request; + return bridge; + }, + }; + const transport: MeetingRealtimeAudioTransport = { + onFatal: vi.fn(), + startInput: vi.fn(), + stop: vi.fn(async () => {}), + writeOutput: vi.fn(async () => {}), + clearOutput: vi.fn(async () => {}), + dispose: vi.fn(async () => {}), + }; + const config = resolveGoogleMeetConfig({ + realtime: { strategy: "bidi", provider: "openai", agentId: "molty" }, + }); + const fullConfig = { + agents: { list: [{ id: "helper" }, { id: "molty" }] }, + } as never; + + const handle = await startMeetingRealtimeEngine({ + config, + fullConfig, + runtime: {} as never, + ...GOOGLE_MEET_ENGINE_BINDINGS, + meetingSessionId: "meet-routed-agent", + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + providers: [provider], + transport, + }); + + expect(isConfigured).toHaveBeenCalledWith(expect.objectContaining({ agentId: "molty" })); + expect(bridgeRequest?.agentId).toBe("molty"); + await handle.stop(); + }); + it("disposes the audio transport when provider connection fails", async () => { const connectError = new Error("voice bridge connect failed"); const stopError = new Error("transport stop failed"); diff --git a/extensions/openai/realtime-voice-bridge.ts b/extensions/openai/realtime-voice-bridge.ts index 83c972b2c033..6425dea71504 100644 --- a/extensions/openai/realtime-voice-bridge.ts +++ b/extensions/openai/realtime-voice-bridge.ts @@ -462,6 +462,7 @@ export class OpenAIRealtimeBridge extends OpenAIRealtimeEvents implements Realti const auth = await requireOpenAIRealtimePlatformAuth({ configuredApiKey: this.config.apiKey, cfg: this.config.cfg, + agentId: this.config.agentId, }); return this.resolveApiKeyConnectionParams(auth.value, model); } diff --git a/extensions/openai/realtime-voice-browser-auth.test.ts b/extensions/openai/realtime-voice-browser-auth.test.ts index e471350f0a57..843ec18710ce 100644 --- a/extensions/openai/realtime-voice-browser-auth.test.ts +++ b/extensions/openai/realtime-voice-browser-auth.test.ts @@ -448,15 +448,60 @@ describe("OpenAI realtime voice browser authentication", () => { ).rejects.toThrow("OpenAI Realtime voice requires an OpenAI Platform API key"); }); - it("treats OpenAI API-key auth profiles as configured for browser realtime sessions", () => { - isProviderAuthProfileConfiguredMock.mockReturnValue(true); + it("checks bridge readiness in the selected agent directory", () => { + isProviderAuthProfileConfiguredMock.mockImplementation( + ({ agentDir }: { agentDir?: string }) => agentDir === "/tmp/openclaw-molty-agent", + ); const provider = buildOpenAIRealtimeVoiceProvider(); - const cfg = { agents: { defaults: {} } } as never; + const cfg = { + agents: { + list: [ + { id: "helper", agentDir: "/tmp/openclaw-helper-agent" }, + { id: "molty", agentDir: "/tmp/openclaw-molty-agent" }, + ], + }, + } as never; - expect(provider.isConfigured({ cfg, providerConfig: {} })).toBe(true); + expect(provider.isConfigured({ cfg, providerConfig: {}, agentId: "molty" })).toBe(true); expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({ provider: "openai", cfg, + agentDir: "/tmp/openclaw-molty-agent", + profileTypes: ["api_key"], + includeExternalCliAuth: false, + }); + }); + + it("resolves bridge Platform auth from the selected agent directory", async () => { + resolveProviderAuthProfileApiKeyMock.mockImplementation( + async ({ agentDir }: { agentDir?: string }) => + agentDir === "/tmp/openclaw-molty-agent" ? "test-api-key-molty" : undefined, + ); + const provider = buildOpenAIRealtimeVoiceProvider(); + const cfg = { + agents: { + list: [ + { id: "helper", agentDir: "/tmp/openclaw-helper-agent" }, + { id: "molty", agentDir: "/tmp/openclaw-molty-agent" }, + ], + }, + } as never; + const bridge = provider.createBridge({ + cfg, + agentId: "molty", + providerConfig: { model: "gpt-realtime-2" }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + void bridge.connect(); + await vi.waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); + bridge.close(); + + expect(resolveProviderAuthProfileApiKeyMock).toHaveBeenCalledWith({ + provider: "openai", + cfg, + agentDir: "/tmp/openclaw-molty-agent", profileTypes: ["api_key"], includeExternalCliAuth: false, }); diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index c8c652eba188..197c0f6f6ea5 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -187,6 +187,7 @@ async function createOpenAIRealtimeBrowserSession( const auth = await requireOpenAIRealtimePlatformAuth({ configuredApiKey: config.apiKey, cfg: req.cfg, + agentId: req.agentId, }); const voice = normalizeOpenAIRealtimeVoice(req.voice) ?? config.voice ?? "alloy"; const sessionConfig = buildOpenAIRealtimeGaSessionPolicy({ @@ -268,12 +269,14 @@ async function createOpenAIRealtimeBrowserSession( const auth = await resolveOpenAIRealtimePlatformAuth({ configuredApiKey: config.apiKey, cfg: req.cfg, + agentId: req.agentId, }); if (auth.status === "missing") { if ( hasOpenAIRealtimePlatformAuthInput({ configuredApiKey: config.apiKey, cfg: req.cfg, + agentId: req.agentId, }) ) { throw new Error(OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED); @@ -334,7 +337,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { autoSelectOrder: 10, capabilities: OPENAI_REALTIME_CAPABILITIES, resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig), - isConfigured: ({ cfg, providerConfig }) => { + isConfigured: ({ cfg, providerConfig, agentId }) => { const config = normalizeProviderConfig(providerConfig); if (config.azureEndpoint || config.azureDeployment) { return hasOpenAIRealtimeApiKeyInput(config.apiKey); @@ -343,6 +346,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { hasOpenAIRealtimePlatformAuthInput({ configuredApiKey: config.apiKey, cfg, + agentId, }) ) { return true; @@ -384,6 +388,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { await requireOpenAIRealtimePlatformAuth({ configuredApiKey: config.apiKey, cfg: req.cfg, + agentId: req.agentId, }) ).value, }), @@ -429,6 +434,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { (hasOpenAIRealtimePlatformAuthInput({ configuredApiKey: config.apiKey, cfg, + agentId, }) || hasOpenAIChatGptSubscriptionAuthInput({ cfg, agentId })) ); @@ -437,6 +443,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { hasOpenAIRealtimePlatformAuthInput({ configuredApiKey: config.apiKey, cfg, + agentId, }) || (options?.quicksilverBrowserSessionBroker !== undefined && hasOpenAIChatGptSubscriptionAuthInput({ cfg, agentId })) @@ -471,6 +478,7 @@ export function buildOpenAIRealtimeVoiceProvider(options?: { (hasOpenAIRealtimePlatformAuthInput({ configuredApiKey: config.apiKey, cfg, + agentId, }) || hasOpenAIChatGptSubscriptionAuthInput({ cfg, agentId })) ); diff --git a/extensions/openai/realtime-voice-session-policy.ts b/extensions/openai/realtime-voice-session-policy.ts index 0b05d42f432a..e6fa30e8d2d1 100644 --- a/extensions/openai/realtime-voice-session-policy.ts +++ b/extensions/openai/realtime-voice-session-policy.ts @@ -520,6 +520,7 @@ export function buildOpenAIRealtimeGaSessionPolicy(params: { export async function resolveOpenAIRealtimePlatformAuth(params: { configuredApiKey: string | undefined; cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; + agentId?: string; }): Promise { const configured = resolveOpenAIRealtimeSecretInput(params.configuredApiKey); if ( @@ -532,6 +533,9 @@ export async function resolveOpenAIRealtimePlatformAuth(params: { const profileApiKey = await resolveProviderAuthProfileApiKey({ provider: "openai", cfg: params.cfg, + ...(params.cfg && params.agentId + ? { agentDir: resolveAgentDir(params.cfg, params.agentId) } + : {}), profileTypes: ["api_key"], includeExternalCliAuth: false, }); @@ -548,6 +552,7 @@ export async function resolveOpenAIRealtimePlatformAuth(params: { export async function requireOpenAIRealtimePlatformAuth(params: { configuredApiKey: string | undefined; cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; + agentId?: string; }): Promise> { const resolved = await resolveOpenAIRealtimePlatformAuth(params); if (resolved.status === "available") { @@ -577,6 +582,7 @@ export async function resolveOpenAIQuicksilverBridgeAuth(params: { hasOpenAIRealtimePlatformAuthInput({ configuredApiKey: params.configuredApiKey, cfg: params.cfg, + agentId: params.agentId, }) ) { throw new Error(OPENAI_GPT_LIVE_AUTHORED_PLATFORM_AUTH_UNAVAILABLE); @@ -587,6 +593,7 @@ export async function resolveOpenAIQuicksilverBridgeAuth(params: { export function hasOpenAIRealtimePlatformAuthInput(params: { configuredApiKey: string | undefined; cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined; + agentId?: string; }): boolean { if (hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey)) { return true; @@ -595,6 +602,9 @@ export function hasOpenAIRealtimePlatformAuthInput(params: { isProviderAuthProfileConfigured({ provider: "openai", cfg: params.cfg, + ...(params.cfg && params.agentId + ? { agentDir: resolveAgentDir(params.cfg, params.agentId) } + : {}), profileTypes: ["api_key"], includeExternalCliAuth: false, }) diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index a1dafc126a5f..6a78b4fd6da1 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -366,6 +366,9 @@ describe("createVoiceCallRuntime lifecycle", () => { }); const resolveInstructions = mocks.realtimeHandlerCtorArgs[0]?.[7]; + expect(mocks.resolveConfiguredRealtimeVoiceProvider).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "operator" }), + ); if (typeof resolveInstructions !== "function") { throw new Error("expected per-call realtime instruction resolver"); } diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index c309d9b2212f..0430ad5b7fb3 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -238,6 +238,7 @@ async function resolveRealtimeProvider(params: { configuredProviderId: params.config.realtime.provider, providerConfigs: params.config.realtime.providers, cfg: params.fullConfig, + agentId: params.config.agentId, }); } diff --git a/extensions/voice-call/src/webhook/realtime-handler.test.ts b/extensions/voice-call/src/webhook/realtime-handler.test.ts index 96a094725f0f..e48367912318 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.test.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.test.ts @@ -572,6 +572,7 @@ describe("RealtimeCallHandler path routing", () => { }), ); expect(createBridge.mock.calls[0]?.[0].instructions).toBe("instructions:support"); + expect(createBridge.mock.calls[0]?.[0].agentId).toBe("support"); } finally { if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { ws.close(); diff --git a/extensions/voice-call/src/webhook/realtime-handler.ts b/extensions/voice-call/src/webhook/realtime-handler.ts index f84ec179dd70..ab90f19fb09d 100644 --- a/extensions/voice-call/src/webhook/realtime-handler.ts +++ b/extensions/voice-call/src/webhook/realtime-handler.ts @@ -256,6 +256,7 @@ export type StreamSession = { type CallRegistration = { callId: string; + agentId?: string; instructions: string; initialGreetingInstructions?: string; }; @@ -654,7 +655,7 @@ export class RealtimeCallHandler { return null; } - const { callId, instructions, initialGreetingInstructions } = registration; + const { callId, agentId, instructions, initialGreetingInstructions } = registration; const callRecord = this.manager.getCallByProviderCallId(callSid); const harness = createRealtimeVoiceSessionHarness({ talk: { @@ -792,6 +793,7 @@ export class RealtimeCallHandler { const bridgeParams: Parameters[0] = { provider: this.realtimeProvider, cfg: this.coreConfig, + agentId, providerConfig: this.providerConfig, interruptResponseOnInputAudio, instructions, @@ -1595,6 +1597,7 @@ export class RealtimeCallHandler { const instructions = this.resolveInstructions?.(callRecord) ?? this.config.instructions; return { callId: callRecord.callId, + agentId: callRecord.agentId, instructions, initialGreetingInstructions: buildGreetingInstructions(instructions, initialGreeting), }; diff --git a/extensions/xai/capability-provider-metadata.ts b/extensions/xai/capability-provider-metadata.ts index 903e7966f03c..21dde480922b 100644 --- a/extensions/xai/capability-provider-metadata.ts +++ b/extensions/xai/capability-provider-metadata.ts @@ -268,8 +268,12 @@ export function createXaiRealtimeVoiceProviderMetadata() { supportsSessionResumption: true, }, resolveConfig: ({ rawConfig }) => normalizeXaiRealtimeProviderConfig(rawConfig), - isConfigured: ({ providerConfig, cfg }) => - hasXaiRealtimeApiKeyInput(normalizeXaiRealtimeProviderConfig(providerConfig).apiKey, cfg), + isConfigured: ({ providerConfig, cfg, agentId }) => + hasXaiRealtimeApiKeyInput( + normalizeXaiRealtimeProviderConfig(providerConfig).apiKey, + cfg, + agentId, + ), } satisfies Omit; } diff --git a/extensions/xai/realtime-voice-auth.runtime.ts b/extensions/xai/realtime-voice-auth.runtime.ts index 6b735960658d..f3204a2110fe 100644 --- a/extensions/xai/realtime-voice-auth.runtime.ts +++ b/extensions/xai/realtime-voice-auth.runtime.ts @@ -1,3 +1,4 @@ +import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -5,13 +6,18 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runti export async function resolveXaiRealtimeApiKey( configApiKey: string | undefined, cfg: OpenClawConfig | undefined, + agentId?: string, ): Promise { const direct = normalizeOptionalString(configApiKey) ?? normalizeOptionalString(process.env.XAI_API_KEY); if (direct) { return direct; } - const auth = await resolveApiKeyForProvider({ provider: "xai", cfg }); + const auth = await resolveApiKeyForProvider({ + provider: "xai", + cfg, + ...(cfg && agentId ? { agentDir: resolveAgentDir(cfg, agentId) } : {}), + }); const oauthKey = normalizeOptionalString(auth?.apiKey); if (oauthKey) { return oauthKey; diff --git a/extensions/xai/realtime-voice-bridge.ts b/extensions/xai/realtime-voice-bridge.ts index b84300f1a128..8b0ac485c27a 100644 --- a/extensions/xai/realtime-voice-bridge.ts +++ b/extensions/xai/realtime-voice-bridge.ts @@ -342,7 +342,7 @@ export class XaiRealtimeVoiceBridge extends XaiRealtimeVoiceEvents implements Re }> { const apiKey = this.config.resolveApiKey ? await this.config.resolveApiKey() - : await resolveXaiRealtimeApiKey(this.config.apiKey, this.config.cfg); + : await resolveXaiRealtimeApiKey(this.config.apiKey, this.config.cfg, this.config.agentId); const model = this.config.model ?? XAI_REALTIME_DEFAULT_MODEL; const url = toXaiRealtimeWsUrl( this.config.baseUrl, diff --git a/extensions/xai/realtime-voice-config.ts b/extensions/xai/realtime-voice-config.ts index d894f7609ea4..5766b0476bf1 100644 --- a/extensions/xai/realtime-voice-config.ts +++ b/extensions/xai/realtime-voice-config.ts @@ -1,3 +1,4 @@ +import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { isProviderAuthProfileConfigured, type OpenClawConfig, @@ -237,9 +238,14 @@ export function toXaiRealtimeWsUrl( export function hasXaiRealtimeApiKeyInput( configApiKey: string | undefined, cfg: OpenClawConfig | undefined, + agentId?: string, ): boolean { if (normalizeOptionalString(configApiKey) || normalizeOptionalString(process.env.XAI_API_KEY)) { return true; } - return isProviderAuthProfileConfigured({ provider: "xai", cfg }); + return isProviderAuthProfileConfigured({ + provider: "xai", + cfg, + ...(cfg && agentId ? { agentDir: resolveAgentDir(cfg, agentId) } : {}), + }); } diff --git a/extensions/xai/realtime-voice-provider.test.ts b/extensions/xai/realtime-voice-provider.test.ts index 7c558b08cd22..505ff7afbc15 100644 --- a/extensions/xai/realtime-voice-provider.test.ts +++ b/extensions/xai/realtime-voice-provider.test.ts @@ -80,9 +80,11 @@ const { FakeWebSocket, isProviderAuthProfileConfiguredMock, resolveApiKeyForProv return { FakeWebSocket: MockWebSocket, - isProviderAuthProfileConfiguredMock: vi.fn(() => false), + isProviderAuthProfileConfiguredMock: vi.fn((_params: { agentDir?: string }) => false), resolveApiKeyForProviderMock: vi.fn( - async (): Promise<{ apiKey: string | undefined }> => ({ apiKey: undefined }), + async (_params: { agentDir?: string }): Promise<{ apiKey: string | undefined }> => ({ + apiKey: undefined, + }), ), }; }); @@ -238,6 +240,60 @@ describe("buildXaiRealtimeVoiceProvider", () => { expect(FakeWebSocket.instances).toHaveLength(0); }); + it("checks realtime readiness in the selected agent directory", () => { + isProviderAuthProfileConfiguredMock.mockImplementation( + ({ agentDir }) => agentDir === "/tmp/openclaw-molty-agent", + ); + const provider = buildXaiRealtimeVoiceProvider(); + const cfg = { + agents: { + list: [ + { id: "helper", agentDir: "/tmp/openclaw-helper-agent" }, + { id: "molty", agentDir: "/tmp/openclaw-molty-agent" }, + ], + }, + } as never; + + expect(provider.isConfigured({ cfg, providerConfig: {}, agentId: "molty" })).toBe(true); + expect(isProviderAuthProfileConfiguredMock).toHaveBeenCalledWith({ + provider: "xai", + cfg, + agentDir: "/tmp/openclaw-molty-agent", + }); + }); + + it("resolves realtime auth from the selected agent directory", async () => { + resolveApiKeyForProviderMock.mockImplementation(async ({ agentDir }) => ({ + apiKey: agentDir === "/tmp/openclaw-molty-agent" ? "xai-molty" : undefined, + })); + const cfg = { + agents: { + list: [ + { id: "helper", agentDir: "/tmp/openclaw-helper-agent" }, + { id: "molty", agentDir: "/tmp/openclaw-molty-agent" }, + ], + }, + } as never; + const bridge = createTestBridge({ + cfg, + agentId: "molty", + providerConfig: {}, + }); + + const { connecting, socket } = await startRealtimeBridge(bridge); + await connecting; + bridge.close(); + + expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ + provider: "xai", + cfg, + agentDir: "/tmp/openclaw-molty-agent", + }); + expect((socket.args[1] as { headers?: Record }).headers?.Authorization).toBe( + "Bearer xai-molty", + ); + }); + it("coalesces concurrent connects and ignores connects after readiness", async () => { vi.stubEnv("XAI_API_KEY", "xai-env"); // pragma: allowlist secret const bridge = createTestBridge(); diff --git a/extensions/xai/realtime-voice-provider.ts b/extensions/xai/realtime-voice-provider.ts index 234d9befc5aa..d1cfc0daea46 100644 --- a/extensions/xai/realtime-voice-provider.ts +++ b/extensions/xai/realtime-voice-provider.ts @@ -27,7 +27,7 @@ export function buildXaiRealtimeVoiceProvider(): RealtimeVoiceProviderPlugin { prefixPaddingMs: config.prefixPaddingMs, reasoningEffort: config.reasoningEffort, sessionResumption: config.sessionResumption, - resolveApiKey: () => resolveXaiRealtimeApiKey(config.apiKey, req.cfg), + resolveApiKey: () => resolveXaiRealtimeApiKey(config.apiKey, req.cfg, req.agentId), }); }, }; diff --git a/src/gateway/talk-realtime-relay-session-create.ts b/src/gateway/talk-realtime-relay-session-create.ts index d0b88b719421..6e31be18a35e 100644 --- a/src/gateway/talk-realtime-relay-session-create.ts +++ b/src/gateway/talk-realtime-relay-session-create.ts @@ -195,13 +195,13 @@ export function createTalkRealtimeRelaySession( createBridge: (request: Parameters[0]) => params.provider.createBridge({ ...request, - ...(relayAgentId ? { agentId: relayAgentId } : {}), runAgentConsult, }), }; const bridge = harness.createBridge({ provider: relayProvider, cfg: params.cfg, + agentId: relayAgentId, providerConfig: params.providerConfig, audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, instructions: params.instructions, diff --git a/src/meeting-bot/plugin-config.ts b/src/meeting-bot/plugin-config.ts index 433d6299bf38..90334b586b33 100644 --- a/src/meeting-bot/plugin-config.ts +++ b/src/meeting-bot/plugin-config.ts @@ -47,7 +47,6 @@ export type MeetingPluginConfig = MeetingRealtimeEngineConfig & { chromeNode: { node?: string }; realtime: MeetingRealtimeEngineConfig["realtime"] & { strategy: "agent" | "bidi"; - agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; diff --git a/src/meeting-bot/realtime-engine-support.ts b/src/meeting-bot/realtime-engine-support.ts index d70cf8586a23..59da518c2aab 100644 --- a/src/meeting-bot/realtime-engine-support.ts +++ b/src/meeting-bot/realtime-engine-support.ts @@ -41,6 +41,7 @@ type MeetingRealtimeLifecycleHandlersParams = { type MeetingRealtimeProviderSelectionConfig = { realtime: { + agentId?: string; provider?: string; transcriptionProvider?: string; voiceProvider?: string; @@ -73,6 +74,7 @@ export function resolveMeetingRealtimeProvider(params: { configuredProviderId: providerId, providerConfigs: params.config.realtime.providers, cfg: params.fullConfig, + agentId: params.config.realtime.agentId, providers: params.providers, defaultModel: params.config.realtime.model, noRegisteredProviderMessage: "No configured realtime voice provider registered", diff --git a/src/meeting-bot/realtime-engine.ts b/src/meeting-bot/realtime-engine.ts index 18288202bb17..52d920d1a507 100644 --- a/src/meeting-bot/realtime-engine.ts +++ b/src/meeting-bot/realtime-engine.ts @@ -48,6 +48,7 @@ export type MeetingRealtimeEngineConfig = { chrome: { audioFormat: MeetingRealtimeAudioFormat }; realtime: { strategy: string; + agentId?: string; provider?: string; transcriptionProvider?: string; voiceProvider?: string; @@ -485,6 +486,7 @@ export async function startMeetingRealtimeEngine(params: { bridge = harness.createBridge({ provider: resolved.provider, cfg: params.fullConfig, + agentId: params.config.realtime.agentId, providerConfig: resolved.providerConfig, audioFormat: resolveMeetingRealtimeAudioFormat(params.config.chrome.audioFormat), instructions: params.config.realtime.instructions, diff --git a/src/talk/provider-resolver.test.ts b/src/talk/provider-resolver.test.ts index 559014995e1b..050ab88830bb 100644 --- a/src/talk/provider-resolver.test.ts +++ b/src/talk/provider-resolver.test.ts @@ -76,6 +76,31 @@ describe("realtime voice provider resolver", () => { }); }); + it("passes the host-selected agent to public provider readiness", () => { + const isConfigured = vi.fn(({ agentId }) => agentId === "molty"); + const provider: RealtimeVoiceProviderPlugin = { + id: "agent-scoped", + label: "Agent scoped", + isConfigured, + createBridge: () => { + throw new Error("unused"); + }, + }; + + expect( + resolveConfiguredRealtimeVoiceProvider({ + cfg: {}, + agentId: "molty", + providers: [provider], + }).provider, + ).toBe(provider); + expect(isConfigured).toHaveBeenCalledWith({ + cfg: {}, + agentId: "molty", + providerConfig: {}, + }); + }); + it("keeps browser-only providers out of bridge auto-selection", () => { const isBrowserSessionConfigured = vi.fn( ({ agentId }: { agentId?: string }) => agentId === "voice-agent", diff --git a/src/talk/provider-resolver.ts b/src/talk/provider-resolver.ts index 0e80f31ad062..fb80eca4472b 100644 --- a/src/talk/provider-resolver.ts +++ b/src/talk/provider-resolver.ts @@ -84,6 +84,7 @@ export function isRealtimeVoiceProviderConfigured(params: { } return params.provider.isConfigured({ cfg: params.cfg, + agentId: params.agentId, providerConfig: params.providerConfig, }); } diff --git a/src/talk/provider-types.ts b/src/talk/provider-types.ts index 119ca31b25b0..bc59091c6814 100644 --- a/src/talk/provider-types.ts +++ b/src/talk/provider-types.ts @@ -183,6 +183,8 @@ export type RealtimeVoiceProviderResolveConfigContext = { export type RealtimeVoiceProviderConfiguredContext = { cfg?: OpenClawConfig; + /** Host-selected agent scope for provider auth readiness. */ + agentId?: string; providerConfig: RealtimeVoiceProviderConfig; }; diff --git a/src/talk/session-runtime.test.ts b/src/talk/session-runtime.test.ts index 3903eeee0cdf..8f087e94deba 100644 --- a/src/talk/session-runtime.test.ts +++ b/src/talk/session-runtime.test.ts @@ -120,6 +120,28 @@ describe("realtime voice bridge session runtime", () => { ); }); + it("passes the host-selected agent to the provider bridge", () => { + let request: Parameters[0] | undefined; + const provider: RealtimeVoiceProviderPlugin = { + id: "test", + label: "Test", + isConfigured: () => true, + createBridge: (nextRequest) => { + request = nextRequest; + return makeBridge(); + }, + }; + + createRealtimeVoiceBridgeSession({ + provider, + agentId: "molty", + providerConfig: {}, + audioSink: { sendAudio: vi.fn() }, + }); + + expect(expectBridgeRequest(request).agentId).toBe("molty"); + }); + it("passes the audio auto-response preference to the provider bridge", () => { let request: Parameters[0] | undefined; const provider: RealtimeVoiceProviderPlugin = { diff --git a/src/talk/session-runtime.ts b/src/talk/session-runtime.ts index 43ed0ed60f3d..b95bdd88a329 100644 --- a/src/talk/session-runtime.ts +++ b/src/talk/session-runtime.ts @@ -57,6 +57,8 @@ export type RealtimeVoiceBridgeSession = { export type RealtimeVoiceBridgeSessionParams = { provider: RealtimeVoiceProviderPlugin; cfg?: OpenClawConfig; + /** Host-selected agent scope for provider auth and agent-owned bridge state. */ + agentId?: string; providerConfig: RealtimeVoiceProviderConfig; audioFormat?: RealtimeVoiceAudioFormat; audioSink: RealtimeVoiceAudioSink; @@ -164,6 +166,7 @@ export function createRealtimeVoiceBridgeSession( }; const bridge = params.provider.createBridge({ cfg: params.cfg, + agentId: params.agentId, providerConfig: params.providerConfig, audioFormat: params.audioFormat, instructions: params.instructions,