From 4bfb26415aed3fae4a8a85c3b539addfc11cccf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Jul 2026 05:37:41 -0700 Subject: [PATCH] feat(agents): explicit per-surface agent targets + default-role materialization (#113637) * refactor(agents): add explicit ambient target seams * fix(doctor): materialize ambient default-agent roles * fix(agents): satisfy dependency and docs checks * chore: leave agent release note to release process * test(agents): prove shared heartbeat fan-out survives doctor --- docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- docs/concepts/multi-agent.md | 2 + docs/docs_map.md | 1 + docs/gateway/config-agents.md | 20 ++ docs/nodes/talk.md | 1 + src/commands/doctor-config-flow.test.ts | 87 +++++++ src/commands/doctor-config-flow.ts | 11 + ...default-agent-role-materialization.test.ts | 214 ++++++++++++++++++ .../default-agent-role-materialization.ts | 150 ++++++++++++ ...t-agent-role-materialization.write.test.ts | 83 +++++++ src/config/schema.help.core.ts | 8 + src/config/schema.labels.ts | 4 + src/config/talk.normalize.test.ts | 5 + src/config/talk.ts | 7 + src/config/types.agent-defaults.ts | 6 + src/config/types.agents.ts | 2 +- src/config/types.gateway.ts | 2 + src/config/zod-schema.agent-defaults.ts | 10 +- src/config/zod-schema.agents.test.ts | 63 ++++++ src/config/zod-schema.root-support.ts | 1 + src/config/zod-schema.ts | 32 ++- src/gateway/server-methods/talk-client.ts | 11 +- src/gateway/server-methods/talk-shared.ts | 7 +- src/gateway/talk-realtime-relay.ts | 9 +- src/infra/heartbeat-runner.ts | 14 +- src/system-agent/inference-fallback.ts | 9 +- src/system-agent/inference-route.ts | 32 ++- src/talk/agent-target.ts | 19 ++ 29 files changed, 775 insertions(+), 41 deletions(-) create mode 100644 src/commands/doctor/shared/default-agent-role-materialization.test.ts create mode 100644 src/commands/doctor/shared/default-agent-role-materialization.ts create mode 100644 src/commands/doctor/shared/default-agent-role-materialization.write.test.ts create mode 100644 src/talk/agent-target.ts diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 89ac2befa2d6..f4e293c5a8da 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2300, + "core": 2304, "channel": 3630, "plugin": 3556 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index d1adab85ca8b..a00857c27893 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -c415d42148c395cafd63021e9decee92db06e796563ad0ab8079c5d8dd4ee288 config-baseline.json -db9465e00c3702a9f3ac0f2fe18d0cf8eef6792533c4543e1e711bbcd571afae config-baseline.core.json +9228db8de076c307b98fed5c4f9253b05a2604e9022c15baf16ccde86da11953 config-baseline.json +5e4dc33f0419716ec1fd8ac03d85fe3d1a0f2014674c62debdcb5d5c9a869eef config-baseline.core.json af6ca0e70007113462270d46fa14ef0551e577e2fa157d2b9f6d0a93d36f96f1 config-baseline.channel.json 85c24c09df92ac432bf22c9ea44e894dec3ffad3a7ee7960612699eec6a42e6e config-baseline.plugin.json diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 70c794be5062..38383e2fc380 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -242,6 +242,8 @@ Bindings are deterministic and most-specific wins. See [Channel routing](/channe - If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields must match (`AND` semantics). - A binding that omits `accountId` matches only the default account, not every account. Use `accountId: "*"` for a channel-wide fallback, or `accountId: ""` for one account. Adding the same binding again with an explicit account id upgrades the existing channel-only binding instead of duplicating it. +For existing multi-agent configs, `openclaw doctor --fix` materializes legacy ambient default routing into channel-wide bindings plus explicit heartbeat, Custodian, and Talk targets. Single-agent configs are unchanged. + ## Multiple accounts / phone numbers Channels that support multiple accounts (e.g. WhatsApp) use `accountId` to identify each login. Each `accountId` routes to its own agent, so one server can host multiple phone numbers without mixing sessions. diff --git a/docs/docs_map.md b/docs/docs_map.md index 3ff46b674000..b272ce4abcee 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -3332,6 +3332,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: CLI backend selection - H3: agents.defaults.promptOverlays - H3: agents.defaults.heartbeat + - H3: agents.defaults.systemAgent - H3: agents.defaults.compaction - H3: agents.defaults.contextPruning - H3: Block streaming diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index 6c5f756fd228..a6434c305b70 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -542,6 +542,7 @@ Periodic heartbeat runs. agents: { defaults: { heartbeat: { + agentId: "ops", // ambient owner when no per-agent heartbeat is configured every: "30m", // 0m disables model: "openai/gpt-5.4-mini", includeReasoning: false, @@ -564,6 +565,7 @@ Periodic heartbeat runs. ``` - `every`: duration string (ms/s/m/h). Default: `30m` (API-key auth) or `1h` (OAuth auth). Set to `0m` to disable. +- `agentId`: explicit owner for ambient heartbeat runs when no `agents.entries.*.heartbeat` block exists. A shared heartbeat block without `agentId` keeps the existing all-agent enrollment behavior. - Cadence is written into a system-owned cron monitor row. Run `openclaw doctor --fix` to materialize a missing or stale row. If cron is disabled, scheduled heartbeats do not run and the gateway logs a startup warning. - `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt. Default: `true`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. @@ -575,6 +577,22 @@ Periodic heartbeat runs. - Per-agent: set `agents.entries.*.heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. +### `agents.defaults.systemAgent` + +Selects the agent whose model and credentials own ambient OpenClaw system-agent and Custodian consults: + +```json5 +{ + agents: { + defaults: { + systemAgent: { agentId: "ops" }, + }, + }, +} +``` + +Delegated consults with a requesting agent keep that requester as their owner. When `agentId` is absent, OpenClaw preserves configured-default routing. + ### `agents.defaults.compaction` ```json5 @@ -1422,6 +1440,7 @@ Defaults for Talk mode (macOS/iOS/Android and the browser Control UI). ```json5 { talk: { + agentId: "ops", provider: "elevenlabs", providers: { elevenlabs: { @@ -1466,6 +1485,7 @@ Defaults for Talk mode (macOS/iOS/Android and the browser Control UI). ``` - `talk.provider` must match a key in `talk.providers` when multiple Talk providers are configured. +- `talk.agentId` owns Talk sessions created without an explicit agent-scoped session key. Session-scoped Talk calls continue to use the agent encoded in that key. Doctor may create a minimal `talk` block containing only this owner for an existing multi-agent config. - Legacy flat Talk keys (`talk.voiceId`, `talk.voiceAliases`, `talk.modelId`, `talk.outputFormat`, `talk.apiKey`) are compatibility-only. Run `openclaw doctor --fix` to rewrite persisted config into `talk.providers.`. - Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` (macOS Talk client behavior). - `providers.*.apiKey` accepts plaintext strings or SecretRef objects. diff --git a/docs/nodes/talk.md b/docs/nodes/talk.md index b368c35521be..c7d5eaad5fe0 100644 --- a/docs/nodes/talk.md +++ b/docs/nodes/talk.md @@ -97,6 +97,7 @@ Supported keys: `voice` / `voice_id` / `voiceId`, `model` / `model_id` / `modelI | Key | Default | Notes | | ---------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agentId` | configured default agent | Owns Talk sessions created without an explicit agent-scoped session key. | | `provider` | - | Active Talk TTS provider. Use `elevenlabs`, `mlx`, or `system` for macOS-local playback paths. | | `providers..voiceId` | - | ElevenLabs falls back to `ELEVENLABS_VOICE_ID` / `SAG_VOICE_ID`, or the first available voice with an API key. | | `speechLocale` | device default | BCP 47 locale for Android, iOS, and macOS native speech recognition. Apple Speech may use network services; Android also forwards the language component to realtime input transcription. | diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 98daffebaf85..2d7f52ffc1ea 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1648,6 +1648,93 @@ describe("doctor config flow", () => { expect(result.cfg.agents).not.toHaveProperty("list"); }); + it("materializes ambient roles for a multi-agent configured default", async () => { + const config = { + agents: { + entries: { + ops: { default: true }, + research: {}, + }, + }, + channels: { telegram: { enabled: true } }, + talk: { provider: "test" }, + }; + const result = await runDoctorConfigWithInput({ + config, + parsedConfig: config, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.bindings).toEqual([ + { agentId: "ops", match: { channel: "telegram", accountId: "*" } }, + ]); + expect(result.cfg.agents?.defaults).toMatchObject({ + heartbeat: { agentId: "ops" }, + systemAgent: { agentId: "ops" }, + }); + expect(result.cfg.talk).toMatchObject({ provider: "test", agentId: "ops" }); + }); + + it("preserves shared all-agent heartbeat enrollment during materialization", async () => { + const config = { + agents: { + defaults: { heartbeat: { every: "1h" } }, + entries: { + ops: { default: true }, + research: {}, + }, + }, + channels: { telegram: { enabled: true } }, + talk: { provider: "test" }, + }; + const result = await runDoctorConfigWithInput({ + config, + parsedConfig: config, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(result.shouldWriteConfig).toBe(true); + expect(result.cfg.agents?.defaults?.heartbeat).toEqual({ every: "1h" }); + expect(result.cfg.agents?.defaults?.heartbeat).not.toHaveProperty("agentId"); + expect(result.cfg.agents?.defaults?.systemAgent).toEqual({ agentId: "ops" }); + }); + + it("does not rematerialize explicit roles or touch single-agent configs", async () => { + const materialized = { + agents: { + defaults: { + heartbeat: { agentId: "ops" }, + systemAgent: { agentId: "ops" }, + }, + entries: { ops: { default: true }, research: {} }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "*" } }], + channels: { telegram: { enabled: true } }, + talk: { provider: "test", agentId: "ops" }, + }; + const secondRun = await runDoctorConfigWithInput({ + config: materialized, + parsedConfig: materialized, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + const singleAgent = await runDoctorConfigWithInput({ + config: { + agents: { entries: { ops: { default: true } } }, + channels: { telegram: { enabled: true } }, + talk: { provider: "test" }, + }, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + + expect(secondRun.shouldWriteConfig).toBe(false); + expect(singleAgent.shouldWriteConfig).toBe(false); + }); + it("preserves malformed keyed entries for schema validation during repair", async () => { const agents = { entries: { main: {}, broken: null as never } }; const result = await runDoctorConfigWithInput({ diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 053bd17f8f69..a738efc1d56e 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -24,6 +24,7 @@ import { applyUnknownConfigKeyStep, } from "./doctor/shared/config-flow-steps.js"; import { applyDoctorConfigMutation } from "./doctor/shared/config-mutation-state.js"; +import { materializeDefaultAgentRoles } from "./doctor/shared/default-agent-role-materialization.js"; import { isSingleTopLevelIncludeMigration } from "./doctor/shared/include-migration-ownership.js"; import { normalizeCompatibilityConfigValues } from "./doctor/shared/legacy-config-core-migrate.js"; @@ -200,6 +201,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { fixHint: `Run "${doctorFixCommand}" to persist the explicit agent roster.`, })); } + const defaultRoleMaterialization = materializeDefaultAgentRoles(candidate); + if (defaultRoleMaterialization.changes.length > 0) { + emitDoctorChangesPanel(defaultRoleMaterialization.changes, shouldRepair); + ({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({ + state: { cfg, candidate, pendingChanges, fixHints }, + mutation: defaultRoleMaterialization, + shouldRepair, + fixHint: `Run "${doctorFixCommand}" to persist explicit ambient agent targets.`, + })); + } const { collectBlockedLegacyOpenAICodexProviderPlan } = await import("./doctor/shared/legacy-config-migrations.runtime.models.js"); const blockedCodexProviderPlan = collectBlockedLegacyOpenAICodexProviderPlan(candidate); diff --git a/src/commands/doctor/shared/default-agent-role-materialization.test.ts b/src/commands/doctor/shared/default-agent-role-materialization.test.ts new file mode 100644 index 000000000000..28308155e9c8 --- /dev/null +++ b/src/commands/doctor/shared/default-agent-role-materialization.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { resolveDefaultAgentId } from "../../../agents/agent-scope-config.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { resolveCronJobEffectiveAgentId } from "../../../cron/agent-id.js"; +import { resolveHeartbeatAgents } from "../../../infra/heartbeat-runner.js"; +import { resolveAgentRoute } from "../../../routing/resolve-route.js"; +import { resolveSystemAgentTargetAgentId } from "../../../system-agent/inference-route.js"; +import { resolveTalkSessionAgentId, resolveTalkTargetAgentId } from "../../../talk/agent-target.js"; +import { materializeDefaultAgentRoles } from "./default-agent-role-materialization.js"; + +type SurfaceSnapshot = { + channel: { agentId: string; sessionKey: string }; + heartbeat: string[]; + consult: string; + voice: string; + cron: string; + cli: string; +}; + +function snapshotSurfaces(cfg: OpenClawConfig): SurfaceSnapshot { + const channel = resolveAgentRoute({ + cfg, + channel: "telegram", + accountId: "work", + peer: { kind: "direct", id: "user-1" }, + }); + const defaultAgentId = resolveDefaultAgentId(cfg); + return { + channel: { agentId: channel.agentId, sessionKey: channel.sessionKey }, + heartbeat: resolveHeartbeatAgents(cfg).map((entry) => entry.agentId), + consult: resolveSystemAgentTargetAgentId(cfg), + voice: resolveTalkTargetAgentId(cfg), + cron: resolveCronJobEffectiveAgentId({}, defaultAgentId), + cli: defaultAgentId, + }; +} + +const fixtures: Array<{ name: string; config: OpenClawConfig; materializes: boolean }> = [ + { + name: "legacy single-agent", + config: {}, + materializes: false, + }, + { + name: "explicit single-agent", + config: { + agents: { entries: { solo: { default: true } } }, + channels: { telegram: { enabled: true } }, + talk: { provider: "test" }, + }, + materializes: false, + }, + { + name: "multi-agent default with an unbound channel", + config: { + agents: { + entries: { + ops: { default: true }, + research: {}, + }, + }, + channels: { telegram: { enabled: true } }, + }, + materializes: true, + }, + { + name: "multi-agent fully bound", + config: { + agents: { + defaults: { + heartbeat: { agentId: "ops" }, + systemAgent: { agentId: "ops" }, + }, + entries: { + ops: { default: true }, + research: {}, + }, + }, + bindings: [{ agentId: "ops", match: { channel: "telegram", accountId: "*" } }], + channels: { telegram: { enabled: true } }, + talk: { agentId: "ops", provider: "test" }, + }, + materializes: false, + }, +]; + +describe("default agent role materialization", () => { + it.each(fixtures)("preserves all ambient surface routing for $name", ({ config }) => { + const before = snapshotSurfaces(config); + const result = materializeDefaultAgentRoles(config); + expect(snapshotSurfaces(result.config)).toEqual(before); + + const second = materializeDefaultAgentRoles(result.config); + expect(second.changes).toEqual([]); + expect(second.config).toBe(result.config); + }); + + it.each(fixtures)("materializes only the expected $name fixture", ({ config, materializes }) => { + const result = materializeDefaultAgentRoles(config); + expect(result.changes.length > 0).toBe(materializes); + }); + + it("adds only uncovered channel-wide bindings and preserves narrower routes", () => { + const config: OpenClawConfig = { + agents: { entries: { ops: { default: true }, research: {} } }, + channels: { + telegram: { enabled: true }, + discord: { enabled: true }, + slack: { enabled: false }, + }, + bindings: [ + { agentId: "research", match: { channel: "telegram", accountId: "work" } }, + { agentId: "research", match: { channel: "discord", accountId: "*" } }, + ], + }; + + const result = materializeDefaultAgentRoles(config); + expect(result.config.bindings).toEqual([ + ...config.bindings!, + { agentId: "ops", match: { channel: "telegram", accountId: "*" } }, + ]); + expect( + resolveAgentRoute({ + cfg: result.config, + channel: "telegram", + accountId: "work", + peer: { kind: "direct", id: "user-1" }, + }).agentId, + ).toBe("research"); + }); + + it("keeps all-agent and per-agent heartbeat enrollment unchanged", () => { + const allAgents: OpenClawConfig = { + agents: { + defaults: { heartbeat: { every: "1h" } }, + entries: { ops: { default: true }, research: {} }, + }, + }; + const perAgent: OpenClawConfig = { + agents: { + entries: { + ops: { default: true }, + research: { heartbeat: { every: "1h" } }, + }, + }, + }; + + expect(materializeDefaultAgentRoles(allAgents).config.agents?.defaults?.heartbeat).toEqual({ + every: "1h", + }); + expect(resolveHeartbeatAgents(materializeDefaultAgentRoles(allAgents).config)).toHaveLength(2); + expect( + materializeDefaultAgentRoles(perAgent).config.agents?.entries?.research?.heartbeat, + ).toEqual({ every: "1h" }); + expect(resolveHeartbeatAgents(materializeDefaultAgentRoles(perAgent).config)).toEqual([ + { agentId: "research", heartbeat: { every: "1h" } }, + ]); + }); + + it("materializes absent Talk config but preserves malformed Talk input", () => { + const base: OpenClawConfig = { + agents: { entries: { ops: { default: true }, research: {} } }, + }; + expect(materializeDefaultAgentRoles(base).config.talk).toEqual({ agentId: "ops" }); + + const malformed = { ...base, talk: "invalid" as never }; + const result = materializeDefaultAgentRoles(malformed); + expect(result.config.talk).toBe("invalid"); + expect(result.changes).not.toContain('Assigned ambient Talk sessions to agent "ops".'); + }); + + it("uses the Talk owner for unscoped aliases and explicit agent keys when present", () => { + const config: OpenClawConfig = { + agents: { entries: { ops: { default: true }, research: {} } }, + talk: { agentId: "research" }, + }; + expect(resolveTalkSessionAgentId(config, "main")).toBe("research"); + expect(resolveTalkSessionAgentId(config, "global")).toBe("research"); + expect(resolveTalkSessionAgentId(config, "agent:ops:main")).toBe("ops"); + }); + + it("preserves malformed bindings and agent-default blocks for validation", () => { + const base = { + agents: { entries: { ops: { default: true }, research: {} } }, + channels: { telegram: { enabled: true } }, + } satisfies OpenClawConfig; + const malformedBindings = { ...base, bindings: { bad: true } as never }; + expect(materializeDefaultAgentRoles(malformedBindings).config.bindings).toEqual({ bad: true }); + + const malformedBindingEntry = { + ...base, + bindings: [null as never, { agentId: "ops" } as never], + }; + expect(() => materializeDefaultAgentRoles(malformedBindingEntry)).not.toThrow(); + expect(materializeDefaultAgentRoles(malformedBindingEntry).config.bindings?.[1]).toEqual({ + agentId: "ops", + }); + + const malformedDefaults = { + ...base, + agents: { ...base.agents, defaults: null as never }, + }; + expect(materializeDefaultAgentRoles(malformedDefaults).config.agents?.defaults).toBeNull(); + + const malformedSystemAgent = { + ...base, + agents: { ...base.agents, defaults: { systemAgent: null as never } }, + talk: { agentId: " " }, + }; + const preserved = materializeDefaultAgentRoles(malformedSystemAgent).config; + expect(preserved.agents?.defaults?.systemAgent).toBeNull(); + expect(preserved.talk?.agentId).toBe(" "); + }); +}); diff --git a/src/commands/doctor/shared/default-agent-role-materialization.ts b/src/commands/doctor/shared/default-agent-role-materialization.ts new file mode 100644 index 000000000000..030f608abf4e --- /dev/null +++ b/src/commands/doctor/shared/default-agent-role-materialization.ts @@ -0,0 +1,150 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { listAgentEntries } from "../../../agents/agent-scope-config.js"; +import type { AgentRouteBinding } from "../../../config/types.agents.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { normalizeRouteBindingChannelId } from "../../../routing/binding-scope.js"; +import { normalizeAgentId } from "../../../routing/session-key.js"; +import { isRecord } from "../../../utils.js"; + +type DefaultAgentRoleMaterialization = { + config: OpenClawConfig; + changes: string[]; +}; + +function resolveLegacyMultiAgentDefault(cfg: OpenClawConfig): string | undefined { + const entries = listAgentEntries(cfg); + if (entries.length < 2) { + return undefined; + } + const defaults = entries.filter((entry) => entry.default === true); + return defaults.length === 1 ? normalizeAgentId(defaults[0]!.id) : undefined; +} + +function listAmbientConfiguredChannelIds(cfg: OpenClawConfig): string[] { + if (!isRecord(cfg.channels)) { + return []; + } + return Object.entries(cfg.channels) + .flatMap(([channelId, value]) => { + if (channelId === "defaults" || (isRecord(value) && value.enabled === false)) { + return []; + } + const normalized = normalizeRouteBindingChannelId(channelId); + return normalized ? [normalized] : []; + }) + .toSorted((left, right) => left.localeCompare(right)); +} + +function isChannelWideBinding(binding: AgentRouteBinding, channelId: string): boolean { + const match = binding.match; + if (!isRecord(match)) { + return false; + } + return ( + normalizeRouteBindingChannelId( + typeof match.channel === "string" ? match.channel : undefined, + ) === channelId && + (typeof match.accountId === "string" ? match.accountId.trim() : undefined) === "*" && + match.peer === undefined && + !normalizeOptionalString(typeof match.guildId === "string" ? match.guildId : undefined) && + !normalizeOptionalString(typeof match.teamId === "string" ? match.teamId : undefined) && + (!Array.isArray(match.roles) || match.roles.length === 0) + ); +} + +/** + * Materialize only ambient roles that currently fall through to a multi-agent default. + * The marker remains authoritative in H2-0; these explicit targets are preparation for H2-1. + */ +export function materializeDefaultAgentRoles(cfg: OpenClawConfig): DefaultAgentRoleMaterialization { + const defaultAgentId = resolveLegacyMultiAgentDefault(cfg); + if (!defaultAgentId) { + return { config: cfg, changes: [] }; + } + + let next = cfg; + const changes: string[] = []; + const canMaterializeBindings = cfg.bindings === undefined || Array.isArray(cfg.bindings); + const bindings = Array.isArray(cfg.bindings) + ? cfg.bindings.filter( + (binding): binding is AgentRouteBinding => isRecord(binding) && binding.type !== "acp", + ) + : []; + const missingChannelBindings = canMaterializeBindings + ? listAmbientConfiguredChannelIds(cfg).filter( + (channelId) => !bindings.some((binding) => isChannelWideBinding(binding, channelId)), + ) + : []; + if (missingChannelBindings.length > 0) { + next = { + ...next, + bindings: [ + ...(Array.isArray(next.bindings) ? next.bindings : []), + ...missingChannelBindings.map((channel) => ({ + agentId: defaultAgentId, + match: { channel, accountId: "*" }, + })), + ], + }; + changes.push( + `Bound ${missingChannelBindings.join(", ")} unbound account routing to agent "${defaultAgentId}".`, + ); + } + + const rawDefaults = (cfg.agents as { defaults?: unknown } | undefined)?.defaults; + const defaultsConfig = isRecord(rawDefaults) ? rawDefaults : undefined; + const canMaterializeDefaults = rawDefaults === undefined || defaultsConfig !== undefined; + const hasPerAgentHeartbeat = listAgentEntries(cfg).some((entry) => Boolean(entry.heartbeat)); + // A shared defaults heartbeat already fans out to every agent. Pinning it here + // would silently narrow existing multi-agent enrollment to the legacy default. + if (canMaterializeDefaults && !hasPerAgentHeartbeat && defaultsConfig?.heartbeat === undefined) { + next = { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + heartbeat: { agentId: defaultAgentId }, + }, + }, + }; + changes.push(`Assigned ambient heartbeat runs to agent "${defaultAgentId}".`); + } + + const rawSystemAgent = defaultsConfig?.systemAgent; + const systemAgentConfig = isRecord(rawSystemAgent) ? rawSystemAgent : undefined; + if ( + canMaterializeDefaults && + (rawSystemAgent === undefined || systemAgentConfig !== undefined) && + (!systemAgentConfig || !Object.hasOwn(systemAgentConfig, "agentId")) + ) { + next = { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + systemAgent: { + ...next.agents?.defaults?.systemAgent, + agentId: defaultAgentId, + }, + }, + }, + }; + changes.push(`Assigned ambient system-agent consults to agent "${defaultAgentId}".`); + } + + const talkConfig = isRecord(cfg.talk) ? cfg.talk : undefined; + if ( + (cfg.talk === undefined || talkConfig !== undefined) && + (!talkConfig || !Object.hasOwn(talkConfig, "agentId")) + ) { + next = { + ...next, + talk: { ...talkConfig, agentId: defaultAgentId }, + }; + changes.push(`Assigned ambient Talk sessions to agent "${defaultAgentId}".`); + } + + return { config: next, changes }; +} diff --git a/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts b/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts new file mode 100644 index 000000000000..38fb2337c790 --- /dev/null +++ b/src/commands/doctor/shared/default-agent-role-materialization.write.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createConfigIO, resetConfigRuntimeState } from "../../../config/io.js"; +import { materializeDefaultAgentRoles } from "./default-agent-role-materialization.js"; + +const roots: string[] = []; + +afterEach(async () => { + resetConfigRuntimeState(); + await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); +}); + +describe("default role materialization authored writes", () => { + it("preserves env references and includes and is idempotent after persistence", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-default-roles-")); + roots.push(root); + const configPath = path.join(root, "openclaw.json"); + const channelsPath = path.join(root, "channels.json5"); + const includeRaw = `${JSON.stringify({ telegram: { enabled: true } }, null, 2)}\n`; + await fs.writeFile(channelsPath, includeRaw, "utf-8"); + await fs.writeFile( + configPath, + `${JSON.stringify( + { + agents: { + defaults: { model: "${DEFAULT_MODEL}" }, + entries: { + ops: { default: true }, + research: { model: "${RESEARCH_MODEL}" }, + }, + }, + channels: { $include: "./channels.json5" }, + talk: { provider: "test" }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + const io = createConfigIO({ + configPath, + env: { + HOME: root, + OPENCLAW_TEST_FAST: "1", + DEFAULT_MODEL: "openai/default-model", + RESEARCH_MODEL: "openai/research-model", + } as NodeJS.ProcessEnv, + homedir: () => root, + observe: false, + logger: { warn: () => {}, error: () => {} }, + }); + + const snapshot = await io.readConfigFileSnapshot(); + const materialized = materializeDefaultAgentRoles(snapshot.config); + expect(materialized.changes.length).toBeGreaterThan(0); + await io.writeConfigFile(materialized.config, { baseSnapshot: snapshot }); + + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + agents?: { + defaults?: { model?: string; heartbeat?: { agentId?: string } }; + entries?: Record; + }; + channels?: { $include?: string }; + bindings?: Array<{ agentId?: string; match?: { channel?: string; accountId?: string } }>; + talk?: { agentId?: string }; + }; + expect(persisted.agents?.defaults?.model).toBe("${DEFAULT_MODEL}"); + expect(persisted.agents?.entries?.research?.model).toBe("${RESEARCH_MODEL}"); + expect(persisted.channels).toEqual({ $include: "./channels.json5" }); + await expect(fs.readFile(channelsPath, "utf-8")).resolves.toBe(includeRaw); + expect(persisted.bindings).toContainEqual({ + agentId: "ops", + match: { channel: "telegram", accountId: "*" }, + }); + expect(persisted.agents?.defaults?.heartbeat?.agentId).toBe("ops"); + expect(persisted.talk?.agentId).toBe("ops"); + + const reread = await io.readConfigFileSnapshot(); + expect(materializeDefaultAgentRoles(reread.config).changes).toEqual([]); + }); +}); diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 92497aef7ed9..c90783a5f045 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -313,6 +313,14 @@ export const CORE_FIELD_HELP: Record = { "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", "agents.defaults.heartbeat.timeoutSeconds": "Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds when set, otherwise the heartbeat cadence capped at 600 seconds.", + "agents.defaults.heartbeat.agentId": + "Agent that owns ambient heartbeat runs when no per-agent heartbeat configuration exists. Leave unset to preserve configured-default routing.", "agents.entries.*.heartbeat.timeoutSeconds": "Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat timeout, then agents.defaults.timeoutSeconds when set, otherwise the heartbeat cadence capped at 600 seconds.", + "agents.defaults.systemAgent": + "Target settings for ambient OpenClaw system-agent and Custodian inference.", + "agents.defaults.systemAgent.agentId": + "Agent whose model and credentials own ambient system-agent and Custodian consults. Delegated consults still use their requesting agent.", + "talk.agentId": + "Agent that owns Talk sessions created without an explicit agent-scoped session key.", }; diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 6df920cbf446..d7cf58e66f94 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -641,9 +641,12 @@ export const FIELD_LABELS: Record = { "agents.entries.*.embeddedAgent": "Agent Embedded OpenClaw", "agents.entries.*.embeddedAgent.executionContract": "Agent Embedded OpenClaw Execution Contract", "agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy", + "agents.defaults.heartbeat.agentId": "Heartbeat Agent", "agents.entries.*.heartbeat.directPolicy": "Heartbeat Direct Policy", "agents.defaults.heartbeat.timeoutSeconds": "Heartbeat Timeout (Seconds)", "agents.entries.*.heartbeat.timeoutSeconds": "Heartbeat Timeout (Seconds)", + "agents.defaults.systemAgent": "System Agent Target", + "agents.defaults.systemAgent.agentId": "System Agent Owner", "agents.defaults.sandbox.browser.network": "Sandbox Browser Network", "agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range", "agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin": @@ -825,6 +828,7 @@ export const FIELD_LABELS: Record = { "discovery.wideArea.domain": "Wide-area Discovery Domain", "discovery.mdns": "mDNS Discovery", talk: "Talk", + "talk.agentId": "Talk Agent", "talk.speechLocale": "Talk Speech Locale", "talk.interruptOnSpeech": "Talk Interrupt on Speech", "talk.silenceTimeoutMs": "Talk Silence Timeout (ms)", diff --git a/src/config/talk.normalize.test.ts b/src/config/talk.normalize.test.ts index f26e9d2f36b4..4671f36d65c0 100644 --- a/src/config/talk.normalize.test.ts +++ b/src/config/talk.normalize.test.ts @@ -4,6 +4,11 @@ import { TALK_TEST_PROVIDER_ID } from "../test-utils/talk-test-provider.js"; import { buildTalkConfigResponse, normalizeTalkSection } from "./talk.js"; describe("talk normalization", () => { + it("preserves the explicit ambient Talk agent", () => { + expect(normalizeTalkSection({ agentId: " ops " })).toEqual({ agentId: "ops" }); + expect(buildTalkConfigResponse({ agentId: "ops" })).toEqual({ agentId: "ops" }); + }); + it("keeps core Talk normalization generic and ignores legacy provider-flat fields", () => { const normalized = normalizeTalkSection({ voiceId: "voice-123", diff --git a/src/config/talk.ts b/src/config/talk.ts index 45a1d64235a8..014c233aa55d 100644 --- a/src/config/talk.ts +++ b/src/config/talk.ts @@ -210,6 +210,10 @@ export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig const source = value as Record; const normalized: TalkConfig = {}; + const agentId = normalizeOptionalString(source.agentId); + if (agentId) { + normalized.agentId = agentId; + } const speechLocale = normalizeOptionalString(source.speechLocale); if (speechLocale) { normalized.speechLocale = speechLocale; @@ -302,6 +306,9 @@ export function buildTalkConfigResponse(value: unknown): TalkConfigResponse | un } const payload: TalkConfigResponse = {}; + if (typeof normalized?.agentId === "string") { + payload.agentId = normalized.agentId; + } if (typeof normalized?.interruptOnSpeech === "boolean") { payload.interruptOnSpeech = normalized.interruptOnSpeech; } diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 2f75d0dd3ac7..708e1d12d785 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -286,6 +286,8 @@ export type AgentDefaultsConfig = { typingMode?: TypingMode; /** Periodic background heartbeat runs. */ heartbeat?: { + /** Agent that owns ambient heartbeat runs when no per-agent heartbeat is configured. */ + agentId?: string; /** Heartbeat interval (duration string, default unit: minutes; default: 30m). */ every?: string; /** Optional active-hours window (local time); heartbeats run only inside this window. */ @@ -326,6 +328,10 @@ export type AgentDefaultsConfig = { */ isolatedSession?: boolean; }; + /** Owner for ambient OpenClaw system-agent/Custodian inference. */ + systemAgent?: { + agentId?: string; + }; /** Max concurrent agent runs across all conversations. Default: 4. */ maxConcurrent?: number; /** Sub-agent defaults (spawned via sessions_spawn). */ diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index 940f67751fdb..21e510205384 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -139,7 +139,7 @@ export type AgentConfig = { contextLimits?: AgentContextLimitsConfig; contextTokens?: number; /** Optional per-agent heartbeat overrides. */ - heartbeat?: AgentDefaultsConfig["heartbeat"]; + heartbeat?: Omit, "agentId">; identity?: IdentityConfig; groupChat?: Omit; subagents?: { diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index f2cdca7faf38..abc4a27b338e 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -88,6 +88,8 @@ export type ResolvedTalkConfig = { }; export type TalkConfig = { + /** Agent that owns Talk sessions created without an agent-scoped session key. */ + agentId?: string; /** Active Talk TTS provider (for example "acme-speech"). */ provider?: string; /** Provider-specific Talk config keyed by provider id. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index e65e328ce931..5ee49d8d273b 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -198,7 +198,15 @@ export const AgentDefaultsSchema = z imageQuality: z.enum(["auto", "efficient", "balanced", "high"]).optional(), typingIntervalSeconds: z.number().int().positive().optional(), typingMode: TypingModeSchema.optional(), - heartbeat: HeartbeatSchema, + heartbeat: HeartbeatSchema.unwrap() + .safeExtend({ agentId: z.string().trim().min(1).optional() }) + .optional(), + systemAgent: z + .object({ + agentId: z.string().trim().min(1).optional(), + }) + .strict() + .optional(), maxConcurrent: z.number().int().positive().optional(), subagents: z .object({ diff --git a/src/config/zod-schema.agents.test.ts b/src/config/zod-schema.agents.test.ts index bd4cbd63cf75..553ac6863fab 100644 --- a/src/config/zod-schema.agents.test.ts +++ b/src/config/zod-schema.agents.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { AgentsSchema } from "./zod-schema.agents.js"; +import { OpenClawSchema } from "./zod-schema.js"; describe("agent roster defaults", () => { it("rejects an empty roster after load-time migration", () => { @@ -17,3 +18,65 @@ describe("agent roster defaults", () => { } }); }); + +describe("explicit ambient agent targets", () => { + it.each([ + { + agents: { + defaults: { heartbeat: { agentId: "missing" } }, + entries: { main: { default: true } }, + }, + }, + { + agents: { + defaults: { systemAgent: { agentId: "missing" } }, + entries: { main: { default: true } }, + }, + }, + { agents: { entries: { main: { default: true } } }, talk: { agentId: "missing" } }, + ])("rejects an unknown explicit target", (target) => { + const result = OpenClawSchema.safeParse(target); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toContain("Unknown agent id"); + } + }); + + it("accepts configured heartbeat, system-agent, and Talk targets", () => { + expect( + OpenClawSchema.safeParse({ + agents: { + defaults: { + heartbeat: { agentId: "ops" }, + systemAgent: { agentId: "ops" }, + }, + entries: { ops: { default: true } }, + }, + talk: { agentId: "ops" }, + }).success, + ).toBe(true); + }); + + it.each([ + { + agents: { + defaults: { heartbeat: { agentId: " " } }, + entries: { main: { default: true } }, + }, + }, + { + agents: { + defaults: { systemAgent: { agentId: " " } }, + entries: { main: { default: true } }, + }, + }, + { agents: { entries: { main: { default: true } } }, talk: { agentId: " " } }, + ])("rejects blank explicit targets", (config) => { + expect(OpenClawSchema.safeParse(config).success).toBe(false); + }); + + it("validates targets against the implicit main roster", () => { + expect(OpenClawSchema.safeParse({ talk: { agentId: "main" } }).success).toBe(true); + expect(OpenClawSchema.safeParse({ talk: { agentId: "missing" } }).success).toBe(false); + }); +}); diff --git a/src/config/zod-schema.root-support.ts b/src/config/zod-schema.root-support.ts index 7f630c408e57..8400a6b1afa5 100644 --- a/src/config/zod-schema.root-support.ts +++ b/src/config/zod-schema.root-support.ts @@ -245,6 +245,7 @@ const TalkRealtimeSchema = z export const TalkSchema = z .strictObject({ + agentId: z.string().trim().min(1).optional(), provider: z.string().optional(), providers: z.record(z.string(), TalkProviderEntrySchema).optional(), realtime: TalkRealtimeSchema.optional(), diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 8e663dcbda4e..877692134a28 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -16,11 +16,39 @@ installZodDefaultLocale(); export const OpenClawSchema = z.strictObject(OpenClawSchemaShape).superRefine((cfg, ctx) => { const agents = listAgentEntries(cfg as OpenClawConfig); + const agentIds = new Set(agents.map((agent) => agent.id)); + const effectiveAgentIds = new Set(agents.map((agent) => normalizeAgentId(agent.id))); + if (agents.length === 0) { + effectiveAgentIds.add("main"); + } + + const explicitTargets = [ + { + path: ["agents", "defaults", "heartbeat", "agentId"], + agentId: cfg.agents?.defaults?.heartbeat?.agentId, + }, + { + path: ["agents", "defaults", "systemAgent", "agentId"], + agentId: cfg.agents?.defaults?.systemAgent?.agentId, + }, + { path: ["talk", "agentId"], agentId: cfg.talk?.agentId }, + ] as const; + for (const target of explicitTargets) { + if ( + typeof target.agentId === "string" && + !effectiveAgentIds.has(normalizeAgentId(target.agentId)) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [...target.path], + message: `Unknown agent id "${target.agentId}" (not in agents.entries).`, + }); + } + } + if (agents.length === 0) { return; } - const agentIds = new Set(agents.map((agent) => agent.id)); - const effectiveAgentIds = new Set(agents.map((agent) => normalizeAgentId(agent.id))); // Bindings referencing a missing agent id silently misroute at gateway // load time. Match routing's normalized id semantics; otherwise valid diff --git a/src/gateway/server-methods/talk-client.ts b/src/gateway/server-methods/talk-client.ts index 0f94a4e671b8..6b7c33f6016c 100644 --- a/src/gateway/server-methods/talk-client.ts +++ b/src/gateway/server-methods/talk-client.ts @@ -14,11 +14,7 @@ import { validateTalkClientToolCallParams, validateTalkClientTranscriptParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; -import { - buildAgentMainSessionKey, - resolveAgentIdFromSessionKey, -} from "../../routing/session-key.js"; +import { buildAgentMainSessionKey } from "../../routing/session-key.js"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL, REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, @@ -26,6 +22,7 @@ import { } from "../../talk/agent-consult-tool.js"; import { REALTIME_VOICE_AGENT_CONTROL_TOOL } from "../../talk/agent-run-control-shared.js"; import { controlRealtimeVoiceAgentRun } from "../../talk/agent-run-control.js"; +import { resolveTalkSessionAgentId } from "../../talk/agent-target.js"; import { authorizeClientVoiceConfirmation, bindAuthorizedClientVoiceConfirmation, @@ -75,10 +72,10 @@ function pruneLegacyVoiceBindings(now = Date.now()): void { } function resolveTalkClientAgentId( - config: Parameters[0], + config: Parameters[0], key: string, ) { - return resolveAgentIdFromSessionKey(key, resolveDefaultAgentId(config)); + return resolveTalkSessionAgentId(config, key); } /** diff --git a/src/gateway/server-methods/talk-shared.ts b/src/gateway/server-methods/talk-shared.ts index 6c8750adfbce..311ec5e19e01 100644 --- a/src/gateway/server-methods/talk-shared.ts +++ b/src/gateway/server-methods/talk-shared.ts @@ -13,7 +13,6 @@ import { resolveSupportedVoiceModelRefs, type VoiceModelProvider, } from "../../../packages/speech-core/voice-models.js"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveRealtimeBootstrapContextInstructions } from "../../agents/realtime-bootstrap-context.js"; import type { TalkRealtimeConfig } from "../../config/types.gateway.js"; import type { OpenClawConfig } from "../../config/types.js"; @@ -22,9 +21,9 @@ import { listRealtimeTranscriptionProviders, } from "../../realtime-transcription/provider-registry.js"; import type { RealtimeTranscriptionProviderConfig } from "../../realtime-transcription/provider-types.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME } from "../../talk/agent-consult-tool.js"; import { REALTIME_VOICE_AGENT_CONTROL_TOOL_NAME } from "../../talk/agent-run-control-shared.js"; +import { resolveTalkSessionAgentId, resolveTalkTargetAgentId } from "../../talk/agent-target.js"; import { listRealtimeVoiceProviders } from "../../talk/provider-registry.js"; import type { RealtimeVoiceBrowserSession, @@ -72,11 +71,11 @@ export async function resolveTalkRealtimeProviderInstructions(params: { warn: (message: string) => void; }): Promise<{ agentId: string; instructions: string; requestedSessionKey?: string }> { const requestedSessionKey = normalizeOptionalString(params.sessionKey); - const defaultAgentId = resolveDefaultAgentId(params.config); + const defaultAgentId = resolveTalkTargetAgentId(params.config); // Older clients can prefetch without a key. Client-owned creates bind to the // default agent immediately, so its workspace profile stays consistent there. const agentId = requestedSessionKey - ? resolveAgentIdFromSessionKey(requestedSessionKey, defaultAgentId) + ? resolveTalkSessionAgentId(params.config, requestedSessionKey) : defaultAgentId; const bootstrapContext = params.requireSessionKeyForProfile && !requestedSessionKey diff --git a/src/gateway/talk-realtime-relay.ts b/src/gateway/talk-realtime-relay.ts index 5fcb882ee6be..46cbabae02fc 100644 --- a/src/gateway/talk-realtime-relay.ts +++ b/src/gateway/talk-realtime-relay.ts @@ -2,11 +2,9 @@ // Bridges browser Talk audio sessions with realtime voice provider plugins. import { randomUUID } from "node:crypto"; import { resolveExpiresAtMsFromDurationMs } from "@openclaw/normalization-core/number-coercion"; -import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { RealtimeVoiceProviderPlugin } from "../plugins/types.js"; -import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, @@ -18,6 +16,7 @@ import { shouldAutoControlRealtimeVoiceAgentText, type RealtimeVoiceAgentControlResult, } from "../talk/agent-run-control.js"; +import { resolveTalkSessionAgentId } from "../talk/agent-target.js"; import { appendRelayVoiceTranscript, closeClientVoiceSession, @@ -190,7 +189,7 @@ function logRelayVoiceFailure(session: RelaySession, message: string, error: unk function resolveRelayAgentIdFromCurrentConfig(session: RelaySession, sessionKey: string): string { const config = session.voiceConfig ?? session.context.getRuntimeConfig(); - return resolveAgentIdFromSessionKey(sessionKey, resolveDefaultAgentId(config)); + return resolveTalkSessionAgentId(config, sessionKey); } function bindRelaySessionKey(session: RelaySession, sessionKey: string): void { @@ -1028,9 +1027,9 @@ export function createTalkRealtimeRelaySession( sessionKey: initialSessionKey, ...(initialSessionKey ? { - agentId: resolveAgentIdFromSessionKey( + agentId: resolveTalkSessionAgentId( + params.cfg ?? params.context.getRuntimeConfig(), initialSessionKey, - resolveDefaultAgentId(params.cfg ?? params.context.getRuntimeConfig()), ), } : {}), diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 686fb4b34216..eb01de1f243f 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -360,6 +360,11 @@ function resolveHeartbeatConfig( return { ...defaults, ...overrides }; } +function resolveAmbientHeartbeatAgentId(cfg: OpenClawConfig): string { + const configured = normalizeOptionalString(cfg.agents?.defaults?.heartbeat?.agentId); + return normalizeAgentId(configured ?? resolveDefaultAgentId(cfg)); +} + function omitExplicitHeartbeatDestination(heartbeat: HeartbeatConfig | undefined) { if (!heartbeat) { return undefined; @@ -399,13 +404,18 @@ export function resolveHeartbeatAgents(cfg: OpenClawConfig): HeartbeatAgent[] { }) .filter((entry) => entry.agentId); } + const configuredAgentId = normalizeOptionalString(cfg.agents?.defaults?.heartbeat?.agentId); + if (configuredAgentId) { + const agentId = normalizeAgentId(configuredAgentId); + return [{ agentId, heartbeat: resolveHeartbeatConfig(cfg, agentId) }]; + } if (cfg.agents?.defaults?.heartbeat) { return listAgentIds(cfg).map((agentId) => ({ agentId, heartbeat: resolveHeartbeatConfig(cfg, agentId), })); } - const fallbackId = resolveDefaultAgentId(cfg); + const fallbackId = resolveAmbientHeartbeatAgentId(cfg); return [{ agentId: fallbackId, heartbeat: resolveHeartbeatConfig(cfg, fallbackId) }]; } @@ -2612,7 +2622,7 @@ export function startHeartbeatRunner(opts: { }; if (requestedSessionKey || requestedAgentId) { - const targetAgentId = requestedTargetAgentId ?? resolveDefaultAgentId(wakeConfig); + const targetAgentId = requestedTargetAgentId ?? resolveAmbientHeartbeatAgentId(wakeConfig); const targetAgent = state.agents.get(targetAgentId); // Task intent wins scheduled-task coalescing, so the cadence payload—not // the final intent—proves that the persisted monitor tick joined this turn. diff --git a/src/system-agent/inference-fallback.ts b/src/system-agent/inference-fallback.ts index d95685e805c0..06c987e7a2e6 100644 --- a/src/system-agent/inference-fallback.ts +++ b/src/system-agent/inference-fallback.ts @@ -1,16 +1,13 @@ // Provider-neutral live inference ladder for delegated OpenClaw sessions. import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { - listAgentIds, - resolveDefaultAgentId, - tryResolveDefaultAgentId, -} from "../agents/agent-scope.js"; +import { listAgentIds, tryResolveDefaultAgentId } from "../agents/agent-scope.js"; import { hasAvailableAuthForProvider } from "../agents/model-auth.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveSystemAgentConfiguredRouteFromConfig, + resolveSystemAgentTargetAgentId, type SystemAgentConfiguredRoute, } from "./inference-route.js"; import { verifySetupInference, type BoundVerifySetupInferenceResult } from "./setup-inference.js"; @@ -62,7 +59,7 @@ export async function verifySystemAgentInferenceWithFallback(params: { const config = await (deps.readConfig ?? readCurrentConfig)(); const defaultAgentId = params.requestingAgentId ? tryResolveDefaultAgentId(config) - : resolveDefaultAgentId(config); + : resolveSystemAgentTargetAgentId(config); const requestedAgentId = normalizeAgentId(params.requestingAgentId ?? defaultAgentId); const candidateAgentIds = [ requestedAgentId, diff --git a/src/system-agent/inference-route.ts b/src/system-agent/inference-route.ts index 1127c651f004..f605b2e591a1 100644 --- a/src/system-agent/inference-route.ts +++ b/src/system-agent/inference-route.ts @@ -1,7 +1,12 @@ // Resolves the configured default agent route shared by OpenClaw inference calls. import { isDeepStrictEqual } from "node:util"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; -import { listAgentEntries, toAgentEntriesRecord } from "../agents/agent-scope-config.js"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + listAgentEntries, + resolveDefaultAgentId, + toAgentEntriesRecord, +} from "../agents/agent-scope-config.js"; import { cliBackendAcceptsAuthProfileForwarding, resolveCliExecutionAuthProfileId, @@ -25,6 +30,19 @@ export type SystemAgentConfiguredRoute = { } ); +export function resolveSystemAgentTargetAgentId( + config: OpenClawConfig, + requestedAgentId?: string, +): string { + const configuredAgentId = + normalizeOptionalString(requestedAgentId) ?? + normalizeOptionalString(config.agents?.defaults?.systemAgent?.agentId); + if (configuredAgentId) { + return normalizeAgentId(configuredAgentId); + } + return normalizeAgentId(resolveDefaultAgentId(config)); +} + export type SystemAgentConfiguredRouteDeps = { readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot; loadAuthProfileStoreForRuntime?: typeof import("../agents/auth-profiles/store.js").loadAuthProfileStoreForRuntime; @@ -106,9 +124,7 @@ export async function resolveSystemAgentConfiguredRouteFromConfig( import("../agents/simple-completion-runtime.js"), import("../agents/harness/policy.js"), ]); - const modelOwnerAgentId = normalizeAgentId( - requestedAgentId ?? agentScope.resolveDefaultAgentId(runConfig), - ); + const modelOwnerAgentId = resolveSystemAgentTargetAgentId(runConfig, requestedAgentId); if (!agentScope.resolveAgentEffectiveModelPrimary(runConfig, modelOwnerAgentId)) { return null; } @@ -214,12 +230,8 @@ export async function projectInferenceRoute( requestedAgentId?: string, deps: Pick = {}, ): Promise { - const [{ resolveDefaultAgentId }, { resolveProviderIdForAuth }] = await Promise.all([ - import("../agents/agent-scope.js"), - import("../agents/provider-auth-aliases.js"), - ]); - const defaultAgentId = resolveDefaultAgentId(config); - const routeAgentId = normalizeAgentId(requestedAgentId ?? defaultAgentId); + const { resolveProviderIdForAuth } = await import("../agents/provider-auth-aliases.js"); + const routeAgentId = resolveSystemAgentTargetAgentId(config, requestedAgentId); const route = await resolveSystemAgentConfiguredRouteFromConfig(config, routeAgentId, deps); const list = listAgentEntries(config); const agent = list.find((entry) => normalizeAgentId(entry.id) === routeAgentId); diff --git a/src/talk/agent-target.ts b/src/talk/agent-target.ts new file mode 100644 index 000000000000..007f659b6613 --- /dev/null +++ b/src/talk/agent-target.ts @@ -0,0 +1,19 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; + +/** Resolves the configured owner for Talk work that has no agent-scoped session key. */ +export function resolveTalkTargetAgentId(config: OpenClawConfig): string { + return normalizeAgentId( + normalizeOptionalString(config.talk?.agentId) ?? resolveDefaultAgentId(config), + ); +} + +/** Agent-scoped keys own their Talk session; legacy/unscoped aliases use the Talk target. */ +export function resolveTalkSessionAgentId( + config: OpenClawConfig, + sessionKey?: string | null, +): string { + return resolveAgentIdFromSessionKey(sessionKey, resolveTalkTargetAgentId(config)); +}