Z ZPI 文档 v0.3.8
介绍页

源码

packages/coding-agent/src/plugins/session-hooks.ts

typescript141 行查看原文
import type { AgentLoopConfig, AgentMessage } from "@zpi-dev/agent";
import { contentText } from "@zpi-dev/ai";
import { Check } from "typebox/value";
import {
  type HookEvent,
  type HookExecution,
  type HookRegistration,
  HookRunner,
  hookEvents,
  maxHookContextChars,
} from "./hooks.ts";

/** Bridges lifecycle decisions to the loop; the runner never owns session history. */
export class SessionHooks {
  readonly runner: HookRunner;
  private started = false;
  private startMessages: AgentMessage[] = [];
  private pending = new Map<HookEvent, Map<number, string[]>>();
  private callOrder = new Map<string, number>();
  private continuations = 0;
  private context: { sessionId: string; cwd: string; source: "startup" | "resume" };
  constructor(
    context: { sessionId: string; cwd: string; source: "startup" | "resume" },
    hooks: readonly (HookRegistration & { pluginId?: string })[],
    observe: (event: HookExecution) => void,
  ) {
    this.context = context;
    this.runner = new HookRunner(hooks, observe);
  }
  beginRun(): void {
    this.pending.clear();
    this.callOrder.clear();
    this.continuations = 0;
  }
  async preparePrompt(prompt: string, signal?: AbortSignal): Promise<AgentMessage[]> {
    if (!this.started) {
      const result = await this.runner.run({ ...this.context, event: "SessionStart" }, signal);
      this.startMessages = contextMessages("SessionStart", result.additionalContexts);
      this.started = true;
    }
    const result = await this.runner.run({ ...this.context, event: "UserPromptSubmit", prompt }, signal);
    if (result.block) throw new Error(result.reason ?? "Input blocked by hook");
    const messages = [
      ...this.startMessages,
      ...contextMessages("UserPromptSubmit", result.additionalContexts),
    ];
    this.startMessages = [];
    return messages;
  }
  drainMessages(): AgentMessage[] {
    const messages = [...this.pending]
      .sort(([a], [b]) => hookEvents.indexOf(a) - hookEvents.indexOf(b))
      .flatMap(([event, entries]) =>
        contextMessages(
          event,
          [...entries].sort(([a], [b]) => a - b).flatMap(([, contexts]) => contexts),
        ),
      );
    this.pending.clear();
    this.callOrder.clear();
    return messages;
  }
  beforeToolCall: NonNullable<AgentLoopConfig["beforeToolCall"]> = async (call, signal) => {
    this.continuations = 0;
    this.callOrder.set(call.toolCall.id, this.callOrder.size);
    const result = await this.runner.run(
      {
        ...this.context,
        event: "PreToolUse",
        toolCallId: call.toolCall.id,
        toolName: call.toolCall.name,
        toolInput: call.args,
      },
      signal,
      (input) => {
        const tool = call.context.tools?.find((tool) => tool.name === call.toolCall.name);
        const args = tool?.prepareArguments ? tool.prepareArguments(structuredClone(input)) : input;
        if (!tool || !Check(tool.parameters, args)) throw new Error("Invalid hook tool input");
        return args;
      },
    );
    this.enqueue("PreToolUse", call.toolCall.id, result.additionalContexts);
    return { block: result.block, reason: result.reason, args: result.updatedInput };
  };
  afterToolCall: NonNullable<AgentLoopConfig["afterToolCall"]> = async (call, signal) => {
    const event = call.isError ? "PostToolUseFailure" : "PostToolUse";
    try {
      const result = await this.runner.run(
        {
          ...this.context,
          event,
          toolCallId: call.toolCall.id,
          toolName: call.toolCall.name,
          toolInput: call.args,
          toolResult: call.result,
        },
        signal,
      );
      this.enqueue(event, call.toolCall.id, result.additionalContexts);
    } catch (error) {
      // 工具已经完成;取消 hook 只能结束等待,不能抹掉已执行结果和文件变更事实。
      if (!signal?.aborted) throw error;
    }
    return undefined;
  };
  beforeStop: NonNullable<AgentLoopConfig["beforeStop"]> = async (message, signal) => {
    if (this.continuations >= 3) return;
    const result = await this.runner.run(
      {
        ...this.context,
        event: "Stop",
        response: contentText(message.content, ""),
        stopHookActive: this.continuations > 0,
      },
      signal,
    );
    if (!result.additionalContexts.length) return;
    this.continuations++;
    return contextMessages("Stop", result.additionalContexts);
  };
  private enqueue(event: HookEvent, toolCallId: string, contexts: string[]): void {
    if (!contexts.length) return;
    const entries = this.pending.get(event) ?? new Map<number, string[]>();
    entries.set(this.callOrder.get(toolCallId) ?? 0, contexts);
    this.pending.set(event, entries);
  }
}

function contextMessages(event: HookEvent, contexts: string[]): AgentMessage[] {
  return contexts.length
    ? [
        {
          role: "user",
          synthetic: true,
          content: `<hook_context event="${event}">\n${contexts.join("\n\n").slice(0, maxHookContextChars)}\n</hook_context>`,
          timestamp: Date.now(),
        },
      ]
    : [];
}