Skip to content

This document is synchronized from the repository file docs/core-integration.md. Links to source code point to the GitHub repository; related pages that are readable on this site have been changed to in-site links.

Integrating @zqagent/core into Third-Party Systems: Complete Integration Guide ​

Simplified Chinese · Corresponds to the current repository 0.3.25

This document is intended for teams that want to embed AI Agent capabilities into their own systems: your system may be a web service, a desktop application, a task queue Worker, or a product that already has a session/user/billing system. The goal is to avoid installing the CLI and avoid starting the zqagent server, and instead treat @zqagent/core as a library to obtain the entire set of Agent capabilities: "model + tools + permissions + event stream + persistence."

If you just want to run a minimal example first: see embedding.md and examples/embed-basic. This document is its complete version, covering all public APIs, integration patterns, and production considerations.


Table of Contents ​

  1. What it is and what it is not
  2. Architecture positioning and package relationship
  3. Installation and runtime environment
  4. Up and running in five minutes
  5. Core concepts
  6. Assembling the engine
  7. Tools: built-in and custom
  8. Permissions, approval, and security boundaries
  9. Complete event stream reference
  10. Model Provider
  11. Lifecycle hooks and persistence
  12. Controlling a turn
  13. Attachments
  14. Context compaction
  15. MCP integration
  16. Sub-agents and Agent registry
  17. Change tracking and rollback
  18. Four integration patterns and code skeletons
  19. Production checklist
  20. API quick reference
  21. Troubleshooting FAQ
  22. Version, build, and release

1. What it is and what it is not ​

@zqagent/core is an Agent engine library. It is responsible for:

  • Maintaining the message history of a session;
  • Calling the LLM (streaming), parsing text, reasoning process, and tool calls;
  • Executing tools through ToolRegistry;
  • Using PermissionEngine to determine whether each tool call should be allowed, denied, or require human approval;
  • Exposing the entire process to the caller as an async generator event stream;
  • Compacting history when the context exceeds the limit;
  • Optionally: persisting events and checkpoints, MCP integration, sub-agents, and Git snapshots.

It is not responsible for:

  • UI / terminal rendering (ConsoleHook is only a reference implementation);
  • HTTP / WebSocket / SSE services (@zqagent/server is a reference implementation);
  • Users, tenants, billing, and authentication;
  • Session indexing and databases (FileLogHook is a file-level reference implementation);
  • Making approval decisions on behalf of the caller.

In one sentence: you are responsible for the product shell and session lifecycle; the engine is responsible for how a turn of conversation runs.


2. Architecture positioning and package relationship ​

                   ┌─────────────────────────────┐
   Your system ───▶│ @zqagent/core               │
   (Web/desktop/Worker)│ TurnEngine / ToolRegistry   │
                   │ PermissionEngine / Provider │
                   │ Built-in tools / MCP / compaction / VCS  │
                   └──────────────┬──────────────┘
                                  │ depends on
                                  ▼
                   ┌─────────────────────────────┐
                   │ @zqagent/shared             │
                   │ Model capability table / protocol / encryption, etc.   │
                   └─────────────────────────────┘

   The two "complete applications" included in the repository can serve as reference implementations:
   @zqagent/server = multi-tenant sessions + HTTP/SSE + pending (suspended) approval
   @zqagent/cli    = terminal rendering + session persistence + interactive approval

The dependency direction is one-way: shared ← core. core does not know that server / cli exist, and will not depend on them in reverse. When a third-party system introduces core, it will not drag in the HTTP service, Tauri, console UI, etc.

Packages in the repository:

PackagePurposeCan be used independently
@zqagent/coreAgent engine (the main subject of this document)Yes
@zqagent/sharedShared foundational modules used by core and hubAutomatically installed as a dependency of core
@zqagent/serverMulti-tenant HTTP service reference implementationYes, but belongs to the application layer
@zqagent/cliTerminal application reference implementationYes, but belongs to the application layer

3. Installation and runtime environment ​

3.1 Environment requirements ​

ItemRequirement
Node.js>= 18.17 (engines in package.json)
Module systemDual ESM and CommonJS output; both import / require work
TypesType declarations are published with the package (dist/index.d.ts / .d.cts)
Operating systemLinux / macOS / Windows; the shell tool automatically selects an available shell on Windows

3.2 Installation ​

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

Version check: This document corresponds to repository 0.3.25. The available version on npm is determined by npm view @zqagent/core version; if the npm version lags behind the repository, you can run pnpm --filter @zqagent/core build in the repository and then reference it via file: / workspace. It is recommended to pin an exact version in package.json to avoid engine behavior drifting with minor versions.

3.3 TypeScript configuration ​

The package uses node16 module resolution, and exports include the types condition. Recommended consumer configuration:

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

If the project is pure ESM ("type": "module"), use import directly; if it is CommonJS, require("@zqagent/core") also works, but requires version 0.2.60 or later (earlier CJS outputs fail to load due to ESM-only dependencies).

3.4 Subpath imports ​

The exports in packages/core/package.json are closed; only the following subpaths are available. When importing tool implementations on demand, use subpaths and do not reference internal files under 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";

The internal/* subpaths (internal/engine/FileLogHook, internal/mcp/config, etc.) are unstable APIs for the server / CLI in the same repository, and third parties should not depend on them; this document only uses the main entry point and the stable subpaths above. FileLogHook, SavedState, MCP configuration functions, etc. are actually also exported from the main entry point; prefer the main entry point.


4. Up and running in five minutes ​

Below is a complete runnable ESM script (Node 18+):

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

const engine = createDefaultEngine({
  workspace: process.cwd(),          // Permission boundary: all files/commands are restricted here
  model: process.env.OPENAI_MODEL!,  // Required; no fallback model is provided
  mode: Mode.INTERACTIVE,            // Operations with side effects require approval each time
});

// 1) Approval callback: if not injected, an error is thrown the first time authorization is needed
engine.setApprover(async (request) => {
  console.log(`\nApproval required: ${request.toolName} — ${request.reason}`);
  console.log(request.arguments);
  return ApprovalOutcome.ONCE; // In production, connect this to your UI / IM / ticket system
});

// 2) Answer callback for ask_user (module-level global; inject once)
setQuestionHandler(async (question, options, allowText, multi) => {
  // In production, connect this to your UI; the example directly returns the first option
  console.log(`Model question: ${question}`, options);
  return options[0] ?? "";
});

// 3) Run one turn and consume the event stream
for await (const event of engine.run("Read this project and tell me what it does")) {
  switch (event.type) {
    case EventType.ASSISTANT_DELTA:
      process.stdout.write(String(event.data.text ?? ""));
      break;
    case EventType.TOOL_STARTED:
      console.log(`\n[tool] ${event.data.name}`);
      break;
    case EventType.TOOL_FINISHED:
      console.log(`[tool finished] ${event.data.name}: ${event.data.status}`);
      break;
    case EventType.ERROR:
      console.error(`\n[error] ${event.data.error}`);
      break;
    case EventType.TURN_END:
      console.log(`\n[end] ${event.data.status}`);
      break;
  }
}

Before running, set:

bash
export OPENAI_API_KEY=sk-...
export OPENAI_MODEL=gpt-4o-mini        # or any OpenAI-compatible model
node agent-demo.mjs

This single script already covers the four most important things for embedding: assembly, approval, event consumption, and multi-turn context (calling run() again on the same engine will carry the previous turn's history).


5. Core concepts ​

Once you understand the following six objects, all the later APIs will make sense.

ConceptTypeResponsibility
EngineTurnEngineRuntime for a session. Holds message history, provider, tool table, and permission engine
TurnOne call to engine.run()One user input → several LLM/tool iterations → one turn_end
IterationInternal loopOne LLM call + all tool executions requested by that reply
EventWsEventThe only real-time channel for engine output (async generator)
ToolToolSpecA function callable by the model + JSON Schema + risk metadata
ApproverApproverWhen the permission engine determines "needs human approval," the caller decides allow/deny

5.1 Sessions and Engine are one-to-one ​

Create one TurnEngine per session; do not share the same engine across multiple sessions:

  • engine.messages is the session history and is naturally serial;
  • "always allow for this session" in engine.permissions is session-level state;
  • engine.compactionState is session-level compaction state;
  • Running two run() turns concurrently on the same engine will corrupt each other's history.

The correct approach for multiple sessions is Map<sessionId, TurnEngine>, with one instance per session. See Chapter 18.

5.2 The event stream is the main channel; hooks are a side channel ​

  • The for await over engine.run() is the main channel: ordered, backpressure-capable, and each event appears only once, suitable for direct conversion into SSE / WebSocket frames.
  • EngineHook is a side callback: synchronous, no return value, exceptions are swallowed, and suitable for a second consumer of the same data (persistence, billing, logging). Do not drive the UI solely from hooks, because they do not guarantee backpressure.

6. Assembling the engine ​

There are two paths:

  • Path A: createDefaultEngine(opts) — assemble all built-in capabilities in one line; suitable for 90% of scenarios;
  • Path B: new TurnEngine(opts) — decide the provider, tool table, permission engine, and system prompt yourself; suitable for deep customization or reuse of existing infrastructure.

6.1 Path A: createDefaultEngine ​

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

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

It performs the following steps in order (see packages/core/src/index.ts:409):

  1. If no provider is passed, create new OpenAIProvider({ logDir });
  2. Resolve the sandbox plan with resolveSandbox();
  3. Create a ToolRegistry and ConflictGuard, and register 21 built-in tools (see Chapter 7);
  4. Create a PermissionEngine (workspace, mode, command allowlist, shell policy, sandbox state);
  5. If no instructions is passed, use buildInstructions(agentType, workspace, today, lang) to generate the system prompt;
  6. Assemble the TurnEngine; if vcs is enabled, use CompositeHook to connect VcsHook in parallel;
  7. Inject a fallback approver according to defaultApproval.

All parameters of CreateEngineOpts ​

ParameterType / defaultDescription
workspacestring (required)Workspace root = permission boundary for files and commands
modelstring, defaults to reading OPENAI_MODELModel identifier; if both are empty, run() raises a clear error and no fallback model is chosen
providerProviderClientCustom provider; defaults to creating OpenAIProvider
modeMode.INTERACTIVEDISCUSS / PLAN / INTERACTIVE / AUTO
maxIterationsDEFAULT_MAX_ITERATIONS = 800Maximum number of iterations per turn
reasoningEffortDefaults to reading OPENAI_REASONING_EFFORTlow / medium / high; automatically skipped for DeepSeek
allowedCommands[]Command prefixes automatically allowed in Auto mode
shellPolicy"approve"approve / sandbox / workspace / all
sandboxDefaults to auto{ mode, extraWritable, ... }, see 8.4
vcs{ mode: "off", sessionId: "default" }Git snapshot per turn, see Chapter 17
instructionsGenerated by buildInstructionsOverrides the system prompt
agentType"code"code (coding assistant) / cowork (general collaboration), affects the default prompt
langSimplified Chinesezh / en / zh-TW / ja / ko / es, affects prompt, naming, and summary
messages[]Initial conversation history (passed when restoring a session)
hookNoneLifecycle hook, see Chapter 11
subagentHookFactoryNoneCreates a sub-agent hook for each explore call
contextWindowAuto-detected, falls back to 128KModel context window (tokens)
thresholdPct0.8Hard compaction threshold (as a proportion of the window)
softThresholdPct75% of the hard thresholdSoft threshold: only evict old tool results, without calling the LLM
capTokensnullAbsolute cap for the hard threshold (tokens); null = no limit
toolResultDirtool-results under the state directoryDirectory for persisting over-budget tool results; in multi-tenant cases it must be isolated per tenant
logDirNot recordedRoot directory for LLM request audit logs (split by date)
defaultApproval"error"When no approver is injected: error / deny / allow

lang only affects the language of the default prompt, session naming, and summary; if instructions is passed explicitly, it takes precedence.

6.2 Path B: manual assembly of TurnEngine ​

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

const workspace = process.cwd();

// 1) Tool table: register only what you need
const registry = new ToolRegistry();
registerFileTools(registry, workspace);
registerShellTool(registry, workspace);
registerSearchTool(registry, workspace);

// 2) Permissions: workspace + mode + allowlist
const permissions = new PermissionEngine({
  workspaceRoot: workspace,
  mode: Mode.INTERACTIVE,
  allowedCommands: ["git status", "ls", "cat"],
});

// 3) Provider: any OpenAI-compatible endpoint
const provider = new OpenAIProvider({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL,
});

// 4) Engine
const engine = new TurnEngine({
  provider,
  registry,
  permissions,
  model: process.env.OPENAI_MODEL!,
  instructions: "You are an assistant that only handles data analysis tasks.",
  lang: "zh",
  maxIterations: 50,
});

engine.setApprover(async () => ApprovalOutcome.DENY); // Don't forget this

All parameters of TurnEngineOpts ​

ParameterRequiredDescription
providerYesProviderClient (stream() / chat())
registryYesToolRegistry
permissionsYesPermissionEngine
modelYesModel identifier; an empty string will throw in run()
instructionsYesSystem prompt, injected as the first system message
genSessionNamePromptNoPrompt for generating the session name; defaults according to lang
langNoDefaults to "zh"; affects naming and compaction summary
maxIterationsNoDefaults to 800
finalizeOnMaxIterationsNoAfter hitting the limit, run one more tool-free finalization turn; defaults to false; sub-agents use true
reasoningEffortNoPassed through to the provider
messagesNoInitial history; if the first message is not system, instructions is automatically inserted
hookNoEngineHook
contextWindowNoOverrides auto-detection
thresholdPctNoDefaults to 0.8
softThresholdPctNoDefaults to 75% of the hard threshold
capTokensNoDefaults to null
toolResultDirNoDirectory for persisting tool results

6.3 What can be changed at runtime ​

Public properties and methods of TurnEngine:

ts
engine.model = "another-model";          // Model (takes effect on the next turn)
engine.messages;                         // Full history (including notice messages)
engine.permissions.mode = Mode.AUTO;     // Run mode
engine.registry;                         // Tool table (tools can be dynamically enabled/disabled)
engine.compactionState;                  // Compaction state (read-only observation)
engine.approver;                         // Current approval callback

engine.setApprover(fn);                  // Set approval
engine.setSystemPrompt(text);            // Replace the system prompt at runtime (for switching agents)
engine.requestInterrupt();               // Interrupt the current turn
engine.queueSteering(text, attachments, refs); // Queue supplemental instructions (the last two are optional)
engine.removeSteering(text);             // Remove supplemental instructions that have not yet been injected

7. Tools: built-in and custom ​

7.1 The 21 tools registered by createDefaultEngine ​

CategoryToolRiskRegistration function
Fileread_file, list_filesRead-onlyregisterFileTools / registerReadOnlyFileTools
Filewrite_file, replace_in_fileLocal writeregisterFileTools
Shellrun_shell, shell_task_output, shell_task_killExecutionregisterShellTool
SearchgrepRead-onlyregisterSearchTool
Networkweb_search, web_fetchExternalregisterWebTools
Gitgit_log, git_status, git_diffRead-onlyregisterGitTools
Patchapply_patch, apply_unified_diffLocal writeregisterDiffTools
Collaborationask_user, propose_plan, request_directoryInteractiveregisterAskTool / registerPlanTool / registerDirectoryTool
Tasktodo_writeRead-only/stateregisterTodoTool
Sub-agentexploreRead-onlyregisterExploreTool
MediamediaExternalregisterMediaTool

Terminal tools are not part of the one-line assembly: terminal_list / terminal_read depend on the host-provided TerminalSource (the long-running PTY behind the console "Terminal" panel), and must be injected manually:

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

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

projectId is the isolation boundary and must be passed per project; otherwise project A can read terminal content from project B.

For the full parameters and behavior of each tool, see Built-in tools.

7.2 Custom tools ​

Signature of 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,   // Strongly recommended to provide explicitly
  source?: string,       // For display: "internal" / "mcp:xxx" / "myapp"
) => ToolSpec;

Example:

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: "Query the internal order system by order number and return order status and amount",
      parameters: {
        type: "object",
        properties: {
          order_id: { type: "string", description: "Order number" },
        },
        required: ["order_id"],
      },
    },
  },
  "myapp",
);

Notes:

  • The tool name comes from schema.function.name; if no schema is passed, it falls back to the function name, the parameter structure is empty, and the model will basically not call it correctly, so production tools should all provide an explicit schema.
  • func may return a Promise; the engine will await it.
  • The second parameter, ctx.signal, is the interrupt signal. Long-running tools should pass it to the underlying task; otherwise the tool will still run to completion after the user interrupts.
  • Registering the same name with register replaces the old tool, which can be used to override built-in implementations; remove() deletes; disable() / enable() control whether it appears in schemas() (invisible to the model).
  • The result returned to the model goes through the result budget: if it exceeds the threshold, it is persisted to disk and the model is given a summary/path, adjustable via the environment variables TOOL_RESULT_MAX_CHARS, TOOL_RESULT_SAVE_THRESHOLD, and TOOL_RESULT_DIR.
  • Exceptions thrown by tools are caught by the engine and converted into { error, error_type } as the tool result, allowing the model to decide the next step; it will not crash the entire turn.

7.3 Read-only tool set ​

For sub-agents or read-only scenarios:

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

8. Permissions, approval, and security boundaries ​

8.1 Decision flow ​

PermissionEngine.evaluate() makes decisions in a fixed order (packages/core/src/permissions/PermissionEngine.ts:173):

  1. Read-only mode (DISCUSS / PLAN) → operations with side effects are directly denied;
  2. Write file path not under workspaceRoot → directly denied;
  3. Read-only risk (RiskClass.READ) → directly allowed;
  4. AUTO mode → all operations with side effects are allowed;
  5. shell approval-exemption policy (shellPolicy + sandbox) → may be directly allowed;
  6. Command allowlist / session command allowlist → directly allowed;
  7. Session tool allowlist (the user has selected "always allow this tool") → directly allowed;
  8. Everything else → needsUser: true, handed to the approver.

Return structure:

ts
interface Decision {
  allowed: boolean;
  reason: string;     // Displayed to the user / on the approval card
  needsUser: boolean; // true = requires approval
}

8.2 Run modes ​

ts
enum Mode {
  DISCUSS = "discuss",         // Read-only; writes and execution prohibited
  PLAN = "plan",               // Read-only; writes and execution prohibited
  INTERACTIVE = "interactive", // Operations with side effects require approval each time (recommended default)
  AUTO = "auto",               // All operations with side effects are allowed
}

interactive is the default and the safest mode for real users. auto should only be used in isolated environments, disposable workspaces, or with fully trusted input.

8.3 Approval callback ​

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

interface PermissionRequest {
  toolName: string;
  arguments: Record<string, unknown>;
  metadata: unknown;       // ToolMetadata | null
  reason: string;          // Reason given by the permission engine
  toolCallId?: string;     // Corresponds to reqId in events
}

enum ApprovalOutcome {
  ONCE = "once",                   // This time only
  ALWAYS_TOOL = "always_tool",     // Always allow this tool for this session
  ALWAYS_COMMAND = "always_command", // Always allow this command for this session (only run_shell)
  DENY = "deny",                   // Deny
}

Implementation approaches:

  • CLI / desktop: block and wait for the user to click a button in the UI;
  • Web service: put the request into a Promise, push an approval card to the client, and resolve when the callback is received — this is the "pending (suspended) approval" of @zqagent/server;
  • Unattended Worker: automatically return ONCE according to the allowlist, and DENY for the rest (or defaultApproval: "allow", but first confirm that the workspace is isolated).

You must implement an approver. The default defaultApproval: "error" throws on the first authorization request, which is intentional: it avoids "forgot to connect approval → tools silently do not execute → users think the model is broken."

8.4 shell approval exemption and OS sandbox ​

shellPolicy:

ValueBehavior
approve (default)Always require approval except for the allowlist
sandboxExempt from approval only when the OS-level sandbox is actually in effect; otherwise require approval
workspaceSandbox in effect → exempt; not in effect → fall back to workspace path-based judgment
allAll shell commands are exempt from approval (high risk; equivalent to auto only for shell)

Sandbox configuration:

ts
createDefaultEngine({
  workspace,
  sandbox: {
    mode: "auto",                  // auto / off / required
    extraWritable: ["/data/cache"], // Additional writable paths
  },
});
  • macOS: sandbox-exec, narrowing file-write* to the workspace + temporary directories;
  • Linux: bwrap, whole-machine read-only mounts + workspace writable;
  • Windows: there is no equivalent process-level filesystem sandbox, enforced: false, degenerating to path heuristics (this is a strategy to reduce nuisance, not a security boundary).

In required mode, a clear error is raised if the sandbox cannot be detected, which is suitable for deployments that treat "must be isolated" as a compliance requirement.

8.5 Command allowlist ​

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

Rules:

  • Prefix matching, but it must end at a word boundary: ls will not allow lsof;
  • Commands containing shell operators (; & | > < \ $( ( \n) are **never automatically allowed**, to prevent bypasses such as ls; rm -rf /`;
  • After the user selects "always allow this command" in the session, it is remembered by executable program name, and only applies to simple commands.

8.6 Security boundary checklist ​

  • Do not set workspace to /, the user's home directory, or a directory containing secrets;
  • Use Mode.INTERACTIVE by default; do not default to AUTO just for an "automated experience";
  • In multi-tenant scenarios, use an independent workspace, toolResultDir, FileLogHook directory, and MCP configuration for each tenant;
  • MCP tools default to requiresApproval: true; do not disable approval globally;
  • logs / logDir record LLM request content; decide whether to enable it and how long to retain it according to data classification;
  • Web page/file content returned by tools is untrusted data; do not execute it as instructions (the engine's prompt already includes relevant constraints, but the business side should still perform output validation).

9. Complete event stream reference ​

9.1 Structure ​

ts
interface WsEvent {
  type: EventType;
  data: Record<string, unknown>; // Fields vary by type
}

run() returns AsyncGenerator<WsEvent>. It must be fully consumed (or break and let the generator be reclaimed), otherwise the engine will not continue advancing. Typical order:

turn_start
  ├─ (compacting → compacted)?          # When the context approaches the threshold
  ├─ llm_request
  ├─ assistant_delta / reasoning_delta* # Streaming increments
  ├─ assistant_message                  # Complete reply for this turn
  ├─ tool_proposed
  ├─ (permission_required → approver)?  # When approval is needed
  ├─ tool_started
  ├─ tool_finished
  ├─ iteration_end
  └─ back to llm_request (when there are tool results to feed back to the model)
turn_end

Exception path: after error, a turn_end is still appended; interruption path: after interrupted, a turn_end is appended. Consumers should treat turn_end as the terminal state of the turn's state machine, not the end of the stream.

9.2 Event types and fields ​

EventKey fields in dataWhen it appears
turn_starttaskId, input, attachmentCount?, attachments?At the start of each turn
assistant_deltatext, reqId, taskIdStreaming text increment from the model
reasoning_deltatext, reqId, taskIdReasoning process increment (some models)
assistant_messagetext, reasoning, tool_calls (array of tool names), usage?, reqId, taskId, logTimeEnd of one LLM reply
tool_proposedreqId (= tool call ID), name, arguments, taskIdThe model proposes a tool call
permission_requiredtaskId, reqId, name, arguments, reason, categoryApproval required (the approver is then called)
tool_startedtaskId, reqId, name, startTime?Tool starts executing
tool_finishedtaskId, reqId, name, status, reason?, duration?, result_preview?Tool finishes
iteration_enditeration, taskIdEnd of one iteration (LLM + tools)
turn_endstatus, iterations, produced_files, taskIdEnd of one turn (always emitted)
errorerror, error_type, retriable, iteration, reqId, logTime, taskId, stack?Engine error
interruptediterations, reqId, taskIdUser interruption
compactingtaskId, trigger, tokens, estimated, contextWindow, thresholdPct, thresholdTokens, outboundMessagesCompaction starts
compactedreqId, taskId, method, messagesBefore, messagesAfter, boundaryIndex?, model?, summaryPreview?, keptUserMessages?, droppedUserMessages?Compaction completed
queue_steeringtext, taskId, attachments?Queued supplemental instructions are injected
llm_requestreqId, taskId, logTime, model, msgCount, toolCount, compact?Before each LLM request (for auditing)
ask_user_questiontaskId, question, options, allowText, multiThe model calls ask_user
plan_proposedplanThe model calls propose_plan
directory_requestedreason, path, writableThe model calls request_directory
llm_retryattempt, max, reason, waitMs, iteration, reqIdAutomatic retry after transient network error
session_namedname, sourceEnum reserved; currently broadcast by the CLI/server after naming; TurnEngine does not produce it

Values of tool_finished.status:

statusMeaning
okExecuted successfully
errorThe tool threw an exception or returned an error
deniedPermission denied or user denied
interruptedInterrupted during execution

Values of turn_end.status:

statusMeaning
completedThe model no longer requests tools; normal completion
max_iterations_exceededReached maxIterations
interruptedUser interruption
errorTerminated by LLM/engine error

9.3 Consumption example: converting to 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();
});

Note: an SSE disconnect does not automatically stop the engine. When the client disconnects, you should call engine.requestInterrupt(), otherwise the engine will continue running to completion and consume tokens.

9.4 Errors and retries ​

  • error.retriable === true: you can try engine.retry() (only valid when the previous turn ended with an error and the end is a retriable error).
  • 429/5xx are retried by the OpenAI SDK itself; transient network errors cause the engine to automatically retry NETWORK_RETRY_MAX (default 2) times, with backoff base NETWORK_RETRY_BASE_MS (default 800ms), during which llm_retry is emitted.
  • Context overflow is automatically compacted and retried, without reporting an error externally; only when compaction also fails is an error reported and marked retriable.

10. Model Provider ​

10.1 OpenAIProvider ​

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

const provider = new OpenAIProvider({
  apiKey: process.env.OPENAI_API_KEY,   // Defaults to reading the environment variable of the same name
  baseURL: process.env.OPENAI_BASE_URL, // Any OpenAI-compatible endpoint
  logDir: "/var/log/zqagent/llm",       // Optional: request audit logs
});

Key behaviors:

  • Responses API is preferred, falling back to Chat Completions by error type on failure:
    • Endpoint unsupported (404/405, etc.) → fallback and cache by model;
    • Parameters incompatible (some compatible gateways) → fallback for this request only;
    • 401/429/5xx/network errors → no fallback; rethrow as-is.
  • The environment variable OPENAI_FORCE_API_MODE=responses|chat can force the mode.
  • When the model name or baseURL contains deepseek, the Responses path automatically injects the server-side web_search built-in tool and filters out the local tool with the same name.
  • logDir writes request/response audit files split by date and by reqId; it includes message content, so decide whether to enable it according to data compliance requirements.

Related environment variables:

VariablePurpose
OPENAI_API_KEYAPI Key
OPENAI_BASE_URLCompatible endpoint
OPENAI_MODELDefault model
OPENAI_REASONING_EFFORTlow / medium / high
OPENAI_FORCE_API_MODEresponses / chat
OPENAI_MAX_RETRIESSDK retry count
OPENAI_TIMEOUT_MSRequest timeout
OPENAI_STREAM_IDLE_MSStream idle watchdog
SOCKS5_PROXYLLM and network tools use a SOCKS5 proxy

10.2 Custom Provider ​

The engine depends on only two methods (packages/core/src/providers/OpenAIProvider.ts):

ts
// Note: StreamOpts is currently not exported from the package root; when implementing a custom provider,
// declare it yourself according to the fields used below (reqId / logTime are required; the rest are optional).
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; // Only the stream-end chunk provides the complete list
  usage: TokenUsage | null;
  finishReason: string | null;
}

The two most critical points when implementing a custom provider:

  1. The last chunk must include toolCalls or finishReason, otherwise the engine considers the stream not properly ended;
  2. When receiving an abort from opts.signal, stop reading immediately and throw/end the stream, otherwise the "Stop" button has no effect.

chat() is mainly used for lightweight requests such as session naming; even if it returns null, it does not affect the main flow.

10.3 Reasoning effort ​

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

Passed through to the provider; effective for OpenAI-family models, and automatically skipped for DeepSeek (which uses a thinking switch and does not support level control).


11. Lifecycle hooks and persistence ​

11.1 All callbacks of EngineHook ​

CallbackParametersPurpose
onTurnStartinputStart of a turn
onTurnEndstatus, iterations, producedFilesEnd of a turn
onAssistantDeltatextStreaming text
onReasoningDeltatextStreaming reasoning
onAssistantMessagetext, toolCalls, usage?Complete reply (commonly used for billing)
onToolProposedname, argsTool proposal
onToolStartednameTool start
onToolFinishedname, status, resultPreviewTool end
onPermissionRequiredname, reasonApproval required
onIterationEnditerationEnd of iteration (commonly used for checkpoints)
onCompacting / onCompactedinfo / text, infoCompaction
onErrorerror, errorType, info?Error (including retriable)
onRetryattempt, max, reasonAutomatic network retry
onInterruptediterationsInterruption
onEventtype, dataFallback for unmatched events

Hooks are synchronous callbacks; the engine does not await them. Therefore:

  • Do not perform long blocking operations inside hooks (this will stall the engine);
  • Exceptions thrown by hooks are swallowed by the engine (try/catch inside _emitHook) and do not affect the main flow;
  • For data that requires backpressure/ordering guarantees, consume the event stream from run(), not hooks.

11.2 Combining multiple Hooks ​

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

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

Internally, createDefaultEngine({ vcs }) also uses CompositeHook to connect VcsHook in parallel to the caller's own hook, without overwriting it.

11.3 FileLogHook: file-level persistence and recovery ​

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

const hook = new FileLogHook(sessionId, stateDir);
// Writes:
//   <stateDir>/<sessionId>.jsonl      Event log (JSONL)
//   <stateDir>/<sessionId>_state.json Checkpoint

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

// Save state at key points (usually called in onIterationEnd / onTurnEnd)
hook.saveState(engine, { name, nameSource: "auto", taskCount });

Fields of 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[];
}

Notes when restoring:

  • Pass messages to new TurnEngine({ messages }) or createDefaultEngine({ messages });
  • Restore permissionMode and allowlists (engine.permissions.mode = ..., allowToolForSession / allowCommandForSession), otherwise the user's previous authorizations will be lost after a restart;
  • FileLogHook.loadState() returns null when the file cannot be read; it does not throw;
  • In multi-tenant scenarios, use one stateDir per session; do not mix directories.

FileLogHook also provides onPaused / onResumed methods for recording suspension/resumption, but they are not in the EngineHook interface; call the instance methods directly as needed.

11.4 Purpose of the event log ​

The JSONL event log is the foundation for auditing (who made the Agent do what, and when) and replay; it is not the only source of session state. For file content changes, use the VCS snapshots in Chapter 17.


12. Controlling a turn ​

12.1 Multi-turn conversations ​

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

async function drain(stream: AsyncIterable<WsEvent>): Promise<void> {
  for await (const event of stream) {
    // Render or ignore
  }
}

await drain(engine.run("First look at the project structure"));
await drain(engine.run("Change the examples in README to TypeScript")); // Automatically carries the previous turn's context

Consecutive calls on the same engine preserve history. To start a new session, create a new engine.

12.2 Interruption ​

ts
// Elsewhere (for example, HTTP DELETE / user clicks "Stop")
engine.requestInterrupt();

It will:

  1. Set the cancellation flag;
  2. Abort the in-flight LLM stream;
  3. Abort the currently executing tool (run_shell will kill the child process tree);

The event stream will then produce interrupted + turn_end(status: "interrupted"). Do not use "stop consuming the event stream" as a substitute for interruption: that will not advance the engine, nor clean up state.

12.3 Retry ​

ts
for await (const e of engine.retry()) {
  // Events are produced only when the previous turn ended with a retriable error
}

12.4 Resuming from pending approval ​

engine.resume() is used for the scenario where "there is an unanswered tool call at the end of history" (typically restoring a pending approval after a service restart):

ts
for await (const e of engine.resume()) {
  // Reprocess the pending tool_calls; the approver will return immediately with the already-resolved result
}

If there are no pending tool calls, resume() returns directly and produces no events.

12.5 Steering: adding instructions during a run ​

ts
engine.queueSteering("Also run the tests while you're at it");
engine.queueSteering("Ignore the dist directory", attachments, refs);

The engine injects queued instructions as user messages between iterations, and emits queue_steering. Suitable for interactions where "the Agent is running a long task and the user wants to add a sentence." Instructions that have not yet been injected can be removed:

ts
engine.removeSteering("Also run the tests while you're at it"); // Returns false if already injected

12.6 Concurrency constraints ​

  • Only one turn can run at a time on the same engine;
  • At the tool level: low-risk tools execute concurrently, while high-risk/approval-required/interactive tools execute serially;
  • Your service layer should queue run() calls for the same session to avoid two requests writing to messages at the same time.

13. Attachments ​

13.1 Data structures ​

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; // Relative to the session directory, e.g. "attachments/xxx-0-shot.png"
}

Limits (packages/core/src/engine/attachments.ts):

ConstantValueMeaning
MAX_ATTACHMENTS4Maximum number of attachments per request
MAX_ATTACHMENT_BYTES10 MBPer-file and total limit
ATTACHMENT_DIR"attachments"Subdirectory under the session directory
ts
import {
  normalizeAttachments,
  saveAttachments,
  type MessageAttachment,
  type StoredAttachment,
} from "@zqagent/core";

// 1) Validate and normalize (throws AttachmentError if invalid)
const attachments: MessageAttachment[] = normalizeAttachments(rawInput) ?? [];

// 2) Save to the session directory
const refs: StoredAttachment[] = saveAttachments(sessionDir, attachments);

// 3) Run one turn: base64 is only sent to the model for this turn; historical turns automatically strip it
for await (const e of engine.run(text, taskId, attachments, refs)) {
  // turn_start.data.attachmentCount / attachments(refs)
}

StoredAttachment flows with messages and events and can be used to render thumbnails in the UI; the actual bytes live only in <sessionDir>/attachments/. When reading, use:

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

Security notes:

  • readStoredAttachment only accepts the form attachments/<file>, and rejects .. and subdirectories;
  • Attachments enter LLM requests, and logDir audit logs record messages (whether base64 is recorded depends on the provider's logging implementation; evaluate this in production);
  • Do not directly render arbitrary user-uploaded HTML/scripts as text attachments.

14. Context compaction ​

Long sessions cannot be packed into the model window indefinitely; the engine compacts automatically.

14.1 Trigger mechanism ​

ConceptDefaultDescription
Context windowAuto-detected, falls back to 128KCan be overridden with contextWindow
Hard threshold80% of the windowthresholdPct
Soft threshold75% of the hard threshold (≈60% of the window)softThresholdPct; only evicts old tool results
Absolute capNonecapTokens
Recent retentionAbout 25%Proportion of recent conversation retained after compaction
Summary output cap3000 tokensFixed internally

Flow:

  1. When the soft threshold is reached: evict old tool results and deduplicate repeated read_file results, without calling the LLM;
  2. When the hard threshold is reached: call the LLM to generate a summary, emitting compacting / compacted;
  3. If the API reports context overflow: force compaction and retry.

14.2 Events and hooks ​

ts
// In the event stream of engine.run(...):
// { type: "compacting", data: { trigger: "threshold", tokens, contextWindow, ... } }
// { type: "compacted",  data: { method: "summarized" | "trimmed", messagesBefore, messagesAfter, ... } }

The onCompacting / onCompacted hooks receive the same information. The UI can use this to indicate "compacting context."

14.3 Tunable parameters and environment variables ​

ts
createDefaultEngine({
  workspace,
  contextWindow: 200_000,
  thresholdPct: 0.85,
  softThresholdPct: 0.7,
  capTokens: 120_000,
});
Environment variablePurpose
COMPACTION_MODELModel used for summaries (defaults to the current model)
COMPACTION_REASONING_EFFORTReasoning effort for summaries (default low)
COMPACTION_ELIDE0 disables eviction of old tool results
COMPACTION_DEDUP0 disables deduplication of repeated reads
CONTEXT_WINDOWWindow override during server assembly (read by @zqagent/server)
COMPACTION_THRESHOLD_PCT / COMPACTION_SOFT_THRESHOLD_PCT / COMPACTION_CAP_TOKENSThreshold overrides during server assembly

CompactionState and compaction utility functions are currently not exported from the package root; third parties only need to observe state through engine.compactionState, or rely on events/hooks. Do not deep-import internal implementations.


15. MCP integration ​

MCP (Model Context Protocol) allows the Agent to use external tool servers. @zqagent/core includes the client and tool registration logic.

15.1 Minimal usage ​

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 }, // Multimodal fallback for built-in OCR
  },
});

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

After connection is complete, registry.schemas() automatically includes MCP tools, and the model can see them on the next LLM call (no need to rebuild the engine).

15.2 Configuration format ​

<workspace>/.agent/mcp.json supports multiple forms:

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

// Or { "servers": [...] } / { "servers": { "name": {...} } }
// Or Claude/Cursor style { "mcpServers": { "name": { "command": "npx -y pkg", "args": ["..."] } } }
  • When type is omitted, it is inferred from builtin → url → command;
  • command can be a string or an array;
  • ${VAR} / ${env:VAR} are expanded from environment variables;
  • enabled: false or disabled: true disables it;
  • Built-in presets (OCR) are automatically appended when the user has no configuration with the same name; { "name": "ocr", "enabled": false } disables it.

15.3 Tool naming and approval ​

  • Registration name format: mcp__<server>__<tool> (both parts are character-sanitized and truncated);
  • Metadata: category: "mcp", riskLevel: "high", requiresApproval: true;
  • Therefore an approver must be connected, otherwise all MCP tools will use the default fallback policy.

15.4 Lazy loading (when there are many tools) ​

ts
new McpManager(registry, configs, {
  lazy: {
    enabled: true,       // Or let the threshold decide automatically
    thresholdTools: 16,
    thresholdChars: 24_000,
    maxLoaded: 40,
  },
});

When enabled, only two meta-tools are registered:

  • mcp_search_tools (read-only, approval-exempt): searches for tools by keyword; the top 8 matches are automatically loaded;
  • mcp_call_tool (requires approval): calls a loaded/resolvable tool by name.

The environment variable ZQAGENT_MCP_LAZY=1/0 can force it on or off.

15.5 Lifecycle ​

ts
await manager.connectServer("github");
await manager.refresh("github");   // Disconnect → reconnect → re-register
manager.attach(newRegistry);       // Migrate loaded tools when the engine is rebuilt
await manager.closeAll();          // On process exit

McpManager only manages memory and does not persist configuration: after addConfig / updateConfig / removeConfig, you need to call saveMcpConfigFile() yourself.

15.6 Built-in OCR ​

The ocr preset exposes mcp__ocr__recognize, supporting image/PDF text extraction, with fallback in the order macOS Vision → tesseract → multimodal LLM. builtin.llm should be given the current session's provider configuration; otherwise the third-level fallback is unavailable.


16. Sub-agents and Agent registry ​

16.1 The explore sub-agent ​

createDefaultEngine already registers the explore tool (packages/core/src/tools/explore.ts):

  • The main agent hands a "research task" to the sub-agent;
  • The sub-agent uses an independent context and independent tool table, and runs in Mode.PLAN (hard-blocking writes);
  • By default it only includes read-only file tools, search, and Git read-only tools;
  • By default it runs up to 30 turns (EXPLORE_MAX_ITERATIONS); after hitting the limit it runs one more finalization turn and outputs a report;
  • The main agent only receives the final report; the sub-agent's intermediate process does not enter the main context.

Customize sub-agent behavior:

ts
createDefaultEngine({
  workspace,
  subagentHookFactory: () => new SubagentHook("explore"), // One instance per sub-agent
});

If subagentHookFactory is not passed, the sub-agent is completely silent. SubagentHook prints the sub-agent's tool calls indented to the terminal and is the CLI's reference implementation; in a web scenario you can implement your own hook to forward sub-agent events to the frontend (note: sub-agent events do not go through the main run() event stream).

Manual registration:

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

16.2 AgentRegistry ​

AgentRegistry is an in-memory role table that manages "name + description + system prompt + optional model":

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

const agents = new AgentRegistry(); // Built in by default: code / cowork
agents.add({ name: "analyst", description: "Data analysis", prompt: "You are a data analyst…" });
agents.update("analyst", { model: "deepseek-chat" });
agents.switch("analyst");

const prompt = agents.buildSystemPrompt("analyst", workspace, today, "zh");
engine.setSystemPrompt(prompt); // Switch roles
engine.model = agents.currentDef.model ?? engine.model;

AgentRegistry only manages memory; persistence (such as agents.json) is implemented by the caller.


17. Change tracking and rollback ​

The Agent will modify files. The event log only records "tool intent," not file content; to answer "what exactly changed in this turn, and how to roll it back," use VCS snapshots.

17.1 Enabling ​

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

mode:

ValueBehavior
off (default)Disabled
autoUse native mode if it is a Git repository; otherwise use a shadow repository
nativeUse the workspace .git, but with an independent index and refs/agent/*, without touching the user's HEAD/staging
shadowCreate a shadow repository at <workspace>/.agent/vcs

Both native / shadow require git on the machine; if not found, they degrade to off (write conflict detection is still available).

17.2 Querying and rollback ​

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

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

const turns = await vcs.listTurns();       // Lists only "turns with changes"; numbering starts at 1
const patch = await vcs.diffTurn(2);       // git show --patch
const result = await vcs.restoreTurn(2, {   // Roll back to the state after turn 2
  paths: ["src/app.ts"],                    // Optional: roll back only some files
  dryRun: false,
});
// result: { restored, overwritten, missing }

await vcs.forget();  // Delete the session's refs/agent/<id> without restoring files

VcsHook already connects onTurnStart → beginTurn() and onTurnEnd → commitTurn(status); createDefaultEngine({ vcs }) combines it automatically. When you need to manage the lifecycle yourself, directly new VcsHook(vcs) and put it into your hook composition.

Notes:

  • The turn returned by commitTurn() is always 0; the real turn number comes from listTurns();
  • The turn in diffTurn / restoreTurn is the number of a "turn with changes"; empty turns do not consume a number;
  • The snapshot content is the workspace state of git add -A, including untracked files (subject to exclusion rules such as .agent/, .git/, node_modules/).

18. Four integration patterns and code skeletons ​

18.1 Pattern 1: HTTP service + SSE (closest to @zqagent/server) ​

Key design:

  • Map<sessionId, SessionRuntime>; each runtime holds the engine, event buffer, and pending approval Promise;
  • run() calls for the same session are queued serially;
  • Approval is implemented as "pending (suspended)" through the permission_required event + a Promise waiting to be resolved;
  • Call requestInterrupt() when the client disconnects.
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 };
      // Push request to the frontend via SSE/WebSocket; when the frontend POSTs /approve:
      //   runtime.pendingApproval?.resolve(ApprovalOutcome.ONCE)
      // On timeout or connection loss:
      //   runtime.pendingApproval?.resolve(ApprovalOutcome.DENY)
    });
  });

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

// POST /sessions/:id/messages
// res is the response object from frameworks such as Express/Fastify; here only write/end are required
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(() => {}); // Ensure the next message is not blocked by an exception from the previous one
  await task;
}

18.2 Pattern 2: Desktop application (Electron / Tauri sidecar) ​

  • The engine runs in the main process or a sidecar process, and the UI receives events via IPC;
  • workspace points to the working directory selected by the user;
  • Approval uses native dialogs;
  • File tools and shell execute directly on the local machine; be sure to keep INTERACTIVE and the sandbox;
  • Before exit, await mcpManager.closeAll() and hook.saveState().

18.3 Pattern 3: Unattended Worker ​

ts
const engine = createDefaultEngine({
  workspace: jobWorkspace,
  mode: Mode.INTERACTIVE,
  defaultApproval: "deny", // No one to ask: deny by default
  allowedCommands: ["npm test", "npm run build"],
});

engine.setApprover(async (req) => {
  // Use business rules for static approval instead of asking a human
  if (req.toolName === "run_shell" && String(req.arguments.command).startsWith("npm test")) {
    return ApprovalOutcome.ONCE;
  }
  return ApprovalOutcome.DENY;
});

Worker scenarios require extra attention:

  • Use an independent workspace for each job; clean up or archive it when finished;
  • Set limits for maxIterations, contextWindow, and capTokens to prevent runaway token usage;
  • Use onAssistantMessage to track usage;
  • Use onTurnEnd to check status; treat anything other than completed as failure and record it.

18.4 Pattern 4: Multi-tenant SaaS ​

Dimensions that must be isolated:

ResourceIsolation method
FilesystemIndependent workspace per tenant
Tool result persistenceIndependent toolResultDir per tenant/session
Events and checkpointsIndependent stateDir per session (FileLogHook)
Git snapshotssessionId per session → refs/agent/<id>
MCPPer-tenant configuration source and McpBuiltinContext.workspace
ApprovalApproval requests must carry tenant/user context; do not share a global queue
Model credentialsProvider instance per tenant; do not put keys into prompts/tool parameters

Do not reuse engines, registries (MCP tools would contaminate each other), or permissions (allowlists would leak across tenants).


19. Production checklist ​

Confirm each item before going live:

Assembly

  • [ ] model is explicitly configured and startup fails immediately if it is not;
  • [ ] workspace is an independent directory per tenant/task, not / or the home directory;
  • [ ] mode defaults to INTERACTIVE;
  • [ ] engine.setApprover() is implemented, with timeout/disconnect fallback;
  • [ ] setQuestionHandler() / setPlanHandler() / setDirectoryHandler() are implemented (if the corresponding tools are used);
  • [ ] maxIterations, contextWindow, and capTokens have clear limits;
  • [ ] toolResultDir is isolated per tenant.

Security

  • [ ] Shell policy and sandbox state match expectations (engine.permissions.sandbox);
  • [ ] The command allowlist does not contain wildcard/compound commands;
  • [ ] MCP tools keep approval enabled, and configuration files do not contain plaintext secrets (use ${ENV});
  • [ ] Attachment size/count limits are validated again at the outer layer;
  • [ ] The retention period and redaction strategy for audit logs are determined.

Reliability

  • [ ] run() calls for the same session are serialized;
  • [ ] Client disconnect → requestInterrupt();
  • [ ] Process exit → mcpManager.closeAll(), hook.saveState();
  • [ ] Restart recovery → read SavedState.messages and restore the permission mode/allowlists;
  • [ ] Provide a "Retry" entry point for error.retriable;
  • [ ] Monitor the distribution of turn_end.status and token usage.

Performance

  • [ ] Events for a single session are not buffered indefinitely (SSE is forwarded directly, without accumulating large arrays in memory);
  • [ ] logDir is sharded by date and cleaned up periodically;
  • [ ] Large tool results go through the persistence budget and are not stuffed back into the model in full.

20. API quick reference ​

20.1 Main entry exports (excerpt) ​

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

// Tool registration
registerFileTools, registerReadOnlyFileTools, registerShellTool, registerSearchTool,
registerWebTools, registerGitTools, registerDiffTools, registerTodoTool, TodoList,
registerExploreTool, registerMediaTool, registerTerminalTools,
registerAskTool, setQuestionHandler,
registerPlanTool, setPlanHandler,
registerDirectoryTool, setDirectoryHandler

// Hooks and views
ConsoleHook, SubagentHook, CompositeHook, FileLogHook,
MarkdownRenderer, approvalFields, approvalToHtmlTable, approvalToMarkdownTable

// Prompts
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

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

// Sandbox
resolveSandbox, wrapWithSandbox, describeSandbox, buildMacProfile, buildBwrapArgs

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

// Session naming: TurnEngine internally uses genSessionNamePrompt;
// Pure logic implementations (shouldAutoName / sanitizeSessionName) are not in the main entry;
// when custom naming is needed, refer to packages/core/src/engine/sessionNaming.ts (unstable API)

20.2 Common environment variables ​

VariablePurpose
OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODELProvider configuration
OPENAI_REASONING_EFFORTReasoning effort
OPENAI_FORCE_API_MODEresponses / chat
OPENAI_MAX_RETRIES / OPENAI_TIMEOUT_MS / OPENAI_STREAM_IDLE_MSRequest reliability
SOCKS5_PROXYSOCKS5 proxy
MAX_ITERATIONSIteration limit during server assembly
CONTEXT_WINDOW / COMPACTION_THRESHOLD_PCT / COMPACTION_SOFT_THRESHOLD_PCT / COMPACTION_CAP_TOKENSCompaction parameters during server assembly
COMPACTION_MODEL / COMPACTION_REASONING_EFFORT / COMPACTION_ELIDE / COMPACTION_DEDUPCompaction behavior
NETWORK_RETRY_MAX / NETWORK_RETRY_BASE_MSTransient network error retries
TOOL_RESULT_MAX_CHARS / TOOL_RESULT_SAVE_THRESHOLD / TOOL_RESULT_DIRTool result budget
EXPLORE_MAX_ITERATIONSSub-agent iteration limit (default 30)
MCP_CALL_TIMEOUT_MSMCP tool call timeout (default 300s)
ZQAGENT_MCP_LAZYMCP lazy-loading switch

21. Troubleshooting FAQ ​

Q: The tool did not execute, and the model says "the user denied it." The approver returned DENY, or defaultApproval: "deny" was set. Check the approver logs.

Q: Error: "Tool X requires approval, but no approver was injected when assembling the engine."engine.setApprover() was not called. This is the expected behavior of the default defaultApproval: "error". Note that this error is thrown by the approver and will emerge directly from for await; the caller needs try/catch (or switch to deny / provide a real approver).

Q: Error: "No model specified." Pass model or set OPENAI_MODEL. The engine will not choose a model for you.

Q: Error: "OPENAI_API_KEY is not set." Pass a custom provider, or set OPENAI_API_KEY.

Q: run() never finishes. Check whether the event stream has been fully consumed; check whether a tool is stuck (long-running run_shell tasks should be moved to the background); check whether the provider ignores opts.signal.

Q: Nothing happens after clicking "Stop." You must call engine.requestInterrupt(), not stop reading the event stream. Custom providers must respond to opts.signal.

Q: After a restart, the user's previous "always allow" choices are lost.SavedState contains permissionMode / allowTools / allowCommands; when restoring, you need to manually write them back to engine.permissions.

Q: Multiple sessions in the same workspace overwrite each other's files. Each session has an independent engine, but the filesystem is shared. The built-in ConflictGuard can detect "modified by someone else after reading." For complete isolation, give each session an independent workspace, or enable vcs auditing.

Q: MCP tools do not appear.connectAll() is asynchronous; you need to wait for it to complete. The tool table is read in real time on each LLM call, so they will appear on the next turn after the connection completes. Check the failed list and the configuration path.

Q: A CommonJS project's require() reports ERR_PACKAGE_PATH_NOT_EXPORTED. Use version 0.2.60 or later; earlier CJS outputs fail to load due to ESM-only dependencies.

Q: Model requests need to go through a proxy. Set SOCKS5_PROXY=socks5://host:port; HTTP/HTTPS proxies are not currently supported.


22. Version, build, and release ​

  • Current package version: 0.3.25 (packages/core/package.json).

  • License: Apache-2.0.

  • Build:

    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
  • Release: bash publish_npm.sh [patch|minor|major] (core only), or the repository-level scripts/publish-all-npm.sh (shared → core → server → cli).

  • Output artifacts: dist/index.js (ESM), dist/index.cjs (CJS), dist/index.d.ts, dist/index.d.cts; @zqagent/shared is installed as an external dependency.

Upgrade recommendations:

  • Pin exact versions, and run integration tests before upgrading;
  • Watch for changes to TurnEngineOpts / CreateEngineOpts / event fields (new fields are usually compatible; removals/renames will be noted in major release notes);
  • Do not treat internal/* as stable APIs; they may change on upgrade.

Related documentation

Last updated: