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.
- Engine implementation:
packages/core/src/engine/TurnEngine.ts - Package entry point and all exports:
packages/core/src/index.ts - Package configuration (
exports/ dependencies / build):packages/core/package.json - Repository architecture overview: architecture.md
- Internal server reference implementation:
packages/server/src/session.ts
Table of Contents
- What it is and what it is not
- Architecture positioning and package relationship
- Installation and runtime environment
- Up and running in five minutes
- Core concepts
- Assembling the engine
- Tools: built-in and custom
- Permissions, approval, and security boundaries
- Complete event stream reference
- Model Provider
- Lifecycle hooks and persistence
- Controlling a turn
- Attachments
- Context compaction
- MCP integration
- Sub-agents and Agent registry
- Change tracking and rollback
- Four integration patterns and code skeletons
- Production checklist
- API quick reference
- Troubleshooting FAQ
- 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
PermissionEngineto 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 (
ConsoleHookis only a reference implementation); - HTTP / WebSocket / SSE services (
@zqagent/serveris a reference implementation); - Users, tenants, billing, and authentication;
- Session indexing and databases (
FileLogHookis 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 approvalThe 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:
| Package | Purpose | Can be used independently |
|---|---|---|
@zqagent/core | Agent engine (the main subject of this document) | Yes |
@zqagent/shared | Shared foundational modules used by core and hub | Automatically installed as a dependency of core |
@zqagent/server | Multi-tenant HTTP service reference implementation | Yes, but belongs to the application layer |
@zqagent/cli | Terminal application reference implementation | Yes, but belongs to the application layer |
3. Installation and runtime environment
3.1 Environment requirements
| Item | Requirement |
|---|---|
| Node.js | >= 18.17 (engines in package.json) |
| Module system | Dual ESM and CommonJS output; both import / require work |
| Types | Type declarations are published with the package (dist/index.d.ts / .d.cts) |
| Operating system | Linux / macOS / Windows; the shell tool automatically selects an available shell on Windows |
3.2 Installation
npm i @zqagent/core
# or
pnpm add @zqagent/core
# or
yarn add @zqagent/coreVersion check: This document corresponds to repository
0.3.25. The available version on npm is determined bynpm view @zqagent/core version; if the npm version lags behind the repository, you can runpnpm --filter @zqagent/core buildin the repository and then reference it viafile:/ workspace. It is recommended to pin an exact version inpackage.jsonto 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:
{
"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/:
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+):
// 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:
export OPENAI_API_KEY=sk-...
export OPENAI_MODEL=gpt-4o-mini # or any OpenAI-compatible model
node agent-demo.mjsThis 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.
| Concept | Type | Responsibility |
|---|---|---|
| Engine | TurnEngine | Runtime for a session. Holds message history, provider, tool table, and permission engine |
| Turn | One call to engine.run() | One user input → several LLM/tool iterations → one turn_end |
| Iteration | Internal loop | One LLM call + all tool executions requested by that reply |
| Event | WsEvent | The only real-time channel for engine output (async generator) |
| Tool | ToolSpec | A function callable by the model + JSON Schema + risk metadata |
| Approver | Approver | When 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.messagesis the session history and is naturally serial;- "always allow for this session" in
engine.permissionsis session-level state; engine.compactionStateis 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 awaitoverengine.run()is the main channel: ordered, backpressure-capable, and each event appears only once, suitable for direct conversion into SSE / WebSocket frames. EngineHookis 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
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):
- If no
provideris passed, createnew OpenAIProvider({ logDir }); - Resolve the sandbox plan with
resolveSandbox(); - Create a
ToolRegistryandConflictGuard, and register 21 built-in tools (see Chapter 7); - Create a
PermissionEngine(workspace, mode, command allowlist, shell policy, sandbox state); - If no
instructionsis passed, usebuildInstructions(agentType, workspace, today, lang)to generate the system prompt; - Assemble the
TurnEngine; ifvcsis enabled, useCompositeHookto connectVcsHookin parallel; - Inject a fallback approver according to
defaultApproval.
All parameters of CreateEngineOpts
| Parameter | Type / default | Description |
|---|---|---|
workspace | string (required) | Workspace root = permission boundary for files and commands |
model | string, defaults to reading OPENAI_MODEL | Model identifier; if both are empty, run() raises a clear error and no fallback model is chosen |
provider | ProviderClient | Custom provider; defaults to creating OpenAIProvider |
mode | Mode.INTERACTIVE | DISCUSS / PLAN / INTERACTIVE / AUTO |
maxIterations | DEFAULT_MAX_ITERATIONS = 800 | Maximum number of iterations per turn |
reasoningEffort | Defaults to reading OPENAI_REASONING_EFFORT | low / medium / high; automatically skipped for DeepSeek |
allowedCommands | [] | Command prefixes automatically allowed in Auto mode |
shellPolicy | "approve" | approve / sandbox / workspace / all |
sandbox | Defaults to auto | { mode, extraWritable, ... }, see 8.4 |
vcs | { mode: "off", sessionId: "default" } | Git snapshot per turn, see Chapter 17 |
instructions | Generated by buildInstructions | Overrides the system prompt |
agentType | "code" | code (coding assistant) / cowork (general collaboration), affects the default prompt |
lang | Simplified Chinese | zh / en / zh-TW / ja / ko / es, affects prompt, naming, and summary |
messages | [] | Initial conversation history (passed when restoring a session) |
hook | None | Lifecycle hook, see Chapter 11 |
subagentHookFactory | None | Creates a sub-agent hook for each explore call |
contextWindow | Auto-detected, falls back to 128K | Model context window (tokens) |
thresholdPct | 0.8 | Hard compaction threshold (as a proportion of the window) |
softThresholdPct | 75% of the hard threshold | Soft threshold: only evict old tool results, without calling the LLM |
capTokens | null | Absolute cap for the hard threshold (tokens); null = no limit |
toolResultDir | tool-results under the state directory | Directory for persisting over-budget tool results; in multi-tenant cases it must be isolated per tenant |
logDir | Not recorded | Root 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
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 thisAll parameters of TurnEngineOpts
| Parameter | Required | Description |
|---|---|---|
provider | Yes | ProviderClient (stream() / chat()) |
registry | Yes | ToolRegistry |
permissions | Yes | PermissionEngine |
model | Yes | Model identifier; an empty string will throw in run() |
instructions | Yes | System prompt, injected as the first system message |
genSessionNamePrompt | No | Prompt for generating the session name; defaults according to lang |
lang | No | Defaults to "zh"; affects naming and compaction summary |
maxIterations | No | Defaults to 800 |
finalizeOnMaxIterations | No | After hitting the limit, run one more tool-free finalization turn; defaults to false; sub-agents use true |
reasoningEffort | No | Passed through to the provider |
messages | No | Initial history; if the first message is not system, instructions is automatically inserted |
hook | No | EngineHook |
contextWindow | No | Overrides auto-detection |
thresholdPct | No | Defaults to 0.8 |
softThresholdPct | No | Defaults to 75% of the hard threshold |
capTokens | No | Defaults to null |
toolResultDir | No | Directory for persisting tool results |
6.3 What can be changed at runtime
Public properties and methods of TurnEngine:
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 injected7. Tools: built-in and custom
7.1 The 21 tools registered by createDefaultEngine
| Category | Tool | Risk | Registration function |
|---|---|---|---|
| File | read_file, list_files | Read-only | registerFileTools / registerReadOnlyFileTools |
| File | write_file, replace_in_file | Local write | registerFileTools |
| Shell | run_shell, shell_task_output, shell_task_kill | Execution | registerShellTool |
| Search | grep | Read-only | registerSearchTool |
| Network | web_search, web_fetch | External | registerWebTools |
| Git | git_log, git_status, git_diff | Read-only | registerGitTools |
| Patch | apply_patch, apply_unified_diff | Local write | registerDiffTools |
| Collaboration | ask_user, propose_plan, request_directory | Interactive | registerAskTool / registerPlanTool / registerDirectoryTool |
| Task | todo_write | Read-only/state | registerTodoTool |
| Sub-agent | explore | Read-only | registerExploreTool |
| Media | media | External | registerMediaTool |
Terminal tools are not part of the one-line assembly:
terminal_list/terminal_readdepend on the host-providedTerminalSource(the long-running PTY behind the console "Terminal" panel), and must be injected manually:tsimport { registerTerminalTools, type TerminalSource } from "@zqagent/core"; registerTerminalTools(registry, { terminals: myTerminalManager, // implements TerminalSource projectId: "tenant-42", });
projectIdis 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):
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:
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. funcmay return a Promise; the engine willawaitit.- 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
registerreplaces the old tool, which can be used to override built-in implementations;remove()deletes;disable()/enable()control whether it appears inschemas()(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, andTOOL_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:
import { registerReadOnlyFileTools } from "@zqagent/core";
registerReadOnlyFileTools(registry, workspace); // read_file / list_files8. Permissions, approval, and security boundaries
8.1 Decision flow
PermissionEngine.evaluate() makes decisions in a fixed order (packages/core/src/permissions/PermissionEngine.ts:173):
- Read-only mode (
DISCUSS/PLAN) → operations with side effects are directly denied; - Write file path not under
workspaceRoot→ directly denied; - Read-only risk (
RiskClass.READ) → directly allowed; AUTOmode → all operations with side effects are allowed;- shell approval-exemption policy (
shellPolicy+ sandbox) → may be directly allowed; - Command allowlist / session command allowlist → directly allowed;
- Session tool allowlist (the user has selected "always allow this tool") → directly allowed;
- Everything else →
needsUser: true, handed to the approver.
Return structure:
interface Decision {
allowed: boolean;
reason: string; // Displayed to the user / on the approval card
needsUser: boolean; // true = requires approval
}8.2 Run modes
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
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
ONCEaccording to the allowlist, andDENYfor the rest (ordefaultApproval: "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:
| Value | Behavior |
|---|---|
approve (default) | Always require approval except for the allowlist |
sandbox | Exempt from approval only when the OS-level sandbox is actually in effect; otherwise require approval |
workspace | Sandbox in effect → exempt; not in effect → fall back to workspace path-based judgment |
all | All shell commands are exempt from approval (high risk; equivalent to auto only for shell) |
Sandbox configuration:
createDefaultEngine({
workspace,
sandbox: {
mode: "auto", // auto / off / required
extraWritable: ["/data/cache"], // Additional writable paths
},
});- macOS:
sandbox-exec, narrowingfile-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
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:
lswill not allowlsof; - Commands containing shell operators (
; & | > < \$( ( \n) are **never automatically allowed**, to prevent bypasses such asls; 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
workspaceto/, the user's home directory, or a directory containing secrets; - Use
Mode.INTERACTIVEby default; do not default toAUTOjust for an "automated experience"; - In multi-tenant scenarios, use an independent
workspace,toolResultDir,FileLogHookdirectory, and MCP configuration for each tenant; - MCP tools default to
requiresApproval: true; do not disable approval globally; logs/logDirrecord 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
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_endException 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
| Event | Key fields in data | When it appears |
|---|---|---|
turn_start | taskId, input, attachmentCount?, attachments? | At the start of each turn |
assistant_delta | text, reqId, taskId | Streaming text increment from the model |
reasoning_delta | text, reqId, taskId | Reasoning process increment (some models) |
assistant_message | text, reasoning, tool_calls (array of tool names), usage?, reqId, taskId, logTime | End of one LLM reply |
tool_proposed | reqId (= tool call ID), name, arguments, taskId | The model proposes a tool call |
permission_required | taskId, reqId, name, arguments, reason, category | Approval required (the approver is then called) |
tool_started | taskId, reqId, name, startTime? | Tool starts executing |
tool_finished | taskId, reqId, name, status, reason?, duration?, result_preview? | Tool finishes |
iteration_end | iteration, taskId | End of one iteration (LLM + tools) |
turn_end | status, iterations, produced_files, taskId | End of one turn (always emitted) |
error | error, error_type, retriable, iteration, reqId, logTime, taskId, stack? | Engine error |
interrupted | iterations, reqId, taskId | User interruption |
compacting | taskId, trigger, tokens, estimated, contextWindow, thresholdPct, thresholdTokens, outboundMessages | Compaction starts |
compacted | reqId, taskId, method, messagesBefore, messagesAfter, boundaryIndex?, model?, summaryPreview?, keptUserMessages?, droppedUserMessages? | Compaction completed |
queue_steering | text, taskId, attachments? | Queued supplemental instructions are injected |
llm_request | reqId, taskId, logTime, model, msgCount, toolCount, compact? | Before each LLM request (for auditing) |
ask_user_question | taskId, question, options, allowText, multi | The model calls ask_user |
plan_proposed | plan | The model calls propose_plan |
directory_requested | reason, path, writable | The model calls request_directory |
llm_retry | attempt, max, reason, waitMs, iteration, reqId | Automatic retry after transient network error |
session_named | name, source | Enum reserved; currently broadcast by the CLI/server after naming; TurnEngine does not produce it |
Values of tool_finished.status:
| status | Meaning |
|---|---|
ok | Executed successfully |
error | The tool threw an exception or returned an error |
denied | Permission denied or user denied |
interrupted | Interrupted during execution |
Values of turn_end.status:
| status | Meaning |
|---|---|
completed | The model no longer requests tools; normal completion |
max_iterations_exceeded | Reached maxIterations |
interrupted | User interruption |
error | Terminated by LLM/engine error |
9.3 Consumption example: converting to SSE
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 tryengine.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 baseNETWORK_RETRY_BASE_MS(default 800ms), during whichllm_retryis 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
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|chatcan force the mode. - When the model name or baseURL contains
deepseek, the Responses path automatically injects the server-sideweb_searchbuilt-in tool and filters out the local tool with the same name. logDirwrites request/response audit files split by date and byreqId; it includes message content, so decide whether to enable it according to data compliance requirements.
Related environment variables:
| Variable | Purpose |
|---|---|
OPENAI_API_KEY | API Key |
OPENAI_BASE_URL | Compatible endpoint |
OPENAI_MODEL | Default model |
OPENAI_REASONING_EFFORT | low / medium / high |
OPENAI_FORCE_API_MODE | responses / chat |
OPENAI_MAX_RETRIES | SDK retry count |
OPENAI_TIMEOUT_MS | Request timeout |
OPENAI_STREAM_IDLE_MS | Stream idle watchdog |
SOCKS5_PROXY | LLM and network tools use a SOCKS5 proxy |
10.2 Custom Provider
The engine depends on only two methods (packages/core/src/providers/OpenAIProvider.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:
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:
- The last chunk must include
toolCallsorfinishReason, otherwise the engine considers the stream not properly ended; - 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
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
| Callback | Parameters | Purpose |
|---|---|---|
onTurnStart | input | Start of a turn |
onTurnEnd | status, iterations, producedFiles | End of a turn |
onAssistantDelta | text | Streaming text |
onReasoningDelta | text | Streaming reasoning |
onAssistantMessage | text, toolCalls, usage? | Complete reply (commonly used for billing) |
onToolProposed | name, args | Tool proposal |
onToolStarted | name | Tool start |
onToolFinished | name, status, resultPreview | Tool end |
onPermissionRequired | name, reason | Approval required |
onIterationEnd | iteration | End of iteration (commonly used for checkpoints) |
onCompacting / onCompacted | info / text, info | Compaction |
onError | error, errorType, info? | Error (including retriable) |
onRetry | attempt, max, reason | Automatic network retry |
onInterrupted | iterations | Interruption |
onEvent | type, data | Fallback 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
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
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:
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
messagestonew TurnEngine({ messages })orcreateDefaultEngine({ messages }); - Restore
permissionModeand allowlists (engine.permissions.mode = ...,allowToolForSession/allowCommandForSession), otherwise the user's previous authorizations will be lost after a restart; FileLogHook.loadState()returnsnullwhen the file cannot be read; it does not throw;- In multi-tenant scenarios, use one
stateDirper 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
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 contextConsecutive calls on the same engine preserve history. To start a new session, create a new engine.
12.2 Interruption
// Elsewhere (for example, HTTP DELETE / user clicks "Stop")
engine.requestInterrupt();It will:
- Set the cancellation flag;
- Abort the in-flight LLM stream;
- Abort the currently executing tool (
run_shellwill 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
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):
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
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:
engine.removeSteering("Also run the tests while you're at it"); // Returns false if already injected12.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 tomessagesat the same time.
13. Attachments
13.1 Data structures
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):
| Constant | Value | Meaning |
|---|---|---|
MAX_ATTACHMENTS | 4 | Maximum number of attachments per request |
MAX_ATTACHMENT_BYTES | 10 MB | Per-file and total limit |
ATTACHMENT_DIR | "attachments" | Subdirectory under the session directory |
13.2 Recommended flow: persist first, then pass references
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:
const { name, mimeType, size, base64 } = readStoredAttachment(sessionDir, ref.path);Security notes:
readStoredAttachmentonly accepts the formattachments/<file>, and rejects..and subdirectories;- Attachments enter LLM requests, and
logDiraudit 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
| Concept | Default | Description |
|---|---|---|
| Context window | Auto-detected, falls back to 128K | Can be overridden with contextWindow |
| Hard threshold | 80% of the window | thresholdPct |
| Soft threshold | 75% of the hard threshold (≈60% of the window) | softThresholdPct; only evicts old tool results |
| Absolute cap | None | capTokens |
| Recent retention | About 25% | Proportion of recent conversation retained after compaction |
| Summary output cap | 3000 tokens | Fixed internally |
Flow:
- When the soft threshold is reached: evict old tool results and deduplicate repeated
read_fileresults, without calling the LLM; - When the hard threshold is reached: call the LLM to generate a summary, emitting
compacting/compacted; - If the API reports context overflow: force compaction and retry.
14.2 Events and hooks
// 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
createDefaultEngine({
workspace,
contextWindow: 200_000,
thresholdPct: 0.85,
softThresholdPct: 0.7,
capTokens: 120_000,
});| Environment variable | Purpose |
|---|---|
COMPACTION_MODEL | Model used for summaries (defaults to the current model) |
COMPACTION_REASONING_EFFORT | Reasoning effort for summaries (default low) |
COMPACTION_ELIDE | 0 disables eviction of old tool results |
COMPACTION_DEDUP | 0 disables deduplication of repeated reads |
CONTEXT_WINDOW | Window override during server assembly (read by @zqagent/server) |
COMPACTION_THRESHOLD_PCT / COMPACTION_SOFT_THRESHOLD_PCT / COMPACTION_CAP_TOKENS | Threshold overrides during server assembly |
CompactionStateand compaction utility functions are currently not exported from the package root; third parties only need to observe state throughengine.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
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:
// 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
typeis omitted, it is inferred frombuiltin→url→command; commandcan be a string or an array;${VAR}/${env:VAR}are expanded from environment variables;enabled: falseordisabled: truedisables 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)
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
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 exitMcpManager 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:
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:
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":
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
const engine = createDefaultEngine({
workspace,
vcs: { mode: "auto", sessionId: "tenant-42-session-7" },
});mode:
| Value | Behavior |
|---|---|
off (default) | Disabled |
auto | Use native mode if it is a Git repository; otherwise use a shadow repository |
native | Use the workspace .git, but with an independent index and refs/agent/*, without touching the user's HEAD/staging |
shadow | Create 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
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 filesVcsHook 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
turnreturned bycommitTurn()is always 0; the real turn number comes fromlistTurns(); - The
turnindiffTurn/restoreTurnis 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_requiredevent + a Promise waiting to be resolved; - Call
requestInterrupt()when the client disconnects.
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;
workspacepoints 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
INTERACTIVEand the sandbox; - Before exit,
await mcpManager.closeAll()andhook.saveState().
18.3 Pattern 3: Unattended Worker
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, andcapTokensto prevent runaway token usage; - Use
onAssistantMessageto track usage; - Use
onTurnEndto checkstatus; treat anything other thancompletedas failure and record it.
18.4 Pattern 4: Multi-tenant SaaS
Dimensions that must be isolated:
| Resource | Isolation method |
|---|---|
| Filesystem | Independent workspace per tenant |
| Tool result persistence | Independent toolResultDir per tenant/session |
| Events and checkpoints | Independent stateDir per session (FileLogHook) |
| Git snapshots | sessionId per session → refs/agent/<id> |
| MCP | Per-tenant configuration source and McpBuiltinContext.workspace |
| Approval | Approval requests must carry tenant/user context; do not share a global queue |
| Model credentials | Provider 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
- [ ]
modelis explicitly configured and startup fails immediately if it is not; - [ ]
workspaceis an independent directory per tenant/task, not/or the home directory; - [ ]
modedefaults toINTERACTIVE; - [ ]
engine.setApprover()is implemented, with timeout/disconnect fallback; - [ ]
setQuestionHandler()/setPlanHandler()/setDirectoryHandler()are implemented (if the corresponding tools are used); - [ ]
maxIterations,contextWindow, andcapTokenshave clear limits; - [ ]
toolResultDiris 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.messagesand restore the permission mode/allowlists; - [ ] Provide a "Retry" entry point for
error.retriable; - [ ] Monitor the distribution of
turn_end.statusand token usage.
Performance
- [ ] Events for a single session are not buffered indefinitely (SSE is forwarded directly, without accumulating large arrays in memory);
- [ ]
logDiris 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)
// 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
| Variable | Purpose |
|---|---|
OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL | Provider configuration |
OPENAI_REASONING_EFFORT | Reasoning effort |
OPENAI_FORCE_API_MODE | responses / chat |
OPENAI_MAX_RETRIES / OPENAI_TIMEOUT_MS / OPENAI_STREAM_IDLE_MS | Request reliability |
SOCKS5_PROXY | SOCKS5 proxy |
MAX_ITERATIONS | Iteration limit during server assembly |
CONTEXT_WINDOW / COMPACTION_THRESHOLD_PCT / COMPACTION_SOFT_THRESHOLD_PCT / COMPACTION_CAP_TOKENS | Compaction parameters during server assembly |
COMPACTION_MODEL / COMPACTION_REASONING_EFFORT / COMPACTION_ELIDE / COMPACTION_DEDUP | Compaction behavior |
NETWORK_RETRY_MAX / NETWORK_RETRY_BASE_MS | Transient network error retries |
TOOL_RESULT_MAX_CHARS / TOOL_RESULT_SAVE_THRESHOLD / TOOL_RESULT_DIR | Tool result budget |
EXPLORE_MAX_ITERATIONS | Sub-agent iteration limit (default 30) |
MCP_CALL_TIMEOUT_MS | MCP tool call timeout (default 300s) |
ZQAGENT_MCP_LAZY | MCP 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:
bashpnpm --filter @zqagent/core build # tsup → packages/core/dist (ESM + CJS + d.ts) pnpm --filter @zqagent/core typecheck pnpm --filter @zqagent/core test # VitestRelease:
bash publish_npm.sh [patch|minor|major](core only), or the repository-levelscripts/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/sharedis 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
