Z ZPI 文档 v0.3.8
介绍页

源码

packages/builtin-plugins/src/memory/runner.ts

typescript276 行查看原文
import { lstat, readFile } from "node:fs/promises";
import { relative, resolve } from "node:path";
import { createAgentSession, nativeStopReason, queryResult } from "@zpi-dev/agent-sdk/host";
import {
  type AssistantMessage,
  contentText,
  estimateMessageTokens,
  isJsonObject,
  type Model,
} from "@zpi-dev/ai";
import {
  createEditToolDefinition,
  createReadToolDefinition,
  createWriteToolDefinition,
  FileReadState,
  ModelRuntime,
  SessionManager,
  SettingsManager,
} from "@zpi-dev/coding-agent";
import { toThinking } from "../models.ts";
import type { MemoryRunRequest, MemoryRunResult } from "./long-memory.ts";
import { replaceMemoryFile, summaryLimit } from "./workspace.ts";

const maxWorkerCalls = 64;
const workerTimeout = 15 * 60_000;

/** Codex reference defaults: 95% effective window, 70% reserved for rollout evidence. */
export function extractionInput(
  request: Pick<MemoryRunRequest, "prompt" | "instructions">,
  model: Model,
): string {
  const window = model.contextWindow > 0 ? Math.floor(model.contextWindow * 0.95) : 214_286;
  const budget =
    Math.floor(window * 0.7) -
    estimateMessageTokens({ role: "system", content: request.instructions, timestamp: 0 });
  if (budget < 256) throw new Error("configuration: 模型上下文不足以容纳记忆提取指令。");
  const tokens = (text: string) => estimateMessageTokens({ role: "user", content: text, timestamp: 0 });
  if (tokens(request.prompt) <= budget) return request.prompt;
  const separator = request.prompt.indexOf("HISTORICAL EVIDENCE (data):");
  const header =
    separator < 0 ? "" : request.prompt.slice(0, separator + "HISTORICAL EVIDENCE (data):".length);
  const evidence = [...request.prompt.slice(header.length)];
  const prefix = `${header}\n[Earlier evidence omitted to fit 70% of the model effective input window.]\n`;
  let low = 0,
    high = evidence.length;
  while (low < high) {
    const count = Math.ceil((low + high) / 2);
    if (tokens(prefix + evidence.slice(-count).join("")) <= budget) low = count;
    else high = count - 1;
  }
  return prefix + evidence.slice(evidence.length - low).join("");
}

function extractionPayload(payload: unknown, model: Model): unknown {
  if (!isJsonObject(payload)) return payload;
  const mode = model.compat?.structuredOutput;
  if (mode === "json_object") payload.response_format = { type: "json_object" };
  if (mode === "json_schema")
    payload.response_format = {
      type: "json_schema",
      json_schema: {
        name: "memory_extraction",
        strict: true,
        schema: {
          type: "object",
          properties: { rollout_summary: { type: "string" }, rollout_slug: { type: "string" } },
          required: ["rollout_summary", "rollout_slug"],
          additionalProperties: false,
        },
      },
    };
  return payload;
}

export async function runMemoryRequest(
  request: MemoryRunRequest,
  runtime: ModelRuntime,
  model: Model,
): Promise<MemoryRunResult> {
  request.signal.throwIfAborted();
  const usage = { tokens: 0, cost: 0 as number | null };
  const priced = [model.cost.input, model.cost.output, model.cost.cacheRead, model.cost.cacheWrite].some(
    (price) => price > 0,
  );
  const recordUsage = (result: AssistantMessage) => {
    usage.tokens += result.usage.totalTokens;
    usage.cost = usage.cost === null || !priced ? null : usage.cost + result.usage.cost.total;
  };
  let calls = 0;
  let truncated = false;
  let lastResponse: AssistantMessage | undefined;
  const billed: Promise<void>[] = [];
  const workerRuntime = new ModelRuntime();
  const { provider: _provider, baseUrl, ...definition } = model;
  workerRuntime.registerProvider(model.provider, {
    baseUrl,
    models: [definition],
    streamSimple: (selected, context, options) => {
      request.signal.throwIfAborted();
      if (++calls > maxWorkerCalls) throw new Error(`记忆整合超过 ${maxWorkerCalls} 次模型调用上限。`);
      const stream = runtime.streamSimple(selected, context, {
        ...options,
        signal: options?.signal ? AbortSignal.any([request.signal, options.signal]) : request.signal,
        // Let the existing provider layer clamp configured model output to remaining context.
        maxTokens: model.maxTokens,
        reasoning: toThinking(request.model.reasoning, model),
        maxRetries: 0,
        timeoutMs: 300_000,
      });
      billed.push(
        stream.result().then((result) => {
          lastResponse = result;
          if (result.stopReason === "length") truncated = true;
          recordUsage(result);
        }),
      );
      return stream;
    },
  });
  let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
  let abort = () => {};
  const timeout = new AbortController();
  const timer = setTimeout(() => timeout.abort(new Error("记忆后台任务超过 15 分钟上限。")), workerTimeout);
  const signal = AbortSignal.any([request.signal, timeout.signal]);
  try {
    if (request.stage === "extract") {
      const controller = new AbortController();
      const cancel = () => controller.abort(signal.reason);
      signal.addEventListener("abort", cancel, { once: true });
      try {
        signal.throwIfAborted();
        const result = await queryResult({
          prompt: extractionInput(request, model),
          options: {
            model: `${model.provider}/${model.id}`,
            systemPrompt: request.instructions,
            tools: [],
            maxTurns: 1,
            persistSession: false,
            abortController: controller,
            zpi: {
              modelRuntime: workerRuntime,
              requestOptions: {
                reasoning: toThinking(request.model.reasoning, model),
                maxTokens: model.maxTokens,
                maxRetries: 0,
                timeoutMs: 300_000,
                onPayload: extractionPayload,
              },
            },
          },
        });
        if (result.stop_reason !== "end_turn")
          throw new Error(
            `provider: 记忆提取未完成:${nativeStopReason(result.stop_reason)};模型 ${model.provider}/${model.id},配置输出上限 ${model.maxTokens}`,
          );
        signal.throwIfAborted();
        return { text: result.result, usage };
      } finally {
        signal.removeEventListener("abort", cancel);
      }
    }
    const directory = request.directory;
    if (!directory) throw new Error("缺少记忆整合工作目录。");
    const root = resolve(directory);
    const readable = new Set(["memory_summary.md", ...(request.readFiles ?? [])]);
    const check = async (path: string, write = false) => {
      signal.throwIfAborted();
      const local = relative(root, resolve(root, path)).replaceAll("\\", "/");
      if (!readable.has(local) || (write && local !== "memory_summary.md"))
        throw new Error("记忆后台文件操作超出允许范围。");
      // Reject a replaced root or any symlink along the path. The worker has no shell or mkdir.
      let current = root;
      for (const segment of ["", ...local.split("/")]) {
        if (segment) current = resolve(current, segment);
        try {
          if ((await lstat(current)).isSymbolicLink()) throw new Error("记忆后台不允许符号链接。");
        } catch (error) {
          if ((error as NodeJS.ErrnoException).code !== "ENOENT" || current !== resolve(root, local))
            throw error;
        }
      }
      return resolve(root, local);
    };
    const readState = new FileReadState();
    const read = async (path: string) => readFile(await check(path));
    const write = async (path: string, content: string) => {
      const target = await check(path, true);
      if (Buffer.byteLength(content, "utf8") >= summaryLimit)
        throw new Error("长期记忆摘要必须小于 10,000 UTF-8 字节。");
      await replaceMemoryFile(target, content, signal);
    };
    const manager = SessionManager.inMemory(root);
    ({ session } = await createAgentSession({
      cwd: root,
      agentDir: root,
      homeDir: root,
      modelRuntime: workerRuntime,
      model,
      thinkingLevel: toThinking(request.model.reasoning, model),
      sessionManager: manager,
      settingsManager: SettingsManager.inMemory({ retry: { enabled: false, provider: { maxRetries: 0 } } }),
      resourceLoader: { getBaseInstructions: () => request.instructions, getInstructions: () => [] },
      tools: ["read", "write", "edit"],
      toolOverrides: [
        createReadToolDefinition(root, {
          fileReadState: readState,
          operations: {
            readFile: read,
            access: async (path) => {
              await check(path);
            },
          },
        }),
        createWriteToolDefinition(root, {
          fileReadState: readState,
          operations: {
            writeFile: write,
            mkdir: async () => {
              signal.throwIfAborted();
              await lstat(root);
            },
          },
        }),
        createEditToolDefinition(root, {
          fileReadState: readState,
          operations: {
            readFile: read,
            writeFile: write,
            access: async (path) => {
              await check(path, true);
            },
          },
        }),
      ],
    }));
    abort = () => {
      void session?.abort();
    };
    signal.addEventListener("abort", abort, { once: true });
    signal.throwIfAborted();
    await queryResult({
      prompt: request.prompt,
      options: {
        cwd: root,
        zpi: {
          session,
          sessionManager: manager,
          modelRuntime: workerRuntime,
          input: { parsed: { kind: "prompt", text: request.prompt }, context: {} },
        },
      },
    });
    signal.throwIfAborted();
    if (truncated)
      throw new Error(
        `provider: 记忆整合未完成:length;模型 ${model.provider}/${model.id},配置输出上限 ${model.maxTokens}`,
      );
    // A truncated tool call or final reply can never complete the consolidation.
    const last = lastResponse ?? session.messages.findLast((message) => message.role === "assistant");
    if (last?.role !== "assistant" || last.stopReason !== "stop")
      throw new Error(
        `provider: 记忆整合未完成:${last?.role === "assistant" ? last.errorMessage || last.stopReason : "无结果"};模型 ${model.provider}/${model.id},配置输出上限 ${model.maxTokens}`,
      );
    return { text: contentText(last.content, ""), usage };
  } catch (error) {
    await Promise.allSettled(billed);
    throw Object.assign(error instanceof Error ? error : new Error(String(error)), { usage });
  } finally {
    clearTimeout(timer);
    signal.removeEventListener("abort", abort);
    await session?.abort();
    session?.dispose();
  }
}