Skip to content

本文同步自仓库 docs/core-integration.md。文中指向源码的链接跳到 GitHub 仓库, 站内可读的相关页面已改为站内链接。

把 @zqagent/core 接入第三方系统:完整接入手册 ​

简体中文 · 对应当前仓库 0.3.25

本文面向要把 AI Agent 能力嵌进自有系统的团队:你的系统可能是一个 Web 服务、 桌面应用、任务队列 Worker,或者已经有一套会话/用户/计费体系的产品。 目标是不安装 CLI、不启动 zqagent 的 server,只把 @zqagent/core 当作库, 拿到「模型 + 工具 + 权限 + 事件流 + 持久化」这一整套 Agent 能力。

只想先跑一个最小例子:看 embedding.md 和 examples/embed-basic。 本文是它的完整版,覆盖全部公开 API、集成模式与生产注意事项。


目录 ​

  1. 它是什么,不是什么
  2. 架构定位与包关系
  3. 安装与运行环境
  4. 五分钟跑通
  5. 核心概念
  6. 装配引擎
  7. 工具:内置与自定义
  8. 权限、审批与安全边界
  9. 事件流完整参考
  10. 模型 Provider
  11. 生命周期钩子与持久化
  12. 一轮 Turn 的控制
  13. 附件
  14. 上下文压缩
  15. MCP 接入
  16. 子代理与 Agent 注册表
  17. 变更追踪与回滚
  18. 四种集成模式与代码骨架
  19. 生产检查清单
  20. API 速查
  21. 排错 FAQ
  22. 版本、构建与发布

1. 它是什么,不是什么 ​

@zqagent/core 是一个 Agent 引擎库。它负责:

  • 维护一次会话的消息历史;
  • 调用 LLM(流式),解析文本、推理过程与工具调用;
  • 通过 ToolRegistry 执行工具;
  • 通过 PermissionEngine 判断每个工具调用该放行、拒绝还是问人;
  • 以异步生成器事件流的形式把全过程暴露给调用方;
  • 在上下文超限时压缩历史;
  • 可选:持久化事件与检查点、MCP 接入、子代理、Git 快照。

它不负责:

  • UI / 终端渲染(ConsoleHook 只是参考实现);
  • HTTP / WebSocket / SSE 服务(@zqagent/server 是参考实现);
  • 用户、租户、计费、鉴权;
  • 会话索引与数据库(FileLogHook 是文件级参考实现);
  • 替调用方做审批决策。

一句话:你负责产品外壳与会话生命周期,引擎负责一轮对话怎么跑。


2. 架构定位与包关系 ​

                   ┌─────────────────────────────┐
   你的系统 ──────▶│ @zqagent/core               │
   (Web/桌面/Worker)│ TurnEngine / ToolRegistry   │
                   │ PermissionEngine / Provider │
                   │ 内置工具 / MCP / 压缩 / VCS  │
                   └──────────────┬──────────────┘
                                  │ 依赖
                                  ▼
                   ┌─────────────────────────────┐
                   │ @zqagent/shared             │
                   │ 模型能力表 / 协议 / 加密等   │
                   └─────────────────────────────┘

   仓库自带的两个「完整应用」,可作为参考实现:
   @zqagent/server = 多租户会话 + HTTP/SSE + 挂起式审批
   @zqagent/cli    = 终端渲染 + 会话持久化 + 交互审批

依赖方向是单向的:shared ← core。core 不知道 server / cli 的存在, 也不会反向依赖它们。第三方系统引入 core 时不会把 HTTP 服务、Tauri、 控制台 UI 等一起拖进来。

仓库中的包:

包用途是否可独立使用
@zqagent/coreAgent 引擎(本文主角)是
@zqagent/sharedcore 与 hub 共用的基础模块作为 core 的依赖自动安装
@zqagent/server多租户 HTTP 服务参考实现是,但属于应用层
@zqagent/cli终端应用参考实现是,但属于应用层

3. 安装与运行环境 ​

3.1 环境要求 ​

项要求
Node.js>= 18.17(package.json 的 engines)
模块系统ESM 与 CommonJS 双产物,import / require 都能用
类型类型声明随包发布(dist/index.d.ts / .d.cts)
操作系统Linux / macOS / Windows;shell 工具在 Windows 会自动选择可用 shell

3.2 安装 ​

bash
npm i @zqagent/core
# 或
pnpm add @zqagent/core
# 或
yarn add @zqagent/core

版本核对:本文对应仓库 0.3.25。npm 上的可用版本以 npm view @zqagent/core version 为准;如果 npm 版本落后于仓库, 可在仓库内 pnpm --filter @zqagent/core build 后通过 file: / workspace 引用。 建议在 package.json 中锁定精确版本,避免引擎行为随小版本漂移。

3.3 TypeScript 配置 ​

包使用 node16 模块解析,导出带 types 条件。推荐消费方:

jsonc
{
  "compilerOptions": {
    "target": "es2022",
    "module": "node16",
    "moduleResolution": "node16",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

如果项目是纯 ESM("type": "module"),直接 import;如果是 CommonJS, require("@zqagent/core") 也可以,但需要 0.2.60 以上版本(更早版本的 CJS 产物会因 ESM-only 依赖而加载失败)。

3.4 子路径导入 ​

packages/core/package.json 的 exports 是封闭的,只有下列子路径可用。 按需引入工具实现时走子路径,不要引用 dist/ 内部文件:

ts
import { registerFileTools } from "@zqagent/core/tools/fs";
import { registerShellTool } from "@zqagent/core/tools/shell";
import { registerSearchTool } from "@zqagent/core/tools/search";
import { registerWebTools } from "@zqagent/core/tools/web";
import { registerGitTools } from "@zqagent/core/tools/git";
import { registerDiffTools } from "@zqagent/core/tools/diff";
import { registerTodoTool } from "@zqagent/core/tools/todo";
import { registerAskTool } from "@zqagent/core/tools/ask";
import { registerPlanTool } from "@zqagent/core/tools/plan";
import { registerDirectoryTool } from "@zqagent/core/tools/directories";
import { registerExploreTool } from "@zqagent/core/tools/explore";
import { registerMediaTool } from "@zqagent/core/tools/media";

internal/* 子路径(internal/engine/FileLogHook、internal/mcp/config 等) 是给同仓库的 server / CLI 用的非稳定 API,第三方不应依赖;本文只使用主入口 与上面这些稳定子路径。FileLogHook、SavedState、MCP 配置函数等其实也在主入口 导出,优先用主入口。


4. 五分钟跑通 ​

下面是一段完整可运行的 ESM 脚本(Node 18+):

ts
// agent-demo.mjs
import {
  ApprovalOutcome,
  EventType,
  Mode,
  createDefaultEngine,
  setQuestionHandler,
} from "@zqagent/core";

const engine = createDefaultEngine({
  workspace: process.cwd(),          // 权限边界:所有文件/命令都限制在这里
  model: process.env.OPENAI_MODEL!,  // 必填,不会兜底任何型号
  mode: Mode.INTERACTIVE,            // 有副作用的操作逐次审批
});

// 1) 审批回调:不注入时默认在第一次需要授权时抛错
engine.setApprover(async (request) => {
  console.log(`\n需要审批: ${request.toolName} — ${request.reason}`);
  console.log(request.arguments);
  return ApprovalOutcome.ONCE; // 生产环境这里接你的 UI / IM / 工单
});

// 2) ask_user 的回答回调(模块级全局,注入一次即可)
setQuestionHandler(async (question, options, allowText, multi) => {
  // 生产环境这里接你的 UI;示例直接返回第一个选项
  console.log(`模型提问: ${question}`, options);
  return options[0] ?? "";
});

// 3) 跑一轮,消费事件流
for await (const event of engine.run("读一下这个项目,告诉我它是做什么的")) {
  switch (event.type) {
    case EventType.ASSISTANT_DELTA:
      process.stdout.write(String(event.data.text ?? ""));
      break;
    case EventType.TOOL_STARTED:
      console.log(`\n[工具] ${event.data.name}`);
      break;
    case EventType.TOOL_FINISHED:
      console.log(`[工具结束] ${event.data.name}: ${event.data.status}`);
      break;
    case EventType.ERROR:
      console.error(`\n[错误] ${event.data.error}`);
      break;
    case EventType.TURN_END:
      console.log(`\n[结束] ${event.data.status}`);
      break;
  }
}

运行前设置:

bash
export OPENAI_API_KEY=sk-...
export OPENAI_MODEL=gpt-4o-mini        # 或任意 OpenAI 兼容模型
node agent-demo.mjs

这一个脚本已经覆盖了嵌入时最重要的四件事:装配、审批、事件消费、多轮上下文 (同一个 engine 再调用一次 run() 就会带上上一轮历史)。


5. 核心概念 ​

理解下面六个对象,后面的 API 就都能对上号。

概念类型职责
EngineTurnEngine一次会话的运行时。持有消息历史、provider、工具表、权限引擎
Turnengine.run() 的一次调用用户一条输入 → 若干次 LLM/工具迭代 → 一个 turn_end
Iteration内部循环一次 LLM 调用 + 该回复请求的全部工具执行
EventWsEvent引擎对外输出的唯一实时通道(异步生成器)
ToolToolSpec模型可调用的函数 + JSON Schema + 风险元数据
ApproverApprover当权限引擎判定「需要问人」时,由调用方决定放行/拒绝

5.1 会话与 Engine 是一对一的 ​

每个会话创建一个 TurnEngine,不要多个会话共享同一个 engine:

  • engine.messages 是会话历史,天然串行;
  • engine.permissions 里的「本会话总是允许」是会话级状态;
  • engine.compactionState 是会话级压缩状态;
  • 同一个 engine 同时跑两轮 run() 会互相污染历史。

多会话的正确做法是 Map<sessionId, TurnEngine>,每个会话一个实例,见 第 18 章。

5.2 事件流是主通道,Hook 是旁路 ​

  • engine.run() 的 for await 是主通道:有序、可背压、每个事件只出现一次, 适合直接转成 SSE / WebSocket 帧。
  • EngineHook 是旁路回调:同步、无返回值、异常会被吞掉,适合同一份数据的 第二消费方(落盘、计费、日志)。不要只靠 hook 驱动 UI,因为它不保证背压。

6. 装配引擎 ​

有两条路径:

  • 路径 A:createDefaultEngine(opts) —— 一行装配全部内置能力,适合 90% 的场景;
  • 路径 B:new TurnEngine(opts) —— 自己决定 provider、工具表、权限引擎、 系统提示词,适合要深度定制或复用已有基础设施的场景。

6.1 路径 A:createDefaultEngine ​

ts
import { createDefaultEngine, Mode } from "@zqagent/core";

const engine = createDefaultEngine({
  workspace: "/srv/workspaces/tenant-42",
  model: "deepseek-chat",
  mode: Mode.INTERACTIVE,
});

它依次做这些事(见 packages/core/src/index.ts:409):

  1. 没有传 provider 时创建 new OpenAIProvider({ logDir });
  2. 解析沙箱方案 resolveSandbox();
  3. 创建 ToolRegistry 与 ConflictGuard,注册 21 个内置工具(见第 7 章);
  4. 创建 PermissionEngine(工作区、模式、命令白名单、shell 策略、沙箱状态);
  5. 没有传 instructions 时用 buildInstructions(agentType, workspace, today, lang) 生成系统提示词;
  6. 组装 TurnEngine;如果开了 vcs,用 CompositeHook 把 VcsHook 并联进去;
  7. 按 defaultApproval 注入兜底 approver。

CreateEngineOpts 全参数 ​

参数类型 / 默认值说明
workspacestring(必填)工作区根目录 = 文件与命令的权限边界
modelstring,缺省读 OPENAI_MODEL模型标识;都为空时 run() 明确报错,不兜底型号
providerProviderClient自定义 provider;缺省创建 OpenAIProvider
modeMode.INTERACTIVEDISCUSS / PLAN / INTERACTIVE / AUTO
maxIterationsDEFAULT_MAX_ITERATIONS = 800单轮最大迭代次数
reasoningEffort缺省读 OPENAI_REASONING_EFFORTlow / medium / high;DeepSeek 自动跳过
allowedCommands[]Auto 模式下自动放行的命令前缀
shellPolicy"approve"approve / sandbox / workspace / all
sandbox缺省 auto{ mode, extraWritable, ... },见 8.4
vcs{ mode: "off", sessionId: "default" }每轮 Git 快照,见第 17 章
instructions由 buildInstructions 生成覆盖系统提示词
agentType"code"code(编程助手)/ cowork(通用协作),影响默认提示词
lang简体中文zh / en / zh-TW / ja / ko / es,影响提示词、命名、摘要
messages[]初始对话历史(恢复会话时传入)
hook无生命周期钩子,见第 11 章
subagentHookFactory无每次 explore 调用生成一个子代理 hook
contextWindow自动检测,回退 128K模型上下文窗口(token)
thresholdPct0.8硬压缩阈值(占窗口比例)
softThresholdPct硬阈值的 75%软阈值:只淘汰旧工具结果,不调 LLM
capTokensnull硬阈值的绝对上限(token),null = 不限制
toolResultDir状态目录下 tool-results超预算工具结果的落盘目录;多租户必须按租户隔离
logDir不记录LLM 请求审计日志根目录(按日期分目录)
defaultApproval"error"未注入 approver 时:error / deny / allow

lang 只影响默认提示词、会话命名与摘要的语言;显式传了 instructions 时以它为准。

6.2 路径 B:手动装配 TurnEngine ​

ts
import {
  ApprovalOutcome,
  Mode,
  OpenAIProvider,
  PermissionEngine,
  ToolRegistry,
  TurnEngine,
  registerFileTools,
  registerShellTool,
  registerSearchTool,
} from "@zqagent/core";

const workspace = process.cwd();

// 1) 工具表:只注册你需要的
const registry = new ToolRegistry();
registerFileTools(registry, workspace);
registerShellTool(registry, workspace);
registerSearchTool(registry, workspace);

// 2) 权限:工作区 + 模式 + 白名单
const permissions = new PermissionEngine({
  workspaceRoot: workspace,
  mode: Mode.INTERACTIVE,
  allowedCommands: ["git status", "ls", "cat"],
});

// 3) Provider:任意 OpenAI 兼容端点
const provider = new OpenAIProvider({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL,
});

// 4) 引擎
const engine = new TurnEngine({
  provider,
  registry,
  permissions,
  model: process.env.OPENAI_MODEL!,
  instructions: "你是一个只处理数据分析任务的助手。",
  lang: "zh",
  maxIterations: 50,
});

engine.setApprover(async () => ApprovalOutcome.DENY); // 别忘了

TurnEngineOpts 全参数 ​

参数必填说明
provider是ProviderClient(stream() / chat())
registry是ToolRegistry
permissions是PermissionEngine
model是模型标识;空字符串会在 run() 抛错
instructions是系统提示词,注入为首条 system 消息
genSessionNamePrompt否生成会话名的提示词;缺省按 lang 取
lang否默认 "zh";影响命名与压缩摘要
maxIterations否默认 800
finalizeOnMaxIterations否撞上限后再跑一轮无工具收尾;默认 false,子代理用 true
reasoningEffort否透传给 provider
messages否初始历史;若首条不是 system,会自动插入 instructions
hook否EngineHook
contextWindow否覆盖自动检测
thresholdPct否默认 0.8
softThresholdPct否默认硬阈值的 75%
capTokens否默认 null
toolResultDir否工具结果落盘目录

6.3 运行时可以改什么 ​

TurnEngine 的公开属性与方法:

ts
engine.model = "another-model";          // 模型(下一轮生效)
engine.messages;                         // 完整历史(含 notice 消息)
engine.permissions.mode = Mode.AUTO;     // 运行模式
engine.registry;                         // 工具表(可动态 enable/disable)
engine.compactionState;                  // 压缩状态(只读观察)
engine.approver;                         // 当前审批回调

engine.setApprover(fn);                  // 设置审批
engine.setSystemPrompt(text);            // 运行时替换系统提示词(切 agent 用)
engine.requestInterrupt();               // 中断当前一轮
engine.queueSteering(text, attachments, refs); // 排队补充指令(后两个可选)
engine.removeSteering(text);             // 撤销尚未注入的补充指令

7. 工具:内置与自定义 ​

7.1 createDefaultEngine 注册的 21 个工具 ​

类别工具风险注册函数
文件read_file, list_files只读registerFileTools / registerReadOnlyFileTools
文件write_file, replace_in_file写本地registerFileTools
Shellrun_shell, shell_task_output, shell_task_kill执行registerShellTool
搜索grep只读registerSearchTool
网络web_search, web_fetch外部registerWebTools
Gitgit_log, git_status, git_diff只读registerGitTools
补丁apply_patch, apply_unified_diff写本地registerDiffTools
协作ask_user, propose_plan, request_directory交互registerAskTool / registerPlanTool / registerDirectoryTool
任务todo_write只读/状态registerTodoTool
子代理explore只读registerExploreTool
媒体media外部registerMediaTool

终端工具不在一键装配里:terminal_list / terminal_read 依赖宿主提供的 TerminalSource(控制台「终端」面板的长驻 PTY),需要自己注入:

ts
import { registerTerminalTools, type TerminalSource } from "@zqagent/core";

registerTerminalTools(registry, {
  terminals: myTerminalManager, // 实现 TerminalSource
  projectId: "tenant-42",
});

projectId 是隔离边界,必须按项目传,否则 A 项目能读到 B 项目终端内容。

各工具的完整参数与行为见 内置工具。

7.2 自定义工具 ​

ToolRegistry.register 的签名(packages/core/src/tools/ToolRegistry.ts:77):

ts
type Register = (
  func: (args: Record<string, unknown>, ctx?: { signal?: AbortSignal }) => unknown,
  metadata: {
    category: string;
    riskLevel: "low" | "medium" | "high";
    capabilities: string[];
    requiresApproval: boolean;
  },
  schema?: ToolSchema,   // 强烈建议显式给出
  source?: string,       // 展示用:"internal" / "mcp:xxx" / "myapp"
) => ToolSpec;

示例:

ts
registry.register(
  async (args) => {
    const orderId = String(args.order_id);
    return await myOrderService.query(orderId);
  },
  {
    category: "business",
    riskLevel: "high",
    capabilities: ["query_order"],
    requiresApproval: true,
  },
  {
    type: "function",
    function: {
      name: "query_order",
      description: "按订单号查询内部订单系统,返回订单状态与金额",
      parameters: {
        type: "object",
        properties: {
          order_id: { type: "string", description: "订单号" },
        },
        required: ["order_id"],
      },
    },
  },
  "myapp",
);

注意事项:

  • 工具名来自 schema.function.name;不传 schema 时回退到函数名,参数结构为空, 模型基本不会正确调用,因此正式工具都应显式给 schema。
  • func 可以返回 Promise;引擎会 await。
  • 第二个参数 ctx.signal 是中断信号。长耗时工具应把它传给底层任务, 否则用户中断后工具仍会跑完。
  • 同名 register 会替换旧工具,可用于覆盖内置实现;remove() 删除; disable() / enable() 控制是否出现在 schemas() 中(模型不可见)。
  • 返回给模型的结果会经过结果预算:超过阈值会落盘并给模型一段摘要/路径, 可用环境变量 TOOL_RESULT_MAX_CHARS、TOOL_RESULT_SAVE_THRESHOLD、 TOOL_RESULT_DIR 调整。
  • 工具抛出的异常会被引擎捕获,转成 { error, error_type } 作为工具结果, 并让模型自行决定下一步;不会让整轮崩溃。

7.3 只读工具集 ​

给子代理或只读场景用:

ts
import { registerReadOnlyFileTools } from "@zqagent/core";
registerReadOnlyFileTools(registry, workspace); // read_file / list_files

8. 权限、审批与安全边界 ​

8.1 决策流程 ​

PermissionEngine.evaluate() 按固定顺序判断 (packages/core/src/permissions/PermissionEngine.ts:173):

  1. 只读模式(DISCUSS / PLAN)→ 有副作用的操作直接拒绝;
  2. 写文件路径不在 workspaceRoot 下 → 直接拒绝;
  3. 只读风险(RiskClass.READ)→ 直接允许;
  4. AUTO 模式 → 有副作用的操作全部允许;
  5. shell 免审策略(shellPolicy + 沙箱)→ 可能直接允许;
  6. 命令白名单 / 会话命令白名单 → 直接允许;
  7. 会话工具白名单(用户选过「总是允许该工具」)→ 直接允许;
  8. 其余 → needsUser: true,交给 approver。

返回结构:

ts
interface Decision {
  allowed: boolean;
  reason: string;     // 展示给用户 / 审批卡片
  needsUser: boolean; // true = 需要审批
}

8.2 运行模式 ​

ts
enum Mode {
  DISCUSS = "discuss",         // 只读,禁止写入与执行
  PLAN = "plan",               // 只读,禁止写入与执行
  INTERACTIVE = "interactive", // 有副作用操作逐次审批(推荐默认)
  AUTO = "auto",               // 有副作用操作全部放行
}

interactive 是默认值,也是面向真实用户最安全的模式。auto 只应在隔离环境、 可丢弃的工作区或完全可信的输入下使用。

8.3 审批回调 ​

ts
type Approver = (request: PermissionRequest) => Promise<ApprovalOutcome>;

interface PermissionRequest {
  toolName: string;
  arguments: Record<string, unknown>;
  metadata: unknown;       // ToolMetadata | null
  reason: string;          // 权限引擎给出的原因
  toolCallId?: string;     // 与事件里的 reqId 对应
}

enum ApprovalOutcome {
  ONCE = "once",                   // 仅本次
  ALWAYS_TOOL = "always_tool",     // 本会话总是允许该工具
  ALWAYS_COMMAND = "always_command", // 本会话总是允许该命令(仅 run_shell)
  DENY = "deny",                   // 拒绝
}

实现方式:

  • CLI / 桌面:阻塞等待用户在界面点按钮;
  • Web 服务:把请求放进 Promise,向客户端推送审批卡片,收到回调后 resolve ——这就是 @zqagent/server 的「挂起式审批」;
  • 无人值守 Worker:按白名单自动返回 ONCE,其余 DENY(或 defaultApproval: "allow",但要先确认工作区是隔离的)。

必须实现 approver。 默认 defaultApproval: "error" 会在第一次需要授权时抛错, 这是刻意设计:避免「忘了接审批 → 工具静默不执行 → 用户以为模型坏了」。

8.4 shell 免审与 OS 沙箱 ​

shellPolicy:

值行为
approve(默认)除白名单外一律审批
sandbox仅当 OS 级沙箱真正生效时免审,否则审批
workspace沙箱生效 → 免审;未生效 → 用工作区路径判定兜底
allshell 全部免审(高风险,等同只对 shell 生效的 auto)

沙箱配置:

ts
createDefaultEngine({
  workspace,
  sandbox: {
    mode: "auto",                  // auto / off / required
    extraWritable: ["/data/cache"], // 额外可写路径
  },
});
  • macOS:sandbox-exec,把 file-write* 收窄到工作区 + 临时目录;
  • Linux:bwrap,整机只读挂载 + 工作区可写;
  • Windows:没有等价的进程级文件系统沙箱,enforced: false,退化为路径启发式 (这是降低打扰的策略,不是安全边界)。

required 模式下探测不到沙箱会明确报错,适合把「必须隔离」作为合规要求的部署。

8.5 命令白名单 ​

ts
createDefaultEngine({
  workspace,
  mode: Mode.AUTO,
  allowedCommands: ["git status", "git diff", "ls", "cat", "pnpm test"],
});

规则:

  • 前缀匹配,但必须在词边界结束:ls 不会放行 lsof;
  • 含 shell 运算符(; & | > < \ $( ( \n)的命令**一律不自动放行**, 避免 ls; rm -rf /` 绕过;
  • 会话内用户选择「总是允许该命令」后,按可执行程序名记忆,只对简单命令生效。

8.6 安全边界清单 ​

  • 不要把 workspace 设为 /、用户主目录或包含密钥的目录;
  • 默认用 Mode.INTERACTIVE,不要为了「自动化体验」默认 AUTO;
  • 多租户场景每个租户独立 workspace、toolResultDir、FileLogHook 目录、 MCP 配置;
  • MCP 工具默认 requiresApproval: true,不要整体关闭审批;
  • logs / logDir 会记录 LLM 请求内容,按数据分级决定是否开启、保留多久;
  • 工具返回的网页/文件内容是不可信数据,不要把它当作指令执行(引擎的提示词 已包含相关约束,但业务侧仍应做输出校验)。

9. 事件流完整参考 ​

9.1 结构 ​

ts
interface WsEvent {
  type: EventType;
  data: Record<string, unknown>; // 字段随类型变化
}

run() 返回 AsyncGenerator<WsEvent>。必须消费完(或用 break 后让生成器 被回收),否则引擎不会继续推进。典型顺序:

turn_start
  ├─ (compacting → compacted)?          # 上下文接近阈值时
  ├─ llm_request
  ├─ assistant_delta / reasoning_delta* # 流式增量
  ├─ assistant_message                  # 本轮完整回复
  ├─ tool_proposed
  ├─ (permission_required → approver)?  # 需要审批时
  ├─ tool_started
  ├─ tool_finished
  ├─ iteration_end
  └─ 回到 llm_request(还有工具结果要喂回模型时)
turn_end

异常路径:error 后仍会补一个 turn_end;中断路径:interrupted 后补 turn_end。消费方应以 turn_end 作为一轮的状态机终态,而不是以流结束。

9.2 事件类型与字段 ​

事件data 关键字段何时出现
turn_starttaskId, input, attachmentCount?, attachments?每轮开始
assistant_deltatext, reqId, taskId模型流式文本增量
reasoning_deltatext, reqId, taskId推理过程增量(部分模型)
assistant_messagetext, reasoning, tool_calls(工具名数组), usage?, reqId, taskId, logTime一次 LLM 回复结束
tool_proposedreqId(=工具调用 ID), name, arguments, taskId模型提议调用工具
permission_requiredtaskId, reqId, name, arguments, reason, category需要审批(随后调用 approver)
tool_startedtaskId, reqId, name, startTime?工具开始执行
tool_finishedtaskId, reqId, name, status, reason?, duration?, result_preview?工具结束
iteration_enditeration, taskId一次迭代(LLM + 工具)结束
turn_endstatus, iterations, produced_files, taskId一轮结束(必出)
errorerror, error_type, retriable, iteration, reqId, logTime, taskId, stack?引擎错误
interruptediterations, reqId, taskId用户中断
compactingtaskId, trigger, tokens, estimated, contextWindow, thresholdPct, thresholdTokens, outboundMessages开始压缩
compactedreqId, taskId, method, messagesBefore, messagesAfter, boundaryIndex?, model?, summaryPreview?, keptUserMessages?, droppedUserMessages?压缩完成
queue_steeringtext, taskId, attachments?排队的补充指令被注入
llm_requestreqId, taskId, logTime, model, msgCount, toolCount, compact?每次 LLM 请求前(审计用)
ask_user_questiontaskId, question, options, allowText, multi模型调用 ask_user
plan_proposedplan模型调用 propose_plan
directory_requestedreason, path, writable模型调用 request_directory
llm_retryattempt, max, reason, waitMs, iteration, reqId瞬时网络错误自动重试
session_namedname, source枚举保留;当前由 CLI/server 在命名后自行广播,TurnEngine 不产出

tool_finished.status 取值:

status含义
ok执行成功
error工具抛异常或返回错误
denied权限拒绝或用户拒绝
interrupted执行中被中断

turn_end.status 取值:

status含义
completed模型不再请求工具,正常结束
max_iterations_exceeded达到 maxIterations
interrupted用户中断
errorLLM/引擎错误终止

9.3 消费示例:转成 SSE ​

ts
import type { WsEvent } from "@zqagent/core";

function toSse(event: WsEvent): string {
  return `event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`;
}

app.post("/sessions/:id/messages", async (req, res) => {
  const engine = sessions.get(req.params.id)!.engine;
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });
  for await (const event of engine.run(String(req.body.text))) {
    res.write(toSse(event));
  }
  res.end();
});

注意:SSE 断线不会自动中止引擎。客户端断开时应调用 engine.requestInterrupt(), 否则引擎会继续跑完并消耗 token。

9.4 错误与重试 ​

  • error.retriable === true:可尝试 engine.retry()(上一轮以错误结束、且末尾 是可重试错误时才有效)。
  • 429/5xx 由 OpenAI SDK 自身重试;瞬时网络错误引擎会自动重试 NETWORK_RETRY_MAX(默认 2)次,退避基数 NETWORK_RETRY_BASE_MS(默认 800ms), 期间发 llm_retry。
  • 上下文溢出会自动压缩后重试,不对外报错;压缩也失败时才报错并标记可重试。

10. 模型 Provider ​

10.1 OpenAIProvider ​

ts
import { OpenAIProvider } from "@zqagent/core";

const provider = new OpenAIProvider({
  apiKey: process.env.OPENAI_API_KEY,   // 缺省读同名环境变量
  baseURL: process.env.OPENAI_BASE_URL, // 任意 OpenAI 兼容端点
  logDir: "/var/log/zqagent/llm",       // 可选:请求审计日志
});

行为要点:

  • Responses API 优先,失败按错误类型降级到 Chat Completions:
    • 端点不支持(404/405 等)→ 降级并按模型缓存;
    • 参数不兼容(部分兼容网关)→ 仅本次降级;
    • 401/429/5xx/网络错误 → 不降级,原样抛出。
  • 环境变量 OPENAI_FORCE_API_MODE=responses|chat 可强制模式。
  • 模型名或 baseURL 含 deepseek 时,Responses 路径自动注入服务端 web_search 内置工具,并过滤本地同名工具。
  • logDir 按日期分目录、按 reqId 写请求/响应审计文件;包含消息内容, 按数据合规要求决定是否开启。

相关环境变量:

变量作用
OPENAI_API_KEYAPI Key
OPENAI_BASE_URL兼容端点
OPENAI_MODEL默认模型
OPENAI_REASONING_EFFORTlow / medium / high
OPENAI_FORCE_API_MODEresponses / chat
OPENAI_MAX_RETRIESSDK 重试次数
OPENAI_TIMEOUT_MS请求超时
OPENAI_STREAM_IDLE_MS流空闲看门狗
SOCKS5_PROXYLLM 与联网工具走 SOCKS5 代理

10.2 自定义 Provider ​

引擎只依赖两个方法(packages/core/src/providers/OpenAIProvider.ts):

ts
// 注意:StreamOpts 目前未从包根导出;实现自定义 provider 时,
// 按下面用到的字段自行声明即可(reqId / logTime 必填,其余可选)。
interface ProviderClient {
  stream(
    model: string,
    messages: Message[],
    tools: ToolSchema[],
    opts: StreamOpts,
  ): Promise<AsyncIterable<StreamChunk>>;

  chat(
    model: string,
    messages: any[],
    options: RequestOptions,
    opts: StreamOpts,
    params?: { maxTokens?: number },
  ): Promise<string | null>;
}

StreamChunk:

ts
interface StreamChunk {
  content: string | null;
  reasoning: string | null;
  toolCalls: ToolCall[] | null; // 仅流结束 chunk 给完整列表
  usage: TokenUsage | null;
  finishReason: string | null;
}

实现自定义 provider 时最关键的两点:

  1. 最后一个 chunk 必须带 toolCalls 或 finishReason,否则引擎认为流没有正常结束;
  2. 收到 opts.signal 的 abort 要立即停止读取并抛出/结束流,否则「停止」按钮无效。

chat() 主要用于会话命名等轻量请求;即使返回 null 也不影响主流程。

10.3 推理强度 ​

ts
createDefaultEngine({
  workspace,
  reasoningEffort: "high", // low / medium / high
});

透传给 provider;OpenAI 系模型生效,DeepSeek 自动跳过(它用 thinking 开关, 不支持级别控制)。


11. 生命周期钩子与持久化 ​

11.1 EngineHook 全部回调 ​

回调参数用途
onTurnStartinput一轮开始
onTurnEndstatus, iterations, producedFiles一轮结束
onAssistantDeltatext流式文本
onReasoningDeltatext流式推理
onAssistantMessagetext, toolCalls, usage?完整回复(计费常用)
onToolProposedname, args工具提议
onToolStartedname工具开始
onToolFinishedname, status, resultPreview工具结束
onPermissionRequiredname, reason需要审批
onIterationEnditeration迭代结束(检查点常用)
onCompacting / onCompactedinfo / text, info压缩
onErrorerror, errorType, info?错误(含 retriable)
onRetryattempt, max, reason网络自动重试
onInterruptediterations中断
onEventtype, data未匹配事件的兜底

钩子是同步回调,引擎不会 await。因此:

  • 不要在 hook 里做长时间阻塞操作(会卡住引擎);
  • hook 抛出的异常会被引擎吞掉(_emitHook 内部 try/catch),不会影响主流程;
  • 需要背压/顺序保证的数据,请消费 run() 的事件流,而不是 hook。

11.2 组合多个 Hook ​

ts
import { CompositeHook, ConsoleHook, FileLogHook } from "@zqagent/core";

const hook = new CompositeHook([
  new ConsoleHook(),
  new FileLogHook(sessionId, "/var/lib/myapp/sessions"),
]);

createDefaultEngine({ vcs }) 内部也用 CompositeHook 把 VcsHook 并联进 调用方自己的 hook,不会覆盖它。

11.3 FileLogHook:文件级持久化与恢复 ​

ts
import { FileLogHook } from "@zqagent/core";

const hook = new FileLogHook(sessionId, stateDir);
// 写入:
//   <stateDir>/<sessionId>.jsonl      事件日志(JSONL)
//   <stateDir>/<sessionId>_state.json 检查点

const engine = createDefaultEngine({
  workspace,
  messages: FileLogHook.loadState(sessionId, stateDir)?.messages,
  hook,
});

// 关键节点保存状态(通常在 onIterationEnd / onTurnEnd 里调)
hook.saveState(engine, { name, nameSource: "auto", taskCount });

SavedState 字段:

ts
interface SavedState {
  sessionId: string;
  model: string;
  messages: Message[];
  compaction: Record<string, unknown> | null;
  savedAt: string;
  name?: string;
  nameSource?: "auto" | "user";
  taskCount?: number;
  permissionMode?: string;
  allowTools?: string[];
  allowCommands?: string[];
}

恢复时注意:

  • 把 messages 传给 new TurnEngine({ messages }) 或 createDefaultEngine({ messages });
  • 恢复 permissionMode 与白名单(engine.permissions.mode = ...、 allowToolForSession / allowCommandForSession),否则重启后用户之前的授权会丢;
  • FileLogHook.loadState() 读不到文件时返回 null,不抛错;
  • 多租户场景每个会话一个 stateDir,不要混目录。

FileLogHook 还提供 onPaused / onResumed 方法用于记录挂起/恢复,但它们不在 EngineHook 接口里,按需直接调用实例方法。

11.4 事件日志的用途 ​

JSONL 事件日志是审计(谁在什么时候让 Agent 做了什么)与回放的基础; 它不是会话状态的唯一来源。文件内容变更请用第 17 章的 VCS 快照。


12. 一轮 Turn 的控制 ​

12.1 多轮对话 ​

ts
import type { WsEvent } from "@zqagent/core";

async function drain(stream: AsyncIterable<WsEvent>): Promise<void> {
  for await (const event of stream) {
    // 渲染或忽略
  }
}

await drain(engine.run("先看一下项目结构"));
await drain(engine.run("把 README 里的示例改成 TypeScript")); // 自动带上一轮上下文

同一个 engine 连续调用会保留历史。要开新会话就新建 engine。

12.2 中断 ​

ts
// 在另一个地方(例如 HTTP DELETE / 用户点「停止」)
engine.requestInterrupt();

它会:

  1. 置位取消标记;
  2. abort 在飞的 LLM 流;
  3. abort 正在执行的工具(run_shell 会杀掉子进程树);

随后事件流会产出 interrupted + turn_end(status: "interrupted")。 不要用「停止消费事件流」来代替中断:那样引擎不会被推进,也不会清理状态。

12.3 重试 ​

ts
for await (const e of engine.retry()) {
  // 仅在上一轮以可重试错误结束时才会产出事件
}

12.4 从挂起审批恢复 ​

engine.resume() 用于「历史末尾有未回答的工具调用」的场景(典型是服务重启后 恢复挂起的审批):

ts
for await (const e of engine.resume()) {
  // 重新处理挂起的 tool_calls;approver 会按已 resolve 的结果立即返回
}

如果没有挂起的工具调用,resume() 直接返回,不产出事件。

12.5 Steering:运行中追加指令 ​

ts
engine.queueSteering("顺便把测试也跑一下");
engine.queueSteering("忽略 dist 目录", attachments, refs);

引擎会在迭代间隙把排队的指令作为 user 消息注入,并产出 queue_steering。 适合「Agent 正在跑长任务,用户想补充一句」的交互。尚未注入的指令可以撤销:

ts
engine.removeSteering("顺便把测试也跑一下"); // 已注入返回 false

12.6 并发约束 ​

  • 同一个 engine 同时只能有一轮在跑;
  • 工具层面:低风险工具会并发执行,高风险/需审批/交互类工具串行;
  • 你的服务层应对同一会话的 run() 做队列化,避免两个请求同时写 messages。

13. 附件 ​

13.1 数据结构 ​

ts
interface MessageAttachment {
  name: string;
  mimeType: string; // image/* audio/* video/*
  dataUrl: string;  // data:<mime>;base64,<payload>
}

interface StoredAttachment {
  name: string;
  mimeType: string;
  size: number;
  path: string; // 相对会话目录,如 "attachments/xxx-0-shot.png"
}

限制(packages/core/src/engine/attachments.ts):

常量值含义
MAX_ATTACHMENTS4单次最多附件数
MAX_ATTACHMENT_BYTES10 MB单文件且合计上限
ATTACHMENT_DIR"attachments"会话目录下的子目录

13.2 推荐流程:先落盘,再传引用 ​

ts
import {
  normalizeAttachments,
  saveAttachments,
  type MessageAttachment,
  type StoredAttachment,
} from "@zqagent/core";

// 1) 校验并归一化(非法会抛 AttachmentError)
const attachments: MessageAttachment[] = normalizeAttachments(rawInput) ?? [];

// 2) 存到会话目录
const refs: StoredAttachment[] = saveAttachments(sessionDir, attachments);

// 3) 跑一轮:base64 只在本轮发给模型,历史轮次会自动剥离
for await (const e of engine.run(text, taskId, attachments, refs)) {
  // turn_start.data.attachmentCount / attachments(refs)
}

StoredAttachment 随消息与事件流转,可用来在 UI 渲染缩略图;字节本体只在 <sessionDir>/attachments/。读取时用:

ts
const { name, mimeType, size, base64 } = readStoredAttachment(sessionDir, ref.path);

安全注意:

  • readStoredAttachment 只接受 attachments/<file> 形式,拒绝 .. 与子目录;
  • 附件会进 LLM 请求,logDir 审计日志会记录消息(是否记录 base64 取决于 provider 的日志实现,生产环境应评估);
  • 不要把用户上传的任意 HTML/脚本当作文本附件直接渲染。

14. 上下文压缩 ​

长会话不可能无限塞进模型窗口,引擎会自动压缩。

14.1 触发机制 ​

概念默认说明
上下文窗口自动检测,回退 128K可用 contextWindow 覆盖
硬阈值窗口的 80%thresholdPct
软阈值硬阈值的 75%(≈60% 窗口)softThresholdPct,只淘汰旧工具结果
绝对上限无capTokens
保留最近约 25%压缩后保留的近期对话比例
摘要输出上限3000 token内部固定

流程:

  1. 达到软阈值:淘汰旧工具结果、去重重复的 read_file,不调用 LLM;
  2. 达到硬阈值:调用 LLM 生成摘要,产出 compacting / compacted;
  3. API 报上下文溢出:强制压缩后重试。

14.2 事件与钩子 ​

ts
// engine.run(...) 的事件流中会出现:
// { type: "compacting", data: { trigger: "threshold", tokens, contextWindow, ... } }
// { type: "compacted",  data: { method: "summarized" | "trimmed", messagesBefore, messagesAfter, ... } }

onCompacting / onCompacted 钩子拿到同样的信息。UI 可以据此提示 「正在压缩上下文」。

14.3 可调参数与环境变量 ​

ts
createDefaultEngine({
  workspace,
  contextWindow: 200_000,
  thresholdPct: 0.85,
  softThresholdPct: 0.7,
  capTokens: 120_000,
});
环境变量作用
COMPACTION_MODEL摘要使用的模型(缺省沿用当前模型)
COMPACTION_REASONING_EFFORT摘要推理强度(默认 low)
COMPACTION_ELIDE0 关闭旧工具结果淘汰
COMPACTION_DEDUP0 关闭重复读取去重
CONTEXT_WINDOW服务端装配时的窗口覆盖(@zqagent/server 读取)
COMPACTION_THRESHOLD_PCT / COMPACTION_SOFT_THRESHOLD_PCT / COMPACTION_CAP_TOKENS服务端装配时的阈值覆盖

CompactionState 与压缩工具函数目前没有从包根导出;第三方只需要通过 engine.compactionState 观察状态,或依赖事件/hook。不要深路径导入内部实现。


15. MCP 接入 ​

MCP(Model Context Protocol)让 Agent 使用外部工具服务器。@zqagent/core 自带客户端与工具注册逻辑。

15.1 最小用法 ​

ts
import {
  McpManager,
  ToolRegistry,
  defaultMcpConfigPath,
  loadMcpConfigFile,
  withBuiltinMcpConfigs,
} from "@zqagent/core";

const registry = new ToolRegistry();
const { configs, warnings } = loadMcpConfigFile(
  defaultMcpConfigPath(workspace), // <workspace>/.agent/mcp.json
);
const manager = new McpManager(registry, withBuiltinMcpConfigs(configs), {
  builtin: {
    workspace,
    env: process.env,
    llm: { apiKey, baseURL, model }, // 内置 OCR 的多模态兜底
  },
});

const { connected, failed } = await manager.connectAll();

连接完成后,registry.schemas() 会自动包含 MCP 工具,模型在下一次 LLM 调用 就能看到它们(不需要重建 engine)。

15.2 配置格式 ​

<workspace>/.agent/mcp.json 支持多种写法:

jsonc
// 裸数组
[
  { "name": "filesystem", "type": "stdio", "command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"] },
  { "name": "remote", "type": "http", "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ${TOKEN}" } }
]

// 或 { "servers": [...] } / { "servers": { "name": {...} } }
// 或 Claude/Cursor 风格 { "mcpServers": { "name": { "command": "npx -y pkg", "args": ["..."] } } }
  • type 缺省时按 builtin → url → command 推断;
  • command 可以是字符串或数组;
  • ${VAR} / ${env:VAR} 会用环境变量展开;
  • enabled: false 或 disabled: true 禁用;
  • 内置预设(OCR)会在用户没有同名配置时自动追加,{ "name": "ocr", "enabled": false } 可关闭。

15.3 工具命名与审批 ​

  • 注册名格式:mcp__<server>__<tool>(两段都做过字符清洗与截断);
  • 元数据:category: "mcp"、riskLevel: "high"、requiresApproval: true;
  • 因此必须接 approver,否则所有 MCP 工具都会走默认兜底策略。

15.4 惰性加载(工具很多时) ​

ts
new McpManager(registry, configs, {
  lazy: {
    enabled: true,       // 或交给阈值自动判断
    thresholdTools: 16,
    thresholdChars: 24_000,
    maxLoaded: 40,
  },
});

开启后只注册两个元工具:

  • mcp_search_tools(只读、免审批):按关键词检索工具,命中前 8 条会自动加载;
  • mcp_call_tool(需审批):按名称调用已加载/可解析的工具。

环境变量 ZQAGENT_MCP_LAZY=1/0 可强制开关。

15.5 生命周期 ​

ts
await manager.connectServer("github");
await manager.refresh("github");   // 断开 → 重连 → 重新注册
manager.attach(newRegistry);       // 引擎重建时迁移已加载工具
await manager.closeAll();          // 进程退出时

McpManager 只做内存管理,不持久化配置:addConfig / updateConfig / removeConfig 后需要自己调用 saveMcpConfigFile()。

15.6 内置 OCR ​

ocr 预设暴露 mcp__ocr__recognize,支持图片/PDF 文字提取,按 macOS Vision → tesseract → 多模态 LLM 的顺序降级。builtin.llm 应传当前会话 的 provider 配置,否则第三级降级不可用。


16. 子代理与 Agent 注册表 ​

16.1 explore 子代理 ​

createDefaultEngine 已注册 explore 工具(packages/core/src/tools/explore.ts):

  • 主代理把「研究任务」交给子代理;
  • 子代理使用独立上下文、独立工具表,运行在 Mode.PLAN(硬阻断写入);
  • 默认只带只读文件工具、搜索、Git 只读工具;
  • 默认最多 30 轮(EXPLORE_MAX_ITERATIONS),撞上限后会再跑一轮收尾并输出报告;
  • 主代理只收到最终报告,子代理的中间过程不进入主上下文。

自定义子代理行为:

ts
createDefaultEngine({
  workspace,
  subagentHookFactory: () => new SubagentHook("explore"), // 每个子代理一个实例
});

不传 subagentHookFactory 时子代理完全静默。SubagentHook 会把子代理的工具调用 缩进打印到终端,是 CLI 的参考实现;Web 场景可以自己实现一个 hook,把子代理事件 转发到前端(注意:子代理事件不经过主 run() 的事件流)。

手动注册:

ts
registerExploreTool(registry, {
  workspace,
  provider,
  model,
  lang: "zh",
  hookFactory: () => mySubagentHook,
  toolResultDir,
});

16.2 AgentRegistry ​

AgentRegistry 是内存中的角色表,管理「名称 + 描述 + 系统提示词 + 可选模型」:

ts
import { AgentRegistry } from "@zqagent/core";

const agents = new AgentRegistry(); // 默认内置 code / cowork
agents.add({ name: "analyst", description: "数据分析", prompt: "你是数据分析师…" });
agents.update("analyst", { model: "deepseek-chat" });
agents.switch("analyst");

const prompt = agents.buildSystemPrompt("analyst", workspace, today, "zh");
engine.setSystemPrompt(prompt); // 切换角色
engine.model = agents.currentDef.model ?? engine.model;

AgentRegistry 只做内存管理,持久化(如 agents.json)由调用方实现。


17. 变更追踪与回滚 ​

Agent 会改文件。事件日志只记录「工具意图」,不记录文件内容;要回答「这一轮到底 改了什么、怎么回滚」,用 VCS 快照。

17.1 开启 ​

ts
const engine = createDefaultEngine({
  workspace,
  vcs: { mode: "auto", sessionId: "tenant-42-session-7" },
});

mode:

值行为
off(默认)关闭
auto是 Git 仓库用原生模式,否则用 shadow 仓库
native用工作区 .git,但使用独立 index 与 refs/agent/*,不碰用户 HEAD/staging
shadow在 <workspace>/.agent/vcs 建影子仓库

native / shadow 都要求本机有 git;找不到时退化为 off(写入冲突检测仍然可用)。

17.2 查询与回滚 ​

ts
import { SessionVcs, resolveVcsPlan } from "@zqagent/core";

const vcs = new SessionVcs(resolveVcsPlan(workspace, { mode: "auto" }), sessionId);

const turns = await vcs.listTurns();       // 只列「有改动的轮次」,序号从 1 开始
const patch = await vcs.diffTurn(2);       // git show --patch
const result = await vcs.restoreTurn(2, {   // 回滚到第 2 轮之后的状态
  paths: ["src/app.ts"],                    // 可选:只回滚部分文件
  dryRun: false,
});
// result: { restored, overwritten, missing }

await vcs.forget();  // 删除该会话的 refs/agent/<id>,不还原文件

VcsHook 已把 onTurnStart → beginTurn()、onTurnEnd → commitTurn(status) 接好;createDefaultEngine({ vcs }) 会自动组合它。需要自己管理生命周期时, 直接 new VcsHook(vcs) 并放进你的 hook 组合。

注意:

  • commitTurn() 返回的 turn 恒为 0;真正的轮次序号来自 listTurns();
  • diffTurn / restoreTurn 的 turn 是「有改动的轮次」序号,空轮不占号;
  • 快照内容是 git add -A 的工作区状态,包含未跟踪文件(受 .agent/、 .git/、node_modules/ 等排除规则约束)。

18. 四种集成模式与代码骨架 ​

18.1 模式一:HTTP 服务 + SSE(最像 @zqagent/server) ​

关键设计:

  • Map<sessionId, SessionRuntime>;每个 runtime 持有 engine、事件缓冲、待审批 Promise;
  • 同一会话的 run() 串行排队;
  • 审批通过 permission_required 事件 + 一个待 resolve 的 Promise 实现「挂起」;
  • 客户端断开时 requestInterrupt()。
ts
import {
  ApprovalOutcome,
  Mode,
  createDefaultEngine,
  type PermissionRequest,
} from "@zqagent/core";

interface Runtime {
  engine: ReturnType<typeof createDefaultEngine>;
  queue: Promise<void>;
  pendingApproval?: {
    request: PermissionRequest;
    resolve: (o: ApprovalOutcome) => void;
    reject: (e: Error) => void;
  };
}

const sessions = new Map<string, Runtime>();

function getRuntime(sessionId: string, workspace: string): Runtime {
  let rt = sessions.get(sessionId);
  if (rt) return rt;

  const engine = createDefaultEngine({
    workspace,
    mode: Mode.INTERACTIVE,
    toolResultDir: `${workspace}/.agent/tool-results`,
  });

  const runtime: Runtime = { engine, queue: Promise.resolve() };

  engine.setApprover((request) => {
    return new Promise<ApprovalOutcome>((resolve, reject) => {
      runtime.pendingApproval = { request, resolve, reject };
      // 通过 SSE/WebSocket 把 request 推给前端;前端 POST /approve 时:
      //   runtime.pendingApproval?.resolve(ApprovalOutcome.ONCE)
      // 超时或连接断开时:
      //   runtime.pendingApproval?.resolve(ApprovalOutcome.DENY)
    });
  });

  sessions.set(sessionId, runtime);
  return runtime;
}

// POST /sessions/:id/messages
// res 为 Express/Fastify 等框架的响应对象;这里只要求 write/end 两个方法
async function send(
  sessionId: string,
  workspace: string,
  text: string,
  res: { write(chunk: string): void; end(): void },
) {
  const rt = getRuntime(sessionId, workspace);
  const task = rt.queue.then(async () => {
    for await (const event of rt.engine.run(text)) {
      res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`);
    }
  });
  rt.queue = task.catch(() => {}); // 保证下一条消息不被前一条的异常卡住
  await task;
}

18.2 模式二:桌面应用(Electron / Tauri sidecar) ​

  • 引擎跑在主进程或 sidecar 进程内,UI 通过 IPC 接收事件;
  • workspace 指向用户选择的工作目录;
  • 审批用原生对话框;
  • 文件工具与 shell 直接在本机执行,务必保留 INTERACTIVE 与沙箱;
  • 退出前 await mcpManager.closeAll(),并 hook.saveState()。

18.3 模式三:无人值守 Worker ​

ts
const engine = createDefaultEngine({
  workspace: jobWorkspace,
  mode: Mode.INTERACTIVE,
  defaultApproval: "deny", // 没人可问:默认拒绝
  allowedCommands: ["npm test", "npm run build"],
});

engine.setApprover(async (req) => {
  // 用业务规则做静态审批,而不是问人
  if (req.toolName === "run_shell" && String(req.arguments.command).startsWith("npm test")) {
    return ApprovalOutcome.ONCE;
  }
  return ApprovalOutcome.DENY;
});

Worker 场景要额外注意:

  • 每个 job 一个独立 workspace,结束后清理或归档;
  • 设 maxIterations、contextWindow、capTokens 上限,防止 token 失控;
  • 用 onAssistantMessage 统计用量;
  • 用 onTurnEnd 判断 status,非 completed 视为失败并记录。

18.4 模式四:多租户 SaaS ​

必须隔离的维度:

资源隔离方式
文件系统每租户独立 workspace
工具结果落盘每租户/会话独立 toolResultDir
事件与检查点每会话独立 stateDir(FileLogHook)
Git 快照每会话 sessionId → refs/agent/<id>
MCP每租户配置来源与 McpBuiltinContext.workspace
审批审批请求必须带租户/用户上下文,不能全局共享队列
模型凭据每租户 provider 实例,不要把 key 放进 prompt/工具参数

不要跨租户复用 engine、registry(MCP 工具会互相污染)、permissions(白名单会串)。


19. 生产检查清单 ​

上线前逐项确认:

装配

  • [ ] model 显式配置,未配置时启动即报错;
  • [ ] workspace 是每租户/每任务的独立目录,不是 / 或主目录;
  • [ ] mode 默认 INTERACTIVE;
  • [ ] engine.setApprover() 已实现,且有超时/断线兜底;
  • [ ] setQuestionHandler() / setPlanHandler() / setDirectoryHandler() 已实现(若使用对应工具);
  • [ ] maxIterations、contextWindow、capTokens 有明确上限;
  • [ ] toolResultDir 按租户隔离。

安全

  • [ ] shell 策略与沙箱状态符合预期(engine.permissions.sandbox);
  • [ ] 命令白名单不含通配/复合命令;
  • [ ] MCP 工具保持审批,配置文件不包含明文密钥(用 ${ENV});
  • [ ] 附件大小/数量限制已在外层再校验一次;
  • [ ] 审计日志的保留期与脱敏策略已确定。

可靠性

  • [ ] 同一会话的 run() 已串行化;
  • [ ] 客户端断开 → requestInterrupt();
  • [ ] 进程退出 → mcpManager.closeAll()、hook.saveState();
  • [ ] 重启恢复 → 读 SavedState.messages 并还原权限模式/白名单;
  • [ ] 为 error.retriable 提供「重试」入口;
  • [ ] 监控 turn_end.status 分布与 token 用量。

性能

  • [ ] 单会话事件不无限缓冲(SSE 直接转发,不落内存大数组);
  • [ ] logDir 按日期分片并定期清理;
  • [ ] 大文件工具结果走落盘预算,不整段塞回模型。

20. API 速查 ​

20.1 主入口导出(节选) ​

ts
// 引擎
TurnEngine
createDefaultEngine
EventType, ApprovalOutcome
Mode, PermissionEngine
ToolRegistry, RiskClass
OpenAIProvider

// 工具注册
registerFileTools, registerReadOnlyFileTools, registerShellTool, registerSearchTool,
registerWebTools, registerGitTools, registerDiffTools, registerTodoTool, TodoList,
registerExploreTool, registerMediaTool, registerTerminalTools,
registerAskTool, setQuestionHandler,
registerPlanTool, setPlanHandler,
registerDirectoryTool, setDirectoryHandler

// 钩子与视图
ConsoleHook, SubagentHook, CompositeHook, FileLogHook,
MarkdownRenderer, approvalFields, approvalToHtmlTable, approvalToMarkdownTable

// 提示词
buildInstructions, buildEnvBlock, getPrompts, promptSets, PROMPT_LANGS

// Agent / MCP
AgentRegistry, BUILTIN_AGENTS
McpClient, McpManager, MCP_SEARCH_TOOL, MCP_CALL_TOOL,
loadMcpConfigFile, saveMcpConfigFile, readMcpConfigFile,
normalizeMcpConfig, normalizeMcpConfigs, defaultMcpConfigPath,
withBuiltinMcpConfigs, splitCommandLine, BUILTIN_MCP_CONFIGS

// 附件
MessageAttachment, StoredAttachment,
normalizeAttachments, saveAttachments, readStoredAttachment, stripAttachments,
MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, ATTACHMENT_DIR, AttachmentError

// 沙箱
resolveSandbox, wrapWithSandbox, describeSandbox, buildMacProfile, buildBwrapArgs

// VCS
SessionVcs, VcsHook, resolveVcsPlan, agentRef, sessionIndexPath

// 会话命名:TurnEngine 内部使用 genSessionNamePrompt;
// 纯逻辑实现(shouldAutoName / sanitizeSessionName)不在主入口,
// 需要自定义命名时参考 packages/core/src/engine/sessionNaming.ts(非稳定 API)

20.2 常用环境变量 ​

变量作用
OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODELProvider 配置
OPENAI_REASONING_EFFORT推理强度
OPENAI_FORCE_API_MODEresponses / chat
OPENAI_MAX_RETRIES / OPENAI_TIMEOUT_MS / OPENAI_STREAM_IDLE_MS请求可靠性
SOCKS5_PROXYSOCKS5 代理
MAX_ITERATIONS服务端装配时的迭代上限
CONTEXT_WINDOW / COMPACTION_THRESHOLD_PCT / COMPACTION_SOFT_THRESHOLD_PCT / COMPACTION_CAP_TOKENS服务端装配时的压缩参数
COMPACTION_MODEL / COMPACTION_REASONING_EFFORT / COMPACTION_ELIDE / COMPACTION_DEDUP压缩行为
NETWORK_RETRY_MAX / NETWORK_RETRY_BASE_MS瞬时网络错误重试
TOOL_RESULT_MAX_CHARS / TOOL_RESULT_SAVE_THRESHOLD / TOOL_RESULT_DIR工具结果预算
EXPLORE_MAX_ITERATIONS子代理迭代上限(默认 30)
MCP_CALL_TIMEOUT_MSMCP 工具调用超时(默认 300s)
ZQAGENT_MCP_LAZYMCP 惰性加载开关

21. 排错 FAQ ​

Q:工具没有执行,模型说「用户拒绝」。 approver 返回了 DENY,或设置了 defaultApproval: "deny"。检查 approver 的日志。

Q:报错「工具 X 需要审批,但装配引擎时没有注入 approver」。 没有调用 engine.setApprover()。这是默认 defaultApproval: "error" 的预期行为。 注意这个错误是 approver 抛出的,会直接从 for await 冒出来,调用方需要 try/catch(或改用 deny / 提供真正的 approver)。

Q:报错「未指定模型」。 传 model 或设置 OPENAI_MODEL。引擎不会替你选型号。

Q:报错「未设置 OPENAI_API_KEY」。 传自定义 provider,或设置 OPENAI_API_KEY。

Q:run() 一直不结束。 检查是否消费完了事件流;检查工具是否卡住(run_shell 长任务应放后台); 检查 provider 是否忽略了 opts.signal。

Q:点击「停止」后没反应。 必须调用 engine.requestInterrupt(),而不是停止读取事件流。自定义 provider 必须响应 opts.signal。

Q:重启后用户之前的「总是允许」丢了。SavedState 里有 permissionMode / allowTools / allowCommands,恢复时要 手动写回 engine.permissions。

Q:同一个工作区多个会话互相覆盖文件。 每个会话独立 engine,但文件系统是共享的。用 ConflictGuard(内置)能发现 「读后被别人改过」,要彻底隔离请给每个会话独立 workspace,或开启 vcs 审计。

Q:MCP 工具不出现。connectAll() 是异步的,需要等它完成;工具表是在每次 LLM 调用时实时读取的, 连接完成后下一轮就会出现。检查 failed 列表与配置路径。

Q:CommonJS 项目 require() 报 ERR_PACKAGE_PATH_NOT_EXPORTED。 使用 0.2.60 及以上版本;更早版本的 CJS 产物会因 ESM-only 依赖加载失败。

Q:模型请求要走代理。 设置 SOCKS5_PROXY=socks5://host:port;HTTP/HTTPS 代理暂不支持。


22. 版本、构建与发布 ​

  • 当前包版本:0.3.25(packages/core/package.json)。

  • 许可:Apache-2.0。

  • 构建:

    bash
    pnpm --filter @zqagent/core build     # tsup → packages/core/dist(ESM + CJS + d.ts)
    pnpm --filter @zqagent/core typecheck
    pnpm --filter @zqagent/core test      # Vitest
  • 发布:bash publish_npm.sh [patch|minor|major](仅 core),或仓库级的 scripts/publish-all-npm.sh(shared → core → server → cli)。

  • 产物形态:dist/index.js(ESM)、dist/index.cjs(CJS)、dist/index.d.ts、 dist/index.d.cts;@zqagent/shared 作为外部依赖一起安装。

升级建议:

  • 锁定精确版本,升级前跑一遍集成测试;
  • 关注 TurnEngineOpts / CreateEngineOpts / 事件字段的变更(新增字段通常兼容, 删除/改名会在大版本说明);
  • 不要把 internal/* 当作稳定 API,升级时可能变动。

相关文档

Last updated: