mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
f3d94d2302
commit
4bfb26415a
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2300,
|
||||
"core": 2304,
|
||||
"channel": 3630,
|
||||
"plugin": 3556
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user