源码
packages/builtin-plugins/src/collaboration/profiles.ts
// Profile scopes, built-in identities and model overrides follow ZCode (Apache-2.0).
import { randomUUID } from "node:crypto";
import {
existsSync,
linkSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { isJsonObject } from "@zpi-dev/ai";
import { parseMarkdownFrontmatter } from "@zpi-dev/coding-agent";
import { stringify } from "yaml";
import type { PluginStorage } from "../ports.ts";
import type { CombinedSelection } from "../types.ts";
import { buildExploreAgentPrompt, buildGeneralPurposeSystemPrompt } from "./built-in.ts";
import type { SubagentCatalog, SubagentConfig, SubagentProfile } from "./types.ts";
import { agentColors } from "./types.ts";
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function markdownSelection(fields: Record<string, unknown>): CombinedSelection | undefined {
if (typeof fields.model !== "string") return fields.modelSelection as CombinedSelection | undefined;
const value = fields.model.trim();
if (!value || ["inherit", "main", "sonnet", "opus", "haiku"].includes(value)) return undefined;
let provider: string;
let modelId: string;
let reasoning = "";
if (value.startsWith("custom:")) {
const parts = value.slice(7).split(":");
if (parts.length !== 2) return undefined;
[provider, modelId] = parts.map(decodeURIComponent);
} else {
const index = value.indexOf("/");
if (index < 1 || index === value.length - 1) return undefined;
provider = value.slice(0, index);
modelId = value.slice(index + 1);
const effort = modelId.indexOf("$");
if (effort > 0 && effort < modelId.length - 1) {
reasoning = modelId.slice(effort + 1);
modelId = modelId.slice(0, effort);
}
}
return {
provider,
modelId,
reasoning: typeof fields.thoughtLevel === "string" ? fields.thoughtLevel.trim() : reasoning,
};
}
function markdownModel(selection: CombinedSelection) {
return selection.provider.startsWith("custom:") ||
selection.provider.includes("/") ||
selection.modelId.includes("$")
? `custom:${encodeURIComponent(selection.provider)}:${encodeURIComponent(selection.modelId)}`
: `${selection.provider}/${selection.modelId}`;
}
function toolNames(value: unknown): string[] | undefined {
if (value === undefined) return undefined;
const values = typeof value === "string" ? value.split(",").map((tool) => tool.trim()) : value;
if (!Array.isArray(values) || values.some((tool) => typeof tool !== "string"))
throw new Error("工具列表无效");
return values.map((tool) => (["Read", "Write", "Edit", "Bash"].includes(tool) ? tool.toLowerCase() : tool));
}
export function validateSubagentConfig(value: unknown): asserts value is SubagentConfig {
if (
!isRecord(value) ||
typeof value.name !== "string" ||
!/^[a-zA-Z0-9-]{3,50}$/.test(value.name) ||
typeof value.description !== "string" ||
!value.description.trim() ||
value.description.length > 8000 ||
typeof value.systemPrompt !== "string" ||
!value.systemPrompt.trim() ||
value.systemPrompt.length > 100000 ||
!agentColors.includes(value.color as SubagentConfig["color"]) ||
typeof value.injectAgentsMd !== "boolean" ||
(value.tools !== undefined &&
(!Array.isArray(value.tools) ||
value.tools.some((tool) => typeof tool !== "string" || !tool.trim()))) ||
(value.disallowedTools !== undefined &&
(!Array.isArray(value.disallowedTools) ||
value.disallowedTools.some((tool) => typeof tool !== "string" || !tool.trim()))) ||
(value.modelSelection !== undefined &&
(!isRecord(value.modelSelection) ||
["provider", "modelId", "reasoning"].some(
(key) => typeof (value.modelSelection as Record<string, unknown>)[key] !== "string",
)))
)
throw new Error(
"invalid_input: 子智能体配置无效(名称为 3–50 位字母、数字或连字符,描述和提示词不能为空)",
);
}
export function parseSubagentMarkdown(text: string): SubagentConfig {
const parsed = parseMarkdownFrontmatter(text);
if (!parsed) throw new Error("缺少 YAML frontmatter");
const fields = parsed.fields;
const config = {
...fields,
modelSelection: markdownSelection(fields),
tools: toolNames(fields.tools),
disallowedTools: toolNames(fields.disallowedTools),
color: fields.color ?? "blue",
injectAgentsMd: fields.injectAgentsMd ?? true,
systemPrompt: parsed.body,
};
validateSubagentConfig(config);
return config;
}
export interface SubagentProfileState {
disabled: string[];
overrides: Record<string, SubagentConfig["modelSelection"]>;
}
export function validateSubagentProfileState(value: unknown): asserts value is SubagentProfileState {
if (
!isJsonObject(value) ||
!Array.isArray(value.disabled) ||
value.disabled.some((id) => typeof id !== "string") ||
!isJsonObject(value.overrides) ||
Object.values(value.overrides).some(
(selection) =>
!isRecord(selection) ||
["provider", "modelId", "reasoning"].some((key) => typeof selection[key] !== "string"),
)
)
throw new Error("storage: 子智能体状态无效");
}
export class SubagentProfiles {
private state: SubagentProfileState = { disabled: [], overrides: {} };
private root: string;
private stateStore: PluginStorage;
constructor(root: string, stateStore: PluginStorage) {
this.root = root;
this.stateStore = stateStore;
this.reloadState();
}
private reloadState() {
const state = this.stateStore.getSetting<unknown>("subagentProfiles");
if (state === undefined) this.state = { disabled: [], overrides: {} };
else {
validateSubagentProfileState(state);
this.state = state;
}
}
private directory(scope: "user" | "workspace", cwd?: string) {
if (scope === "workspace" && !cwd) throw new Error("invalid_input: 请先选择项目");
return scope === "workspace" ? join(cwd as string, ".zpi", "agents") : join(this.root, "agents");
}
list(cwd?: string, codexSubagentPrompt = false): SubagentCatalog {
this.reloadState();
const agents: SubagentProfile[] = (
[
{
id: "built-in:general-purpose",
name: "general-purpose",
scope: "built-in",
enabled: true,
color: "blue",
injectAgentsMd: true,
tools: ["*"],
description: codexSubagentPrompt
? "Handles self-contained implementation, debugging, and research subtasks with read and write tools."
: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
systemPrompt: buildGeneralPurposeSystemPrompt(),
},
{
id: "built-in:Explore",
name: "Explore",
scope: "built-in",
enabled: true,
color: "cyan",
injectAgentsMd: false,
tools: ["read", "bash"],
description: codexSubagentPrompt
? "Read-only codebase exploration for a bounded question. Returns relevant paths, symbols, and concise findings. Has read and bash tools; cannot edit files. Specify the search scope and depth in the task."
: 'Read-only search agent for broad fan-out searches - when answering means sweeping many files, directories, or naming conventions and you only need the conclusion, not the file dumps. It reads excerpts rather than whole files, so it locates code; it doesn\'t review or audit it. Specify search breadth: "medium" for moderate exploration, "very thorough" for multiple locations and naming conventions.',
systemPrompt: buildExploreAgentPrompt(),
},
] satisfies SubagentProfile[]
).map((agent) => ({ ...agent, modelSelection: this.state.overrides[agent.id] }));
const diagnostics: SubagentCatalog["diagnostics"] = [];
for (const scope of ["user", ...(cwd ? (["workspace"] as const) : [])] as const) {
const dir = this.directory(scope, cwd);
if (!existsSync(dir)) continue;
for (const file of readdirSync(dir).sort()) {
if (!/\.(md|markdown)$/i.test(file)) continue;
const path = join(dir, file);
try {
if (!statSync(path).isFile()) continue;
const text = readFileSync(path, "utf8");
if (text.length > 128000) throw new Error("配置文件过大");
const config = parseSubagentMarkdown(text);
const id = `${scope}:${file.replace(/\.(md|markdown)$/i, "")}`;
agents.push({ ...config, id, path, scope, enabled: !this.state.disabled.includes(id) });
} catch (error) {
diagnostics.push({ path, message: String(error) });
}
}
}
return { agents, diagnostics };
}
save(config: SubagentConfig, scope: "user" | "workspace", id?: string, cwd?: string) {
validateSubagentConfig(config);
const catalog = this.list(cwd);
const previous = id ? catalog.agents.find((agent) => agent.id === id) : undefined;
if (id && !previous) throw new Error("not_found: 子智能体不存在");
if (previous?.scope === "built-in") {
if (
["name", "description", "systemPrompt", "color", "injectAgentsMd"].some(
(key) => config[key as keyof SubagentConfig] !== previous[key as keyof SubagentConfig],
) ||
JSON.stringify(config.tools) !== JSON.stringify(previous.tools)
)
throw new Error("invalid_input: 内置子智能体仅支持修改模型");
const state = {
...this.state,
overrides: { ...this.state.overrides, [id as string]: structuredClone(config.modelSelection) },
};
if (config.modelSelection === undefined) delete state.overrides[id as string];
this.stateStore.setSetting("subagentProfiles", state);
this.state = state;
return this.list(cwd);
}
if (scope !== "user" && scope !== "workspace") throw new Error("invalid_input: 无效作用域");
if (previous && previous.scope !== scope) throw new Error("invalid_input: 不能移动已保存的子智能体");
if (
catalog.agents.some(
(agent) =>
agent.scope === scope && agent.id !== id && agent.name.toLowerCase() === config.name.toLowerCase(),
)
)
throw new Error("invalid_input: 子智能体名称已存在或为保留名称");
const dir = this.directory(scope, cwd);
mkdirSync(dir, { recursive: true });
const path = join(dir, `${config.name}.md`);
const { systemPrompt, modelSelection, ...edited } = config;
const original = previous?.path
? (parseMarkdownFrontmatter(readFileSync(previous.path, "utf8"))?.fields ?? {})
: {};
const fields: Record<string, unknown> = {
...original,
...edited,
model: modelSelection ? markdownModel(modelSelection) : "inherit",
};
for (const key of ["modelSelection", "id", "scope", "enabled", "path", "thoughtLevel", "systemPrompt"])
delete fields[key];
if (modelSelection?.reasoning) fields.thoughtLevel = modelSelection.reasoning;
if (!config.tools) delete fields.tools;
const temp = `${path}.${randomUUID()}.tmp`;
try {
writeFileSync(temp, `---\n${stringify(fields)}---\n\n${systemPrompt}\n`, { mode: 0o600, flag: "wx" });
// 文件名可与 frontmatter 名称不同;新建或重命名必须原子拒绝覆盖其他已有文件。
if (previous?.path === path) renameSync(temp, path);
else linkSync(temp, path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST")
throw new Error("invalid_input: 子智能体文件已存在,请选择其他名称");
throw error;
} finally {
rmSync(temp, { force: true });
}
if (previous?.path && previous.path !== path) {
rmSync(previous.path);
this.state.disabled = this.state.disabled.map((key) => (key === id ? `${scope}:${config.name}` : key));
this.stateStore.setSetting("subagentProfiles", this.state);
}
return this.list(cwd);
}
delete(id: string, cwd?: string) {
const agent = this.list(cwd).agents.find((agent) => agent.id === id);
if (!agent?.path || agent.scope === "built-in") throw new Error("invalid_input: 此子智能体不可删除");
rmSync(agent.path);
this.state.disabled = this.state.disabled.filter((key) => key !== id);
this.stateStore.setSetting("subagentProfiles", this.state);
return this.list(cwd);
}
enable(id: string, enabled: boolean, cwd?: string) {
const agent = this.list(cwd).agents.find((agent) => agent.id === id);
if (agent?.scope !== "user" || typeof enabled !== "boolean")
throw new Error("invalid_input: 此子智能体不可切换");
const state = { ...this.state, disabled: this.state.disabled.filter((key) => key !== id) };
if (!enabled) state.disabled.push(id);
this.stateStore.setSetting("subagentProfiles", state);
this.state = state;
return this.list(cwd);
}
}