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
This commit is contained in:
Peter Steinberger
2026-07-25 05:37:41 -07:00
committed by GitHub
parent f3d94d2302
commit 4bfb26415a
29 changed files with 775 additions and 41 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"core": 2300,
"core": 2304,
"channel": 3630,
"plugin": 3556
}
+2 -2
View File
@@ -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
+2
View File
@@ -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: "<name>"` 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.
+1
View File
@@ -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
+20
View File
@@ -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.<provider>`.
- Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` (macOS Talk client behavior).
- `providers.*.apiKey` accepts plaintext strings or SecretRef objects.
+1
View File
@@ -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.<id>.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. |
+87
View File
@@ -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({
+11
View File
@@ -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);
@@ -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(" ");
});
});
@@ -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 };
}
@@ -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<string, { model?: string }>;
};
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([]);
});
});
+8
View File
@@ -313,6 +313,14 @@ export const CORE_FIELD_HELP: Record<string, string> = {
"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.",
};
+4
View File
@@ -641,9 +641,12 @@ export const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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)",
+5
View File
@@ -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",
+7
View File
@@ -210,6 +210,10 @@ export function normalizeTalkSection(value: TalkConfig | undefined): TalkConfig
const source = value as Record<string, unknown>;
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;
}
+6
View File
@@ -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). */
+1 -1
View File
@@ -139,7 +139,7 @@ export type AgentConfig = {
contextLimits?: AgentContextLimitsConfig;
contextTokens?: number;
/** Optional per-agent heartbeat overrides. */
heartbeat?: AgentDefaultsConfig["heartbeat"];
heartbeat?: Omit<NonNullable<AgentDefaultsConfig["heartbeat"]>, "agentId">;
identity?: IdentityConfig;
groupChat?: Omit<GroupChatConfig, "visibleReplies">;
subagents?: {
+2
View File
@@ -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. */
+9 -1
View File
@@ -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({
+63
View File
@@ -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);
});
});
+1
View File
@@ -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(),
+30 -2
View File
@@ -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
+4 -7
View File
@@ -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<typeof resolveDefaultAgentId>[0],
config: Parameters<typeof resolveTalkSessionAgentId>[0],
key: string,
) {
return resolveAgentIdFromSessionKey(key, resolveDefaultAgentId(config));
return resolveTalkSessionAgentId(config, key);
}
/**
+3 -4
View File
@@ -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
+4 -5
View File
@@ -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()),
),
}
: {}),
+12 -2
View File
@@ -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.
+3 -6
View File
@@ -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,
+22 -10
View File
@@ -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<SystemAgentConfiguredRouteDeps, "loadAuthProfileStoreForRuntime"> = {},
): Promise<DefaultInferenceRouteProjection> {
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);
+19
View File
@@ -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));
}