Z ZPI 文档 v0.3.8
介绍页

源码

packages/coding-agent/src/plugins/assembly.ts

typescript216 行查看原文
import type { AgentTool } from "@zpi-dev/agent";
import { assertSupportedOptions, isJsonObject } from "@zpi-dev/ai";
import type { TSchema } from "typebox";
import { wrapToolDefinition } from "../core/tools/pi/tool-definition-wrapper.ts";
import type { ToolContext, ToolDefinition } from "../core/types.ts";
import { validateHookRegistration } from "./hooks.ts";
import type { PluginCommand, PluginSnapshot, SessionPlugin } from "./types.ts";

export interface SessionContributions {
  plugins?: readonly SessionPlugin[];
  customTools?: readonly ToolDefinition<TSchema, unknown>[];
  toolOverrides?: readonly ToolDefinition<TSchema, unknown>[];
  noTools?: "all" | "builtin";
  tools?: readonly string[];
  excludeTools?: readonly string[];
}

const identifier = /^[a-zA-Z0-9][a-zA-Z0-9_.:@/-]*$/;
const commandName = /^[a-zA-Z0-9][a-zA-Z0-9_:-]*$/;
const reservedCommands = new Set(["compact"]);

export function validatePluginSnapshot(value: unknown): asserts value is PluginSnapshot {
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid plugin snapshot");
  const plugin = value as PluginSnapshot;
  if (
    typeof plugin.id !== "string" ||
    !identifier.test(plugin.id) ||
    typeof plugin.version !== "string" ||
    !plugin.version.trim()
  )
    throw new Error("Invalid plugin identity");
  if ([plugin.name, plugin.description].some((v) => v !== undefined && typeof v !== "string"))
    throw new Error("Invalid plugin metadata");
  if (
    plugin.runtime !== undefined &&
    (typeof plugin.runtime !== "string" || !identifier.test(plugin.runtime))
  )
    throw new Error("Invalid plugin runtime");
  if (plugin.options !== undefined && !isJsonObject(plugin.options))
    throw new Error("Invalid plugin options");
  if (
    plugin.instructions !== undefined &&
    (!Array.isArray(plugin.instructions) || plugin.instructions.some((part) => typeof part !== "string"))
  )
    throw new Error("Invalid plugin instructions");
  if (
    plugin.commands !== undefined &&
    (!Array.isArray(plugin.commands) ||
      plugin.commands.some(
        (command) =>
          !command ||
          typeof command.name !== "string" ||
          !commandName.test(command.name) ||
          reservedCommands.has(command.name) ||
          typeof command.description !== "string" ||
          typeof command.prompt !== "string",
      ))
  )
    throw new Error("Invalid plugin command");
  if (
    plugin.skills !== undefined &&
    (!Array.isArray(plugin.skills) ||
      plugin.skills.some(
        (skill) =>
          !skill ||
          typeof skill.name !== "string" ||
          !commandName.test(skill.name) ||
          [skill.description, skill.path, skill.baseDir, skill.body].some((part) => typeof part !== "string"),
      ))
  )
    throw new Error("Invalid plugin skill");
}

/** Serializable field projection; snapshotPlugins also validates and copies the data. */
export function pluginSnapshotData(plugin: PluginSnapshot): PluginSnapshot {
  const { id, version, name, description, instructions, commands, skills, runtime, options } = plugin;
  return {
    id,
    version,
    name,
    description,
    instructions,
    commands,
    skills,
    runtime,
    options,
  };
}

export function snapshotPlugins(plugins: readonly SessionPlugin[]): PluginSnapshot[] {
  return plugins.map((plugin) => {
    validatePluginSnapshot(plugin);
    return structuredClone(pluginSnapshotData(plugin));
  });
}

/** Pure assembly: validate contributions and select tools without executing plugin code. */
export function assembleSession(
  options: SessionContributions & {
    builtinTools: readonly AgentTool[];
    context?: (signal?: AbortSignal) => ToolContext;
  },
) {
  if (options.noTools !== undefined && !["all", "builtin"].includes(options.noTools))
    throw new Error("Invalid noTools");
  const plugins = [...(options.plugins ?? [])];
  const identities = new Set<string>();
  const commands: PluginCommand[] = [];
  const names = new Set<string>();
  for (const plugin of plugins) {
    validatePluginSnapshot(plugin);
    assertSupportedOptions(
      plugin,
      [
        "id",
        "version",
        "name",
        "description",
        "instructions",
        "commands",
        "skills",
        "runtime",
        "options",
        "tools",
        "toolOverrides",
        "contextInstructions",
        "hooks",
      ],
      "plugin",
    );
    if (identities.has(plugin.id)) throw new Error(`Duplicate plugin: ${plugin.id}`);
    identities.add(plugin.id);
    if (plugin.contextInstructions !== undefined && typeof plugin.contextInstructions !== "function")
      throw new Error("Invalid plugin context instructions");
    for (const command of plugin.commands ?? []) {
      if (names.has(command.name)) throw new Error(`Duplicate plugin command: ${command.name}`);
      names.add(command.name);
      commands.push(structuredClone(command));
    }
    for (const hook of plugin.hooks ?? []) validateHookRegistration(hook);
  }
  const skills = plugins.flatMap((plugin) => [...(plugin.skills ?? [])]);
  if (new Set(skills.map((skill) => skill.name)).size !== skills.length)
    throw new Error("Duplicate plugin skill");
  const context =
    options.context ??
    (() => {
      throw new Error("Preview tools cannot execute");
    });
  const wrap = (tool: ToolDefinition<TSchema, unknown>) => wrapToolDefinition(tool, context, false);
  const custom = [...(options.customTools ?? []), ...plugins.flatMap((plugin) => plugin.tools ?? [])].map(
    wrap,
  );
  const overrides = [
    ...(options.toolOverrides ?? []),
    ...plugins.flatMap((plugin) => plugin.toolOverrides ?? []),
  ].map(wrap);
  if (new Set(overrides.map((tool) => tool.name)).size !== overrides.length)
    throw new Error("Duplicate tool overrides");
  for (const tool of overrides)
    if (!options.builtinTools.some((base) => base.name === tool.name))
      throw new Error(`Unknown built-in tool override: ${tool.name}`);
  const allTools = [
    ...options.builtinTools.map((base) => overrides.find((tool) => tool.name === base.name) ?? base),
    ...custom,
  ];
  if (new Set(allTools.map((tool) => tool.name)).size !== allTools.length)
    throw new Error("Duplicate tool names");
  const defaults = options.noTools === "all" ? [] : options.noTools === "builtin" ? custom : allTools;
  const activeTools = (options.tools ?? defaults.map((tool) => tool.name))
    .map((name) => {
      const tool = allTools.find((tool) => tool.name === name);
      if (!tool) throw new Error(`Unknown tool: ${name}`);
      return tool;
    })
    .filter((tool) => !options.excludeTools?.includes(tool.name));
  if (new Set(activeTools.map((tool) => tool.name)).size !== activeTools.length)
    throw new Error("Duplicate selected tools");
  for (const name of options.excludeTools ?? [])
    if (!allTools.some((tool) => tool.name === name)) throw new Error(`Unknown excluded tool: ${name}`);
  return {
    allTools,
    activeTools,
    commands,
    skills,
    getContextSections: (): Record<string, string> =>
      Object.fromEntries(
        plugins.flatMap((plugin) => {
          const dynamic = plugin.contextInstructions?.() ?? {};
          if (
            !dynamic ||
            typeof dynamic !== "object" ||
            Array.isArray(dynamic) ||
            Object.entries(dynamic).some(([name, part]) => !name || typeof part !== "string")
          )
            throw new Error("Invalid plugin context instructions");
          // Tuple keys prevent a plugin's local names from colliding with another owner or the SDK.
          return [
            [JSON.stringify(["plugin", plugin.id]), (plugin.instructions ?? []).join("\n\n")],
            ...Object.entries(dynamic).map(([name, value]) => [
              JSON.stringify(["plugin", plugin.id, name]),
              value,
            ]),
          ].filter(([, value]) => value);
        }),
      ),
    hooks: plugins.flatMap((plugin) =>
      (plugin.hooks ?? []).map((hook) => ({ ...hook, pluginId: plugin.id })),
    ),
  };
}

export function expandPluginCommand(command: PluginCommand, args: string, cwd: string): string {
  return command.prompt.replace(/{{\s*(cwd|arguments)\s*}}/g, (_, key) => (key === "cwd" ? cwd : args));
}