@kiturone/kapibala 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/index.cjs +1940 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +636 -0
- package/dist/index.d.ts +636 -0
- package/dist/index.js +1872 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1940 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
AbortError: () => AbortError,
|
|
34
|
+
AgentLoop: () => AgentLoop,
|
|
35
|
+
AgentSession: () => AgentSession,
|
|
36
|
+
DEFAULT_CONTEXT_WINDOW_TOKENS: () => DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
37
|
+
FatalError: () => FatalError,
|
|
38
|
+
HookRegistry: () => HookRegistry,
|
|
39
|
+
JSONLMessageStore: () => JSONLMessageStore,
|
|
40
|
+
KapibalaError: () => KapibalaError,
|
|
41
|
+
ModelError: () => ModelError,
|
|
42
|
+
OpenAICompatibleProvider: () => OpenAICompatibleProvider,
|
|
43
|
+
PathSandbox: () => PathSandbox,
|
|
44
|
+
PromptAssembler: () => PromptAssembler,
|
|
45
|
+
SessionBusyError: () => SessionBusyError,
|
|
46
|
+
SimpleModelRouter: () => SimpleModelRouter,
|
|
47
|
+
ToolError: () => ToolError,
|
|
48
|
+
ToolExecutor: () => ToolExecutor,
|
|
49
|
+
ToolNotFound: () => ToolNotFound,
|
|
50
|
+
ToolRegistry: () => ToolRegistry,
|
|
51
|
+
TransportError: () => TransportError,
|
|
52
|
+
builtinTools: () => builtinTools,
|
|
53
|
+
defineTool: () => defineTool,
|
|
54
|
+
editFileTool: () => editFileTool,
|
|
55
|
+
globTool: () => globTool,
|
|
56
|
+
grepTool: () => grepTool,
|
|
57
|
+
isTextBlock: () => isTextBlock,
|
|
58
|
+
isThinkingBlock: () => isThinkingBlock,
|
|
59
|
+
isToolResultBlock: () => isToolResultBlock,
|
|
60
|
+
isToolUseBlock: () => isToolUseBlock,
|
|
61
|
+
readFileTool: () => readFileTool,
|
|
62
|
+
resolveContextWindow: () => resolveContextWindow,
|
|
63
|
+
toToolDefinition: () => toToolDefinition,
|
|
64
|
+
writeFileTool: () => writeFileTool
|
|
65
|
+
});
|
|
66
|
+
module.exports = __toCommonJS(index_exports);
|
|
67
|
+
|
|
68
|
+
// src/types/index.ts
|
|
69
|
+
function isTextBlock(block) {
|
|
70
|
+
return block.type === "text";
|
|
71
|
+
}
|
|
72
|
+
function isToolUseBlock(block) {
|
|
73
|
+
return block.type === "tool_use";
|
|
74
|
+
}
|
|
75
|
+
function isToolResultBlock(block) {
|
|
76
|
+
return block.type === "tool_result";
|
|
77
|
+
}
|
|
78
|
+
function isThinkingBlock(block) {
|
|
79
|
+
return block.type === "thinking";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/errors/index.ts
|
|
83
|
+
var KapibalaError = class extends Error {
|
|
84
|
+
constructor(message) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "KapibalaError";
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var ModelError = class extends KapibalaError {
|
|
90
|
+
status;
|
|
91
|
+
retryable;
|
|
92
|
+
constructor(message, options) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "ModelError";
|
|
95
|
+
this.status = options?.status;
|
|
96
|
+
this.retryable = options?.retryable ?? (options?.status ? [429, 500, 502, 503, 504, 529].includes(options.status) : false);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
var TransportError = class extends KapibalaError {
|
|
100
|
+
retryable;
|
|
101
|
+
constructor(message, retryable = true) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.name = "TransportError";
|
|
104
|
+
this.retryable = retryable;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
var ToolError = class extends KapibalaError {
|
|
108
|
+
constructor(message) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "ToolError";
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
var ToolNotFound = class extends KapibalaError {
|
|
114
|
+
toolName;
|
|
115
|
+
availableTools;
|
|
116
|
+
constructor(toolName, availableTools = []) {
|
|
117
|
+
super(`Tool '${toolName}' not found. Available tools: ${availableTools.join(", ") || "none"}`);
|
|
118
|
+
this.name = "ToolNotFound";
|
|
119
|
+
this.toolName = toolName;
|
|
120
|
+
this.availableTools = availableTools;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
var AbortError = class extends KapibalaError {
|
|
124
|
+
constructor(message = "Execution aborted by user") {
|
|
125
|
+
super(message);
|
|
126
|
+
this.name = "AbortError";
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
var FatalError = class extends KapibalaError {
|
|
130
|
+
constructor(message) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = "FatalError";
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
var SessionBusyError = class extends KapibalaError {
|
|
136
|
+
constructor(operation) {
|
|
137
|
+
super(`Session is already running; cannot ${operation}`);
|
|
138
|
+
this.name = "SessionBusyError";
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/security/sandbox.ts
|
|
143
|
+
var import_node_fs = __toESM(require("fs"), 1);
|
|
144
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
145
|
+
function tryRealpath(target) {
|
|
146
|
+
try {
|
|
147
|
+
return import_node_fs.default.realpathSync(target);
|
|
148
|
+
} catch {
|
|
149
|
+
return void 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
var PathSandbox = class {
|
|
153
|
+
rootDir;
|
|
154
|
+
allowSymlinks;
|
|
155
|
+
cachedRealRootDir;
|
|
156
|
+
constructor(config) {
|
|
157
|
+
this.rootDir = import_node_path.default.resolve(config.rootDir);
|
|
158
|
+
this.allowSymlinks = config.allowSymlinks ?? false;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* rootDir 的物理路径。rootDir 自身可能是一个符号链接(如 macOS 的 /var → /private/var),
|
|
162
|
+
* 必须先归一化,否则物理路径与 rootDir 比较时会产生误判。
|
|
163
|
+
*/
|
|
164
|
+
get realRootDir() {
|
|
165
|
+
if (this.cachedRealRootDir === void 0) {
|
|
166
|
+
this.cachedRealRootDir = tryRealpath(this.rootDir) ?? this.rootDir;
|
|
167
|
+
}
|
|
168
|
+
return this.cachedRealRootDir;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 检查并解析安全路径
|
|
172
|
+
* 1. 解析相对路径为相对于 rootDir 的绝对路径
|
|
173
|
+
* 2. 词法检查:`..` 归一化后必须仍在 rootDir 内
|
|
174
|
+
* 3. 物理检查:解析符号链接后的真实路径必须仍在 rootDir 内
|
|
175
|
+
*/
|
|
176
|
+
resolveSafePath(targetPath) {
|
|
177
|
+
if (!targetPath || typeof targetPath !== "string") {
|
|
178
|
+
throw new ToolError("Invalid path: path must be a non-empty string");
|
|
179
|
+
}
|
|
180
|
+
const resolvedPath = import_node_path.default.isAbsolute(targetPath) ? import_node_path.default.resolve(targetPath) : import_node_path.default.resolve(this.rootDir, targetPath);
|
|
181
|
+
const relative = import_node_path.default.relative(this.rootDir, resolvedPath);
|
|
182
|
+
if (relative.startsWith("..") || import_node_path.default.isAbsolute(relative)) {
|
|
183
|
+
throw new ToolError(
|
|
184
|
+
`Access denied: path '${targetPath}' escapes root directory '${this.rootDir}'`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
const physicalPath = this.toPhysicalPath(resolvedPath);
|
|
188
|
+
const physicalRelative = import_node_path.default.relative(this.realRootDir, physicalPath);
|
|
189
|
+
if (physicalRelative.startsWith("..") || import_node_path.default.isAbsolute(physicalRelative)) {
|
|
190
|
+
throw new ToolError(
|
|
191
|
+
`Access denied: path '${targetPath}' resolves outside root directory '${this.rootDir}' (via symlink)`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return resolvedPath;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* 把词法路径映射为物理路径。
|
|
198
|
+
*
|
|
199
|
+
* 关键点在于**向上回溯到最近一个真实存在的祖先**再取 realpath,然后把尚未存在的尾段拼回去:
|
|
200
|
+
* - 目标已存在 → 直接 realpath,符号链接立刻被展开;
|
|
201
|
+
* - 目标不存在(典型场景:write_file 新建文件) → 若只看目标本身,所有校验都会被跳过,
|
|
202
|
+
* 于是 `write_file('linkDir/new.txt')` 可以借道指向外部的 linkDir 越界写入。
|
|
203
|
+
* 回溯到 linkDir 取 realpath 即可识破这种穿透。
|
|
204
|
+
*/
|
|
205
|
+
toPhysicalPath(resolvedPath) {
|
|
206
|
+
const pendingSegments = [];
|
|
207
|
+
let probe = resolvedPath;
|
|
208
|
+
while (true) {
|
|
209
|
+
let stat;
|
|
210
|
+
try {
|
|
211
|
+
stat = import_node_fs.default.lstatSync(probe);
|
|
212
|
+
} catch {
|
|
213
|
+
stat = void 0;
|
|
214
|
+
}
|
|
215
|
+
if (stat) {
|
|
216
|
+
const real = tryRealpath(probe);
|
|
217
|
+
if (real === void 0) {
|
|
218
|
+
throw new ToolError(
|
|
219
|
+
`Access denied: cannot resolve real path for '${resolvedPath}' (broken or unresolvable symlink)`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return pendingSegments.length > 0 ? import_node_path.default.join(real, ...pendingSegments) : real;
|
|
223
|
+
}
|
|
224
|
+
const parent = import_node_path.default.dirname(probe);
|
|
225
|
+
if (parent === probe) {
|
|
226
|
+
return resolvedPath;
|
|
227
|
+
}
|
|
228
|
+
pendingSegments.unshift(import_node_path.default.basename(probe));
|
|
229
|
+
probe = parent;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// src/tools/index.ts
|
|
235
|
+
function defineTool(tool) {
|
|
236
|
+
return tool;
|
|
237
|
+
}
|
|
238
|
+
function toToolDefinition(tool) {
|
|
239
|
+
return {
|
|
240
|
+
name: tool.name,
|
|
241
|
+
description: tool.description,
|
|
242
|
+
parameters: tool.parameters
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/tools/registry.ts
|
|
247
|
+
var ToolRegistry = class {
|
|
248
|
+
tools = /* @__PURE__ */ new Map();
|
|
249
|
+
toolSources = /* @__PURE__ */ new Map();
|
|
250
|
+
// toolName -> source
|
|
251
|
+
conflictPolicy;
|
|
252
|
+
constructor(options) {
|
|
253
|
+
this.conflictPolicy = options?.conflictPolicy ?? "error";
|
|
254
|
+
}
|
|
255
|
+
register(tool, opts) {
|
|
256
|
+
const source = opts?.source ?? tool.metadata?.source ?? "builtin";
|
|
257
|
+
const existing = this.tools.get(tool.name);
|
|
258
|
+
if (existing) {
|
|
259
|
+
if (this.conflictPolicy === "error") {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Tool name conflict: '${tool.name}' is already registered by '${this.toolSources.get(tool.name)}'`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (this.conflictPolicy === "prefer-builtin" && this.toolSources.get(tool.name) === "builtin") {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
this.tools.set(tool.name, tool);
|
|
269
|
+
this.toolSources.set(tool.name, source);
|
|
270
|
+
}
|
|
271
|
+
registerSource(source, tools) {
|
|
272
|
+
for (const tool of tools) {
|
|
273
|
+
this.register(tool, { source });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
unregisterSource(source) {
|
|
277
|
+
for (const [name, s] of this.toolSources.entries()) {
|
|
278
|
+
if (s === source) {
|
|
279
|
+
this.tools.delete(name);
|
|
280
|
+
this.toolSources.delete(name);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
get(name) {
|
|
285
|
+
return this.tools.get(name);
|
|
286
|
+
}
|
|
287
|
+
resolve(name) {
|
|
288
|
+
const tool = this.tools.get(name);
|
|
289
|
+
if (!tool) {
|
|
290
|
+
throw new ToolNotFound(name, Array.from(this.tools.keys()));
|
|
291
|
+
}
|
|
292
|
+
return tool;
|
|
293
|
+
}
|
|
294
|
+
list() {
|
|
295
|
+
return Array.from(this.tools.values());
|
|
296
|
+
}
|
|
297
|
+
definitions() {
|
|
298
|
+
return this.list().map(toToolDefinition);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/tools/builtin/fs.ts
|
|
303
|
+
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
304
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
305
|
+
var sandboxCache = /* @__PURE__ */ new Map();
|
|
306
|
+
function getSandbox(rootDir) {
|
|
307
|
+
let sandbox = sandboxCache.get(rootDir);
|
|
308
|
+
if (!sandbox) {
|
|
309
|
+
sandbox = new PathSandbox({ rootDir });
|
|
310
|
+
sandboxCache.set(rootDir, sandbox);
|
|
311
|
+
}
|
|
312
|
+
return sandbox;
|
|
313
|
+
}
|
|
314
|
+
var readFileTool = defineTool({
|
|
315
|
+
name: "read_file",
|
|
316
|
+
description: "Read the text contents of a file at the specified path.",
|
|
317
|
+
metadata: {
|
|
318
|
+
source: "builtin",
|
|
319
|
+
permissions: ["fs:read"]
|
|
320
|
+
},
|
|
321
|
+
parameters: {
|
|
322
|
+
type: "object",
|
|
323
|
+
properties: {
|
|
324
|
+
path: {
|
|
325
|
+
type: "string",
|
|
326
|
+
description: "The path of the file to read (relative to workspace or absolute)."
|
|
327
|
+
},
|
|
328
|
+
startLine: {
|
|
329
|
+
type: "integer",
|
|
330
|
+
description: "Optional 1-based start line to read from."
|
|
331
|
+
},
|
|
332
|
+
endLine: {
|
|
333
|
+
type: "integer",
|
|
334
|
+
description: "Optional 1-based end line to read to (inclusive)."
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
required: ["path"]
|
|
338
|
+
},
|
|
339
|
+
async execute(input, ctx) {
|
|
340
|
+
const sandbox = getSandbox(ctx.rootDir);
|
|
341
|
+
const safePath = sandbox.resolveSafePath(input.path);
|
|
342
|
+
if (!import_node_fs2.default.existsSync(safePath)) {
|
|
343
|
+
throw new ToolError(`File not found: ${input.path}`);
|
|
344
|
+
}
|
|
345
|
+
const stat = import_node_fs2.default.statSync(safePath);
|
|
346
|
+
if (stat.isDirectory()) {
|
|
347
|
+
throw new ToolError(`Target path is a directory, not a file: ${input.path}`);
|
|
348
|
+
}
|
|
349
|
+
const maxReadBytes = 512 * 1024;
|
|
350
|
+
const hasLineRange = input.startLine !== void 0 || input.endLine !== void 0;
|
|
351
|
+
if (stat.size > maxReadBytes && !hasLineRange) {
|
|
352
|
+
throw new ToolError(
|
|
353
|
+
`File too large to read in one call: ${input.path} (${stat.size} bytes > ${maxReadBytes}). Use startLine/endLine to read in chunks, or narrow the file first.`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const prefixSize = Math.min(stat.size, 8192);
|
|
357
|
+
const prefix = Buffer.alloc(prefixSize);
|
|
358
|
+
const fileDescriptor = import_node_fs2.default.openSync(safePath, "r");
|
|
359
|
+
try {
|
|
360
|
+
import_node_fs2.default.readSync(fileDescriptor, prefix, 0, prefixSize, 0);
|
|
361
|
+
} finally {
|
|
362
|
+
import_node_fs2.default.closeSync(fileDescriptor);
|
|
363
|
+
}
|
|
364
|
+
if (prefix.includes(0)) {
|
|
365
|
+
throw new ToolError(`Cannot read binary file: ${input.path}`);
|
|
366
|
+
}
|
|
367
|
+
if (hasLineRange) {
|
|
368
|
+
return readFileLineRange(
|
|
369
|
+
safePath,
|
|
370
|
+
Math.max(1, input.startLine ?? 1),
|
|
371
|
+
input.endLine,
|
|
372
|
+
maxReadBytes
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
return import_node_fs2.default.readFileSync(safePath, "utf-8");
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
async function readFileLineRange(filePath, startLine, endLine, maxReadBytes) {
|
|
379
|
+
if (endLine !== void 0 && endLine < startLine) return "";
|
|
380
|
+
const selectedLines = [];
|
|
381
|
+
let selectedBytes = 0;
|
|
382
|
+
let currentLine = 1;
|
|
383
|
+
let pending = "";
|
|
384
|
+
let reachedEnd = false;
|
|
385
|
+
const stream = import_node_fs2.default.createReadStream(filePath, { encoding: "utf-8" });
|
|
386
|
+
const selectLine = (line) => {
|
|
387
|
+
if (currentLine >= startLine && (endLine === void 0 || currentLine <= endLine)) {
|
|
388
|
+
const addedBytes = Buffer.byteLength(line, "utf-8") + (selectedLines.length > 0 ? 1 : 0);
|
|
389
|
+
if (selectedBytes + addedBytes > maxReadBytes) {
|
|
390
|
+
throw new ToolError(
|
|
391
|
+
`Requested line range exceeds ${maxReadBytes} bytes. Use a narrower startLine/endLine range.`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
selectedLines.push(line);
|
|
395
|
+
selectedBytes += addedBytes;
|
|
396
|
+
}
|
|
397
|
+
if (Buffer.byteLength(line, "utf-8") > maxReadBytes) {
|
|
398
|
+
throw new ToolError(`File line exceeds ${maxReadBytes} byte limit`);
|
|
399
|
+
}
|
|
400
|
+
currentLine++;
|
|
401
|
+
reachedEnd = endLine !== void 0 && currentLine > endLine;
|
|
402
|
+
};
|
|
403
|
+
try {
|
|
404
|
+
for await (const chunk of stream) {
|
|
405
|
+
pending += chunk;
|
|
406
|
+
let newlineIndex = pending.indexOf("\n");
|
|
407
|
+
while (newlineIndex >= 0) {
|
|
408
|
+
selectLine(pending.slice(0, newlineIndex));
|
|
409
|
+
pending = pending.slice(newlineIndex + 1);
|
|
410
|
+
if (reachedEnd) break;
|
|
411
|
+
newlineIndex = pending.indexOf("\n");
|
|
412
|
+
}
|
|
413
|
+
if (Buffer.byteLength(pending, "utf-8") > maxReadBytes) {
|
|
414
|
+
throw new ToolError(`File line exceeds ${maxReadBytes} byte limit`);
|
|
415
|
+
}
|
|
416
|
+
if (reachedEnd) break;
|
|
417
|
+
}
|
|
418
|
+
if (!reachedEnd) selectLine(pending);
|
|
419
|
+
} finally {
|
|
420
|
+
stream.destroy();
|
|
421
|
+
}
|
|
422
|
+
return selectedLines.join("\n");
|
|
423
|
+
}
|
|
424
|
+
var writeFileTool = defineTool({
|
|
425
|
+
name: "write_file",
|
|
426
|
+
description: "Create a new file or overwrite an existing file with new content.",
|
|
427
|
+
metadata: {
|
|
428
|
+
source: "builtin",
|
|
429
|
+
permissions: ["fs:write"]
|
|
430
|
+
},
|
|
431
|
+
parameters: {
|
|
432
|
+
type: "object",
|
|
433
|
+
properties: {
|
|
434
|
+
path: {
|
|
435
|
+
type: "string",
|
|
436
|
+
description: "The path of the file to write to."
|
|
437
|
+
},
|
|
438
|
+
content: {
|
|
439
|
+
type: "string",
|
|
440
|
+
description: "The full text content to write into the file."
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
required: ["path", "content"]
|
|
444
|
+
},
|
|
445
|
+
async execute(input, ctx) {
|
|
446
|
+
const sandbox = getSandbox(ctx.rootDir);
|
|
447
|
+
const safePath = sandbox.resolveSafePath(input.path);
|
|
448
|
+
const dir = import_node_path2.default.dirname(safePath);
|
|
449
|
+
if (!import_node_fs2.default.existsSync(dir)) {
|
|
450
|
+
import_node_fs2.default.mkdirSync(dir, { recursive: true });
|
|
451
|
+
}
|
|
452
|
+
import_node_fs2.default.writeFileSync(safePath, input.content, "utf-8");
|
|
453
|
+
return `Successfully wrote ${Buffer.byteLength(input.content, "utf-8")} bytes to ${input.path}`;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
var editFileTool = defineTool({
|
|
457
|
+
name: "edit_file",
|
|
458
|
+
description: "Perform an exact string replacement in a file.",
|
|
459
|
+
metadata: {
|
|
460
|
+
source: "builtin",
|
|
461
|
+
permissions: ["fs:write"]
|
|
462
|
+
},
|
|
463
|
+
parameters: {
|
|
464
|
+
type: "object",
|
|
465
|
+
properties: {
|
|
466
|
+
path: {
|
|
467
|
+
type: "string",
|
|
468
|
+
description: "The path of the file to edit."
|
|
469
|
+
},
|
|
470
|
+
targetContent: {
|
|
471
|
+
type: "string",
|
|
472
|
+
description: "The exact string snippet in the file to be replaced."
|
|
473
|
+
},
|
|
474
|
+
replacementContent: {
|
|
475
|
+
type: "string",
|
|
476
|
+
description: "The new replacement content to put in place of targetContent."
|
|
477
|
+
}
|
|
478
|
+
},
|
|
479
|
+
required: ["path", "targetContent", "replacementContent"]
|
|
480
|
+
},
|
|
481
|
+
async execute(input, ctx) {
|
|
482
|
+
const sandbox = getSandbox(ctx.rootDir);
|
|
483
|
+
const safePath = sandbox.resolveSafePath(input.path);
|
|
484
|
+
if (!import_node_fs2.default.existsSync(safePath)) {
|
|
485
|
+
throw new ToolError(`File not found: ${input.path}`);
|
|
486
|
+
}
|
|
487
|
+
if (!input.targetContent) {
|
|
488
|
+
throw new ToolError("Target content must not be empty");
|
|
489
|
+
}
|
|
490
|
+
const content = import_node_fs2.default.readFileSync(safePath, "utf-8");
|
|
491
|
+
if (!content.includes(input.targetContent)) {
|
|
492
|
+
throw new ToolError(
|
|
493
|
+
`Target content not found in ${input.path}. Ensure exact indentation and characters.`
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const occurrences = content.split(input.targetContent).length - 1;
|
|
497
|
+
if (occurrences > 1) {
|
|
498
|
+
throw new ToolError(
|
|
499
|
+
`Target content occurs ${occurrences} times in ${input.path}. Please provide more surrounding context to match uniquely.`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const newContent = content.replace(input.targetContent, () => input.replacementContent);
|
|
503
|
+
import_node_fs2.default.writeFileSync(safePath, newContent, "utf-8");
|
|
504
|
+
return `Successfully replaced target content in ${input.path}`;
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
var globTool = defineTool({
|
|
508
|
+
name: "glob",
|
|
509
|
+
description: "Search for files matching a pattern within a directory.",
|
|
510
|
+
metadata: {
|
|
511
|
+
source: "builtin",
|
|
512
|
+
permissions: ["fs:read"]
|
|
513
|
+
},
|
|
514
|
+
parameters: {
|
|
515
|
+
type: "object",
|
|
516
|
+
properties: {
|
|
517
|
+
pattern: {
|
|
518
|
+
type: "string",
|
|
519
|
+
description: 'A glob pattern or filename prefix/suffix to match (e.g. "*.ts", "src/**").'
|
|
520
|
+
},
|
|
521
|
+
subDirectory: {
|
|
522
|
+
type: "string",
|
|
523
|
+
description: "Optional sub-directory to restrict search within."
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
required: ["pattern"]
|
|
527
|
+
},
|
|
528
|
+
async execute(input, ctx) {
|
|
529
|
+
const sandbox = getSandbox(ctx.rootDir);
|
|
530
|
+
const searchRoot = input.subDirectory ? sandbox.resolveSafePath(input.subDirectory) : ctx.rootDir;
|
|
531
|
+
if (!import_node_fs2.default.existsSync(searchRoot)) {
|
|
532
|
+
throw new ToolError(`Directory not found: ${input.subDirectory ?? "."}`);
|
|
533
|
+
}
|
|
534
|
+
const results = [];
|
|
535
|
+
const maxResults = 100;
|
|
536
|
+
function walk(currentDir) {
|
|
537
|
+
if (results.length >= maxResults) return;
|
|
538
|
+
const entries = import_node_fs2.default.readdirSync(currentDir, { withFileTypes: true });
|
|
539
|
+
for (const entry of entries) {
|
|
540
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const fullPath = import_node_path2.default.join(currentDir, entry.name);
|
|
544
|
+
const relPath = import_node_path2.default.relative(ctx.rootDir, fullPath).replace(/\\/g, "/");
|
|
545
|
+
if (entry.isDirectory()) {
|
|
546
|
+
walk(fullPath);
|
|
547
|
+
} else {
|
|
548
|
+
if (simpleMatch(entry.name, input.pattern) || simpleMatch(relPath, input.pattern)) {
|
|
549
|
+
results.push(relPath);
|
|
550
|
+
if (results.length >= maxResults) return;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
walk(searchRoot);
|
|
556
|
+
return results.length > 0 ? results.join("\n") : "No matching files found.";
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
var grepTool = defineTool({
|
|
560
|
+
name: "grep",
|
|
561
|
+
description: "Search for case-insensitive literal text in files within workspace.",
|
|
562
|
+
metadata: {
|
|
563
|
+
source: "builtin",
|
|
564
|
+
permissions: ["fs:read"]
|
|
565
|
+
},
|
|
566
|
+
parameters: {
|
|
567
|
+
type: "object",
|
|
568
|
+
properties: {
|
|
569
|
+
pattern: {
|
|
570
|
+
type: "string",
|
|
571
|
+
description: "Literal text to search for. Regular-expression characters have no special meaning."
|
|
572
|
+
},
|
|
573
|
+
path: {
|
|
574
|
+
type: "string",
|
|
575
|
+
description: "File or directory path to search within."
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
required: ["pattern"]
|
|
579
|
+
},
|
|
580
|
+
async execute(input, ctx) {
|
|
581
|
+
if (ctx.signal?.aborted) throw new AbortError();
|
|
582
|
+
const sandbox = getSandbox(ctx.rootDir);
|
|
583
|
+
const target = input.path ? sandbox.resolveSafePath(input.path) : ctx.rootDir;
|
|
584
|
+
if (!import_node_fs2.default.existsSync(target)) {
|
|
585
|
+
throw new ToolError(`Path not found: ${input.path ?? "."}`);
|
|
586
|
+
}
|
|
587
|
+
if (!input.pattern.trim()) throw new ToolError("Search pattern must not be empty");
|
|
588
|
+
const needle = input.pattern.toLowerCase();
|
|
589
|
+
const matches = [];
|
|
590
|
+
const maxMatches = 50;
|
|
591
|
+
let outputBytes = 0;
|
|
592
|
+
async function searchFile(filePath) {
|
|
593
|
+
if (matches.length >= maxMatches) return;
|
|
594
|
+
const maxLineBytes = 512 * 1024;
|
|
595
|
+
const maxOutputBytes = 512 * 1024;
|
|
596
|
+
const stream = import_node_fs2.default.createReadStream(filePath, { encoding: "utf-8" });
|
|
597
|
+
const relPath = import_node_path2.default.relative(ctx.rootDir, filePath).replace(/\\/g, "/");
|
|
598
|
+
let lineNumber = 0;
|
|
599
|
+
let pending = "";
|
|
600
|
+
let skippingLongLine = false;
|
|
601
|
+
const inspectLine = (line) => {
|
|
602
|
+
lineNumber++;
|
|
603
|
+
if (skippingLongLine || Buffer.byteLength(line, "utf-8") > maxLineBytes) {
|
|
604
|
+
skippingLongLine = false;
|
|
605
|
+
if (line.toLowerCase().includes(needle)) {
|
|
606
|
+
throw new ToolError(`Search line exceeds ${maxLineBytes} byte limit`);
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (!line.toLowerCase().includes(needle)) return;
|
|
611
|
+
const match = `${relPath}:${lineNumber}: ${line.trim()}`;
|
|
612
|
+
const bytes = Buffer.byteLength(match, "utf-8") + (matches.length > 0 ? 1 : 0);
|
|
613
|
+
if (outputBytes + bytes > maxOutputBytes) {
|
|
614
|
+
throw new ToolError(`Search results exceed ${maxOutputBytes} byte limit`);
|
|
615
|
+
}
|
|
616
|
+
matches.push(match);
|
|
617
|
+
outputBytes += bytes;
|
|
618
|
+
};
|
|
619
|
+
try {
|
|
620
|
+
for await (const chunk of stream) {
|
|
621
|
+
if (ctx.signal?.aborted) throw new AbortError();
|
|
622
|
+
pending += chunk;
|
|
623
|
+
let newlineIndex = pending.indexOf("\n");
|
|
624
|
+
while (newlineIndex >= 0) {
|
|
625
|
+
inspectLine(pending.slice(0, newlineIndex).replace(/\r$/, ""));
|
|
626
|
+
pending = pending.slice(newlineIndex + 1);
|
|
627
|
+
if (matches.length >= maxMatches) return;
|
|
628
|
+
newlineIndex = pending.indexOf("\n");
|
|
629
|
+
}
|
|
630
|
+
if (Buffer.byteLength(pending, "utf-8") > maxLineBytes) {
|
|
631
|
+
if (pending.toLowerCase().includes(needle)) {
|
|
632
|
+
throw new ToolError(`Search line exceeds ${maxLineBytes} byte limit`);
|
|
633
|
+
}
|
|
634
|
+
skippingLongLine = true;
|
|
635
|
+
pending = needle.length > 1 ? pending.slice(1 - needle.length) : "";
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (pending.length > 0) inspectLine(pending.replace(/\r$/, ""));
|
|
639
|
+
} catch (error) {
|
|
640
|
+
if (error instanceof AbortError || error instanceof ToolError) throw error;
|
|
641
|
+
} finally {
|
|
642
|
+
stream.destroy();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
async function walk(dirPath) {
|
|
646
|
+
if (matches.length >= maxMatches) return;
|
|
647
|
+
const entries = import_node_fs2.default.readdirSync(dirPath, { withFileTypes: true });
|
|
648
|
+
for (const entry of entries) {
|
|
649
|
+
if (ctx.signal?.aborted) throw new AbortError();
|
|
650
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (entry.isSymbolicLink()) continue;
|
|
654
|
+
const fullPath = import_node_path2.default.join(dirPath, entry.name);
|
|
655
|
+
if (entry.isDirectory()) {
|
|
656
|
+
await walk(fullPath);
|
|
657
|
+
} else {
|
|
658
|
+
await searchFile(fullPath);
|
|
659
|
+
}
|
|
660
|
+
if (matches.length >= maxMatches) return;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const stat = import_node_fs2.default.statSync(target);
|
|
664
|
+
if (stat.isFile()) {
|
|
665
|
+
await searchFile(target);
|
|
666
|
+
} else {
|
|
667
|
+
await walk(target);
|
|
668
|
+
}
|
|
669
|
+
return matches.length > 0 ? matches.join("\n") : "No matches found.";
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
function simpleMatch(str, pattern) {
|
|
673
|
+
if (pattern === "*" || pattern === "**") return true;
|
|
674
|
+
const escaped = pattern.replace(
|
|
675
|
+
/[.+?^${}()|[\]\\]|\*{1,2}/g,
|
|
676
|
+
(match) => match === "**" ? ".*" : match === "*" ? "[^/]*" : `\\${match}`
|
|
677
|
+
);
|
|
678
|
+
return new RegExp(`^${escaped}$`, "i").test(str);
|
|
679
|
+
}
|
|
680
|
+
var builtinTools = [readFileTool, writeFileTool, editFileTool, globTool, grepTool];
|
|
681
|
+
|
|
682
|
+
// src/hooks/registry.ts
|
|
683
|
+
var HookRegistry = class {
|
|
684
|
+
hooks = {};
|
|
685
|
+
on(point, handler) {
|
|
686
|
+
if (!this.hooks[point]) {
|
|
687
|
+
this.hooks[point] = [];
|
|
688
|
+
}
|
|
689
|
+
this.hooks[point].push(handler);
|
|
690
|
+
}
|
|
691
|
+
get(point) {
|
|
692
|
+
return this.hooks[point] ?? [];
|
|
693
|
+
}
|
|
694
|
+
async emit(point, ctx, ...args) {
|
|
695
|
+
const handlers = this.get(point);
|
|
696
|
+
for (const handler of handlers) {
|
|
697
|
+
await handler(ctx, ...args);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
// src/executor/index.ts
|
|
703
|
+
var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
|
|
704
|
+
var ToolExecutor = class {
|
|
705
|
+
tools;
|
|
706
|
+
hooks;
|
|
707
|
+
rootDir;
|
|
708
|
+
signal;
|
|
709
|
+
logger;
|
|
710
|
+
toolTimeoutMs;
|
|
711
|
+
constructor(options) {
|
|
712
|
+
this.tools = options.tools;
|
|
713
|
+
this.hooks = options.hooks;
|
|
714
|
+
this.rootDir = options.rootDir;
|
|
715
|
+
this.signal = options.signal;
|
|
716
|
+
this.logger = options.logger;
|
|
717
|
+
this.toolTimeoutMs = options.toolTimeoutMs ?? DEFAULT_TOOL_TIMEOUT_MS;
|
|
718
|
+
}
|
|
719
|
+
async runAll(calls) {
|
|
720
|
+
const results = [];
|
|
721
|
+
for (const call of calls) {
|
|
722
|
+
if (this.signal?.aborted) {
|
|
723
|
+
results.push({
|
|
724
|
+
type: "tool_result",
|
|
725
|
+
toolUseId: call.id,
|
|
726
|
+
content: "Tool execution aborted",
|
|
727
|
+
isError: true
|
|
728
|
+
});
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
results.push(await this.executeOne(call));
|
|
732
|
+
}
|
|
733
|
+
return results;
|
|
734
|
+
}
|
|
735
|
+
async executeOne(call) {
|
|
736
|
+
const startedAt = Date.now();
|
|
737
|
+
const hookCtx = { signal: this.signal, logger: this.logger };
|
|
738
|
+
let currentInput = call.input;
|
|
739
|
+
const beforeHooks = this.hooks.get("tool:before");
|
|
740
|
+
for (const hook of beforeHooks) {
|
|
741
|
+
const decision = await hook(hookCtx, { id: call.id, name: call.name, input: currentInput });
|
|
742
|
+
if (decision.action === "skip") {
|
|
743
|
+
return {
|
|
744
|
+
type: "tool_result",
|
|
745
|
+
toolUseId: call.id,
|
|
746
|
+
content: decision.result,
|
|
747
|
+
isError: decision.isError ?? false,
|
|
748
|
+
durationMs: Math.max(0, Date.now() - startedAt)
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
if (decision.action === "modify") {
|
|
752
|
+
currentInput = decision.input;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
let outputStr;
|
|
756
|
+
let isError = false;
|
|
757
|
+
try {
|
|
758
|
+
const tool = this.tools.resolve(call.name);
|
|
759
|
+
const executionController = new AbortController();
|
|
760
|
+
const abortFromSession = () => executionController.abort();
|
|
761
|
+
if (this.signal?.aborted) {
|
|
762
|
+
executionController.abort();
|
|
763
|
+
} else {
|
|
764
|
+
this.signal?.addEventListener("abort", abortFromSession, { once: true });
|
|
765
|
+
}
|
|
766
|
+
const toolCtx = {
|
|
767
|
+
rootDir: this.rootDir,
|
|
768
|
+
signal: executionController.signal,
|
|
769
|
+
logger: this.logger
|
|
770
|
+
};
|
|
771
|
+
try {
|
|
772
|
+
const rawResult = await this.withTimeout(
|
|
773
|
+
tool.execute(currentInput, toolCtx),
|
|
774
|
+
call.name,
|
|
775
|
+
executionController
|
|
776
|
+
);
|
|
777
|
+
outputStr = typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult, null, 2);
|
|
778
|
+
} finally {
|
|
779
|
+
this.signal?.removeEventListener("abort", abortFromSession);
|
|
780
|
+
}
|
|
781
|
+
} catch (err) {
|
|
782
|
+
isError = true;
|
|
783
|
+
outputStr = err instanceof Error ? err.message : String(err);
|
|
784
|
+
}
|
|
785
|
+
const resultBlock = {
|
|
786
|
+
type: "tool_result",
|
|
787
|
+
toolUseId: call.id,
|
|
788
|
+
content: outputStr,
|
|
789
|
+
isError
|
|
790
|
+
};
|
|
791
|
+
const afterHooks = this.hooks.get("tool:after");
|
|
792
|
+
for (const hook of afterHooks) {
|
|
793
|
+
await hook(
|
|
794
|
+
hookCtx,
|
|
795
|
+
{ id: call.id, name: call.name, input: currentInput },
|
|
796
|
+
{ output: outputStr, isError }
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
resultBlock.durationMs = Math.max(0, Date.now() - startedAt);
|
|
800
|
+
return resultBlock;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* 给工具执行加超时护栏:Promise.race 在超时后reject,循环拿到 isError 结果继续运转,
|
|
804
|
+
* 不会因某个异步工具(如未来接入的网络/exec 工具)挂起而永久卡死。
|
|
805
|
+
*
|
|
806
|
+
* 局限:同步 CPU 密集型实现会阻塞事件循环,timer 同样无法触发;
|
|
807
|
+
* 因此内置工具仍需避免执行用户可控的同步高复杂度计算。
|
|
808
|
+
*/
|
|
809
|
+
withTimeout(promise, toolName, executionController) {
|
|
810
|
+
let timer;
|
|
811
|
+
const timeout = new Promise((_, reject) => {
|
|
812
|
+
timer = setTimeout(() => {
|
|
813
|
+
const error = new ToolError(`Tool '${toolName}' timed out after ${this.toolTimeoutMs}ms`);
|
|
814
|
+
reject(error);
|
|
815
|
+
executionController.abort(error);
|
|
816
|
+
}, this.toolTimeoutMs);
|
|
817
|
+
});
|
|
818
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
// src/models/router.ts
|
|
823
|
+
var DEFAULT_CONTEXT_WINDOW_TOKENS = 1e6;
|
|
824
|
+
function resolveContextWindow(value) {
|
|
825
|
+
if (value === void 0) {
|
|
826
|
+
return { tokens: DEFAULT_CONTEXT_WINDOW_TOKENS, estimated: true };
|
|
827
|
+
}
|
|
828
|
+
let tokens;
|
|
829
|
+
if (typeof value === "number") {
|
|
830
|
+
tokens = value;
|
|
831
|
+
} else {
|
|
832
|
+
const match = /^([0-9]+(?:\.[0-9]+)?)([km])$/i.exec(value.trim());
|
|
833
|
+
const unit = match?.[2]?.toLowerCase();
|
|
834
|
+
const fractionDigits = match?.[1]?.split(".")[1]?.length ?? 0;
|
|
835
|
+
const maxFractionDigits = unit === "m" ? 6 : 3;
|
|
836
|
+
if (!match || fractionDigits > maxFractionDigits) {
|
|
837
|
+
throw new RangeError(
|
|
838
|
+
`Invalid contextWindow '${value}': expected a positive integer or K/M value`
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
const multiplier = unit === "m" ? 1e6 : 1e3;
|
|
842
|
+
tokens = Number(match[1]) * multiplier;
|
|
843
|
+
}
|
|
844
|
+
if (!Number.isSafeInteger(tokens) || tokens <= 0) {
|
|
845
|
+
throw new RangeError(
|
|
846
|
+
`Invalid contextWindow '${value}': token count must be a positive integer`
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
return { tokens, estimated: false };
|
|
850
|
+
}
|
|
851
|
+
var SimpleModelRouter = class {
|
|
852
|
+
providers = /* @__PURE__ */ new Map();
|
|
853
|
+
profiles = /* @__PURE__ */ new Map();
|
|
854
|
+
defaultRole = "default";
|
|
855
|
+
constructor(defaultProfile, defaultProvider) {
|
|
856
|
+
this.setRole("default", defaultProfile, defaultProvider);
|
|
857
|
+
}
|
|
858
|
+
setRole(role, profile, provider) {
|
|
859
|
+
this.profiles.set(role, profile);
|
|
860
|
+
this.providers.set(role, provider);
|
|
861
|
+
}
|
|
862
|
+
resolve(role = "default") {
|
|
863
|
+
return this.providers.get(role) ?? this.providers.get("default");
|
|
864
|
+
}
|
|
865
|
+
getProfile(role = "default") {
|
|
866
|
+
return this.profiles.get(role) ?? this.profiles.get("default");
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
// src/models/openai-compatible/sse.ts
|
|
871
|
+
async function* parseSSEStream(stream) {
|
|
872
|
+
const reader = stream.getReader();
|
|
873
|
+
const decoder = new TextDecoder();
|
|
874
|
+
let buffer = "";
|
|
875
|
+
try {
|
|
876
|
+
while (true) {
|
|
877
|
+
const { done, value } = await reader.read();
|
|
878
|
+
if (done) break;
|
|
879
|
+
buffer += decoder.decode(value, { stream: true });
|
|
880
|
+
const lines = buffer.split(/\r?\n/);
|
|
881
|
+
buffer = lines.pop() ?? "";
|
|
882
|
+
for (const line of lines) {
|
|
883
|
+
const trimmed = line.trim();
|
|
884
|
+
if (!trimmed || trimmed.startsWith(":")) {
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (trimmed.startsWith("data:")) {
|
|
888
|
+
const dataContent = trimmed.slice(5).trim();
|
|
889
|
+
if (dataContent === "[DONE]") {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
yield dataContent;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (buffer.trim().startsWith("data:")) {
|
|
897
|
+
const dataContent = buffer.trim().slice(5).trim();
|
|
898
|
+
if (dataContent !== "[DONE]") {
|
|
899
|
+
yield dataContent;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
} finally {
|
|
903
|
+
reader.releaseLock();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// src/models/openai-compatible/index.ts
|
|
908
|
+
var DEFAULT_CONNECT_TIMEOUT_MS = 6e4;
|
|
909
|
+
var OpenAICompatibleProvider = class {
|
|
910
|
+
name = "openai-compatible";
|
|
911
|
+
baseURL;
|
|
912
|
+
apiKey;
|
|
913
|
+
modelName;
|
|
914
|
+
supportsThinking;
|
|
915
|
+
connectTimeoutMs;
|
|
916
|
+
constructor(options) {
|
|
917
|
+
this.baseURL = options.baseURL.replace(/\/+$/, "");
|
|
918
|
+
this.apiKey = options.apiKey;
|
|
919
|
+
this.modelName = options.modelName;
|
|
920
|
+
this.supportsThinking = options.supportsThinking ?? false;
|
|
921
|
+
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
922
|
+
}
|
|
923
|
+
async *create(req) {
|
|
924
|
+
const url = `${this.baseURL}/chat/completions`;
|
|
925
|
+
const wireMessages = this.translateMessagesToWire(req.messages, req.systemPrompt);
|
|
926
|
+
const wireTools = req.tools && req.tools.length > 0 ? req.tools.map((t) => ({
|
|
927
|
+
type: "function",
|
|
928
|
+
function: {
|
|
929
|
+
name: t.name,
|
|
930
|
+
description: t.description,
|
|
931
|
+
parameters: t.parameters
|
|
932
|
+
}
|
|
933
|
+
})) : void 0;
|
|
934
|
+
const payload = {
|
|
935
|
+
model: this.modelName,
|
|
936
|
+
messages: wireMessages,
|
|
937
|
+
stream: true,
|
|
938
|
+
stream_options: { include_usage: true },
|
|
939
|
+
temperature: req.temperature ?? 0.7
|
|
940
|
+
};
|
|
941
|
+
if (wireTools) {
|
|
942
|
+
payload.tools = wireTools;
|
|
943
|
+
}
|
|
944
|
+
if (req.maxTokens) {
|
|
945
|
+
payload.max_tokens = req.maxTokens;
|
|
946
|
+
}
|
|
947
|
+
let response;
|
|
948
|
+
const timeoutController = new AbortController();
|
|
949
|
+
const timeoutTimer = setTimeout(() => timeoutController.abort(), this.connectTimeoutMs);
|
|
950
|
+
const onOuterAbort = () => timeoutController.abort();
|
|
951
|
+
if (req.signal?.aborted) {
|
|
952
|
+
timeoutController.abort();
|
|
953
|
+
} else {
|
|
954
|
+
req.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
try {
|
|
958
|
+
response = await fetch(url, {
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers: {
|
|
961
|
+
"Content-Type": "application/json",
|
|
962
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
963
|
+
},
|
|
964
|
+
body: JSON.stringify(payload),
|
|
965
|
+
signal: timeoutController.signal
|
|
966
|
+
});
|
|
967
|
+
} catch (err) {
|
|
968
|
+
if (req.signal?.aborted) {
|
|
969
|
+
throw err;
|
|
970
|
+
}
|
|
971
|
+
if (timeoutController.signal.aborted) {
|
|
972
|
+
throw new ModelError(`Connection to ${url} timed out after ${this.connectTimeoutMs}ms`, {
|
|
973
|
+
status: 408,
|
|
974
|
+
retryable: true
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
throw new ModelError(`Failed to connect to ${url}: ${err.message}`);
|
|
978
|
+
} finally {
|
|
979
|
+
clearTimeout(timeoutTimer);
|
|
980
|
+
}
|
|
981
|
+
if (!response.ok) {
|
|
982
|
+
const errorText = await response.text();
|
|
983
|
+
let errorMsg = `API request failed with status ${response.status}: ${response.statusText}`;
|
|
984
|
+
try {
|
|
985
|
+
const errorJson = JSON.parse(errorText);
|
|
986
|
+
if (errorJson.error?.message) {
|
|
987
|
+
errorMsg = errorJson.error.message;
|
|
988
|
+
}
|
|
989
|
+
} catch {
|
|
990
|
+
if (errorText) errorMsg += ` - ${errorText.slice(0, 300)}`;
|
|
991
|
+
}
|
|
992
|
+
throw new ModelError(errorMsg, { status: response.status });
|
|
993
|
+
}
|
|
994
|
+
if (!response.body) {
|
|
995
|
+
throw new ModelError("Response body is null");
|
|
996
|
+
}
|
|
997
|
+
const pendingToolCalls = /* @__PURE__ */ new Map();
|
|
998
|
+
let usage;
|
|
999
|
+
const requestStartTime = Date.now();
|
|
1000
|
+
let ttftMs;
|
|
1001
|
+
for await (const chunk of parseSSEStream(response.body)) {
|
|
1002
|
+
if (req.signal?.aborted) return;
|
|
1003
|
+
if (!chunk) continue;
|
|
1004
|
+
let json;
|
|
1005
|
+
try {
|
|
1006
|
+
json = JSON.parse(chunk);
|
|
1007
|
+
} catch {
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (json.error) {
|
|
1011
|
+
const message = typeof json.error.message === "string" ? json.error.message : "Model API stream error";
|
|
1012
|
+
const details = [json.error.type, json.error.code].filter((value) => typeof value === "string" && value.length > 0).join(", ");
|
|
1013
|
+
throw new ModelError(details ? `${message} (${details})` : message);
|
|
1014
|
+
}
|
|
1015
|
+
if (json.usage) {
|
|
1016
|
+
usage = {
|
|
1017
|
+
promptTokens: json.usage.prompt_tokens ?? 0,
|
|
1018
|
+
completionTokens: json.usage.completion_tokens ?? 0,
|
|
1019
|
+
totalTokens: json.usage.total_tokens ?? 0
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
const choice = json.choices?.[0];
|
|
1023
|
+
if (!choice) continue;
|
|
1024
|
+
const delta = choice.delta;
|
|
1025
|
+
if (!delta) continue;
|
|
1026
|
+
if ((delta.content || delta.reasoning_content || delta.tool_calls && delta.tool_calls.length > 0) && ttftMs === void 0) {
|
|
1027
|
+
ttftMs = Date.now() - requestStartTime;
|
|
1028
|
+
}
|
|
1029
|
+
if (delta.content) {
|
|
1030
|
+
yield { type: "text_delta", text: delta.content };
|
|
1031
|
+
}
|
|
1032
|
+
if (delta.reasoning_content) {
|
|
1033
|
+
yield { type: "thinking_delta", thinking: delta.reasoning_content };
|
|
1034
|
+
}
|
|
1035
|
+
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
|
|
1036
|
+
for (const tc of delta.tool_calls) {
|
|
1037
|
+
const index = tc.index ?? 0;
|
|
1038
|
+
let entry = pendingToolCalls.get(index);
|
|
1039
|
+
if (!entry) {
|
|
1040
|
+
entry = {
|
|
1041
|
+
id: tc.id ?? `call_${Date.now()}_${index}`,
|
|
1042
|
+
name: tc.function?.name ?? "",
|
|
1043
|
+
argumentChunks: []
|
|
1044
|
+
};
|
|
1045
|
+
pendingToolCalls.set(index, entry);
|
|
1046
|
+
yield { type: "tool_call_start", id: entry.id, name: entry.name };
|
|
1047
|
+
}
|
|
1048
|
+
if (tc.function?.name && !entry.name) {
|
|
1049
|
+
entry.name = tc.function.name;
|
|
1050
|
+
}
|
|
1051
|
+
if (tc.function?.arguments) {
|
|
1052
|
+
entry.argumentChunks.push(tc.function.arguments);
|
|
1053
|
+
yield {
|
|
1054
|
+
type: "tool_call_delta",
|
|
1055
|
+
id: entry.id,
|
|
1056
|
+
argumentChunk: tc.function.arguments
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
for (const [, tc] of pendingToolCalls.entries()) {
|
|
1063
|
+
const fullArgs = tc.argumentChunks.join("");
|
|
1064
|
+
let parsedInput = {};
|
|
1065
|
+
let parseError;
|
|
1066
|
+
try {
|
|
1067
|
+
parsedInput = fullArgs.trim() ? JSON.parse(fullArgs) : {};
|
|
1068
|
+
} catch {
|
|
1069
|
+
parsedInput = { _raw: fullArgs };
|
|
1070
|
+
parseError = true;
|
|
1071
|
+
}
|
|
1072
|
+
yield {
|
|
1073
|
+
type: "tool_call_finish",
|
|
1074
|
+
id: tc.id,
|
|
1075
|
+
name: tc.name,
|
|
1076
|
+
input: parsedInput,
|
|
1077
|
+
parseError
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
const durationMs = Date.now() - requestStartTime;
|
|
1081
|
+
yield { type: "message_stop", usage, ttftMs, durationMs };
|
|
1082
|
+
} finally {
|
|
1083
|
+
req.signal?.removeEventListener("abort", onOuterAbort);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
assembleToolResults(results) {
|
|
1087
|
+
return results.map((r) => ({
|
|
1088
|
+
role: "tool",
|
|
1089
|
+
content: [r],
|
|
1090
|
+
timestamp: Date.now()
|
|
1091
|
+
}));
|
|
1092
|
+
}
|
|
1093
|
+
translateMessagesToWire(messages, systemPrompt) {
|
|
1094
|
+
const wire = [];
|
|
1095
|
+
if (systemPrompt?.trim()) {
|
|
1096
|
+
wire.push({ role: "system", content: systemPrompt.trim() });
|
|
1097
|
+
}
|
|
1098
|
+
for (const msg of messages) {
|
|
1099
|
+
if (msg.role === "system") {
|
|
1100
|
+
const text = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
1101
|
+
if (text) wire.push({ role: "system", content: text });
|
|
1102
|
+
} else if (msg.role === "user") {
|
|
1103
|
+
const text = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
1104
|
+
const previous = wire[wire.length - 1];
|
|
1105
|
+
if (previous?.role === "user" && typeof previous.content === "string") {
|
|
1106
|
+
previous.content = [previous.content, text].filter(Boolean).join("\n\n");
|
|
1107
|
+
} else {
|
|
1108
|
+
wire.push({ role: "user", content: text });
|
|
1109
|
+
}
|
|
1110
|
+
} else if (msg.role === "assistant") {
|
|
1111
|
+
const text = msg.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
1112
|
+
const toolCalls = [];
|
|
1113
|
+
for (const block of msg.content) {
|
|
1114
|
+
if (block.type === "tool_use") {
|
|
1115
|
+
toolCalls.push({
|
|
1116
|
+
id: block.id,
|
|
1117
|
+
type: "function",
|
|
1118
|
+
function: {
|
|
1119
|
+
name: block.name,
|
|
1120
|
+
arguments: JSON.stringify(block.input)
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (!text && toolCalls.length === 0) continue;
|
|
1126
|
+
wire.push({
|
|
1127
|
+
role: "assistant",
|
|
1128
|
+
content: text || null,
|
|
1129
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : void 0
|
|
1130
|
+
});
|
|
1131
|
+
} else if (msg.role === "tool") {
|
|
1132
|
+
for (const block of msg.content) {
|
|
1133
|
+
if (block.type === "tool_result") {
|
|
1134
|
+
wire.push({
|
|
1135
|
+
role: "tool",
|
|
1136
|
+
tool_call_id: block.toolUseId,
|
|
1137
|
+
content: block.content
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return wire;
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
// src/store/jsonl.ts
|
|
1148
|
+
var import_node_fs3 = __toESM(require("fs"), 1);
|
|
1149
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
1150
|
+
var JSONLMessageStore = class {
|
|
1151
|
+
filePath;
|
|
1152
|
+
constructor(filePath) {
|
|
1153
|
+
this.filePath = import_node_path3.default.resolve(filePath);
|
|
1154
|
+
const dir = import_node_path3.default.dirname(this.filePath);
|
|
1155
|
+
if (!import_node_fs3.default.existsSync(dir)) {
|
|
1156
|
+
import_node_fs3.default.mkdirSync(dir, { recursive: true });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async append(message) {
|
|
1160
|
+
const record = {
|
|
1161
|
+
ts: message.timestamp ?? Date.now(),
|
|
1162
|
+
role: message.role,
|
|
1163
|
+
content: message.content
|
|
1164
|
+
};
|
|
1165
|
+
import_node_fs3.default.appendFileSync(this.filePath, `${JSON.stringify(record)}
|
|
1166
|
+
`, "utf-8");
|
|
1167
|
+
}
|
|
1168
|
+
async load() {
|
|
1169
|
+
if (!import_node_fs3.default.existsSync(this.filePath)) {
|
|
1170
|
+
return [];
|
|
1171
|
+
}
|
|
1172
|
+
const content = import_node_fs3.default.readFileSync(this.filePath, "utf-8");
|
|
1173
|
+
const lines = content.split("\n");
|
|
1174
|
+
const messages = [];
|
|
1175
|
+
for (const line of lines) {
|
|
1176
|
+
const trimmed = line.trim();
|
|
1177
|
+
if (!trimmed) continue;
|
|
1178
|
+
try {
|
|
1179
|
+
const record = JSON.parse(trimmed);
|
|
1180
|
+
if (record.role && record.content) {
|
|
1181
|
+
messages.push({
|
|
1182
|
+
role: record.role,
|
|
1183
|
+
content: record.content,
|
|
1184
|
+
timestamp: record.ts
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
} catch {
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
this.healDanglingToolCalls(messages);
|
|
1191
|
+
return messages;
|
|
1192
|
+
}
|
|
1193
|
+
async clear() {
|
|
1194
|
+
if (import_node_fs3.default.existsSync(this.filePath)) {
|
|
1195
|
+
import_node_fs3.default.unlinkSync(this.filePath);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* 历史自愈(设计文档 §4.4):逐条扫描 assistant 的 tool_use,
|
|
1200
|
+
* 若其后的 tool 消息未完整覆盖这些 tool_use,就地补一条 is_error 的 tool_result。
|
|
1201
|
+
*
|
|
1202
|
+
* 之所以做**全历史扫描**而非只看末条:崩溃可能发生在任意一轮工具执行中途,
|
|
1203
|
+
* 悬挂的 tool_use 未必位于历史末尾(例如中断后用户又继续提了问)。
|
|
1204
|
+
* 只看末条会漏掉夹在中间的悬挂,下一次请求仍会因上下文非法而 400。
|
|
1205
|
+
*/
|
|
1206
|
+
healDanglingToolCalls(messages) {
|
|
1207
|
+
const healed = [];
|
|
1208
|
+
let pending = /* @__PURE__ */ new Map();
|
|
1209
|
+
const closePending = () => {
|
|
1210
|
+
if (pending.size === 0) return;
|
|
1211
|
+
healed.push({
|
|
1212
|
+
role: "tool",
|
|
1213
|
+
content: Array.from(pending.values()).map(
|
|
1214
|
+
(toolUse) => ({
|
|
1215
|
+
type: "tool_result",
|
|
1216
|
+
toolUseId: toolUse.id,
|
|
1217
|
+
content: "Tool execution was interrupted or crashed in previous session",
|
|
1218
|
+
isError: true
|
|
1219
|
+
})
|
|
1220
|
+
),
|
|
1221
|
+
timestamp: Date.now()
|
|
1222
|
+
});
|
|
1223
|
+
pending = /* @__PURE__ */ new Map();
|
|
1224
|
+
};
|
|
1225
|
+
for (const msg of messages) {
|
|
1226
|
+
if (msg.role === "tool") {
|
|
1227
|
+
const valid = msg.content.filter((block) => {
|
|
1228
|
+
if (!isToolResultBlock(block) || !pending.has(block.toolUseId)) return false;
|
|
1229
|
+
pending.delete(block.toolUseId);
|
|
1230
|
+
return true;
|
|
1231
|
+
});
|
|
1232
|
+
if (valid.length === 0) continue;
|
|
1233
|
+
healed.push(valid.length === msg.content.length ? msg : { ...msg, content: valid });
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
closePending();
|
|
1237
|
+
healed.push(msg);
|
|
1238
|
+
if (msg.role !== "assistant") continue;
|
|
1239
|
+
const toolUses = msg.content.filter(isToolUseBlock);
|
|
1240
|
+
if (toolUses.length === 0) continue;
|
|
1241
|
+
pending = new Map(toolUses.map((toolUse) => [toolUse.id, toolUse]));
|
|
1242
|
+
}
|
|
1243
|
+
closePending();
|
|
1244
|
+
messages.length = 0;
|
|
1245
|
+
messages.push(...healed);
|
|
1246
|
+
}
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
// src/prompt/index.ts
|
|
1250
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
1251
|
+
var PromptAssembler = class {
|
|
1252
|
+
tools;
|
|
1253
|
+
rootDir;
|
|
1254
|
+
agentName;
|
|
1255
|
+
customInstructions;
|
|
1256
|
+
constructor(options) {
|
|
1257
|
+
this.tools = options.tools;
|
|
1258
|
+
this.rootDir = options.rootDir;
|
|
1259
|
+
this.agentName = options.agentName ?? "Kapibala";
|
|
1260
|
+
this.customInstructions = options.customInstructions;
|
|
1261
|
+
}
|
|
1262
|
+
assemble() {
|
|
1263
|
+
const sections = [];
|
|
1264
|
+
sections.push(this.getL1Behavior());
|
|
1265
|
+
if (this.tools) {
|
|
1266
|
+
sections.push(this.getL2Tools());
|
|
1267
|
+
}
|
|
1268
|
+
sections.push(this.getL3Environment());
|
|
1269
|
+
if (this.customInstructions?.trim()) {
|
|
1270
|
+
sections.push(this.customInstructions.trim());
|
|
1271
|
+
}
|
|
1272
|
+
return sections.filter(Boolean).join("\n\n---\n\n");
|
|
1273
|
+
}
|
|
1274
|
+
getL1Behavior() {
|
|
1275
|
+
return `You are ${this.agentName}, an intelligent and dependable AI software engineering assistant.
|
|
1276
|
+
Your goal is to solve tasks efficiently and accurately by reasoning step-by-step and calling available tools when needed.
|
|
1277
|
+
Follow these key behavioral rules:
|
|
1278
|
+
1. Ground your answers in reality by inspecting files before making assumptions.
|
|
1279
|
+
2. When calling tools, ensure parameters conform strictly to the schema.
|
|
1280
|
+
3. When the user provides an exact file path, read it directly instead of searching for it first.
|
|
1281
|
+
4. Do not broaden a successful exact lookup with wildcard searches or duplicate read-only calls.
|
|
1282
|
+
5. Stop calling tools once you have enough evidence to answer the user's request.
|
|
1283
|
+
6. If a tool call fails, analyze the error before self-correcting. Do not repeat an identical failed tool call.
|
|
1284
|
+
7. Keep answers concise, clear, and focused on user requirements. Do not add unrelated analysis unless requested.`;
|
|
1285
|
+
}
|
|
1286
|
+
getL2Tools() {
|
|
1287
|
+
if (!this.tools) return "";
|
|
1288
|
+
const tools = this.tools.list();
|
|
1289
|
+
if (tools.length === 0) return "";
|
|
1290
|
+
const lines = ["# Available Tools:"];
|
|
1291
|
+
for (const tool of tools) {
|
|
1292
|
+
lines.push(`- **${tool.name}**: ${tool.description}`);
|
|
1293
|
+
}
|
|
1294
|
+
return lines.join("\n");
|
|
1295
|
+
}
|
|
1296
|
+
getL3Environment() {
|
|
1297
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1298
|
+
const platform = import_node_os.default.platform();
|
|
1299
|
+
return `# Environment Context:
|
|
1300
|
+
- Current Working Directory (Workspace Root): ${this.rootDir}
|
|
1301
|
+
- Operating System: ${platform}
|
|
1302
|
+
- Current Date/Time: ${now}`;
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
// src/loop/index.ts
|
|
1307
|
+
var AgentLoop = class {
|
|
1308
|
+
provider;
|
|
1309
|
+
executor;
|
|
1310
|
+
tools;
|
|
1311
|
+
hooks;
|
|
1312
|
+
systemPrompt;
|
|
1313
|
+
maxSteps;
|
|
1314
|
+
maxConsecutiveErrors;
|
|
1315
|
+
signal;
|
|
1316
|
+
constructor(options) {
|
|
1317
|
+
this.provider = options.provider;
|
|
1318
|
+
this.executor = options.executor;
|
|
1319
|
+
this.tools = options.tools;
|
|
1320
|
+
this.hooks = options.hooks;
|
|
1321
|
+
this.systemPrompt = options.systemPrompt;
|
|
1322
|
+
this.maxSteps = options.maxSteps ?? 20;
|
|
1323
|
+
this.maxConsecutiveErrors = options.maxConsecutiveErrors ?? 3;
|
|
1324
|
+
this.signal = options.signal;
|
|
1325
|
+
}
|
|
1326
|
+
async *run(history) {
|
|
1327
|
+
let step = 0;
|
|
1328
|
+
let consecutiveErrors = 0;
|
|
1329
|
+
let completed = false;
|
|
1330
|
+
while (step < this.maxSteps) {
|
|
1331
|
+
step++;
|
|
1332
|
+
const turnStartTime = Date.now();
|
|
1333
|
+
yield { type: "turn_start", turn: step };
|
|
1334
|
+
if (this.signal?.aborted) {
|
|
1335
|
+
throw new AbortError();
|
|
1336
|
+
}
|
|
1337
|
+
let request = {
|
|
1338
|
+
systemPrompt: this.systemPrompt,
|
|
1339
|
+
messages: history,
|
|
1340
|
+
tools: this.tools.definitions(),
|
|
1341
|
+
signal: this.signal
|
|
1342
|
+
};
|
|
1343
|
+
const beforeModelHooks = this.hooks.get("model:before");
|
|
1344
|
+
const hookCtx = { signal: this.signal };
|
|
1345
|
+
for (const hook of beforeModelHooks) {
|
|
1346
|
+
request = await hook(hookCtx, request);
|
|
1347
|
+
}
|
|
1348
|
+
yield {
|
|
1349
|
+
type: "step_log",
|
|
1350
|
+
log: {
|
|
1351
|
+
timestamp: Date.now(),
|
|
1352
|
+
turn: step,
|
|
1353
|
+
stage: "model_request_start",
|
|
1354
|
+
message: `Sending request to model with ${request.messages.length} messages and ${request.tools?.length ?? 0} tools`
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
1357
|
+
let accumulatedText = "";
|
|
1358
|
+
let accumulatedThinking = "";
|
|
1359
|
+
const toolCalls = [];
|
|
1360
|
+
let turnUsage;
|
|
1361
|
+
let turnTtftMs;
|
|
1362
|
+
const modelStartTime = Date.now();
|
|
1363
|
+
let firstTokenReceived = false;
|
|
1364
|
+
const buildMetrics = (modelDurationMs2, toolDurationMs2, toolCallsCount) => {
|
|
1365
|
+
const endTime = Date.now();
|
|
1366
|
+
return {
|
|
1367
|
+
turn: step,
|
|
1368
|
+
startTime: turnStartTime,
|
|
1369
|
+
endTime,
|
|
1370
|
+
totalDurationMs: endTime - turnStartTime,
|
|
1371
|
+
ttftMs: turnTtftMs,
|
|
1372
|
+
modelDurationMs: modelDurationMs2,
|
|
1373
|
+
toolDurationMs: toolDurationMs2,
|
|
1374
|
+
promptTokens: turnUsage?.promptTokens ?? 0,
|
|
1375
|
+
completionTokens: turnUsage?.completionTokens ?? 0,
|
|
1376
|
+
totalTokens: turnUsage?.totalTokens ?? 0,
|
|
1377
|
+
toolCallsCount
|
|
1378
|
+
};
|
|
1379
|
+
};
|
|
1380
|
+
try {
|
|
1381
|
+
for await (const event of this.provider.create(request)) {
|
|
1382
|
+
if (this.signal?.aborted) throw new AbortError();
|
|
1383
|
+
if (!firstTokenReceived && (event.type === "text_delta" || event.type === "thinking_delta" || event.type === "tool_call_start")) {
|
|
1384
|
+
firstTokenReceived = true;
|
|
1385
|
+
turnTtftMs = Date.now() - modelStartTime;
|
|
1386
|
+
yield {
|
|
1387
|
+
type: "step_log",
|
|
1388
|
+
log: {
|
|
1389
|
+
timestamp: Date.now(),
|
|
1390
|
+
turn: step,
|
|
1391
|
+
stage: "first_token",
|
|
1392
|
+
message: `First token received (TTFT: ${turnTtftMs}ms)`,
|
|
1393
|
+
durationMs: turnTtftMs
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
if (event.type === "text_delta") {
|
|
1398
|
+
accumulatedText += event.text;
|
|
1399
|
+
yield { type: "text_delta", text: event.text };
|
|
1400
|
+
} else if (event.type === "thinking_delta") {
|
|
1401
|
+
accumulatedThinking += event.thinking;
|
|
1402
|
+
yield { type: "thinking_delta", thinking: event.thinking };
|
|
1403
|
+
} else if (event.type === "tool_call_finish") {
|
|
1404
|
+
const toolUse = {
|
|
1405
|
+
type: "tool_use",
|
|
1406
|
+
id: event.id,
|
|
1407
|
+
name: event.name,
|
|
1408
|
+
input: event.input
|
|
1409
|
+
};
|
|
1410
|
+
toolCalls.push(toolUse);
|
|
1411
|
+
} else if (event.type === "message_stop") {
|
|
1412
|
+
turnUsage = event.usage;
|
|
1413
|
+
if (event.ttftMs && turnTtftMs === void 0) {
|
|
1414
|
+
turnTtftMs = event.ttftMs;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
if (err instanceof AbortError || this.signal?.aborted) {
|
|
1420
|
+
throw new AbortError();
|
|
1421
|
+
}
|
|
1422
|
+
const modelError = err instanceof Error ? err : new Error(String(err));
|
|
1423
|
+
await this.hooks.emit("error", hookCtx, modelError);
|
|
1424
|
+
yield { type: "error", error: modelError };
|
|
1425
|
+
yield {
|
|
1426
|
+
type: "turn_finish",
|
|
1427
|
+
turn: step,
|
|
1428
|
+
metrics: buildMetrics(Date.now() - modelStartTime, 0, 0)
|
|
1429
|
+
};
|
|
1430
|
+
completed = true;
|
|
1431
|
+
break;
|
|
1432
|
+
}
|
|
1433
|
+
const modelDurationMs = Date.now() - modelStartTime;
|
|
1434
|
+
yield {
|
|
1435
|
+
type: "step_log",
|
|
1436
|
+
log: {
|
|
1437
|
+
timestamp: Date.now(),
|
|
1438
|
+
turn: step,
|
|
1439
|
+
stage: "model_stream_finish",
|
|
1440
|
+
message: `Model streaming completed in ${modelDurationMs}ms (prompt: ${turnUsage?.promptTokens ?? 0}, completion: ${turnUsage?.completionTokens ?? 0})`,
|
|
1441
|
+
durationMs: modelDurationMs,
|
|
1442
|
+
metadata: { usage: turnUsage, ttftMs: turnTtftMs }
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
const contentBlocks = [];
|
|
1446
|
+
if (accumulatedThinking) {
|
|
1447
|
+
contentBlocks.push({ type: "thinking", thinking: accumulatedThinking });
|
|
1448
|
+
}
|
|
1449
|
+
if (accumulatedText) {
|
|
1450
|
+
contentBlocks.push({ type: "text", text: accumulatedText });
|
|
1451
|
+
}
|
|
1452
|
+
for (const tc of toolCalls) {
|
|
1453
|
+
contentBlocks.push(tc);
|
|
1454
|
+
}
|
|
1455
|
+
const assistantMessage = {
|
|
1456
|
+
role: "assistant",
|
|
1457
|
+
content: contentBlocks,
|
|
1458
|
+
timestamp: Date.now()
|
|
1459
|
+
};
|
|
1460
|
+
await this.hooks.emit("model:after", hookCtx, { message: assistantMessage });
|
|
1461
|
+
if (toolCalls.length === 0) {
|
|
1462
|
+
history.push(assistantMessage);
|
|
1463
|
+
yield {
|
|
1464
|
+
type: "message_stop",
|
|
1465
|
+
message: assistantMessage,
|
|
1466
|
+
usage: turnUsage,
|
|
1467
|
+
ttftMs: turnTtftMs,
|
|
1468
|
+
durationMs: modelDurationMs
|
|
1469
|
+
};
|
|
1470
|
+
const metrics2 = buildMetrics(modelDurationMs, 0, 0);
|
|
1471
|
+
yield {
|
|
1472
|
+
type: "step_log",
|
|
1473
|
+
log: {
|
|
1474
|
+
timestamp: metrics2.endTime,
|
|
1475
|
+
turn: step,
|
|
1476
|
+
stage: "turn_finish",
|
|
1477
|
+
message: `Turn ${step} finished in ${metrics2.totalDurationMs}ms (TTFT: ${metrics2.ttftMs ?? 0}ms, Tokens: ${metrics2.totalTokens})`,
|
|
1478
|
+
durationMs: metrics2.totalDurationMs,
|
|
1479
|
+
metadata: { metrics: metrics2 }
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
yield { type: "turn_finish", turn: step, usage: turnUsage, metrics: metrics2 };
|
|
1483
|
+
completed = true;
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
yield {
|
|
1487
|
+
type: "step_log",
|
|
1488
|
+
log: {
|
|
1489
|
+
timestamp: Date.now(),
|
|
1490
|
+
turn: step,
|
|
1491
|
+
stage: "tool_execution_start",
|
|
1492
|
+
message: `Executing ${toolCalls.length} tool(s): ${toolCalls.map((c) => c.name).join(", ")}`
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
for (const call of toolCalls) {
|
|
1496
|
+
yield { type: "tool_start", id: call.id, name: call.name, input: call.input };
|
|
1497
|
+
}
|
|
1498
|
+
const toolStartTime = Date.now();
|
|
1499
|
+
const toolResults = await this.executor.runAll(toolCalls);
|
|
1500
|
+
const toolDurationMs = Date.now() - toolStartTime;
|
|
1501
|
+
const hasError = toolResults.some((result) => result.isError);
|
|
1502
|
+
const metrics = buildMetrics(modelDurationMs, toolDurationMs, toolCalls.length);
|
|
1503
|
+
const toolMessages = this.provider.assembleToolResults(toolResults);
|
|
1504
|
+
history.push(assistantMessage, ...toolMessages);
|
|
1505
|
+
yield {
|
|
1506
|
+
type: "message_stop",
|
|
1507
|
+
message: assistantMessage,
|
|
1508
|
+
usage: turnUsage,
|
|
1509
|
+
ttftMs: turnTtftMs,
|
|
1510
|
+
durationMs: modelDurationMs
|
|
1511
|
+
};
|
|
1512
|
+
for (const res of toolResults) {
|
|
1513
|
+
const matchingCall = toolCalls.find((c) => c.id === res.toolUseId);
|
|
1514
|
+
yield {
|
|
1515
|
+
type: "tool_finish",
|
|
1516
|
+
id: res.toolUseId,
|
|
1517
|
+
name: matchingCall?.name ?? "unknown",
|
|
1518
|
+
result: res.content,
|
|
1519
|
+
isError: res.isError ?? false,
|
|
1520
|
+
durationMs: res.durationMs
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
yield {
|
|
1524
|
+
type: "step_log",
|
|
1525
|
+
log: {
|
|
1526
|
+
timestamp: Date.now(),
|
|
1527
|
+
turn: step,
|
|
1528
|
+
stage: "tool_execution_finish",
|
|
1529
|
+
message: `Tools execution finished in ${toolDurationMs}ms (${hasError ? "with errors" : "success"})`,
|
|
1530
|
+
durationMs: toolDurationMs
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
yield { type: "tool_messages", messages: toolMessages };
|
|
1534
|
+
if (hasError) {
|
|
1535
|
+
consecutiveErrors++;
|
|
1536
|
+
if (consecutiveErrors >= this.maxConsecutiveErrors) {
|
|
1537
|
+
const breakerError = new Error(
|
|
1538
|
+
`Circuit breaker triggered: ${consecutiveErrors} consecutive tool failures`
|
|
1539
|
+
);
|
|
1540
|
+
await this.hooks.emit("error", hookCtx, breakerError);
|
|
1541
|
+
yield { type: "error", error: breakerError };
|
|
1542
|
+
yield { type: "turn_finish", turn: step, usage: turnUsage, metrics };
|
|
1543
|
+
completed = true;
|
|
1544
|
+
break;
|
|
1545
|
+
}
|
|
1546
|
+
} else {
|
|
1547
|
+
consecutiveErrors = 0;
|
|
1548
|
+
}
|
|
1549
|
+
yield {
|
|
1550
|
+
type: "step_log",
|
|
1551
|
+
log: {
|
|
1552
|
+
timestamp: metrics.endTime,
|
|
1553
|
+
turn: step,
|
|
1554
|
+
stage: "turn_finish",
|
|
1555
|
+
message: `Turn ${step} completed in ${metrics.totalDurationMs}ms (tool duration: ${toolDurationMs}ms). Continuing loop.`,
|
|
1556
|
+
durationMs: metrics.totalDurationMs,
|
|
1557
|
+
metadata: { metrics }
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
yield { type: "turn_finish", turn: step, usage: turnUsage, metrics };
|
|
1561
|
+
}
|
|
1562
|
+
if (!completed) {
|
|
1563
|
+
yield {
|
|
1564
|
+
type: "error",
|
|
1565
|
+
error: new Error(
|
|
1566
|
+
`Reached max steps limit (${this.maxSteps}); loop terminated before a final answer was produced`
|
|
1567
|
+
)
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
|
|
1573
|
+
// src/session/index.ts
|
|
1574
|
+
var AgentSession = class {
|
|
1575
|
+
rootDir;
|
|
1576
|
+
tools;
|
|
1577
|
+
hooks;
|
|
1578
|
+
router;
|
|
1579
|
+
store;
|
|
1580
|
+
customSystemPrompt;
|
|
1581
|
+
maxSteps;
|
|
1582
|
+
logger;
|
|
1583
|
+
history = [];
|
|
1584
|
+
totalTurns = 0;
|
|
1585
|
+
usageStats = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
1586
|
+
lastMetrics;
|
|
1587
|
+
lastRunMetrics;
|
|
1588
|
+
initialized = false;
|
|
1589
|
+
destroyed = false;
|
|
1590
|
+
activeRun = false;
|
|
1591
|
+
plugins = [];
|
|
1592
|
+
constructor(config) {
|
|
1593
|
+
resolveContextWindow(config.defaultProfile.contextWindow);
|
|
1594
|
+
this.rootDir = config.rootDir ?? process.cwd();
|
|
1595
|
+
this.tools = new ToolRegistry();
|
|
1596
|
+
this.hooks = new HookRegistry();
|
|
1597
|
+
this.router = new SimpleModelRouter(config.defaultProfile, config.defaultProvider);
|
|
1598
|
+
this.store = config.store;
|
|
1599
|
+
this.customSystemPrompt = config.systemPrompt;
|
|
1600
|
+
this.maxSteps = config.maxSteps ?? 20;
|
|
1601
|
+
this.logger = config.logger;
|
|
1602
|
+
}
|
|
1603
|
+
async init() {
|
|
1604
|
+
if (this.initialized) return;
|
|
1605
|
+
if (this.store) {
|
|
1606
|
+
this.history = await this.store.load();
|
|
1607
|
+
}
|
|
1608
|
+
await this.hooks.emit("session:start", { logger: this.logger });
|
|
1609
|
+
this.initialized = true;
|
|
1610
|
+
}
|
|
1611
|
+
async use(plugin) {
|
|
1612
|
+
if (this.destroyed) {
|
|
1613
|
+
throw new Error(`Cannot mount plugin '${plugin.name}': session already destroyed`);
|
|
1614
|
+
}
|
|
1615
|
+
this.assertIdle(`mount plugin '${plugin.name}'`);
|
|
1616
|
+
await plugin.setup({
|
|
1617
|
+
tools: this.tools,
|
|
1618
|
+
hooks: this.hooks,
|
|
1619
|
+
rootDir: this.rootDir,
|
|
1620
|
+
logger: this.logger
|
|
1621
|
+
});
|
|
1622
|
+
this.plugins.push(plugin);
|
|
1623
|
+
}
|
|
1624
|
+
switchModel(profile, role = "default", provider) {
|
|
1625
|
+
this.assertIdle("switch models");
|
|
1626
|
+
resolveContextWindow(profile.contextWindow);
|
|
1627
|
+
if (!provider) {
|
|
1628
|
+
provider = this.router.resolve(role);
|
|
1629
|
+
}
|
|
1630
|
+
this.router.setRole(role, profile, provider);
|
|
1631
|
+
this.logger?.(`Switched model for role [${role}] to ${profile.name} (${profile.modelName})`);
|
|
1632
|
+
}
|
|
1633
|
+
getActiveProfile(role = "default") {
|
|
1634
|
+
return this.router.getProfile(role);
|
|
1635
|
+
}
|
|
1636
|
+
getHistory() {
|
|
1637
|
+
return [...this.history];
|
|
1638
|
+
}
|
|
1639
|
+
getStats() {
|
|
1640
|
+
return {
|
|
1641
|
+
totalTurns: this.totalTurns,
|
|
1642
|
+
totalTokens: { ...this.usageStats },
|
|
1643
|
+
activeModel: this.router.getProfile("default").name,
|
|
1644
|
+
loadedToolsCount: this.tools.list().length,
|
|
1645
|
+
lastMetrics: this.lastMetrics,
|
|
1646
|
+
lastRunMetrics: this.lastRunMetrics,
|
|
1647
|
+
contextUsage: this.lastRunMetrics?.contextUsage ?? this.createContextUsage()
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
getLastMetrics() {
|
|
1651
|
+
return this.lastMetrics;
|
|
1652
|
+
}
|
|
1653
|
+
getLastRunMetrics() {
|
|
1654
|
+
return this.lastRunMetrics;
|
|
1655
|
+
}
|
|
1656
|
+
async reset() {
|
|
1657
|
+
this.assertIdle("reset session");
|
|
1658
|
+
this.history = [];
|
|
1659
|
+
this.totalTurns = 0;
|
|
1660
|
+
this.usageStats = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
1661
|
+
this.lastMetrics = void 0;
|
|
1662
|
+
this.lastRunMetrics = void 0;
|
|
1663
|
+
if (this.store) {
|
|
1664
|
+
await this.store.clear();
|
|
1665
|
+
}
|
|
1666
|
+
this.logger?.("Session context cleared");
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* 执行一次用户输入,并以宿主无关的语义事件流返回全过程。
|
|
1670
|
+
*
|
|
1671
|
+
* Core 不负责终端或图形界面渲染;CLI、TUI、GUI 与其它宿主应消费同一组
|
|
1672
|
+
* {@link SessionEvent},分别完成展示、交互和中止控制。
|
|
1673
|
+
*
|
|
1674
|
+
* @param userInput 用户本轮输入。
|
|
1675
|
+
* @param options 可选的中止信号。
|
|
1676
|
+
* @returns 文本、思考、工具、指标和错误等结构化事件的异步流。
|
|
1677
|
+
* @throws {SessionBusyError} 当前会话已有正在执行的 run 时抛出。
|
|
1678
|
+
*/
|
|
1679
|
+
async *run(userInput, options) {
|
|
1680
|
+
this.assertIdle("start another run");
|
|
1681
|
+
resolveContextWindow(this.router.getProfile("default").contextWindow);
|
|
1682
|
+
this.activeRun = true;
|
|
1683
|
+
const runStartTime = Date.now();
|
|
1684
|
+
const aggregate = {
|
|
1685
|
+
modelDurationMs: 0,
|
|
1686
|
+
toolDurationMs: 0,
|
|
1687
|
+
promptTokens: 0,
|
|
1688
|
+
completionTokens: 0,
|
|
1689
|
+
totalTokens: 0,
|
|
1690
|
+
turns: 0,
|
|
1691
|
+
toolCalls: 0,
|
|
1692
|
+
ttftMs: void 0
|
|
1693
|
+
};
|
|
1694
|
+
let lastPromptTokens;
|
|
1695
|
+
let runStatus = "completed";
|
|
1696
|
+
let runFinalized = false;
|
|
1697
|
+
const finalizeRun = (status) => {
|
|
1698
|
+
const endTime = Date.now();
|
|
1699
|
+
return {
|
|
1700
|
+
startTime: runStartTime,
|
|
1701
|
+
endTime,
|
|
1702
|
+
totalDurationMs: endTime - runStartTime,
|
|
1703
|
+
...aggregate,
|
|
1704
|
+
status,
|
|
1705
|
+
contextUsage: this.createContextUsage(lastPromptTokens)
|
|
1706
|
+
};
|
|
1707
|
+
};
|
|
1708
|
+
try {
|
|
1709
|
+
if (!this.initialized) {
|
|
1710
|
+
await this.init();
|
|
1711
|
+
}
|
|
1712
|
+
this.totalTurns++;
|
|
1713
|
+
const userMessage = {
|
|
1714
|
+
role: "user",
|
|
1715
|
+
content: [{ type: "text", text: userInput }],
|
|
1716
|
+
timestamp: Date.now()
|
|
1717
|
+
};
|
|
1718
|
+
this.history.push(userMessage);
|
|
1719
|
+
if (this.store) {
|
|
1720
|
+
await this.store.append(userMessage);
|
|
1721
|
+
}
|
|
1722
|
+
const promptAssembler = new PromptAssembler({
|
|
1723
|
+
tools: this.tools,
|
|
1724
|
+
rootDir: this.rootDir,
|
|
1725
|
+
customInstructions: this.customSystemPrompt
|
|
1726
|
+
});
|
|
1727
|
+
const assembledSystemPrompt = promptAssembler.assemble();
|
|
1728
|
+
const executor = new ToolExecutor({
|
|
1729
|
+
tools: this.tools,
|
|
1730
|
+
hooks: this.hooks,
|
|
1731
|
+
rootDir: this.rootDir,
|
|
1732
|
+
signal: options?.signal,
|
|
1733
|
+
logger: this.logger
|
|
1734
|
+
});
|
|
1735
|
+
const loop = new AgentLoop({
|
|
1736
|
+
provider: this.router.resolve("default"),
|
|
1737
|
+
executor,
|
|
1738
|
+
tools: this.tools,
|
|
1739
|
+
hooks: this.hooks,
|
|
1740
|
+
systemPrompt: assembledSystemPrompt,
|
|
1741
|
+
maxSteps: this.maxSteps,
|
|
1742
|
+
signal: options?.signal
|
|
1743
|
+
});
|
|
1744
|
+
let pendingAssistant;
|
|
1745
|
+
let pendingEvents = [];
|
|
1746
|
+
let pendingClosedInMemory = false;
|
|
1747
|
+
let pendingAssistantPersisted = false;
|
|
1748
|
+
let pendingPersistenceStarted = false;
|
|
1749
|
+
let pendingToolMessages = [];
|
|
1750
|
+
try {
|
|
1751
|
+
for await (const event of loop.run(this.history)) {
|
|
1752
|
+
if (event.type === "step_log") {
|
|
1753
|
+
this.logger?.(`[Turn ${event.log.turn} | ${event.log.stage}] ${event.log.message}`);
|
|
1754
|
+
}
|
|
1755
|
+
if (event.type === "turn_finish") {
|
|
1756
|
+
this.lastMetrics = event.metrics;
|
|
1757
|
+
aggregate.modelDurationMs += event.metrics.modelDurationMs;
|
|
1758
|
+
aggregate.toolDurationMs += event.metrics.toolDurationMs;
|
|
1759
|
+
aggregate.promptTokens += event.metrics.promptTokens;
|
|
1760
|
+
aggregate.completionTokens += event.metrics.completionTokens;
|
|
1761
|
+
aggregate.totalTokens += event.metrics.totalTokens;
|
|
1762
|
+
lastPromptTokens = event.usage?.promptTokens;
|
|
1763
|
+
aggregate.turns++;
|
|
1764
|
+
aggregate.toolCalls += event.metrics.toolCallsCount;
|
|
1765
|
+
aggregate.ttftMs ??= event.metrics.ttftMs;
|
|
1766
|
+
}
|
|
1767
|
+
if (event.type === "error") {
|
|
1768
|
+
runStatus = "failed";
|
|
1769
|
+
}
|
|
1770
|
+
if (event.type === "message_stop" && event.message.content.some((block) => block.type === "tool_use")) {
|
|
1771
|
+
pendingAssistant = event;
|
|
1772
|
+
pendingEvents = [event];
|
|
1773
|
+
pendingToolMessages = this.findClosedToolMessages(event);
|
|
1774
|
+
pendingClosedInMemory = pendingToolMessages.length > 0;
|
|
1775
|
+
pendingAssistantPersisted = false;
|
|
1776
|
+
pendingPersistenceStarted = false;
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
if (pendingAssistant) {
|
|
1780
|
+
pendingEvents.push(event);
|
|
1781
|
+
if (event.type !== "tool_messages") continue;
|
|
1782
|
+
pendingClosedInMemory = true;
|
|
1783
|
+
pendingToolMessages = event.messages;
|
|
1784
|
+
pendingPersistenceStarted = true;
|
|
1785
|
+
await this.persistAssistant(pendingAssistant);
|
|
1786
|
+
pendingAssistantPersisted = true;
|
|
1787
|
+
await this.persistToolMessages(event.messages);
|
|
1788
|
+
const releasable = pendingEvents;
|
|
1789
|
+
pendingAssistant = void 0;
|
|
1790
|
+
pendingEvents = [];
|
|
1791
|
+
for (const buffered of releasable) yield buffered;
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
if (event.type === "message_stop") {
|
|
1795
|
+
await this.persistAssistant(event);
|
|
1796
|
+
} else if (event.type === "tool_messages") {
|
|
1797
|
+
await this.persistToolMessages(event.messages);
|
|
1798
|
+
}
|
|
1799
|
+
yield event;
|
|
1800
|
+
}
|
|
1801
|
+
} catch (error) {
|
|
1802
|
+
if (pendingAssistant && !pendingPersistenceStarted) {
|
|
1803
|
+
const interruptedMessages = pendingClosedInMemory ? pendingToolMessages : this.closeInterruptedToolCalls(pendingAssistant, error);
|
|
1804
|
+
if (!pendingAssistantPersisted) {
|
|
1805
|
+
await this.persistAssistant(pendingAssistant);
|
|
1806
|
+
}
|
|
1807
|
+
await this.persistToolMessages(interruptedMessages);
|
|
1808
|
+
for (const buffered of pendingEvents) yield buffered;
|
|
1809
|
+
yield { type: "tool_messages", messages: interruptedMessages };
|
|
1810
|
+
}
|
|
1811
|
+
throw error;
|
|
1812
|
+
}
|
|
1813
|
+
const metrics = finalizeRun(runStatus);
|
|
1814
|
+
this.lastRunMetrics = metrics;
|
|
1815
|
+
runFinalized = true;
|
|
1816
|
+
yield { type: "run_finish", metrics };
|
|
1817
|
+
} catch (error) {
|
|
1818
|
+
const status = error instanceof AbortError || options?.signal?.aborted ? "aborted" : "failed";
|
|
1819
|
+
const metrics = finalizeRun(status);
|
|
1820
|
+
this.lastRunMetrics = metrics;
|
|
1821
|
+
runFinalized = true;
|
|
1822
|
+
yield { type: "run_finish", metrics };
|
|
1823
|
+
throw error;
|
|
1824
|
+
} finally {
|
|
1825
|
+
this.activeRun = false;
|
|
1826
|
+
if (!runFinalized) {
|
|
1827
|
+
this.lastRunMetrics = finalizeRun("aborted");
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
async destroy() {
|
|
1832
|
+
if (this.destroyed) return;
|
|
1833
|
+
this.assertIdle("destroy session");
|
|
1834
|
+
this.destroyed = true;
|
|
1835
|
+
for (const plugin of this.plugins) {
|
|
1836
|
+
try {
|
|
1837
|
+
await plugin.teardown?.();
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
this.logger?.(`Plugin '${plugin.name}' teardown failed: ${err.message}`);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
this.plugins.length = 0;
|
|
1843
|
+
await this.hooks.emit("session:end", { logger: this.logger });
|
|
1844
|
+
}
|
|
1845
|
+
assertIdle(operation) {
|
|
1846
|
+
if (this.activeRun) throw new SessionBusyError(operation);
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* 计算最近一次内部模型请求的上下文占用。
|
|
1850
|
+
* run 累计 promptTokens 会重复计算每一步都重发的历史,不能用于窗口占用率。
|
|
1851
|
+
*/
|
|
1852
|
+
createContextUsage(usedTokens) {
|
|
1853
|
+
const contextWindow = resolveContextWindow(this.router.getProfile("default").contextWindow);
|
|
1854
|
+
return {
|
|
1855
|
+
usedTokens,
|
|
1856
|
+
limitTokens: contextWindow.tokens,
|
|
1857
|
+
percent: usedTokens === void 0 ? void 0 : usedTokens / contextWindow.tokens * 100,
|
|
1858
|
+
estimatedLimit: contextWindow.estimated
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
async persistAssistant(event) {
|
|
1862
|
+
if (this.store) await this.store.append(event.message);
|
|
1863
|
+
if (event.usage) {
|
|
1864
|
+
this.usageStats.promptTokens += event.usage.promptTokens;
|
|
1865
|
+
this.usageStats.completionTokens += event.usage.completionTokens;
|
|
1866
|
+
this.usageStats.totalTokens += event.usage.totalTokens;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
async persistToolMessages(messages) {
|
|
1870
|
+
if (!this.store) return;
|
|
1871
|
+
for (const message of messages) await this.store.append(message);
|
|
1872
|
+
}
|
|
1873
|
+
closeInterruptedToolCalls(assistantEvent, error) {
|
|
1874
|
+
const message = {
|
|
1875
|
+
role: "tool",
|
|
1876
|
+
content: assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => ({
|
|
1877
|
+
type: "tool_result",
|
|
1878
|
+
toolUseId: toolUse.id,
|
|
1879
|
+
content: `Tool execution interrupted before completion: ${error instanceof Error ? error.message : String(error)}`,
|
|
1880
|
+
isError: true
|
|
1881
|
+
})),
|
|
1882
|
+
timestamp: Date.now()
|
|
1883
|
+
};
|
|
1884
|
+
this.history.push(message);
|
|
1885
|
+
return [message];
|
|
1886
|
+
}
|
|
1887
|
+
findClosedToolMessages(assistantEvent) {
|
|
1888
|
+
const assistantIndex = this.history.lastIndexOf(assistantEvent.message);
|
|
1889
|
+
if (assistantIndex < 0) return [];
|
|
1890
|
+
const expected = new Set(
|
|
1891
|
+
assistantEvent.message.content.filter((block) => block.type === "tool_use").map((toolUse) => toolUse.id)
|
|
1892
|
+
);
|
|
1893
|
+
const messages = [];
|
|
1894
|
+
for (let index = assistantIndex + 1; index < this.history.length; index++) {
|
|
1895
|
+
const message = this.history[index];
|
|
1896
|
+
if (message.role !== "tool") break;
|
|
1897
|
+
messages.push(message);
|
|
1898
|
+
for (const block of message.content) {
|
|
1899
|
+
if (block.type === "tool_result") expected.delete(block.toolUseId);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return expected.size === 0 ? messages : [];
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1906
|
+
0 && (module.exports = {
|
|
1907
|
+
AbortError,
|
|
1908
|
+
AgentLoop,
|
|
1909
|
+
AgentSession,
|
|
1910
|
+
DEFAULT_CONTEXT_WINDOW_TOKENS,
|
|
1911
|
+
FatalError,
|
|
1912
|
+
HookRegistry,
|
|
1913
|
+
JSONLMessageStore,
|
|
1914
|
+
KapibalaError,
|
|
1915
|
+
ModelError,
|
|
1916
|
+
OpenAICompatibleProvider,
|
|
1917
|
+
PathSandbox,
|
|
1918
|
+
PromptAssembler,
|
|
1919
|
+
SessionBusyError,
|
|
1920
|
+
SimpleModelRouter,
|
|
1921
|
+
ToolError,
|
|
1922
|
+
ToolExecutor,
|
|
1923
|
+
ToolNotFound,
|
|
1924
|
+
ToolRegistry,
|
|
1925
|
+
TransportError,
|
|
1926
|
+
builtinTools,
|
|
1927
|
+
defineTool,
|
|
1928
|
+
editFileTool,
|
|
1929
|
+
globTool,
|
|
1930
|
+
grepTool,
|
|
1931
|
+
isTextBlock,
|
|
1932
|
+
isThinkingBlock,
|
|
1933
|
+
isToolResultBlock,
|
|
1934
|
+
isToolUseBlock,
|
|
1935
|
+
readFileTool,
|
|
1936
|
+
resolveContextWindow,
|
|
1937
|
+
toToolDefinition,
|
|
1938
|
+
writeFileTool
|
|
1939
|
+
});
|
|
1940
|
+
//# sourceMappingURL=index.cjs.map
|