From 2e1bf01f51f4fa65fab8b4c809e0d5b20cd5ea5d Mon Sep 17 00:00:00 2001 From: xin zhuang <65798732+1052326311@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:15:44 +0800 Subject: [PATCH] fix(config): allow explicit main agent bindings when agents.list is non-empty (#89419) * fix(config): preserve explicit main route bindings * fix(config): preserve configured main-like agent bindings * test(routing): cover implicit main roster precedence --------- Co-authored-by: Vincent Koc --- .../shared/legacy-config-binding-repair.ts | 8 ++- .../shared/legacy-config-migrate.test.ts | 31 ++++++++++ src/config/config.schema-regressions.test.ts | 54 ++++++++++++++++ src/config/zod-schema.ts | 8 ++- src/routing/resolve-route.test.ts | 62 +++++++++++++++++++ src/routing/resolve-route.ts | 10 ++- 6 files changed, 166 insertions(+), 7 deletions(-) diff --git a/src/commands/doctor/shared/legacy-config-binding-repair.ts b/src/commands/doctor/shared/legacy-config-binding-repair.ts index 0b629ba8f16a..a07baf841b15 100644 --- a/src/commands/doctor/shared/legacy-config-binding-repair.ts +++ b/src/commands/doctor/shared/legacy-config-binding-repair.ts @@ -1,6 +1,6 @@ // Repairs canonical binding references after agent config migration. import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { normalizeAgentId } from "../../../routing/session-key.js"; +import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../../routing/session-key.js"; export function pruneBindingsForMissingAgents( cfg: OpenClawConfig, @@ -22,7 +22,11 @@ export function pruneBindingsForMissingAgents( const agentIds = new Set(validAgents.map((agent) => normalizeAgentId(agent.id))); const nextBindings = bindings.filter((binding) => { const agentId = binding && typeof binding === "object" ? binding.agentId : undefined; - return typeof agentId !== "string" || agentIds.has(normalizeAgentId(agentId)); + return ( + typeof agentId !== "string" || + agentId === DEFAULT_AGENT_ID || + agentIds.has(normalizeAgentId(agentId)) + ); }); const removed = bindings.length - nextBindings.length; if (removed === 0) { diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index ffff09012f49..9bcdb769c3e9 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -89,6 +89,37 @@ describe("compatibility binding repair migrate", () => { expect(res.changes).toContain("Removed 1 binding that referenced missing agents.list ids."); }); + it("preserves exact main bindings because the implicit main agent always exists", () => { + const res = repairBindingsForTest({ + agents: { + list: [{ id: "alpha" }], + }, + bindings: [ + { agentId: "main", match: { channel: "discord" } }, + { agentId: "MAIN", match: { channel: "discord" } }, + { agentId: "ghost", match: { channel: "discord" } }, + ], + } as OpenClawConfig); + + expect(res.config.bindings).toEqual([{ agentId: "main", match: { channel: "discord" } }]); + expect(res.changes).toContain("Removed 2 bindings that referenced missing agents.list ids."); + }); + + it("preserves normalized main bindings when the agent is explicitly listed", () => { + const res = repairBindingsForTest({ + agents: { + list: [{ id: "MAIN" }], + }, + bindings: [ + { agentId: "MAIN", match: { channel: "discord" } }, + { agentId: "ghost", match: { channel: "discord" } }, + ], + } as OpenClawConfig); + + expect(res.config.bindings).toEqual([{ agentId: "MAIN", match: { channel: "discord" } }]); + expect(res.changes).toContain("Removed 1 binding that referenced missing agents.list ids."); + }); + it("leaves bindings untouched when agents.list has malformed entries", () => { const cfg = { agents: { diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 28b8b3a1c7e5..ee571e7be338 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -454,6 +454,60 @@ describe("config schema regressions", () => { expect(res.ok).toBe(false); }); + it("accepts exact main bindings when agents.entries omits the implicit main agent", () => { + const res = validateConfigObject({ + agents: { + entries: { alpha: { model: "anthropic/claude-3-5-sonnet" } }, + }, + bindings: [ + { + type: "route", + agentId: "main", + match: { channel: "discord", peer: { kind: "direct", id: "user-1" } }, + }, + ], + }); + + expect(res.ok).toBe(true); + }); + + it("rejects normalized main binding variants when agents.entries omits them", () => { + const res = validateConfigObject({ + agents: { + entries: { alpha: { model: "anthropic/claude-3-5-sonnet" } }, + }, + bindings: [ + { + type: "route", + agentId: "MAIN", + match: { channel: "discord", peer: { kind: "direct", id: "user-1" } }, + }, + ], + }); + + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((iss) => iss.message.includes('Unknown agent id "MAIN"'))).toBe(true); + } + }); + + it("accepts a normalized main binding variant when that agent is explicitly configured", () => { + const res = validateConfigObject({ + agents: { + entries: { MAIN: { model: "anthropic/claude-3-5-sonnet" } }, + }, + bindings: [ + { + type: "route", + agentId: "MAIN", + match: { channel: "discord", peer: { kind: "direct", id: "user-1" } }, + }, + ], + }); + + expect(res.ok).toBe(true); + }); + it("rejects non-default bindings when the implicit-main roster is materialized", () => { const res = validateConfigObject({ bindings: [ diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 877692134a28..2f26023ba722 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { listAgentEntries } from "../agents/agent-scope-config.js"; -import { normalizeAgentId } from "../routing/session-key.js"; +import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import type { OpenClawConfig } from "./types.openclaw.js"; import { OpenClawSchemaShape } from "./zod-schema.root-shape.js"; @@ -61,7 +61,11 @@ export const OpenClawSchema = z.strictObject(OpenClawSchemaShape).superRefine((c continue; } const agentId = (binding as { agentId?: unknown }).agentId; - if (typeof agentId === "string" && !effectiveAgentIds.has(normalizeAgentId(agentId))) { + if ( + typeof agentId === "string" && + agentId !== DEFAULT_AGENT_ID && + !effectiveAgentIds.has(normalizeAgentId(agentId)) + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["bindings", idx, "agentId"], diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index c558810584ab..2b072319a546 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -1,5 +1,6 @@ // Route resolution tests cover resolving channel route targets from input. import { describe, expect, test, vi } from "vitest"; +import { resolveAgentConfig } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/config.js"; import * as routingBindings from "./bindings.js"; import { @@ -116,6 +117,67 @@ describe("resolveAgentRoute", () => { }); }); + test("preserves explicit main bindings when agents.entries has other agents", () => { + const cfg: OpenClawConfig = { + agents: { + entries: { alpha: {} }, + }, + bindings: [ + { + type: "route", + agentId: "main", + match: { channel: "discord", accountId: "default" }, + }, + ], + }; + + const route = resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "direct", id: "user-1" }, + }); + + expectResolvedRoute(route, { + agentId: "main", + sessionKey: "agent:main:main", + matchedBy: "binding.account", + lastRoutePolicy: "main", + }); + }); + + test("resolves exact main bindings through a configured normalized main-like roster entry", () => { + const cfg: OpenClawConfig = { + agents: { + entries: { + MAIN: { model: "anthropic/claude-3-5-sonnet" }, + }, + }, + bindings: [ + { + type: "route", + agentId: "main", + match: { channel: "discord", accountId: "default" }, + }, + ], + }; + + const route = resolveAgentRoute({ + cfg, + channel: "discord", + accountId: "default", + peer: { kind: "direct", id: "user-1" }, + }); + + expectResolvedRoute(route, { + agentId: "main", + sessionKey: "agent:main:main", + matchedBy: "binding.account", + lastRoutePolicy: "main", + }); + expect(resolveAgentConfig(cfg, route.agentId)?.model).toBe("anthropic/claude-3-5-sonnet"); + }); + test("uses the configured main session key for shared direct routes", () => { const route = resolveRoute({ cfg: { session: { dmScope: "main", mainKey: "work" } }, diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 04cd63d21c24..fce32b3f6f70 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -17,6 +17,7 @@ import { buildAgentMainSessionKey, buildAgentPeerSessionKey, DEFAULT_ACCOUNT_ID, + DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, normalizeAccountId, normalizeAgentId, @@ -159,13 +160,16 @@ export function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string): return lookup.fallbackDefaultAgentId; } const normalized = normalizeAgentId(trimmed); - if (lookup.byNormalizedId.size === 0) { - return sanitizeAgentId(trimmed); - } const resolved = lookup.byNormalizedId.get(normalized); if (resolved) { return resolved; } + if (trimmed === DEFAULT_AGENT_ID) { + return DEFAULT_AGENT_ID; + } + if (lookup.byNormalizedId.size === 0) { + return sanitizeAgentId(trimmed); + } return lookup.fallbackDefaultAgentId; }