fix: honor configured default agent in consults (#111749)

This commit is contained in:
Peter Steinberger
2026-07-20 01:43:00 -07:00
committed by GitHub
parent 779cf40d46
commit 6f2fa019bd
14 changed files with 136 additions and 31 deletions
+8 -6
View File
@@ -1,4 +1,5 @@
// Google Meet composes platform strategies with the shared meeting session runtime.
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
@@ -94,10 +95,6 @@ function resolveMode(input: GoogleMeetModeInput | undefined, config: GoogleMeetC
return input === "realtime" ? "agent" : (input ?? config.defaultMode);
}
function resolveSessionAgentId(request: GoogleMeetJoinRequest, config: GoogleMeetConfig): string {
return normalizeAgentId(request.agentId ?? config.realtime.agentId);
}
function withSessionAgentConfig(config: GoogleMeetConfig, agentId: string): GoogleMeetConfig {
return config.realtime.agentId === agentId
? config
@@ -118,6 +115,7 @@ function noteSession(session: GoogleMeetSession, note: string): void {
export class GoogleMeetRuntime {
readonly #createdBrowserTabs = new Map<string, string>();
readonly #agentId: string;
readonly #voiceCallGateway: VoiceCallGateway;
readonly #sessions: GoogleMeetSessionRuntime;
@@ -129,6 +127,7 @@ export class GoogleMeetRuntime {
logger: RuntimeLogger;
},
) {
this.#agentId = resolveDefaultAgentId(params.fullConfig);
this.#voiceCallGateway = createVoiceCallGateway(params);
this.#sessions = new MeetingSessionRuntime({
logger: params.logger,
@@ -169,7 +168,9 @@ export class GoogleMeetRuntime {
url: GOOGLE_MEET_PLATFORM_ADAPTER.urls.validateAndNormalize(request.url),
transport: resolveTransport(request.transport, params.config),
mode: resolveMode(request.mode, params.config),
agentId: resolveSessionAgentId(request, params.config),
agentId: normalizeAgentId(
request.agentId ?? params.config.realtime.agentId ?? this.#agentId,
),
}),
createSession: ({ request: _request, resolved, createdAt }) =>
createGoogleMeetSession({ config: params.config, resolved, createdAt }),
@@ -305,7 +306,8 @@ export class GoogleMeetRuntime {
#probeContext(): GoogleMeetRuntimeProbeContext {
return {
config: this.params.config,
resolveAgentId: (request) => resolveSessionAgentId(request, this.params.config),
resolveAgentId: (request) =>
normalizeAgentId(request.agentId ?? this.params.config.realtime.agentId ?? this.#agentId),
list: () => this.list(),
join: async (request) => await this.join(request),
isReusable: (session, resolved) => this.#sessions.isReusableSession(session, resolved),
@@ -105,12 +105,13 @@ describe("Microsoft Teams meeting session flow", () => {
defaultMode: "transcribe",
chrome: { waitForInCallMs: 1 },
}),
fullConfig: {},
fullConfig: { agents: { list: [{ id: "operator", default: true }] } },
runtime: harness.runtime,
logger,
});
const first = await runtime.join({ url: URL, mode: "transcribe" });
expect(first.session.agentId).toBe("operator");
expect(first.session.chrome?.health).toMatchObject({ inCall: true, cameraOff: true });
const reused = await runtime.join({
+7 -3
View File
@@ -1,3 +1,4 @@
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
@@ -81,6 +82,7 @@ function noteSession(session: TeamsMeetingsSession, note: string): void {
}
export class TeamsMeetingsRuntime {
readonly #defaultAgentId: string;
readonly #sessions: SessionRuntime;
readonly #requesterSessionKeys = new Map<string, string>();
@@ -92,6 +94,9 @@ export class TeamsMeetingsRuntime {
logger: RuntimeLogger;
},
) {
this.#defaultAgentId = normalizeAgentId(
params.config.realtime.agentId ?? resolveDefaultAgentId(params.fullConfig),
);
this.#sessions = new MeetingSessionRuntime({
logger: params.logger,
logScope: TEAMS_MEETINGS_PLATFORM_ADAPTER.logScope,
@@ -131,7 +136,7 @@ export class TeamsMeetingsRuntime {
url: TEAMS_MEETINGS_PLATFORM_ADAPTER.urls.validateAndNormalize(request.url),
transport: resolveTransport(request, params.config),
mode: request.mode ?? params.config.defaultMode,
agentId: normalizeAgentId(request.agentId ?? params.config.realtime.agentId),
agentId: normalizeAgentId(request.agentId ?? this.#defaultAgentId),
}),
createSession: ({ request, resolved, createdAt }) => {
const session = createTeamsMeetingsSession({ config: params.config, resolved, createdAt });
@@ -257,8 +262,7 @@ export class TeamsMeetingsRuntime {
#probeContext(): TeamsMeetingsProbeContext {
return {
config: this.params.config,
resolveAgentId: (request) =>
normalizeAgentId(request.agentId ?? this.params.config.realtime.agentId),
resolveAgentId: (request) => normalizeAgentId(request.agentId ?? this.#defaultAgentId),
list: () => this.list(),
join: async (request) => await this.join(request),
isReusable: (session, resolved) => this.#sessions.isReusableSession(session, resolved),
@@ -183,13 +183,14 @@ describe("voice-call outbound helpers", () => {
expect(persistCallRecordMock).toHaveBeenCalledTimes(2);
});
it("assigns per-call session keys to outbound calls when configured", async () => {
it("persists the configured agent on outbound call records", async () => {
const initiateProviderCall = vi.fn(async () => ({ providerCallId: "provider-1" }));
const ctx = {
activeCalls: new Map(),
providerCallIdMap: new Map(),
provider: { name: "twilio", initiateCall: initiateProviderCall },
config: {
agentId: "operator",
maxConcurrentCalls: 3,
outbound: { defaultMode: "conversation" },
fromNumber: "+14155550100",
@@ -205,9 +206,9 @@ describe("voice-call outbound helpers", () => {
expect(result.callId).toBeTypeOf("string");
expect(result.callId).not.toBe("");
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
`agent:main:voice:call:${result.callId}`,
`agent:operator:voice:call:${result.callId}`,
);
expect(ctx.activeCalls.get(result.callId)?.agentId).toBe("main");
expect(ctx.activeCalls.get(result.callId)?.agentId).toBe("operator");
});
it("uses the per-call agent for explicit session normalization", async () => {
@@ -81,6 +81,7 @@ describe("buildRealtimeVoiceInstructions", () => {
}),
coreConfig,
agentRuntime: createAgentRuntime(workspaceDir),
agentId: "voice",
});
expect(instructions).toContain("OpenClaw agent voice context:");
@@ -114,6 +115,7 @@ describe("buildRealtimeVoiceInstructions", () => {
config,
coreConfig: { agents: { list: [{ id: agentId }] } } as OpenClawConfig,
agentRuntime: createAgentRuntime("/unused"),
agentId,
});
expect(instructions).toBe(`Base voice instructions.\n\n${expectedContext}\n[truncated]`);
@@ -61,6 +61,7 @@ export async function buildRealtimeVoiceInstructions(params: {
config: VoiceCallConfig;
coreConfig: OpenClawConfig;
agentRuntime: CoreAgentDeps;
agentId: string;
}): Promise<string> {
const { config } = params;
const sections: string[] = [params.baseInstructions];
@@ -74,7 +75,7 @@ export async function buildRealtimeVoiceInstructions(params: {
return sections.filter(Boolean).join("\n\n");
}
const agentId = config.agentId ?? "main";
const { agentId } = params;
const capsule: string[] = [
"OpenClaw agent voice context:",
`- Agent id: ${agentId}`,
+12 -2
View File
@@ -334,13 +334,13 @@ describe("createVoiceCallRuntime lifecycle", () => {
files: ["SOUL.md"],
};
const fullConfig = {
agents: { list: [{ id: "main" }, { id: "support" }] },
agents: { list: [{ id: "operator", default: true }, { id: "support" }] },
} as OpenClawConfig;
const resolveAgentIdentity = vi.fn((_cfg: OpenClawConfig, agentId: string) => ({
name: agentId === "support" ? "Support Voice" : "Main Voice",
}));
await createVoiceCallRuntime({
const runtime = await createVoiceCallRuntime({
config,
coreConfig: {} as CoreConfig,
fullConfig,
@@ -353,6 +353,16 @@ describe("createVoiceCallRuntime lifecycle", () => {
if (typeof resolveInstructions !== "function") {
throw new Error("expected per-call realtime instruction resolver");
}
expect(runtime.config.agentId).toBe("operator");
const defaultInstructions = resolveInstructions({
callId: "call-default",
direction: "outbound",
from: "+15550001111",
to: "+15550002222",
});
expect(defaultInstructions).toContain("- Agent id: operator");
expect(resolveAgentIdentity).toHaveBeenCalledWith(fullConfig, "operator");
const supportInstructions = resolveInstructions({
callId: "call-support",
agentId: "support",
+9 -2
View File
@@ -1,4 +1,5 @@
// Voice Call plugin module implements runtime behavior.
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
@@ -252,7 +253,7 @@ function listRealtimeAgentIds(config: VoiceCallConfig, coreConfig: OpenClawConfi
}
async function createRealtimeInstructionsResolver(params: {
config: VoiceCallConfig;
config: VoiceCallConfig & { agentId: string };
coreConfig: OpenClawConfig;
agentRuntime: CoreAgentDeps;
}): Promise<(call: CallRecord) => string> {
@@ -268,6 +269,7 @@ async function createRealtimeInstructionsResolver(params: {
config: genericConfig,
coreConfig: params.coreConfig,
agentRuntime: params.agentRuntime,
agentId: params.config.agentId,
});
const entries = await Promise.all(
listRealtimeAgentIds(params.config, params.coreConfig).map(async (agentId) => {
@@ -276,6 +278,7 @@ async function createRealtimeInstructionsResolver(params: {
config: { ...params.config, agentId },
coreConfig: params.coreConfig,
agentRuntime: params.agentRuntime,
agentId,
});
return [agentId, instructions] as const;
}),
@@ -315,8 +318,12 @@ export async function createVoiceCallRuntime(params: {
debug: console.debug,
};
const config = resolveVoiceCallConfig(rawConfig);
const cfg = fullConfig ?? (coreConfig as OpenClawConfig);
const unresolvedConfig = resolveVoiceCallConfig(rawConfig);
const configuredAgentId = unresolvedConfig.agentId
? normalizeAgentId(unresolvedConfig.agentId)
: resolveDefaultAgentId(cfg);
const config = { ...unresolvedConfig, agentId: configuredAgentId };
if (!config.enabled) {
throw new Error("Voice call disabled. Enable the plugin entry in config.");
+2 -1
View File
@@ -152,12 +152,13 @@ describe("Zoom meeting session flow", () => {
defaultMode: "transcribe",
chrome: { waitForInCallMs: 1 },
}),
fullConfig: {},
fullConfig: { agents: { list: [{ id: "operator", default: true }] } },
runtime: harness.runtime,
logger,
});
const first = await runtime.join({ url: URL, mode: "transcribe" });
expect(first.session.agentId).toBe("operator");
expect(first.session.chrome?.health).toMatchObject({ inCall: true, cameraOff: true });
const reused = await runtime.join({
+10 -6
View File
@@ -1,3 +1,4 @@
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
@@ -72,10 +73,9 @@ function resolveTransport(
}
function withSessionAgentConfig(config: ZoomMeetingsConfig, agentId: string): ZoomMeetingsConfig {
const consultAgentId = config.realtime.agentId ?? agentId;
return config.realtime.agentId === consultAgentId
return config.realtime.agentId === agentId
? config
: { ...config, realtime: { ...config.realtime, agentId: consultAgentId } };
: { ...config, realtime: { ...config.realtime, agentId } };
}
function noteSession(session: ZoomMeetingsSession, note: string): void {
@@ -90,6 +90,7 @@ function isAwaitingAdmission(session: ZoomMeetingsSession): boolean {
}
export class ZoomMeetingsRuntime {
readonly #defaultAgentId: string;
readonly #sessions: SessionRuntime;
readonly #requesterSessionKeys = new Map<string, string>();
@@ -101,6 +102,9 @@ export class ZoomMeetingsRuntime {
logger: RuntimeLogger;
},
) {
this.#defaultAgentId = normalizeAgentId(
params.config.realtime.agentId ?? resolveDefaultAgentId(params.fullConfig),
);
this.#sessions = new MeetingSessionRuntime({
logger: params.logger,
logScope: ZOOM_MEETINGS_PLATFORM_ADAPTER.logScope,
@@ -139,7 +143,7 @@ export class ZoomMeetingsRuntime {
url: ZOOM_MEETINGS_PLATFORM_ADAPTER.urls.validateAndNormalize(request.url),
transport: resolveTransport(request, params.config),
mode: request.mode ?? params.config.defaultMode,
agentId: normalizeAgentId(request.agentId),
agentId: normalizeAgentId(request.agentId ?? this.#defaultAgentId),
}),
createSession: ({ request, resolved, createdAt }) => {
const session = createZoomMeetingsSession({ config: params.config, resolved, createdAt });
@@ -250,7 +254,7 @@ export class ZoomMeetingsRuntime {
async join(request: ZoomMeetingsJoinRequest): Promise<ZoomMeetingsJoinResult> {
try {
const url = ZOOM_MEETINGS_PLATFORM_ADAPTER.urls.validateAndNormalize(request.url);
const agentId = normalizeAgentId(request.agentId);
const agentId = normalizeAgentId(request.agentId ?? this.#defaultAgentId);
return await this.#sessions.join({ ...request, agentId, url });
} catch (error) {
const activeIds = new Set(this.list().map((session) => session.id));
@@ -314,7 +318,7 @@ export class ZoomMeetingsRuntime {
#probeContext(): ZoomMeetingsProbeContext {
return {
config: this.params.config,
resolveAgentId: (request) => normalizeAgentId(request.agentId),
resolveAgentId: (request) => normalizeAgentId(request.agentId ?? this.#defaultAgentId),
list: () => this.list(),
join: async (request) => await this.join(request),
isReusable: (session, resolved) => this.#sessions.isReusableSession(session, resolved),
+68
View File
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const consultRealtimeVoiceAgent = vi.hoisted(() => vi.fn(async () => ({ text: "done" })));
vi.mock("../talk/agent-consult-runtime.js", () => ({ consultRealtimeVoiceAgent }));
import { consultMeetingAgent, type MeetingAgentConsultSurface } from "./agent-consult.js";
const surface: MeetingAgentConsultSurface = {
id: "test-meeting",
provider: "test-meeting",
lane: "test-meeting",
surface: "a test meeting",
userLabel: "Participant",
assistantLabel: "Agent",
questionSourceLabel: "participant",
workingResponseLabel: "participant",
extraSystemPrompt: "Answer briefly.",
};
describe("consultMeetingAgent", () => {
beforeEach(() => {
consultRealtimeVoiceAgent.mockClear();
});
it("targets the configured default agent when agentId is omitted", async () => {
await consultMeetingAgent({
surface,
config: { agents: { list: [{ id: "operator", default: true }] } },
runtime: { agent: {} } as never,
logger: {} as never,
toolPolicy: "safe-read-only",
meetingSessionId: "meeting-1",
args: { question: "What should I say?" },
transcript: [],
});
expect(consultRealtimeVoiceAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "operator",
sessionKey: "agent:operator:subagent:test-meeting:meeting-1",
spawnedBy: "agent:operator:main",
}),
);
});
it("keeps an explicit agentId ahead of the configured default", async () => {
await consultMeetingAgent({
surface,
config: { agents: { list: [{ id: "operator", default: true }] } },
runtime: { agent: {} } as never,
logger: {} as never,
agentId: "Support",
toolPolicy: "safe-read-only",
meetingSessionId: "meeting-2",
args: { question: "What should I say?" },
transcript: [],
});
expect(consultRealtimeVoiceAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "support",
sessionKey: "agent:support:subagent:test-meeting:meeting-2",
spawnedBy: "agent:support:main",
}),
);
});
});
+4 -1
View File
@@ -1,4 +1,5 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveDefaultAgentId } from "../agents/agent-scope-config.js";
import type { OpenClawConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
import type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
@@ -60,7 +61,9 @@ export async function consultMeetingAgent(params: {
args: unknown;
transcript: Array<{ role: "user" | "assistant"; text: string }>;
}): Promise<{ text: string }> {
const agentId = normalizeAgentId(params.agentId);
const agentId = params.agentId
? normalizeAgentId(params.agentId)
: resolveDefaultAgentId(params.config);
const requesterSessionKey =
normalizeOptionalString(params.requesterSessionKey) ?? `agent:${agentId}:main`;
const sessionKey = `agent:${agentId}:subagent:${params.surface.id}:${params.meetingSessionId}`;
+4 -4
View File
@@ -214,7 +214,7 @@ describe("realtime voice agent consult runtime", () => {
const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime();
const result = await consultRealtimeVoiceAgent({
cfg: {} as never,
cfg: { agents: { list: [{ id: "operator", default: true }] } } as never,
agentRuntime: runtime as never,
logger: { warn: vi.fn() },
sessionKey: "voice:15550001234",
@@ -245,8 +245,8 @@ describe("realtime voice agent consult runtime", () => {
const call = requireEmbeddedAgentCall(runEmbeddedAgent);
expect(call.sessionId).toBe(voiceSession.sessionId);
expect(call.sessionKey).toBe("voice:15550001234");
expect(call.sandboxSessionKey).toBe("agent:main:voice:15550001234");
expect(call.agentId).toBe("main");
expect(call.sandboxSessionKey).toBe("agent:operator:voice:15550001234");
expect(call.agentId).toBe("operator");
expect(call.messageProvider).toBe("voice");
expect(call.lane).toBe("voice");
expect(call.toolsAllow).toStrictEqual(["read"]);
@@ -416,7 +416,7 @@ describe("realtime voice agent consult runtime", () => {
const { runtime, runEmbeddedAgent } = createAgentRuntime();
await consultRealtimeVoiceAgent({
cfg: {} as never,
cfg: { agents: { list: [{ id: "operator", default: true }] } } as never,
agentRuntime: runtime as never,
logger: { warn: vi.fn() },
agentId: "voice",
+2 -1
View File
@@ -1,5 +1,6 @@
// Agent consult runtime starts agent consultation flows from talk sessions.
import { randomUUID } from "node:crypto";
import { resolveDefaultAgentId } from "../agents/agent-scope-config.js";
import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js";
import { forkSessionEntryFromParent } from "../auto-reply/reply/session-fork.js";
import { resolveSessionWorkStartError } from "../config/sessions/lifecycle.js";
@@ -258,7 +259,7 @@ export async function consultRealtimeVoiceAgent(params: {
extraSystemPrompt?: string;
fallbackText?: string;
}): Promise<RealtimeVoiceAgentConsultResult> {
const agentId = params.agentId ?? "main";
const agentId = params.agentId ?? resolveDefaultAgentId(params.cfg);
const agentDir = params.agentRuntime.resolveAgentDir(params.cfg, agentId);
const workspaceDir = params.agentRuntime.resolveAgentWorkspaceDir(params.cfg, agentId);
const storePath = params.agentRuntime.session.resolveStorePath(params.cfg.session?.store, {