跳转到内容

输入关键词开始搜索文档。

工具

最后更新 查看 Markdown 源文件

工具是你编写的一个函数,向模型描述,模型在工作时可能会调用它——查询订单、提交工单、处理退款。模型决定_何时_调用;你的代码决定_发生什么_。技能提供可重用的指令,沙盒提供文件和命令访问,而工具执行你的应用程序代码。

本指南涵盖定义自定义工具并使用 useTool 挂载它们、沙盒带来的文件和 shell 工具、运行时工具、持久化工具、条件工具、审批门,以及保护工具可以访问的内容。

你的第一个工具

工具定义有四个部分:模型调用它的 name、教导模型何时使用它的 description、可选的参数 input schema,以及包含你的代码的 run 函数。使用 defineTool(...) 定义它:

src/tools/lookup-order.ts
import { defineTool } from '@flue/runtime';
import * as v from 'valibot';
import { orders } from '../shared/orders.ts';

export const lookupOrder = defineTool({
  name: 'lookup_order',
  description: '通过 ID 查询一个订单并返回其当前状态。',
  input: v.object({ orderId: v.string() }),
  async run({ data }) {
    const order = await orders.get(data.orderId);
    return { output: { status: order.status, eta: order.eta } };
  },
});

然后使用 useTool hook 在智能体中挂载它:

src/agents/order-assistant.ts
'use agent';
import { useModel, useTool } from '@flue/runtime';
import { lookupOrder } from '../tools/lookup-order.ts';

export function OrderAssistant() {
  useModel('anthropic/claude-haiku-4-5');
  useTool(lookupOrder);
  return '帮助客户检查他们的订单状态。';
}

模型读取工具的名称、描述和输入 schema;当它决定工具合适时,它会用参数调用;Flue 根据 schema 验证它们,运行你的 run 函数,并将结果返回给模型。

defineTool(...) 验证定义并返回冻结的版本——这是从 src/tools/ 目录跨智能体共享工具的自然形式。对于一次性使用,useTool 接受内联编写的相同定义对象(参见下面的条件示例)。无论哪种方式,每个活动工具都需要唯一的名称:重复的名称或与框架保留名称(如 task 或 activate_skill)冲突的名称会在工具集组装时抛出异常。

工具调用如何工作

模型看到什么。 每个挂载的工具都以其 name、description 和 input schema(转换为 JSON Schema;没有 input schema 的工具呈现空对象)呈现给模型。描述是模型的_唯一_文档:说明工具做什么、何时使用它以及它返回什么。模糊的描述是工具被错误调用或根本未被调用的最常见原因。

输入。 input schema 是一个 Valibot schema,必须是顶级对象 schema。模型提供的参数在 run 执行之前由它解析,run 接收解析后的值作为 data,完全类型化。当验证失败时,run 永远不会被调用——失败作为工具错误返回给模型,以便它可以纠正参数并重试。

输出。 run 返回一个结果信封,{ output?, terminate? },而不是裸值。output 是 JSON 兼容的数据(对象、数组、字符串、数字——任何 JSON 可序列化的东西),它被 JSON 字符串化给模型,裸 string 返回是 { output: <string> } 的简写。只有在没有声明 output schema 时才允许不返回任何内容;任何其他裸返回都会抛出异常。terminate: true 在当前工具批次稳定后结束智能体的回合,与 finish/give_up 使用相同的契约。当返回的形状也需要类型化和验证时,添加可选的 output schema:

const checkInventory = defineTool({
  name: 'check_inventory',
  description: '检查一个 SKU 的库存水平。',
  input: v.object({ sku: v.string() }),
  output: v.object({ inStock: v.number(), warehouse: v.string() }),
  async run({ data }) {
    return { output: await inventory.lookup(data.sku) };
  },
});

错误。 run 内部的抛出不会使智能体崩溃。它成为模型看到的错误结果,因此它可以重试、尝试其他方法或告诉用户。抛出(或返回描述性失败值)而不是吞没错误——模型只能响应它能看到的失败。

上下文的其余部分。 除了 data,每个 run 还接收:

  • signal — 调用的 AbortSignal。将其传递给你自己的异步工作,以便取消的工具调用能及时停止。忽略信号的 run 不会使智能体挂起:当信号触发时,运行时放弃 await——调用以 AbortError 失败,说明工作可能仍在运行,孤立的 promise 的最终结果被丢弃。
  • log — 长时间运行工具的进度日志(log.info(...)、log.warn(...)、log.error(...))。行作为你的应用程序可以观察的事件流式传输到对话中;它们不是结果的一部分,模型永远看不到它们。
  • toolCallId — 此特定调用的 ID,与调用的对话事件携带的 ID 相同。使用它将副作用与引发它们的调用关联起来。

定义上的可选标志进一步扩展上下文:harness: true 添加 harness,durable: true 添加 step,两者都在下面介绍。完整契约在 defineTool 参考中。

内置工具

带有沙盒的智能体会获得一组标准的内置工具来操作它(没有沙盒,这些工具不在集合中——模型无法调用不存在的东西):

工具功能
read读取文件(截断为 2000 行或 50KB;支持 offset/limit)
write写入文件,根据需要创建它和父目录
edit通过精确文本替换编辑文件
bash执行 shell 命令并返回 stdout/stderr
grep搜索文件内容的正则表达式模式
glob按文件名模式查找文件

每个工具的参数、截断限制和错误行为在智能体行为——内置工具中有文档记录。

在此基础上,当能力存在时,框架会添加自己的工具:task 用于子智能体委托(始终存在),activate_skill 当智能体有技能时,read_skill_resource 当技能打包资源文件时。这些名称是保留的——自定义工具不能使用它们。

沙盒适配器可以用自己的集合替换这个集合——请参阅沙盒提供的工具和沙盒适配器 API中的 SandboxToolFactory。

运行时工具

普通工具是其输入的纯函数:数据进,结果出。当工具需要回溯到智能体自己的运行时——它的沙盒或模型本身时,声明 harness: true。run 函数然后接收 harness,工具与两者的接口:

  • harness.sandbox — 智能体的活动环境:readFile、writeFile、exec 和其他沙盒动词,直接操作无对话记录。当智能体没有声明沙盒时抛出异常。
  • harness.prompt(text, options?) — 在运行时自己的临时对话中运行模型操作。重复调用继续它,因此后面的提示可以看到前面调用建立的内容。传递 options.result(Valibot schema)以要求经过验证的结构化数据,或传递 options.tools 仅为该操作提供额外工具。

运行时工具可以在一个工具调用中暂存输入、运行专注的模型工作并验证结果:

src/tools/review-contract.ts
import { defineTool } from '@flue/runtime';
import * as v from 'valibot';

const Report = v.object({ riskLevel: v.picklist(['low', 'medium', 'high']), summary: v.string() });

export const reviewContract = defineTool({
  name: 'review_contract',
  description: '审查提供的合同并返回结构化风险报告。',
  input: v.object({ contract: v.string() }),
  harness: true,
  async run({ harness, data }) {
    await harness.sandbox.writeFile('contract.md', data.contract);
    const { data: report } = await harness.prompt(
      '审查 contract.md 的非标准条款并评估风险。',
      { result: Report },
    );
    return { output: report };
  },
});

运行时调用的范围是工具调用:运行时在调用运行时具体化,在调用稳定时关闭。它们计入委托深度上限,它们打开的任何子对话都保留在父对话上以供检查——与委托的子智能体使用的相同记账方式。因为运行时只存在于智能体会话内部,harness: true 工具永远不会独立运行;没有该标志的工具根本无法访问运行时。请参阅运行时参考了解完整表面。

持久化工具

当进程在回合中途崩溃时,Flue 从其持久化记录中恢复对话——但正在进行的普通工具调用_不会_重新执行。运行时无法知道哪些副作用已经发生,因此中断的调用以未知结果错误稳定,模型从那里继续。

对于必须完成的工作——支付、多步骤同步、配置作业——声明工具 durable: true。这使其加入不同的契约:run 接收 step,每个副作用都通过 step.do(name, fn):

src/tools/provision-workspace.ts
import { defineTool } from '@flue/runtime';
import * as v from 'valibot';
import { billing, projects, DEFAULT_PROJECTS } from '../shared/provisioning.ts';

export const provisionWorkspace = defineTool({
  name: 'provision_workspace',
  description: '配置客户工作空间:创建租户,然后播种每个默认项目。',
  input: v.object({ customerId: v.string() }),
  durable: true,
  async run({ data, step }) {
    const tenant = await step.do('create-tenant', () => billing.createTenant(data.customerId));
    for (const project of DEFAULT_PROJECTS) {
      await step.do(`seed:${project.name}`, () => projects.seed(tenant.id, project));
    }
    return { output: { tenantId: tenant.id, projects: DEFAULT_PROJECTS.length } };
  },
});

step.do(name, fn) 为工具调用运行 fn 一次,并在解析之前持久化记录其返回值。当中断在运行过程中发生时,恢复重新执行整个调用:完成的步骤返回其记录的值而不重新运行,执行从第一个从未完成的步骤继续。如果崩溃发生在上面的 create-tenant 和第三个 seed: 步骤之间,重新运行从记录中重放租户和前两个播种,并在第三个处继续。

四条规则:

  • 所有有副作用的代码都在步骤中。 步骤之间的代码在恢复时重新运行,因此保持其廉价且无副作用——派生值、分支、循环。
  • 名称标识工作。 确定性地派生它们(seed:${project.name}),绝不来自随机性或计时。在一个调用中重用名称会抛出异常。
  • 值是 JSON 且应该保持小。 将大型工件存储在沙盒中并记录指针。
  • 步骤恰好记录一次,至少执行一次。 在步骤完成和其记录落地之间的狭窄窗口中崩溃会重新运行该步骤,因此围绕外部效果的步骤应该单独幂等。

步骤记录是操作性的簿记:模型只看到工具的最终结果,步骤进度作为调用的日志事件实时显示。抛出的错误不是中断——像任何工具一样,抛出的持久化工具将调用稳定为模型看到的工具错误,没有任何东西自动重试。步骤的范围是一个调用:当模型再次调用工具时,它们重新运行。标志可以组合——durable: true, harness: true 工具同时接收 step 和 harness;将 harness.prompt(...) 包装在步骤中,以便恢复不会重新提示。请参阅持久性了解这如何适应更广泛的恢复模型。

有界工具

内部挂起的工具——从不观察其中止信号的传输、无界的响应体读取、卡住的 SDK 调用——可能在周围的每个回合看起来健康时悄悄耗尽整个提交的持久性预算。声明 timeoutMs 来限制一个调用:

src/tools/lookup-catalog.ts
import { defineTool } from '@flue/runtime';
import * as v from 'valibot';

export const lookupCatalog = defineTool({
  name: 'lookup_catalog',
  description: '查询上游目录 API。',
  input: v.object({ sku: v.string() }),
  timeoutMs: 15_000,
  async run({ data, signal }) {
    const response = await fetch(`https://catalog.example.com/${data.sku}`, { signal });
    // …
  },
});

当截止时间到期时,运行时中止工具的 context.signal(感知信号的代码可以清理),用 ToolTimeoutError 稳定调用——作为工具的错误结果显示给模型,与抛出的工具错误不同——并丢弃被放弃的运行的延迟结算。对话继续:模型看到 Tool "<name>" timed out after <ms>ms 并可以重试或更改方法,而提交的持久性超时仍然是外部支撑。主机中止(会话中止、部署)仍然以中止形式落地,而不是超时。

条件工具

智能体函数在每次模型调用前重新渲染,每次渲染都从头声明其工具集。这使得工具的_存在_只是另一段程序逻辑:将 useTool 包装在条件中,工具只在条件成立的渲染中存在。用持久化状态控制它,智能体就可以解锁自己的能力:

src/agents/release-manager.ts
'use agent';
import { useModel, usePersistentState, useTool } from '@flue/runtime';
import * as v from 'valibot';
import { approvals } from '../shared/approvals.ts';
import { publishRelease } from '../tools/publish-release.ts';

export function ReleaseManager() {
  useModel('anthropic/claude-sonnet-4-6');
  const [approved, setApproved] = usePersistentState('approved', false);

  useTool({
    name: 'record_approval',
    description: '记录此版本的操作员批准代码。',
    input: v.object({ code: v.string() }),
    async run({ data }) {
      if (!(await approvals.verify(data.code))) return '无效的批准代码。';
      setApproved(true);
      return '批准已记录。发布工具现在可用。';
    },
  });

  if (approved) useTool(publishRelease);

  return '准备发布。发布在操作员批准后解锁。';
}

在操作员批准之前,publish_release 不存在——未挂载的工具无法被调用,这比指令不使用它更强的保证。当集合在渲染之间变化时,运行时在下一个回合边界用 resources 信号向模型宣布增量(“新工具可用:…”),保持记录连贯。请参阅动态资源了解变化如何被叙述。

**注意:**更改工具集会重写提供商的工具数组,这会使其提示缓存失效,因此将工具控制在很少变化的状态上。例外是由完成的工具调用解锁的工具,就像这里 record_approval 解锁 publish_release:当前的 Anthropic 模型(Haiku 除外)在它出现的对话点加载其定义,缓存存活。

以这种方式构建的工具自然与自定义 hooks配对:一个捆绑了控制、工具和匹配指令的 useEscalation() hook 可以在每个需要相同行为的智能体之间共享。

审批门

条件工具解锁的是整个_能力_。有时你需要一个人来批准某一次具体的调用——这次退款、这次发布、这些参数——然后才执行。Flue 没有内置的审批 API;本节展示一种基于上述 hooks 构建的模式,你可以复制并调整。

不要让工具的 run 等待人工审批。当工具调用运行时,智能体的响应仍在进行中,新消息只会在回合边界加入,因此审批永远无法到达。等待还会占用提交的持久性超时,且中断的调用不会重新运行。正确做法是:记录请求并结束响应。决策稍后作为独立消息到达,智能体届时执行存储的调用:

  1. 模型调用工具。run 将确切参数保存到持久化状态中,以调用的 toolCallId 为键,返回带 terminate: true 的 pending_approval 结果。响应结束,对话空闲——没有任何进程等待任何人。
  2. 你的应用程序向用户展示请求:UI 中的卡片、聊天渠道中的按钮。
  3. 受信任的路由将决策作为信号投递到对话中。
  4. useAgentStart 回调在模型读取该信号之前运行,在批准时执行存储的参数——而不是模型下次可能发送的任何内容——并将结果追加以供模型读取。

将审批门实现为自定义 hook:

src/shared/use-approval-gate.ts
import { useAgentStart, useDelivery, usePersistentState, useTool } from '@flue/runtime';
import * as v from 'valibot';

type PendingCall = { args: unknown; requestedAt: string };

export function useApprovalGate<TInput extends v.GenericSchema<Record<string, unknown>>>(gate: {
  name: string;
  description: string;
  input: TInput;
  /** 返回 false 可以不经询问直接执行调用。省略则总是需要询问。 */
  needsApproval?: (args: v.InferOutput<TInput>) => boolean;
  /** 副作用函数。返回给模型的摘要;`approvalId` 兼作幂等键。 */
  execute: (args: v.InferOutput<TInput>, approvalId: string) => Promise<string>;
}) {
  const [pending, setPending] = usePersistentState<Record<string, PendingCall>>(
    `approvals:${gate.name}`,
    {},
  );
  const delivery = useDelivery();

  useTool({
    name: gate.name,
    description: `${gate.description} Some calls need operator approval before they run.`,
    input: gate.input,
    async run({ data, toolCallId }) {
      if (gate.needsApproval && !gate.needsApproval(data)) {
        return { output: { status: 'executed', result: await gate.execute(data, toolCallId) } };
      }
      setPending((previous) => ({
        ...previous,
        [toolCallId]: { args: data, requestedAt: new Date().toISOString() },
      }));
      return { output: { status: 'pending_approval', approvalId: toolCallId }, terminate: true };
    },
  });

  useAgentStart(async ({ append }) => {
    if (delivery.kind !== 'signal' || delivery.type !== 'tool-approval') return;
    const { approvalId = '', decision, approver = 'An operator' } = delivery.attributes ?? {};
    const call = pending[approvalId];
    if (!call) return; // not this gate's request, or already decided

    setPending(({ [approvalId]: _decided, ...rest }) => rest);

    if (decision !== 'approve') {
      append({
        kind: 'signal',
        type: 'tool-approval-result',
        body: `${approver} denied ${gate.name} (${approvalId}). Do not retry it; ask how to proceed.`,
      });
      return;
    }
    try {
      const result = await gate.execute(call.args as v.InferOutput<TInput>, approvalId);
      append({
        kind: 'signal',
        type: 'tool-approval-result',
        body: `${approver} approved ${gate.name} (${approvalId}). Result: ${result}`,
      });
    } catch (error) {
      append({
        kind: 'signal',
        type: 'tool-approval-result',
        body: `${gate.name} (${approvalId}) was approved but failed: ${String(error)}`,
      });
    }
  });
}

智能体像挂载任何自定义 hook 一样挂载它。这里预发布版本立即发布,稳定版本需要等待审批:

src/agents/release-manager.ts
'use agent';
import { useModel } from '@flue/runtime';
import * as v from 'valibot';
import { releases } from '../shared/releases.ts';
import { useApprovalGate } from '../shared/use-approval-gate.ts';

export function ReleaseManager() {
  useModel('anthropic/claude-sonnet-4-6');

  useApprovalGate({
    name: 'publish_release',
    description: 'Publish a tagged release to npm.',
    input: v.object({ pkg: v.string(), version: v.string() }),
    needsApproval: ({ version }) => !version.includes('-next'),
    async execute({ pkg, version }, approvalId) {
      const { url } = await releases.publish({ pkg, version, idempotencyKey: approvalId });
      return `Published ${pkg}@${version}: ${url}`;
    },
  });

  return 'Prepare releases. Stable releases need operator approval before they publish.';
}

决策通过你控制的路由进入,而不是从浏览器直接进入对话:否则任何能向对话发消息的人都可以批准调用。路由先验证审批者身份,然后 dispatch 信号。以审批 ID 作为分派的幂等键意味着第一个决策生效——同一请求的第二个不同决策将以 409 拒绝:

src/app.ts
import { dispatch } from '@flue/runtime';
import { createAgentRouter } from '@flue/runtime/routing';
import { Hono } from 'hono';
import { ReleaseManager } from './agents/release-manager.ts';
import { requireOperator } from './shared/auth.ts';

const app = new Hono();

app.route('/api/agents/release-manager', createAgentRouter(ReleaseManager));

app.post('/api/approvals/:conversationId', async (c) => {
  const operator = await requireOperator(c.req.raw);
  const { approvalId, decision } = await c.req.json<{
    approvalId: string;
    decision: 'approve' | 'deny';
  }>();

  const receipt = await dispatch(ReleaseManager, {
    id: c.req.param('conversationId'),
    message: {
      kind: 'signal',
      type: 'tool-approval',
      body: `${operator.name} chose "${decision}" for ${approvalId}.`,
      attributes: { approvalId, decision, approver: operator.name },
    },
    idempotencyKey: `approval:${approvalId}`,
  });

  return c.json(receipt, 202);
});

export default app;

渠道以同样的方式处理提供商的按钮点击 webhook:验证它,然后分派相同的信号。

在客户端,待处理的请求是一个普通的 dynamic-tool 部分,其 output.status 为 pending_approval。该部分的 input 持有要展示给审批者的参数。决策信号在其属性中携带 approvalId,因此当带有该属性的消息出现时,请求即被决定:

src/ui/Approvals.tsx
import { type FlueConversationMessage, useFlueAgent } from '@flue/react';

function pendingApprovals(messages: FlueConversationMessage[]) {
  const decided = new Set(
    messages.flatMap((message) => message.signal?.attributes?.approvalId ?? []),
  );
  return messages.flatMap((message) =>
    message.parts.flatMap((part) =>
      part.type === 'dynamic-tool' &&
      part.state === 'output-available' &&
      (part.output as { status?: string } | null)?.status === 'pending_approval' &&
      !decided.has(part.toolCallId)
        ? [{ approvalId: part.toolCallId, toolName: part.toolName, input: part.input }]
        : [],
    ),
  );
}

export function Approvals({ conversationId }: { conversationId: string }) {
  const agent = useFlueAgent({ url: `/api/agents/release-manager/${conversationId}` });

  async function decide(approvalId: string, decision: 'approve' | 'deny') {
    await fetch(`/api/approvals/${conversationId}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ approvalId, decision }),
    });
  }

  return (
    <ul>
      {pendingApprovals(agent.messages).map(({ approvalId, toolName, input }) => (
        <li key={approvalId}>
          <code>{toolName}</code>
          <pre>{JSON.stringify(input, null, 2)}</pre>
          <button onClick={() => decide(approvalId, 'approve')}>Approve</button>
          <button onClick={() => decide(approvalId, 'deny')}>Deny</button>
        </li>
      ))}
    </ul>
  );
}

调整此模式时需要注意的事项:

  • **确保 execute 幂等。useAgentStart 回调是至少一次的:运行中的崩溃会在下次尝试时重新运行它。将 approvalId 作为幂等键传递给外部系统,如上面的 releases.publish 所做的那样。
  • **请求结束响应但没有回复。terminate: true 在模型写任何文本之前停止它,因此审批卡片是用户看到的内容。如果你希望模型说它正在等待,请去掉 terminate;调用仍然不会在批准之前运行。
  • **决定没有人在场时的行为。**由调度或 webhook 启动的对话可能没有人查看。使用 needsApproval 或对 useDelivery() 的检查来拒绝或跳过这些调用,而不是让它们处于待处理状态。
  • **请求不会自行过期。**它们留在持久化状态中直到被决定。要使其过期,让定时任务为超过你时限的请求分派一个 deny 决策。
  • **记录会记录两个步骤。**原始工具调用保留其 pending_approval 结果,实际结果稍后作为 tool-approval-result 信号到达。

MCP 工具注解如 destructiveHint 可以为来自你信任的服务器的工具提供 needsApproval 依据。它们是服务器提供的提示,因此用它们来要求审批,绝不用来跳过审批。

保护访问

工具的参数是模型选择的输入,而不是授权边界。你的应用程序应该决定工具可以使用哪个客户、账户、仓库或凭据,然后让模型只在该边界内选择值。

对于接收分派的、每个客户事件的智能体——支持系统 webhook、聊天平台消息——在已验证的信号的 attributes 中携带你的应用程序已经验证的授权标识符,并使用 useDelivery() 读取它,而不是信任模型提供的值:

src/agents/customer-orders.ts
'use agent';
import { useDelivery, useModel, useTool } from '@flue/runtime';
import * as v from 'valibot';
import { orders } from '../shared/orders.ts';

export function CustomerOrders() {
  useModel('anthropic/claude-haiku-4-5');
  const delivery = useDelivery();
  const customerId = delivery.kind === 'signal' ? delivery.attributes?.customerId : undefined;

  useTool({
    name: 'lookup_customer_order',
    description: '查询属于此客户的一个订单。',
    input: v.object({ orderId: v.string() }),
    async run({ data }) {
      const status = customerId ? await orders.getStatus(customerId, data.orderId) : undefined;
      return status ?? '未找到可访问的订单。';
    },
  });

  return '帮助此客户检查他们的订单状态。';
}

模型可以选择要查询的订单 ID,但它不能选择查询中使用的客户——customerId 来自信号的 attributes,由调用 dispatch(...) 的可信代码设置。你的路由或分派代码在附加该标识符之前仍然必须验证调用者;请参阅智能体和路由。

相同的原则适用于工具接触模型不应选择的内容的任何地方:在运行时工具内部,在包装提供商 SDK 的工具中,其中可信代码通过闭包或配置绑定令牌、仓库或目标,工具只暴露狭窄的操作。请参阅通道指南中的使用提供商 SDK了解该模式;避免暴露任意目标或 API 方法的通用提供商工具,除非应用程序有明确的授权设计。

连接 MCP 服务器

远程 MCP 服务器插入到这个相同的工具集中:useMcpConnection(...) 声明一个服务器,运行时将其工具作为 mcp__<server>__<tool> 条目挂载在你的 useTool 挂载旁边。请参阅 MCP 指南了解连接、选择要挂载哪些工具、身份验证以及在模块范围连接。

后续步骤

  • 智能体 Hooks — useTool 所属的 hook 模型,包括持久化状态和自定义 hooks。
  • 智能体 API — 完整的 defineTool、useTool、ToolContext 和运行时契约。
  • 沙盒 — 带来内置文件和 shell 工具的环境。
  • 子智能体 — 通过内置的 task 工具委托专注的工作。
  • 持久性 — 对话、状态和持久化工具步骤如何恢复。