@ikarmic/forge-core 5.2.0 → 5.3.0
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/dist/agent/agent.js +195 -55
- package/dist/agent/enhance-prompt.js +2 -1
- package/dist/agent/prompts.d.ts +15 -0
- package/dist/agent/prompts.js +34 -0
- package/dist/agent/retry.d.ts +50 -0
- package/dist/agent/retry.js +192 -0
- package/dist/agent/shell-command.d.ts +23 -0
- package/dist/agent/shell-command.js +93 -0
- package/dist/catalog/models-dev-snapshot.d.ts +1 -0
- package/dist/catalog/models-dev-snapshot.js +3 -0
- package/dist/catalog/models-dev.d.ts +35 -0
- package/dist/catalog/models-dev.js +143 -0
- package/dist/context/compaction.js +8 -2
- package/dist/git/auto-commit.d.ts +4 -2
- package/dist/git/auto-commit.js +16 -5
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/memory/session-digest.js +2 -1
- package/dist/pricing.js +12 -0
- package/dist/tools/command-sandbox.d.ts +24 -0
- package/dist/tools/command-sandbox.js +73 -0
- package/dist/tools/edit-file.d.ts +8 -0
- package/dist/tools/edit-file.js +64 -16
- package/dist/tools/run-command.js +12 -2
- package/dist/tools/search-files.js +8 -49
- package/dist/tools/search-worker.d.ts +23 -0
- package/dist/tools/search-worker.js +77 -0
- package/dist/tools/sub-agent.d.ts +1 -1
- package/dist/tools/sub-agent.js +1 -0
- package/dist/types.d.ts +58 -1
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
import { z } from 'zod';
|
|
4
3
|
import { globToRegex } from './policy.js';
|
|
5
4
|
import { IgnoreRules } from './ignore-rules.js';
|
|
6
|
-
import {
|
|
5
|
+
import { DEFAULT_SEARCH_TIMEOUT_MS, matchInWorker } from './search-worker.js';
|
|
6
|
+
import { resolveAndCheckPath, DEFAULT_IGNORED_DIRS } from './shared.js';
|
|
7
7
|
const params = z.object({
|
|
8
8
|
pattern: z.string().describe('Regex pattern to search for in file contents.'),
|
|
9
9
|
path: z
|
|
@@ -33,15 +33,10 @@ export const searchFilesTool = {
|
|
|
33
33
|
const includeRe = args.include ? globToRegex(args.include) : null;
|
|
34
34
|
const maxResults = args.max_results ?? 200;
|
|
35
35
|
const contextLines = args.context_lines ?? 1;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
let filesScanned = 0;
|
|
36
|
+
// The walk (I/O and ignore rules) stays here; reading and matching run in a worker with a time limit (RB-22).
|
|
37
|
+
const files = [];
|
|
39
38
|
const rules = args.include_ignored ? null : IgnoreRules.forRoot(ctx.cwd);
|
|
40
39
|
async function walk(dir) {
|
|
41
|
-
if (matches.length >= maxResults) {
|
|
42
|
-
truncated = true;
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
40
|
const { readdir } = await import('node:fs/promises');
|
|
46
41
|
let entries;
|
|
47
42
|
try {
|
|
@@ -54,10 +49,6 @@ export const searchFilesTool = {
|
|
|
54
49
|
throw err;
|
|
55
50
|
}
|
|
56
51
|
for (const entry of entries) {
|
|
57
|
-
if (matches.length >= maxResults) {
|
|
58
|
-
truncated = true;
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
52
|
if (entry.name.startsWith('.'))
|
|
62
53
|
continue;
|
|
63
54
|
if (DEFAULT_IGNORED_DIRS.has(entry.name))
|
|
@@ -72,40 +63,7 @@ export const searchFilesTool = {
|
|
|
72
63
|
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
73
64
|
if (includeRe && !includeRe.test(rel))
|
|
74
65
|
continue;
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
async function searchFile(abs, rel) {
|
|
80
|
-
filesScanned++;
|
|
81
|
-
let buf;
|
|
82
|
-
try {
|
|
83
|
-
buf = await readFile(abs);
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
// Skip binary files.
|
|
89
|
-
if (buf.includes(0))
|
|
90
|
-
return;
|
|
91
|
-
const text = buf.toString('utf8');
|
|
92
|
-
const lines = text.split(/\r?\n/);
|
|
93
|
-
for (let i = 0; i < lines.length; i++) {
|
|
94
|
-
if (matches.length >= maxResults) {
|
|
95
|
-
truncated = true;
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
const line = lines[i];
|
|
99
|
-
regex.lastIndex = 0;
|
|
100
|
-
if (regex.test(line)) {
|
|
101
|
-
const start = Math.max(0, i - contextLines);
|
|
102
|
-
const end = Math.min(lines.length - 1, i + contextLines);
|
|
103
|
-
const block = [];
|
|
104
|
-
for (let j = start; j <= end; j++) {
|
|
105
|
-
const marker = j === i ? '>' : ' ';
|
|
106
|
-
block.push(`${marker} ${String(j + 1).padStart(6)}\t${lines[j]}`);
|
|
107
|
-
}
|
|
108
|
-
matches.push({ path: rel, line: i + 1, text: truncateByLines(block.join('\n'), 10) });
|
|
66
|
+
files.push({ abs, rel });
|
|
109
67
|
}
|
|
110
68
|
}
|
|
111
69
|
}
|
|
@@ -113,12 +71,13 @@ export const searchFilesTool = {
|
|
|
113
71
|
if (!stat)
|
|
114
72
|
throw new Error(`Path not found: ${root}`);
|
|
115
73
|
if (stat.isFile()) {
|
|
116
|
-
|
|
74
|
+
files.push({ abs: root, rel: path.basename(root) });
|
|
117
75
|
}
|
|
118
76
|
else {
|
|
119
77
|
await walk(root);
|
|
120
78
|
}
|
|
121
|
-
|
|
79
|
+
const result = await matchInWorker({ files, source: regex.source, flags: regex.flags, maxResults, contextLines }, { timeoutMs: ctx.searchTimeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS });
|
|
80
|
+
return { pattern: args.pattern, ...result };
|
|
122
81
|
},
|
|
123
82
|
};
|
|
124
83
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import type { SearchMatch } from './search-files.js';
|
|
3
|
+
export interface MatchJob {
|
|
4
|
+
files: Array<{
|
|
5
|
+
abs: string;
|
|
6
|
+
rel: string;
|
|
7
|
+
}>;
|
|
8
|
+
source: string;
|
|
9
|
+
flags: string;
|
|
10
|
+
maxResults: number;
|
|
11
|
+
contextLines: number;
|
|
12
|
+
}
|
|
13
|
+
export interface MatchResult {
|
|
14
|
+
matches: SearchMatch[];
|
|
15
|
+
truncated: boolean;
|
|
16
|
+
filesScanned: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const DEFAULT_SEARCH_TIMEOUT_MS = 20000;
|
|
19
|
+
/** Match `job` in a worker; throws when it runs longer than `timeoutMs`. Falls back to in-process if no worker starts. */
|
|
20
|
+
export declare function matchInWorker(job: MatchJob, opts: {
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
startWorker?: (source: string, data: MatchJob) => Worker;
|
|
23
|
+
}): Promise<MatchResult>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search_files' matching, run in a worker thread with a time limit. JavaScript cannot interrupt a running regex, so a
|
|
3
|
+
* pattern that backtracks badly on the main thread froze the process (in VS Code, the extension host): in a worker it
|
|
4
|
+
* is terminated after `timeoutMs`. The worker source is a plain string, not a function's text, so a bundler cannot
|
|
5
|
+
* rename or wrap anything in it; the same string runs in-process when a worker cannot start.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { Worker } from 'node:worker_threads';
|
|
9
|
+
export const DEFAULT_SEARCH_TIMEOUT_MS = 20_000;
|
|
10
|
+
/** Body of `function (job, readFile)`: read each file, skip binaries, match line by line, keep context. */
|
|
11
|
+
const MATCH_BODY = String.raw `
|
|
12
|
+
var regex = new RegExp(job.source, job.flags);
|
|
13
|
+
var matches = [];
|
|
14
|
+
var truncated = false;
|
|
15
|
+
var filesScanned = 0;
|
|
16
|
+
function truncateByLines(text, maxLines) {
|
|
17
|
+
var lines = text.split(/\r?\n/);
|
|
18
|
+
if (lines.length <= maxLines) return text;
|
|
19
|
+
var dropped = lines.length - maxLines;
|
|
20
|
+
return lines.slice(0, maxLines).join('\n') + '\n\n... (' + dropped + ' more line' + (dropped === 1 ? '' : 's') + ' truncated; re-read with a smaller offset to see more)';
|
|
21
|
+
}
|
|
22
|
+
for (var f = 0; f < job.files.length; f++) {
|
|
23
|
+
if (matches.length >= job.maxResults) { truncated = true; break; }
|
|
24
|
+
var file = job.files[f];
|
|
25
|
+
filesScanned++;
|
|
26
|
+
var buf;
|
|
27
|
+
try { buf = readFile(file.abs); } catch (e) { continue; }
|
|
28
|
+
if (buf.includes(0)) continue;
|
|
29
|
+
var lines = buf.toString('utf8').split(/\r?\n/);
|
|
30
|
+
for (var i = 0; i < lines.length; i++) {
|
|
31
|
+
if (matches.length >= job.maxResults) { truncated = true; break; }
|
|
32
|
+
regex.lastIndex = 0;
|
|
33
|
+
if (regex.test(lines[i])) {
|
|
34
|
+
var start = Math.max(0, i - job.contextLines);
|
|
35
|
+
var end = Math.min(lines.length - 1, i + job.contextLines);
|
|
36
|
+
var block = [];
|
|
37
|
+
for (var j = start; j <= end; j++) block.push((j === i ? '>' : ' ') + ' ' + String(j + 1).padStart(6) + '\t' + lines[j]);
|
|
38
|
+
matches.push({ path: file.rel, line: i + 1, text: truncateByLines(block.join('\n'), 10) });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { matches: matches, truncated: truncated, filesScanned: filesScanned };
|
|
43
|
+
`;
|
|
44
|
+
const WORKER_SOURCE = `
|
|
45
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
46
|
+
const fs = require('node:fs');
|
|
47
|
+
const match = new Function('job', 'readFile', ${JSON.stringify(MATCH_BODY)});
|
|
48
|
+
parentPort.postMessage(match(workerData, (p) => fs.readFileSync(p)));
|
|
49
|
+
`;
|
|
50
|
+
function matchInProcess(job) {
|
|
51
|
+
return new Function('job', 'readFile', MATCH_BODY)(job, (p) => readFileSync(p));
|
|
52
|
+
}
|
|
53
|
+
/** Match `job` in a worker; throws when it runs longer than `timeoutMs`. Falls back to in-process if no worker starts. */
|
|
54
|
+
export function matchInWorker(job, opts) {
|
|
55
|
+
let worker;
|
|
56
|
+
try {
|
|
57
|
+
worker = (opts.startWorker ?? ((source, data) => new Worker(source, { eval: true, workerData: data })))(WORKER_SOURCE, job);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return Promise.resolve(matchInProcess(job));
|
|
61
|
+
}
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
void worker.terminate();
|
|
65
|
+
reject(new Error(`The search pattern took longer than ${Math.round(opts.timeoutMs / 1000)} s and was stopped. Simplify it (avoid repeated groups that can match the same text in several ways) or search fewer files.`));
|
|
66
|
+
}, opts.timeoutMs);
|
|
67
|
+
worker.once('message', (result) => {
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
void worker.terminate();
|
|
70
|
+
resolve(result);
|
|
71
|
+
});
|
|
72
|
+
worker.once('error', (err) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
reject(err);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -77,7 +77,7 @@ export declare function childAgentConfig(setup: {
|
|
|
77
77
|
model: ModelConfig;
|
|
78
78
|
maxIterations: number;
|
|
79
79
|
systemPrompt?: string;
|
|
80
|
-
}, ctx: Pick<ToolContext, 'autoApproveTools' | 'permissionRules' | 'toolSandbox' | 'askBudget'>, cwd: string, auto: boolean): AgentConfig;
|
|
80
|
+
}, ctx: Pick<ToolContext, 'autoApproveTools' | 'permissionRules' | 'toolSandbox' | 'askBudget' | 'commandSandbox'>, cwd: string, auto: boolean): AgentConfig;
|
|
81
81
|
/** What a child runs without asking: what the parent does, else the read-only tools. Destructive tools still never run unasked. */
|
|
82
82
|
export declare function childAutoApprove(ctx: Pick<ToolContext, 'autoApproveTools'>): string[];
|
|
83
83
|
/** The configured sub-agent model, else the parent's, else a last-resort default. */
|
package/dist/tools/sub-agent.js
CHANGED
|
@@ -328,6 +328,7 @@ export function childAgentConfig(setup, ctx, cwd, auto) {
|
|
|
328
328
|
autoApproveTools: childAutoApprove(ctx),
|
|
329
329
|
...(ctx.permissionRules ? { permissionRules: ctx.permissionRules } : {}),
|
|
330
330
|
...(ctx.toolSandbox ? { toolSandbox: ctx.toolSandbox } : {}),
|
|
331
|
+
...(ctx.commandSandbox ? { commandSandbox: ctx.commandSandbox } : {}),
|
|
331
332
|
// The same budget object, not a copy: a child that started its own four
|
|
332
333
|
// would be a way around the limit the parent is under (RB-14).
|
|
333
334
|
...(ctx.askBudget ? { askBudget: ctx.askBudget } : {}),
|
package/dist/types.d.ts
CHANGED
|
@@ -107,6 +107,10 @@ export interface ToolContext {
|
|
|
107
107
|
* the default `child_process` runner.
|
|
108
108
|
*/
|
|
109
109
|
commandRunner?: CommandRunner;
|
|
110
|
+
/** How long `search_files` may match before its worker is stopped. Default 20 s. */
|
|
111
|
+
searchTimeoutMs?: number;
|
|
112
|
+
/** Run `run_command` inside the OS sandbox (`AgentConfig.commandSandbox`); handed to sub-agents. */
|
|
113
|
+
commandSandbox?: CommandSandbox;
|
|
110
114
|
/** The model sub-agents use instead of the parent's (`AgentConfig.subAgentModel`). */
|
|
111
115
|
subAgentModel?: ModelConfig;
|
|
112
116
|
/** Folder `AGENTS.md` paths already shown in this run; `read_file` adds each folder's file once. */
|
|
@@ -176,6 +180,13 @@ export type ModelRouter = (ctx: {
|
|
|
176
180
|
/** Tool names the model called on the previous step (empty on the first). */
|
|
177
181
|
lastToolNames: string[];
|
|
178
182
|
}) => ModelConfig | undefined;
|
|
183
|
+
/** What `run_command`'s OS sandbox allows besides writing to the workspace and the temp folder. */
|
|
184
|
+
export interface CommandSandbox {
|
|
185
|
+
/** Let commands reach the network. */
|
|
186
|
+
network: boolean;
|
|
187
|
+
/** More folders commands may write to (a package manager's cache, say). */
|
|
188
|
+
extraWritable?: string[];
|
|
189
|
+
}
|
|
179
190
|
/**
|
|
180
191
|
* Executes one shell command on behalf of `run_command`. Return `null` to decline
|
|
181
192
|
* (e.g. no terminal available); the default `child_process` runner then handles it.
|
|
@@ -250,6 +261,11 @@ export interface ChildProgressEvent {
|
|
|
250
261
|
/** Any event the child emitted. */
|
|
251
262
|
event: AgentEvent;
|
|
252
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* How a tool call was let through: the person approved it, a permission rule allowed it, it is on the
|
|
266
|
+
* auto-approve list, memory writes are allowed, the command is on the "Always allow" list, or the run is Autonomous.
|
|
267
|
+
*/
|
|
268
|
+
export type ApprovalSource = 'person' | 'rule' | 'list' | 'memory' | 'always' | 'autonomous';
|
|
253
269
|
/** Streamed agent event: surfaced to the UI via the AgentRunOptions.onEvent callback. */
|
|
254
270
|
export type AgentEvent = {
|
|
255
271
|
type: 'text_delta';
|
|
@@ -263,11 +279,14 @@ export type AgentEvent = {
|
|
|
263
279
|
} | {
|
|
264
280
|
type: 'reasoning_done';
|
|
265
281
|
text: string;
|
|
266
|
-
}
|
|
282
|
+
}
|
|
283
|
+
/** `approval` says how the call was let through; absent for bookkeeping tools that never ask (the todo list). */
|
|
284
|
+
| {
|
|
267
285
|
type: 'tool_call';
|
|
268
286
|
name: string;
|
|
269
287
|
id: string;
|
|
270
288
|
args: unknown;
|
|
289
|
+
approval?: ApprovalSource;
|
|
271
290
|
} | {
|
|
272
291
|
type: 'tool_result';
|
|
273
292
|
name: string;
|
|
@@ -343,6 +362,22 @@ export type AgentEvent = {
|
|
|
343
362
|
} | {
|
|
344
363
|
type: 'continuation'; /** 1-based count of continuations so far in this run. */
|
|
345
364
|
count: number;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* The step now running failed (a provider error worth retrying, or an empty reply) and is being asked again after
|
|
368
|
+
* `delayMs`. Everything it streamed is discarded: a UI drops the step's partial text.
|
|
369
|
+
*/
|
|
370
|
+
/** The model ended a turn with `open` items of its own todo list still open and was reminded once (RB-44). */
|
|
371
|
+
| {
|
|
372
|
+
type: 'todo_nudge';
|
|
373
|
+
open: number;
|
|
374
|
+
} | {
|
|
375
|
+
type: 'retry';
|
|
376
|
+
kind: 'provider_error' | 'empty_reply' | 'offline';
|
|
377
|
+
attempt: number;
|
|
378
|
+
maxRetries: number;
|
|
379
|
+
delayMs: number;
|
|
380
|
+
reason: string;
|
|
346
381
|
} | {
|
|
347
382
|
type: 'phase';
|
|
348
383
|
/** Architect/editor mode: which phase the following events belong to. */
|
|
@@ -742,6 +777,23 @@ export interface AgentConfig {
|
|
|
742
777
|
* Each continuation is a normal iteration (counts toward every cap).
|
|
743
778
|
*/
|
|
744
779
|
maxContinuations?: number;
|
|
780
|
+
/**
|
|
781
|
+
* Retrying a step whose model call failed with something a retry can fix (429, 5xx, overload, dropped
|
|
782
|
+
* connection). Default 5 retries, back-off from 2 s (doubling, capped at 30 s) unless the provider sends
|
|
783
|
+
* retry-after. An empty or reasoning-only reply is asked for again twice, separately.
|
|
784
|
+
*/
|
|
785
|
+
providerRetry?: {
|
|
786
|
+
maxRetries?: number;
|
|
787
|
+
baseDelayMs?: number;
|
|
788
|
+
/**
|
|
789
|
+
* With no network at all (DNS or route gone) the run waits instead of spending retries: it probes every
|
|
790
|
+
* `offlinePollMs` (default 3 s) for up to `maxOfflineMs` in total (default 15 min), then reports the error.
|
|
791
|
+
*/
|
|
792
|
+
offlinePollMs?: number;
|
|
793
|
+
maxOfflineMs?: number;
|
|
794
|
+
/** True when the network is reachable; default dials a few public addresses over TCP. */
|
|
795
|
+
networkProbe?: () => Promise<boolean>;
|
|
796
|
+
};
|
|
745
797
|
/**
|
|
746
798
|
* Pick the model for each step (cheap for exploration, strong for planning…).
|
|
747
799
|
* Return `undefined` to keep the current model. Cost is priced per routed model.
|
|
@@ -749,6 +801,11 @@ export interface AgentConfig {
|
|
|
749
801
|
modelRouter?: ModelRouter;
|
|
750
802
|
/** Replace how `run_command` executes. See {@link CommandRunner}. */
|
|
751
803
|
commandRunner?: CommandRunner;
|
|
804
|
+
/**
|
|
805
|
+
* Confine `run_command` with the OS sandbox: bubblewrap on Linux, sandbox-exec on macOS. A command is refused when
|
|
806
|
+
* the tool is missing; Windows has no equivalent, so there commands run as before. Absent = off.
|
|
807
|
+
*/
|
|
808
|
+
commandSandbox?: CommandSandbox;
|
|
752
809
|
/**
|
|
753
810
|
* Answers `ask_user`. When absent, or when `auto` is on, the model is not offered the tool.
|
|
754
811
|
*/
|
package/package.json
CHANGED