* docs: add ACP persistent binding experiment plan * docs: align ACP persistent binding spec to channel-local config * docs: scope Telegram ACP bindings to forum topics only * docs: lock bound /new and /reset behavior to in-place ACP reset * ACP: add persistent discord/telegram conversation bindings * ACP: fix persistent binding reuse and discord thread parent context * docs: document channel-specific persistent ACP bindings * ACP: split persistent bindings and share conversation id helpers * ACP: defer configured binding init until preflight passes * ACP: fix discord thread parent fallback and explicit disable inheritance * ACP: keep bound /new and /reset in-place * ACP: honor configured bindings in native command flows * ACP: avoid configured fallback after runtime bind failure * docs: refine ACP bindings experiment config examples * acp: cut over to typed top-level persistent bindings * ACP bindings: harden reset recovery and native command auth * Docs: add ACP bound command auth proposal * Tests: normalize i18n registry zh-CN assertion encoding * ACP bindings: address review findings for reset and fallback routing * ACP reset: gate hooks on success and preserve /new arguments * ACP bindings: fix auth and binding-priority review findings * Telegram ACP: gate ensure on auth and accepted messages * ACP bindings: fix session-key precedence and unavailable handling * ACP reset/native commands: honor fallback targets and abort on bootstrap failure * Config schema: validate ACP binding channel and Telegram topic IDs * Discord ACP: apply configured DM bindings to native commands * ACP reset tails: dispatch through ACP after command handling * ACP tails/native reset auth: fix target dispatch and restore full auth * ACP reset detection: fallback to active ACP keys for DM contexts * Tests: type runTurn mock input in ACP dispatch test * ACP: dedup binding route bootstrap and reset target resolution * reply: align ACP reset hooks with bound session key * docs: replace personal discord ids with placeholders * fix: add changelog entry for ACP persistent bindings (#34873) (thanks @dutifulbob) --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
212 lines
6.3 KiB
TypeScript
212 lines
6.3 KiB
TypeScript
import {
|
|
listAgentEntries,
|
|
resolveAgentDir,
|
|
resolveAgentWorkspaceDir,
|
|
resolveDefaultAgentId,
|
|
} from "../agents/agent-scope.js";
|
|
import type { AgentIdentityFile } from "../agents/identity-file.js";
|
|
import {
|
|
identityHasValues,
|
|
loadAgentIdentityFromWorkspace,
|
|
parseIdentityMarkdown as parseIdentityMarkdownFile,
|
|
} from "../agents/identity-file.js";
|
|
import { listRouteBindings } from "../config/bindings.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { normalizeAgentId } from "../routing/session-key.js";
|
|
|
|
export type AgentSummary = {
|
|
id: string;
|
|
name?: string;
|
|
identityName?: string;
|
|
identityEmoji?: 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 };
|
|
|
|
export function findAgentEntryIndex(list: AgentEntry[], agentId: string): number {
|
|
const id = normalizeAgentId(agentId);
|
|
return list.findIndex((entry) => normalizeAgentId(entry.id) === id);
|
|
}
|
|
|
|
function resolveAgentName(cfg: OpenClawConfig, agentId: string) {
|
|
const entry = listAgentEntries(cfg).find(
|
|
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
|
|
);
|
|
return entry?.name?.trim() || undefined;
|
|
}
|
|
|
|
function resolveAgentModel(cfg: OpenClawConfig, agentId: string) {
|
|
const entry = listAgentEntries(cfg).find(
|
|
(agent) => normalizeAgentId(agent.id) === normalizeAgentId(agentId),
|
|
);
|
|
if (entry?.model) {
|
|
if (typeof entry.model === "string" && entry.model.trim()) {
|
|
return entry.model.trim();
|
|
}
|
|
if (typeof entry.model === "object") {
|
|
const primary = entry.model.primary?.trim();
|
|
if (primary) {
|
|
return primary;
|
|
}
|
|
}
|
|
}
|
|
const raw = cfg.agents?.defaults?.model;
|
|
if (typeof raw === "string") {
|
|
return raw;
|
|
}
|
|
return raw?.primary?.trim() || undefined;
|
|
}
|
|
|
|
export function parseIdentityMarkdown(content: string): AgentIdentity {
|
|
return parseIdentityMarkdownFile(content);
|
|
}
|
|
|
|
export function loadAgentIdentity(workspace: string): AgentIdentity | null {
|
|
const parsed = loadAgentIdentityFromWorkspace(workspace);
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
return identityHasValues(parsed) ? parsed : null;
|
|
}
|
|
|
|
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 = orderedIds.filter((id, index) => orderedIds.indexOf(id) === index);
|
|
|
|
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 identitySource = identity
|
|
? "identity"
|
|
: configIdentity && (identityName || identityEmoji)
|
|
? "config"
|
|
: undefined;
|
|
return {
|
|
id,
|
|
name: resolveAgentName(cfg, id),
|
|
identityName,
|
|
identityEmoji,
|
|
identitySource,
|
|
workspace,
|
|
agentDir: resolveAgentDir(cfg, id),
|
|
model: resolveAgentModel(cfg, id),
|
|
bindings: bindingCounts.get(id) ?? 0,
|
|
isDefault: id === defaultAgentId,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function applyAgentConfig(
|
|
cfg: OpenClawConfig,
|
|
params: {
|
|
agentId: string;
|
|
name?: string;
|
|
workspace?: string;
|
|
agentDir?: string;
|
|
model?: string;
|
|
},
|
|
): 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] : { id: agentId };
|
|
const nextEntry: AgentEntry = {
|
|
...base,
|
|
...(name ? { name } : {}),
|
|
...(params.workspace ? { workspace: params.workspace } : {}),
|
|
...(params.agentDir ? { agentDir: params.agentDir } : {}),
|
|
...(params.model ? { model: params.model } : {}),
|
|
};
|
|
const nextList = [...list];
|
|
if (index >= 0) {
|
|
nextList[index] = nextEntry;
|
|
} else {
|
|
if (nextList.length === 0 && agentId !== normalizeAgentId(resolveDefaultAgentId(cfg))) {
|
|
nextList.push({ id: resolveDefaultAgentId(cfg) });
|
|
}
|
|
nextList.push(nextEntry);
|
|
}
|
|
return {
|
|
...cfg,
|
|
agents: {
|
|
...cfg.agents,
|
|
list: nextList,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function pruneAgentConfig(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): {
|
|
config: OpenClawConfig;
|
|
removedBindings: number;
|
|
removedAllow: number;
|
|
} {
|
|
const id = normalizeAgentId(agentId);
|
|
const agents = listAgentEntries(cfg);
|
|
const nextAgentsList = agents.filter((entry) => normalizeAgentId(entry.id) !== id);
|
|
const nextAgents = nextAgentsList.length > 0 ? 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 nextAgentsConfig = cfg.agents
|
|
? { ...cfg.agents, list: nextAgents }
|
|
: nextAgents
|
|
? { list: 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,
|
|
};
|
|
}
|