Files
openclaw/src/commands/agents.config.ts
T
Peter Steinberger 82d1a03f25 refactor(agents): move implicit-main fallback into load-time roster injection (#112678)
* refactor(agents): require explicit roster defaults

* feat(onboard): create named first roster agent

* refactor(agents): remove runtime main fallbacks

* style(agents): apply roster refactor formatting

* refactor(agents): finish roster-only runtime sweep

* fix(doctor): migrate legacy main session sqlite

* fix(doctor): harden roster session migrations

* fix(onboard): commit first agent atomically

* fix(config): support empty-roster analysis

* fix(agents): preserve legacy main state during creation

* fix(setup): materialize baseline agent roster

* fix(agents): harden legacy default transfer recovery

* fix(agents): simplify roster-only legacy compatibility

* fix(agents): preserve staged first-agent entries

* fix(config): migrate persisted implicit-main rosters

* fix(config): preserve staged empty rosters

* fix(agents): finalize roster-only upgrade paths

* fix(sessions): close legacy main migration outcomes

* fix(config): migrate legacy roster markers at load

* fix(sessions): preserve roster upgrade history

* refactor(sessions): restore lean legacy main compatibility

* fix(setup): prepare first-agent credentials before publish

* fix(config): stabilize roster snapshot migration

* refactor(sessions): shrink legacy main compatibility

* fix(agents): restore roster compatibility fidelity

* fix(sessions): preserve divergent legacy history

* refactor(agents): narrow roster-only scope

* fix(config): isolate roster migration

* test(agents): align roster-only fixtures

* fix(agents): keep main agent undeletable

* fix(agents): harden roster migration invariants

* fix(agents): close setup and audit scope gaps

* fix(cron): scope session reaper throttles by agent

* fix(agents): preserve scoped owner precedence

* fix(config): preserve authored config ownership

* fix(setup): keep default workspace and roster in sync

* fix(setup): preserve default entry workspace on bare runs

* fix(agents): adapt roster rebase to keyed entries

* fix(agents): honor both roster representations

* fix(agents): route roster reads through shared helpers

* fix(config): preserve canonical roster writes

* fix(cron): resolve dynamic default for session reaper

* fix(agents): close dynamic default migration gaps

* fix(agents): align scoped session ownership

* fix(sessions): preserve legacy main directory casing

* fix(agents): align cron and legacy auth ownership

* fix(setup): provision the committed default workspace

* fix(cron): align scoped ownership and reaping

* fix(cron): treat blank agent ids as absent

* fix(cron): retain configured session-store owners

* fix(agents): repair roster-aware CI boundaries

* fix(cron): preserve scoped ownership resolution

* fix(agents): preserve rosterless maintenance paths

* fix(agents): propagate roster ownership through runtime boundaries

* fix(agents): preserve roster ownership across runtime paths

* fix(agents): harden roster diagnostics and legacy routing

* fix(agents): remove redundant diagnostic import

* test(agents): type CLI policy fixture explicitly

* fix(config): preserve canonical roster mutation identity

* fix(doctor): read canonical agent rosters consistently

* fix(config): resolve compound roster unsets safely

* fix(config): finalize main-session reconciliation

* fix(doctor): read canonical session state safely

* fix(sessions): preserve current visibility alias

* fix(config): track roster include provenance

* test(config): type roster provenance cases

* fix(config): refine roster include ownership

* fix(agents): preserve staged roster invariants

* test(config): align fixtures with explicit roster ownership

* test(node-host): preserve optional plan typing

* fix(config): preserve authored roster projections

* test(config): keep raw roster fixtures explicit

* test(config): normalize rosters at runtime fixtures

* fix(config): protect authored roster ownership

* fix(agents): require explicit session ownership

* fix(agents): enforce scoped roster ownership

* fix(sessions): merge fixed-store agent partitions

* fix(agents): harden roster ownership boundaries

* fix(config): reject ambiguous roster projections

* fix(sessions): preserve persisted store ownership

* fix(sessions): keep collision diagnostics additive

* fix(security): scan malformed roster workspaces

* test(config): align snapshot fixtures after rebase

* test(agents): use explicit roster fixtures

* fix(config): harden roster diagnostic boundaries

* fix(sessions): isolate fixed-store agent databases

* test(agents): type malformed default markers

* refactor(sessions): extract store collision resolution

* test(system-agent): split oversized setup coverage

* style(system-agent): format split setup suite

* fix(sessions): preserve promoted store ownership

* fix(sessions): derive scoped owner before target

* fix(sessions): preserve explicit sqlite ownership

* fix(agents): restore roster compatibility across CI

* fix(agents): enforce roster-owned runtime boundaries

* fix(agents): satisfy default lookup lint

* test(sessions): split known-owner coverage

* fix(state): satisfy path identity lint

* fix(agents): preserve malformed roster safety boundaries

* fix(agents): restore roster compatibility at runtime boundaries

* fix(config): satisfy roster boundary type checks

* fix(agents): preserve roster ownership across runtime probes

Setup inference probes now execute as the configured roster owner. Malformed agent-prefixed session rows are intentionally omitted by the fail-closed visibility contract rather than normalized by tests.

* fix(agents): satisfy session list owner lint

* fix(agents): preserve roster-owned runtime boundaries

Restore shared logical rows for exact SQLite session locators while keeping their physical database owner separate. The ownership regression test now constructs an explicit sole-owner database directly instead of relying on first-touch capture, matching the intentional shared-store contract.

* fix(sessions): preserve multiply owned exact stores

* fix(sessions): restore runtime owner boundaries

Keep incognito sentinels agent-owned, fold default-agent approvals into the global snapshot, and preserve the configless legacy-main CLI policy fallback. Also repair the existing CLI watchdog test lifecycle so the compact shard observes its timeout without an unawaited assertion or async timer stall; product behavior is unchanged by that test-only fix.

* test(ci): align owner-scoped fixtures

These assertions are unchanged. The fixtures now declare the intended non-default runner, expose the session-key constant imported by production status code, and select the main approvals bucket explicitly on Windows.

* fix(agents): close final roster ownership gaps
2026-07-24 22:38:09 -07:00

252 lines
8.1 KiB
TypeScript

// Agent config mutation and summary builders used by `openclaw agents` commands.
import {
normalizeOptionalString,
resolvePrimaryStringValue,
} from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import {
listAgentEntries,
resolveAgentDir,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
toAgentEntriesRecord,
} from "../agents/agent-scope.js";
import { resolveAgentAvatarUrlFromSource } from "../agents/identity-avatar-file.js";
import type { AgentIdentityFile } from "../agents/identity-file.js";
import { identityHasValues, loadAgentIdentityFromWorkspace } from "../agents/identity-file.js";
import { listRouteBindings } from "../config/bindings.js";
import type { IdentityConfig } from "../config/types.base.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeAgentId } from "../routing/session-key.js";
export type AgentSummary = {
id: string;
name?: string;
identityName?: string;
identityEmoji?: string;
identityAvatarUrl?: string;
identitySource?: "identity" | "config";
workspace: string;
agentDir: string;
model?: string;
bindings: number;
bindingDetails?: string[];
routes?: string[];
providers?: string[];
isDefault: boolean;
};
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
export type AgentIdentity = AgentIdentityFile;
export { listAgentEntries };
/** Find a configured agent entry by normalized id. */
export function findAgentEntryIndex(list: AgentEntry[], agentId: string): number {
const id = normalizeAgentId(agentId);
return list.findIndex((entry) => normalizeAgentId(entry.id) === id);
}
function resolveAgentModel(cfg: OpenClawConfig, agentId: string) {
const entry = listAgentEntries(cfg).find(
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
);
const entryPrimary = resolvePrimaryStringValue(entry?.model);
if (entryPrimary) {
return entryPrimary;
}
return resolvePrimaryStringValue(cfg.agents?.defaults?.model);
}
/** Load non-empty identity metadata from a workspace identity file. */
export function loadAgentIdentity(workspace: string): AgentIdentity | null {
const parsed = loadAgentIdentityFromWorkspace(workspace);
if (!parsed) {
return null;
}
return identityHasValues(parsed) ? parsed : null;
}
/** Build config-derived summaries for text/JSON agent listing. */
export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] {
const defaultAgentId = normalizeAgentId(resolveDefaultAgentId(cfg));
const configuredAgents = listAgentEntries(cfg);
const orderedIds =
configuredAgents.length > 0
? configuredAgents.map((agent) => normalizeAgentId(agent.id))
: [defaultAgentId];
const bindingCounts = new Map<string, number>();
for (const binding of listRouteBindings(cfg)) {
const agentId = normalizeAgentId(binding.agentId);
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
}
const ordered = uniqueStrings(orderedIds);
return ordered.map((id) => {
const workspace = resolveAgentWorkspaceDir(cfg, id);
const identity = loadAgentIdentity(workspace);
const configIdentity = configuredAgents.find(
(agent) => normalizeAgentId(agent.id) === id,
)?.identity;
const identityName = identity?.name ?? configIdentity?.name?.trim();
const identityEmoji = identity?.emoji ?? configIdentity?.emoji?.trim();
const identityAvatarUrl = resolveAgentAvatarUrlFromSource(
cfg,
id,
identity?.avatar ?? configIdentity?.avatar,
);
const identitySource = identity
? "identity"
: configIdentity && (identityName || identityEmoji || identityAvatarUrl)
? "config"
: undefined;
const summary: AgentSummary = {
id,
name: normalizeOptionalString(
configuredAgents.find((agent) => normalizeAgentId(agent.id) === id)?.name,
),
identityName,
identityEmoji,
identitySource,
workspace,
agentDir: resolveAgentDir(cfg, id),
model: resolveAgentModel(cfg, id),
bindings: bindingCounts.get(id) ?? 0,
isDefault: id === defaultAgentId,
};
if (identityAvatarUrl) {
summary.identityAvatarUrl = identityAvatarUrl;
}
return summary;
});
}
/** Add or update one agent entry. The first roster entry becomes the explicit default. */
export function applyAgentConfig(
cfg: OpenClawConfig,
params: {
agentId: string;
name?: string;
workspace?: string;
agentDir?: string;
model?: string | null;
identity?: IdentityConfig;
},
): OpenClawConfig {
const agentId = normalizeAgentId(params.agentId);
const name = params.name?.trim();
const list = listAgentEntries(cfg);
const index = findAgentEntryIndex(list, agentId);
const base = (index >= 0 ? list[index] : undefined) ?? {
id: agentId,
...(list.length === 0 ? { default: true } : {}),
};
const mergedIdentity = params.identity ? { ...base.identity, ...params.identity } : undefined;
const nextEntry: AgentEntry = {
...base,
...(name ? { name } : {}),
...(params.workspace ? { workspace: params.workspace } : {}),
...(params.agentDir ? { agentDir: params.agentDir } : {}),
...(mergedIdentity ? { identity: mergedIdentity } : {}),
};
// Model is tri-state: omission preserves the override, null restores inheritance.
if (params.model === null) {
delete nextEntry.model;
} else if (params.model !== undefined) {
nextEntry.model = params.model;
}
const nextList = [...list];
if (index >= 0) {
nextList[index] = nextEntry;
} else {
nextList.push(nextEntry);
}
const { list: _legacyList, ...agentsConfig } = cfg.agents ?? {};
return {
...cfg,
agents: {
...agentsConfig,
entries: toAgentEntriesRecord(nextList),
},
};
}
/** Remove an agent and any config references that route or allow traffic to it. */
export function pruneAgentConfig(
cfg: OpenClawConfig,
agentId: string,
): {
config: OpenClawConfig;
removedBindings: number;
removedAllow: number;
} {
const id = normalizeAgentId(agentId);
const agents = listAgentEntries(cfg);
const pruneAllowAgents = (allowAgents: string[] | undefined) =>
allowAgents?.filter((entry) => {
const trimmed = entry.trim();
return !trimmed || trimmed === "*" || normalizeAgentId(trimmed) !== id;
});
const nextAgentsList = [];
for (const entry of agents) {
if (normalizeAgentId(entry.id) === id) {
continue;
}
nextAgentsList.push(
entry.subagents?.allowAgents
? {
...entry,
subagents: {
...entry.subagents,
allowAgents: pruneAllowAgents(entry.subagents.allowAgents),
},
}
: entry,
);
}
const nextAgents = nextAgentsList.length > 0 ? toAgentEntriesRecord(nextAgentsList) : undefined;
const bindings = cfg.bindings ?? [];
const filteredBindings = bindings.filter((binding) => normalizeAgentId(binding.agentId) !== id);
const allow = cfg.tools?.agentToAgent?.allow ?? [];
const filteredAllow = allow.filter((entry) => entry !== id);
const nextDefaults = cfg.agents?.defaults?.subagents?.allowAgents
? {
...cfg.agents.defaults,
subagents: {
...cfg.agents.defaults.subagents,
allowAgents: pruneAllowAgents(cfg.agents.defaults.subagents.allowAgents),
},
}
: cfg.agents?.defaults;
const { list: _legacyList, ...agentsConfig } = cfg.agents ?? {};
const nextAgentsConfig = cfg.agents
? { ...agentsConfig, defaults: nextDefaults, entries: nextAgents }
: nextAgents
? { entries: nextAgents }
: undefined;
const nextTools = cfg.tools?.agentToAgent
? {
...cfg.tools,
agentToAgent: {
...cfg.tools.agentToAgent,
allow: filteredAllow.length > 0 ? filteredAllow : undefined,
},
}
: cfg.tools;
return {
config: {
...cfg,
agents: nextAgentsConfig,
bindings: filteredBindings.length > 0 ? filteredBindings : undefined,
tools: nextTools,
},
removedBindings: bindings.length - filteredBindings.length,
removedAllow: allow.length - filteredAllow.length,
};
}