Z ZPI 文档 v0.3.8
介绍页

源码

packages/agent/src/tool-execution.ts

typescript157 行查看原文
import type { AssistantMessage, ToolCall, ToolResultMessage } from "@zpi-dev/ai";
import { isJsonValue } from "@zpi-dev/ai";
import { Check } from "typebox/value";
import type { AgentContext, AgentEventSink, AgentLoopConfig, AgentTool, AgentToolResult } from "./types.ts";

export const toolFailure = (error: unknown): AgentToolResult => ({
  content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
  details: undefined,
  isError: true,
});

interface PreparedCall {
  call: ToolCall;
  tool?: AgentTool;
  args: unknown;
  error?: AgentToolResult;
}

export function toolResultMessage(call: ToolCall, result: AgentToolResult): ToolResultMessage {
  return {
    role: "toolResult",
    toolCallId: call.id,
    toolName: call.name,
    content: result.content,
    details: result.details,
    isError: result.isError ?? false,
    timestamp: Date.now(),
  };
}

export async function finishToolCall(
  call: ToolCall,
  result: AgentToolResult,
  emit: AgentEventSink,
): Promise<ToolResultMessage> {
  await emit({
    type: "tool_execution_end",
    toolCallId: call.id,
    toolName: call.name,
    result,
    isError: result.isError ?? false,
  });
  return toolResultMessage(call, result);
}

/** Shared execution path for Pi batches and ZCode's streamed read-only calls. */
export function createToolExecutor(
  context: AgentContext,
  assistant: AssistantMessage,
  config: AgentLoopConfig,
  emit: AgentEventSink,
  signal?: AbortSignal,
) {
  async function finish(call: ToolCall, result: AgentToolResult): Promise<ToolResultMessage> {
    return finishToolCall(call, result, emit);
  }

  async function prepare(call: ToolCall): Promise<PreparedCall> {
    // Pi emits start before validation and the ordered before hook.
    await emit({
      type: "tool_execution_start",
      toolCallId: call.id,
      toolName: call.name,
      args: call.arguments,
      toolCall: call,
    });
    const tool = context.tools?.find((t) => t.name === call.name);
    let args: unknown = call.arguments;
    try {
      if (signal?.aborted || assistant.stopReason === "aborted")
        throw new Error("Tool cancelled before execution; effects unknown");
      if (assistant.stopReason === "error") throw new Error("Tool not executed: provider response failed");
      if (assistant.stopReason === "length")
        throw new Error("Tool not executed: output length limit may have truncated arguments");
      if (!tool) throw new Error(`Unknown tool: ${call.name}`);
      args = tool.prepareArguments ? tool.prepareArguments(args) : args;
      if (!Check(tool.parameters, args)) throw new Error(`Invalid arguments for tool ${call.name}`);
      const blocked = await config.beforeToolCall?.(
        { assistantMessage: assistant, toolCall: call, args, context },
        signal,
      );
      if (signal?.aborted) throw new Error("Tool cancelled before execution");
      if (blocked?.block) throw new Error(blocked.reason ?? "Tool blocked by hook");
      if (blocked?.args !== undefined) {
        args = blocked.args;
        if (!Check(tool.parameters, args)) throw new Error(`Invalid arguments for tool ${call.name}`);
      }
      return { call, tool, args };
    } catch (error) {
      return { call, tool, args, error: toolFailure(error) };
    }
  }

  async function execute({ call, tool, args }: PreparedCall): Promise<ToolResultMessage> {
    if (signal?.aborted) return finish(call, toolFailure("Tool cancelled before execution"));
    let alive = true;
    let updates = Promise.resolve();
    let result: AgentToolResult;
    try {
      const executed = await tool?.execute(call.id, args, signal, (partialResult) => {
        if (!alive) return;
        updates = updates
          .then(() =>
            emit({
              type: "tool_execution_update",
              toolCallId: call.id,
              toolName: call.name,
              args,
              partialResult,
            }),
          )
          .catch(() => {
            // The loop retains sink failures. Still finalize the executed tool so
            // its result and side effects survive a failed progress notification.
          });
      });
      if (!executed) throw new Error("Tool returned no result");
      result = executed;
    } catch (error) {
      result = toolFailure(error);
    } finally {
      alive = false;
      await updates;
    }
    try {
      const replacement = await config.afterToolCall?.(
        {
          assistantMessage: assistant,
          toolCall: call,
          args,
          context,
          result,
          isError: result.isError ?? false,
        },
        signal,
      );
      if (replacement) result = { ...result, ...replacement };
      if (result.details !== undefined && !isJsonValue(result.details))
        throw new Error("Tool details must be JSON serializable");
    } catch (error) {
      result = toolFailure(error);
    }
    return finish(call, result);
  }

  // Preflight failures are finalized immediately and never enter afterToolCall.
  async function schedule(call: ToolCall): Promise<() => Promise<ToolResultMessage>> {
    const prepared = await prepare(call);
    if (prepared.error) {
      const result = await finish(call, prepared.error);
      return async () => result;
    }
    return () => execute(prepared);
  }
  return { schedule };
}