跳到正文

自定义工具

你现在index.ts是手工制作工具列表。readgrepbashtaskaskUsertodoloadSkill。这对课程里的七个人来说是可以的。当有人想添加自己的deploy工具,或者用项目特定的安全命令包裹bash而不分叉Harness时,这种方法就不起作用。

一个工具注册表是接缝。新工具通过它注册。现有工具可以组合成新的工具。核心Harness保持不变。

学习成果

src/registry.ts暴露了一个工具注册表,包含registergetlist和一个小型wrapTool辅助。Agent的工具集是基于注册表构建的,而不是硬编码在index.ts中。

快速路径

  1. 定义一个包含register(name, tool)get(name)list()ToolRegistry
  2. 把内置工具线路移到registerBuiltins(registry, sandbox)辅助器里
  3. 添加wrapTool(base, { beforeExecute, afterExecute })用于合成
  4. registry.list()而不是内嵌对象来构建Agent的工具集

动手练习 11.2

构建注册表并迁移现有工具到那里。

要求:

  1. src/registry.ts导出时createRegistry()返回一个对象,包含registergetlistentries
  2. 内置工具通过registerBuiltins(registry, sandbox)注册。和之前一样的套装
  3. wrapTool(base, hooks)还回来了一个新的工具,可以beforeExecute运行,afterExecute``base.execute
  4. index.ts中,Agent的工具来自Object.fromEntries(registry.entries())而非内联对象
  5. 演示一个定制套位(deploy工具)以证明接缝有效

实现提示:

  • 注册表是一个Map<string, Tool>加上三到四个方法名称。别建得太过头
  • wrapTool是一个薄函数,返回一个新工具。基础工具保持不变
  • entries()退回 [name, tool][]Object.fromEntries 干净利落地工作

注册表

ts
import type { Tool } from "ai";

export interface ToolRegistry {
  register(name: string, tool: Tool): void;
  get(name: string): Tool | undefined;
  list(): string[];
  entries(): [string, Tool][];
}

export function createRegistry(): ToolRegistry {
  const tools = new Map<string, Tool>();
  return {
    register: (name, tool) => {
      tools.set(name, tool);
    },
    get: (name) => tools.get(name),
    list: () => [...tools.keys()],
    entries: () => [...tools.entries()],
  };
}

注册表不拥有任何策略。它是一个带有打字界面的映射。谁调用 register谁决定什么能进去。

内置辅助器

ts
import type { Sandbox } from "./sandbox";
import { createReadTool, createGrepTool, createBashTool, createTaskTool, createAskUserTool, createTodoTool, createLoadSkillTool } from "./tools";
import { createApproval } from "./approval";
import type { Skill } from "./skills";

export function registerBuiltins(
  registry: ToolRegistry,
  sandbox: Sandbox,
  skills: Skill[],
) {
  registry.register("read", createReadTool(sandbox));
  registry.register("grep", createGrepTool(sandbox));
  registry.register(
    "bash",
    createBashTool(sandbox, createApproval({ mode: "interactive" })),
  );
  registry.register(
    "task",
    createTaskTool(sandbox, {
      read: registry.get("read")!,
      grep: registry.get("grep")!,
    }),
  );
  registry.register("askUser", createAskUserTool());
  registry.register("todo", createTodoTool());
  registry.register("loadSkill", createLoadSkillTool(skills));
}

顺序在这里很重要。task需要readgrep已经存在于注册表中,因为它会生成使用它们的子 Agent。如果顺序错误,你会注册task引用未明确。

包装器

ts
import { tool, type Tool } from "ai";

interface WrapHooks {
  beforeExecute?: (input: any) => any | Promise<any>;
  afterExecute?: (result: any) => any | Promise<any>;
}

export function wrapTool(base: Tool, hooks: WrapHooks): Tool {
  return tool({
    description: base.description,
    inputSchema: base.inputSchema,
    execute: async (input) => {
      const transformed = hooks.beforeExecute ? await hooks.beforeExecute(input) : input;
      const result = await base.execute(transformed);
      return hooks.afterExecute ? await hooks.afterExecute(result) : result;
    },
  });
}

beforeExecute可以重写输入。afterExecute可以对输出进行后处理。这两种方式都是可选的。基础工具保持不变,因此项目可以包裹内置功能而不破坏其他用户的内置功能。

接入 CLI

ts
import { createRegistry, registerBuiltins } from "./src/registry";

const registry = createRegistry();
registerBuiltins(registry, sandbox, skills);

registry.register("deploy", tool({
  description: `Deploy the project to a target environment.
WHEN TO USE: pushing changes to staging or production.
WHEN NOT TO USE: testing changes (use bash with the test runner instead).`,
  inputSchema: z.object({
    environment: z.enum(["staging", "production"]),
  }),
  execute: async ({ environment }) => {
    const { stdout } = await sandbox.exec(`vercel deploy --${environment}`);
    return stdout;
  },
}));

const agent = new ToolLoopAgent({
  // ...
  tools: Object.fromEntries(registry.entries()),
  instructions: buildSystemPrompt({
    // ...
    toolNames: registry.list(),
  }),
});

Agent的tools场现在由注册表推导出来。添加新工具只需一个registry.register(...) 调用,Agent就建好了。移除工具只是一条线。

为项目包装bash

这就是wrapTool赢得其地位的原因:

ts
const baseBash = registry.get("bash")!;
registry.register("bash", wrapTool(baseBash, {
  beforeExecute: (input) => {
    if (input.command.startsWith("bun test")) {
      return { ...input, command: input.command + " --reporter=spec" };
    }
    return input;
  },
}));

基础bash仍在记忆中;注册表现在收录了一个包装版本,增加了针对项目的调整。Agent从未见过未包装的那张。Harness核心从未改变。

**注意:延伸曲面表**

表面你可以自定义的内容怎么会这样
工具添加、移除、包裹注册表加wrapTool
技能增加专业知识skills/目录加loadSkill
沙箱自定义后端createSandbox工厂
审批自定义策略配置加事件(下一课)
系统提示词自定义部分PromptContextbuildSystemPrompt
模型每个榜样子 Agent定义

每一行都映射到你已经写好的代码。注册表是第一排的入口。其余的也是伸展曲面,每个都有自己的约定。

动手试试

添加一个专门针对项目的小工具来确认接缝:

ts
registry.register("now", tool({
  description: "Return the current timestamp",
  inputSchema: z.object({}),
  execute: async () => new Date().toISOString(),
}));

运行一个使用它(Nits)的任务:

bash
bun run index.ts . "What's the current timestamp? Use the now tool."

Agent应该调用 now并返回时间戳。移除register线,运行同一个提示词,Agent应该会报告now不存在。

bash
npx tsc --noEmit

提交

bash
git add src/registry.ts index.ts
git commit -m "feat(registry): tool registry with wrapTool composition"

完成标准

  • [ ] createRegistry退还了一台工作用注册表
  • [ ] registerBuiltins线连接七个内置工具
  • [ ] wrapTool 在基础工具周围构建钩子
  • [ ] Agent的tools场地建于registry.entries()
  • [ ] registerBuiltins之后注册的自定义工具可被Agent呼叫
  • [ ] npx tsc --noEmit

**注意:替换,不要包裹**

包裹工具可以保持底座不变。更换一个后,它会完全消失。添加registry.unregister(name)和一个模式,项目可以将内置bash替换为带有更小安全前缀列表的项目专用版本。替换需要在自助顺序中发生在哪里?如果Agent建好后才更换,什么会坏?

参考实现

请参见上方完整代码块。

非官方简体中文翻译 · 原课程来自 Vercel Academy