@sema-agent/core 2.0.0 → 2.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/dist/core/tools.js +1 -0
- package/dist/engine/loop/agent-loop.d.ts +1 -1
- package/dist/engine/loop/agent-loop.js +10 -9
- package/dist/stores/file/mailbox-store.js +33 -2
- package/dist/tools/fs/fs-search-tools.js +3 -1
- package/dist/tools/fs/fs-shared.d.ts +2 -1
- package/dist/tools/fs/fs-shared.js +7 -3
- package/dist/tools/fs/fs-write.js +9 -11
- package/dist/tools/fs/safety.d.ts +3 -0
- package/dist/tools/fs/safety.js +7 -1
- package/dist/tools/fs/search.d.ts +1 -0
- package/dist/tools/fs/search.js +21 -3
- package/dist/tools/web.js +45 -7
- package/package.json +1 -1
package/dist/core/tools.js
CHANGED
|
@@ -2,7 +2,7 @@ import { type AgentCoreStreamRuntimeDeps } from "./runtime-deps.js";
|
|
|
2
2
|
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
|
|
3
3
|
export type AgentEventSink = (event: AgentEvent) => Promise<void> | void;
|
|
4
4
|
export type LoopContinueReason = "next_turn" | "steer_injected" | "follow_up_injected" | "reactive_compact_retry" | "max_output_tokens_recovery" | "malformed_tool_use_retry" | "thinking_only_retry" | "midstream_partial_recovery" | "degenerate_output_recovery" | "walltime_cutoff_recovery";
|
|
5
|
-
export type LoopTerminalReason = "completed" | "aborted_before_stream" | "assistant_error" | "stop_requested" | "malformed_tool_use_exhausted";
|
|
5
|
+
export type LoopTerminalReason = "completed" | "aborted_before_stream" | "assistant_error" | "stop_requested" | "malformed_tool_use_exhausted" | "truncated_output_exhausted";
|
|
6
6
|
export type LoopStep = {
|
|
7
7
|
kind: "continue";
|
|
8
8
|
reason: LoopContinueReason;
|
|
@@ -375,15 +375,16 @@ async function runSingleTurn(state, signal, emit, streamFn, runtime, trace) {
|
|
|
375
375
|
state.pendingMessages = (await state.config.getSteeringMessages?.()) || [];
|
|
376
376
|
const truncated = state.config.recovery?.truncatedOutput;
|
|
377
377
|
if (message.stopReason === "length") {
|
|
378
|
-
if (truncated &&
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
378
|
+
if (truncated && toolCalls.length === 0 && state.pendingMessages.length === 0) {
|
|
379
|
+
if (state.truncatedOutputContinues < (truncated.maxContinues ?? 3)) {
|
|
380
|
+
state.truncatedOutputContinues++;
|
|
381
|
+
state.midstreamPartialContinues = 0;
|
|
382
|
+
const noVisibleText = !message.content.some((c) => c.type === "text" && c.text.trim() !== "");
|
|
383
|
+
state.pendingMessages = [...state.pendingMessages, noVisibleText ? createReasoningCutContinueNudge() : createContinueNudge()];
|
|
384
|
+
return { kind: "ran", recovered: "max_output_tokens_recovery" };
|
|
385
|
+
}
|
|
386
|
+
await emit({ type: "agent_end", messages: state.newMessages });
|
|
387
|
+
return { kind: "terminal", reason: "truncated_output_exhausted" };
|
|
387
388
|
}
|
|
388
389
|
}
|
|
389
390
|
else {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join, resolve, sep } from "node:path";
|
|
2
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
3
3
|
import { newestSentAt, } from "../../core/mailbox-store.js";
|
|
4
4
|
import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, sanitizeScope, sanitizePathComponent } from "./fs-atomic.js";
|
|
5
5
|
function realpathSyncSafe(p) {
|
|
@@ -11,6 +11,16 @@ function realpathSyncSafe(p) {
|
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
const sharedBoxes = new Map();
|
|
14
|
+
function listBoxFiles(scopeDir) {
|
|
15
|
+
try {
|
|
16
|
+
return readdirSync(scopeDir, { withFileTypes: true })
|
|
17
|
+
.filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
|
|
18
|
+
.map((e) => e.name);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
14
24
|
const pathLocks = new Map();
|
|
15
25
|
function withPathLock(key, fn) {
|
|
16
26
|
const prev = pathLocks.get(key) ?? Promise.resolve();
|
|
@@ -157,7 +167,8 @@ export class FileMailboxStore {
|
|
|
157
167
|
if (opts?.maxAgeMs === undefined)
|
|
158
168
|
return 0;
|
|
159
169
|
let dropped = 0;
|
|
160
|
-
const
|
|
170
|
+
const scopeDir = join(this.dir, sanitizeScope(scope));
|
|
171
|
+
const prefix = scopeDir + sep;
|
|
161
172
|
const maxAgeMs = opts.maxAgeMs;
|
|
162
173
|
for (const [key, b] of [...sharedBoxes]) {
|
|
163
174
|
if (!b.path.startsWith(prefix))
|
|
@@ -180,6 +191,26 @@ export class FileMailboxStore {
|
|
|
180
191
|
if (swept)
|
|
181
192
|
dropped++;
|
|
182
193
|
}
|
|
194
|
+
for (const name of listBoxFiles(scopeDir)) {
|
|
195
|
+
const path = join(scopeDir, name);
|
|
196
|
+
const key = canonicalStoreKey(path);
|
|
197
|
+
if (sharedBoxes.has(key))
|
|
198
|
+
continue;
|
|
199
|
+
const swept = await withPathLock(key, () => {
|
|
200
|
+
if (sharedBoxes.has(key))
|
|
201
|
+
return false;
|
|
202
|
+
const b = { messages: [], nextSeq: 1 };
|
|
203
|
+
for (const ev of readJsonlRecords(path))
|
|
204
|
+
applyEvent(b, ev);
|
|
205
|
+
const newest = newestSentAt(b.messages);
|
|
206
|
+
if (newest === undefined || newest >= now - maxAgeMs)
|
|
207
|
+
return false;
|
|
208
|
+
atomicWriteFile(this.tmpDir, path, `${JSON.stringify({ t: "lease", owner: "", expiresAt: 0, maxSeq: b.nextSeq - 1 })}\n`);
|
|
209
|
+
return true;
|
|
210
|
+
});
|
|
211
|
+
if (swept)
|
|
212
|
+
dropped++;
|
|
213
|
+
}
|
|
183
214
|
return dropped;
|
|
184
215
|
}
|
|
185
216
|
close() {
|
|
@@ -151,6 +151,8 @@ export function createGlobTool(env, rootCanonical, additionalRoots) {
|
|
|
151
151
|
scoped = r.key;
|
|
152
152
|
}
|
|
153
153
|
const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results }, ctx.signal);
|
|
154
|
+
if (r2.error !== undefined)
|
|
155
|
+
return errorResult(r2.error);
|
|
154
156
|
return {
|
|
155
157
|
content: r2.text,
|
|
156
158
|
details: { type: "glob", filenames: r2.filenames, numFiles: r2.numFiles, truncated: r2.truncated, durationMs: r2.durationMs, totalMatches: r2.totalMatches, countIsComplete: r2.countIsComplete },
|
|
@@ -162,7 +164,7 @@ export const HAND_TOOL_EFFECTS = {
|
|
|
162
164
|
Read: "read",
|
|
163
165
|
Edit: "write",
|
|
164
166
|
MultiEdit: "write",
|
|
165
|
-
Write: "
|
|
167
|
+
Write: "write",
|
|
166
168
|
NotebookEdit: "write",
|
|
167
169
|
Grep: "read",
|
|
168
170
|
Glob: "read",
|
|
@@ -18,7 +18,8 @@ export declare function persistedTextOf(encoded: string | Uint8Array): string;
|
|
|
18
18
|
export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: {
|
|
19
19
|
code: string;
|
|
20
20
|
message: string;
|
|
21
|
-
|
|
21
|
+
partialView?: boolean;
|
|
22
|
+
}, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
|
|
22
23
|
export declare const MAX_IMAGE_READ_BYTES: number;
|
|
23
24
|
export declare const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES: number;
|
|
24
25
|
export type ReadImageDownsamplerOption = ImageDownsampler | false | undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
import { clipWithFilePointer } from "../../core/tool-errors.js";
|
|
3
|
-
import { sha256, similarNameSuggestion, OVERSIZE_READ_ESCAPE_HINT, } from "./safety.js";
|
|
3
|
+
import { sha256, similarNameSuggestion, OVERSIZE_READ_ESCAPE_HINT, PARTIAL_VIEW_READ_ESCAPE_HINT, } from "./safety.js";
|
|
4
4
|
import { decodeTextBytes, normalizeFileText } from "./encoding.js";
|
|
5
5
|
import { shellQuote } from "./search.js";
|
|
6
6
|
import { isNotebookPath } from "./notebook.js";
|
|
@@ -30,10 +30,14 @@ export function decodeEditBytes(bytes, path) {
|
|
|
30
30
|
export function persistedTextOf(encoded) {
|
|
31
31
|
return decodeTextBytes(typeof encoded === "string" ? Buffer.from(encoded, "utf8") : encoded).text;
|
|
32
32
|
}
|
|
33
|
-
export async function notReadRefusalText(env, toolName, key, v, signal) {
|
|
33
|
+
export async function notReadRefusalText(env, toolName, key, v, signal, fallbackHint) {
|
|
34
34
|
const base = `Error (${toolName}): ${v.message}`;
|
|
35
|
+
if (v.partialView === true && !isNotebookPath(key))
|
|
36
|
+
return `${base} ${PARTIAL_VIEW_READ_ESCAPE_HINT}`;
|
|
35
37
|
const info = await env.fileInfo(key, signal);
|
|
36
|
-
|
|
38
|
+
if (info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES && !isNotebookPath(key))
|
|
39
|
+
return `${base} ${OVERSIZE_READ_ESCAPE_HINT}`;
|
|
40
|
+
return fallbackHint === undefined ? base : `${base} ${fallbackHint}`;
|
|
37
41
|
}
|
|
38
42
|
export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024;
|
|
39
43
|
export const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES = 64 * 1024 * 1024;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { defineTool, errorResult } from "../../core/tools.js";
|
|
4
|
-
import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, } from "./safety.js";
|
|
5
|
-
import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText
|
|
4
|
+
import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, fileArgPath, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT, } from "./safety.js";
|
|
5
|
+
import { decodeTextBytes, encodeTextForFile, normalizeEditText, normalizeFileText } from "./encoding.js";
|
|
6
6
|
import { MAX_EDIT_BYTES, formatByteSize, decodeEditBytes, persistedTextOf, notReadRefusalText, enoentMessage, FILE_STATE_TRAILER, FILE_PATH_PARAMS, ipynbRedirect, countLines, } from "./fs-shared.js";
|
|
7
7
|
async function gateToolWrite(hook, tool, path, key, content) {
|
|
8
8
|
if (hook === undefined)
|
|
@@ -218,7 +218,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
|
|
|
218
218
|
...FILE_PATH_PARAMS,
|
|
219
219
|
content: Type.String({ description: "The content to write to the file" }),
|
|
220
220
|
}),
|
|
221
|
-
effect: "
|
|
221
|
+
effect: "write",
|
|
222
222
|
execute: async (args, ctx) => {
|
|
223
223
|
const { content } = args;
|
|
224
224
|
const path = fileArgPath(args);
|
|
@@ -237,8 +237,7 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
|
|
|
237
237
|
if (exists.value) {
|
|
238
238
|
const notRead = requireRead(state, r.key);
|
|
239
239
|
if (notRead) {
|
|
240
|
-
return errorResult(
|
|
241
|
-
`(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. \`rm\` + rewrite, or \`iconv\`.)`);
|
|
240
|
+
return errorResult(await notReadRefusalText(env, "Write", r.key, notRead, ctx.signal, WRITE_ENCODING_DEADLOCK_ESCAPE_HINT));
|
|
242
241
|
}
|
|
243
242
|
const readBin = await env.readBinaryFile(r.key, ctx.signal);
|
|
244
243
|
if (!readBin.ok)
|
|
@@ -253,16 +252,15 @@ export function createWriteFileTool(env, state, rootCanonical, cwdRef, additiona
|
|
|
253
252
|
const gated = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
|
|
254
253
|
if (gated !== undefined)
|
|
255
254
|
return errorResult(gated);
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
const write = await env.writeFile(r.key, encodeTextForFile(outgoing.text, writeEncoding, "preserve"), ctx.signal);
|
|
255
|
+
const encodedWrite = encodeTextForFile(content, decodedPrev.encoding, "preserve");
|
|
256
|
+
const write = await env.writeFile(r.key, encodedWrite, ctx.signal);
|
|
259
257
|
if (!write.ok)
|
|
260
258
|
return errorResult(`Error (Write): cannot write "${path}": ${write.error.message}`);
|
|
261
|
-
const
|
|
262
|
-
state.set(r.key, { hash: sha256(
|
|
259
|
+
const persistedWrite = persistedTextOf(encodedWrite);
|
|
260
|
+
state.set(r.key, { hash: sha256(persistedWrite), totalLines: countLines(persistedWrite), truncated: false, lastReadAt: Date.now() });
|
|
263
261
|
return {
|
|
264
262
|
content: `The file ${path} has been updated successfully.${FILE_STATE_TRAILER}`,
|
|
265
|
-
details: { type: "update", filePath: path, content:
|
|
263
|
+
details: { type: "update", filePath: path, content: persistedWrite, originalFile },
|
|
266
264
|
};
|
|
267
265
|
}
|
|
268
266
|
const gatedCreate = await gateToolWrite(beforeWrite, "Write", path, r.key, content);
|
|
@@ -17,6 +17,7 @@ export type ReadFileState = Map<string, ReadEntry>;
|
|
|
17
17
|
export declare function sha256(content: string): string;
|
|
18
18
|
export interface FsViolation {
|
|
19
19
|
code: "path_not_in_root" | "not_read" | "stale" | "ambiguous_edit" | "invalid";
|
|
20
|
+
partialView?: true;
|
|
20
21
|
message: string;
|
|
21
22
|
}
|
|
22
23
|
export declare function isBlockedDevicePath(key: string): boolean;
|
|
@@ -44,6 +45,8 @@ export declare function canonicalizeTarget(env: ExecutionEnv, path: string, sign
|
|
|
44
45
|
export declare function violationText(toolName: string, v: FsViolation): string;
|
|
45
46
|
export declare function requireRead(state: ReadFileState, key: string): FsViolation | undefined;
|
|
46
47
|
export declare const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused \u2014 read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
48
|
+
export declare const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view \u2014 the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
49
|
+
export declare const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead \u2014 e.g. `rm` + rewrite, or `iconv`.)";
|
|
47
50
|
export declare function checkNoChange(oldString: string, newString: string): FsViolation | undefined;
|
|
48
51
|
export declare function checkStale(entry: ReadEntry, currentHash: string): FsViolation | undefined;
|
|
49
52
|
export declare function countOccurrences(haystack: string, needle: string): number;
|
package/dist/tools/fs/safety.js
CHANGED
|
@@ -230,11 +230,17 @@ export function violationText(toolName, v) {
|
|
|
230
230
|
export function requireRead(state, key) {
|
|
231
231
|
const entry = state.get(key);
|
|
232
232
|
if (entry === undefined || entry.isPartialView) {
|
|
233
|
-
return {
|
|
233
|
+
return {
|
|
234
|
+
code: "not_read",
|
|
235
|
+
message: "File has not been read yet. Read it first before writing to it.",
|
|
236
|
+
...(entry?.isPartialView ? { partialView: true } : {}),
|
|
237
|
+
};
|
|
234
238
|
}
|
|
235
239
|
return undefined;
|
|
236
240
|
}
|
|
237
241
|
export const OVERSIZE_READ_ESCAPE_HINT = "(This file is over the Read tool's whole-file byte cap, so a default Read is refused — read it in slices with explicit offset/limit to satisfy the read-first rule, or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
242
|
+
export const PARTIAL_VIEW_READ_ESCAPE_HINT = "(Your last Read of this file returned only a PARTIAL view — the output token cap paginated it, so a default Read will keep returning the same page. Re-read it with explicit offset/limit (start from the page marker's next-page hint) until you have seen the part you are about to change; an explicit slice that fits satisfies the read-first rule. Or inspect/transform it with bash (e.g. `sed -n`, `grep`) instead.)";
|
|
243
|
+
export const WRITE_ENCODING_DEADLOCK_ESCAPE_HINT = "(If the Read tool refuses this file (binary/unknown encoding), overwrite or convert it with bash instead — e.g. `rm` + rewrite, or `iconv`.)";
|
|
238
244
|
export function checkNoChange(oldString, newString) {
|
|
239
245
|
if (oldString === newString) {
|
|
240
246
|
return { code: "invalid", message: "No changes to make: old_string and new_string are exactly the same." };
|
package/dist/tools/fs/search.js
CHANGED
|
@@ -1089,6 +1089,13 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1089
1089
|
const baseIgnore = await buildIgnore(env, root, signal);
|
|
1090
1090
|
const rootPrefix = root.replace(/[\\/]+$/, "") + (root.includes("\\") ? "\\" : "/");
|
|
1091
1091
|
const start = opts.path ? (opts.path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(opts.path) ? opts.path : `${rootPrefix}${opts.path}`) : root;
|
|
1092
|
+
if (opts.path !== undefined) {
|
|
1093
|
+
const probe = await env.fileInfo(start, signal);
|
|
1094
|
+
if (!probe.ok && probe.error.code === "not_found") {
|
|
1095
|
+
const error = `Error (Glob): path ${JSON.stringify(opts.path)} does not exist — check the path, or omit \`path\` to search the whole root.`;
|
|
1096
|
+
return { text: error, error, filenames: [], numFiles: 0, truncated: false, durationMs: Date.now() - t0, totalMatches: 0, countIsComplete: false };
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1092
1099
|
const pat = normalizeGlobToken(pattern);
|
|
1093
1100
|
const anchored = globIsAnchored(pattern);
|
|
1094
1101
|
const re = globTokenToRegExp(pat, anchored, true);
|
|
@@ -1131,6 +1138,7 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1131
1138
|
return k < starStarIdx ? literalAt[k] === segs[k] : literalAfterStarStar.has(segs[k]);
|
|
1132
1139
|
};
|
|
1133
1140
|
let ignoredDirs = 0;
|
|
1141
|
+
let ignoredFiles = 0;
|
|
1134
1142
|
const rescued = [];
|
|
1135
1143
|
const ignore = (relPath, isDir) => {
|
|
1136
1144
|
const sr = toStartRel(relPath);
|
|
@@ -1144,8 +1152,12 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1144
1152
|
if (rescued.length > 0 && rescued.some((r) => sr.startsWith(r)))
|
|
1145
1153
|
return false;
|
|
1146
1154
|
const ig = baseIgnore(relPath, isDir);
|
|
1147
|
-
if (ig
|
|
1148
|
-
|
|
1155
|
+
if (ig) {
|
|
1156
|
+
if (isDir)
|
|
1157
|
+
ignoredDirs++;
|
|
1158
|
+
else
|
|
1159
|
+
ignoredFiles++;
|
|
1160
|
+
}
|
|
1149
1161
|
return ig;
|
|
1150
1162
|
};
|
|
1151
1163
|
const walked = await walk(env, root, start, ignore, signal);
|
|
@@ -1173,6 +1185,12 @@ export async function runGlobDetailed(env, root, pattern, opts = {}, signal) {
|
|
|
1173
1185
|
const ignoreNote = matched.length === 0 && ignoredDirs > 0
|
|
1174
1186
|
? `\n[note: ${ignoredDirs} ignored director${ignoredDirs === 1 ? "y was" : "ies were"} not searched (dependency/build/VCS trees and .gitignore) — name a directory in the pattern (e.g. "dist/**") or pass \`path\` to include it]`
|
|
1175
1187
|
: "";
|
|
1176
|
-
const
|
|
1188
|
+
const ignoredFileNote = matched.length === 0 && ignoredFiles > 0
|
|
1189
|
+
? `\n[note: ${ignoredFiles} file(s) matching an ignore rule (.gitignore) were skipped — an empty result here is NOT proof the file is absent; name it literally with a root-anchored path (e.g. "/notes.secret", "logs/app.log") to include it, or read it directly]`
|
|
1190
|
+
: "";
|
|
1191
|
+
const budgetNote = matched.length === 0 && walked.incomplete
|
|
1192
|
+
? `\n[note: the file-walk budget (${WALK_MAX_FILES} files / ${WALK_MAX_DEPTH} directory levels) ran out before the whole tree was scanned — this empty result is NOT proof the file is absent; scope the search with \`path\`, or use a root-anchored pattern (e.g. "src/**/*.ts") so the walk is pruned toward it, then retry]`
|
|
1193
|
+
: "";
|
|
1194
|
+
const text = matched.length === 0 ? "No files matched." + ignoreNote + ignoredFileNote + budgetNote + caveat : matched.join("\n") + capNote + caveat;
|
|
1177
1195
|
return { text, filenames: matched, numFiles: matched.length, truncated, durationMs: Date.now() - t0, totalMatches, countIsComplete };
|
|
1178
1196
|
}
|
package/dist/tools/web.js
CHANGED
|
@@ -576,6 +576,34 @@ export function createWebFetchSummarizer(brain, model) {
|
|
|
576
576
|
}
|
|
577
577
|
const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
|
|
578
578
|
const SEARCH_ERROR_EXCERPT_CHARS = 2048;
|
|
579
|
+
const SEARCH_QUERY_MAX_CHARS = 2_000;
|
|
580
|
+
function classifySearchFailure(message) {
|
|
581
|
+
const m = message.toLowerCase();
|
|
582
|
+
const status = /(?:\bhttp\b|\bstatus\b|\bcode\b|\berror\b)\D{0,12}?(\d{3})\b/.exec(m)?.[1];
|
|
583
|
+
const code = status === undefined ? undefined : Number(status);
|
|
584
|
+
if (code === 429 || /\brate[- ]?limit/.test(m)) {
|
|
585
|
+
return { retryable: true, hint: "The backend rate-limited this request — retry after a pause, not immediately." };
|
|
586
|
+
}
|
|
587
|
+
if (code === 408 || /\btimed? ?out\b|\betimedout\b/.test(m)) {
|
|
588
|
+
return { retryable: true, hint: "This looks like a timeout — a retry may succeed; a narrower query tends to return faster." };
|
|
589
|
+
}
|
|
590
|
+
if (/\becconnrefused\b|\benotfound\b|\beai_again\b|\becconnreset\b|\bepipe\b|\bfetch failed\b|socket hang up|\bnetwork\b|\bdns\b/.test(m)) {
|
|
591
|
+
return { retryable: true, hint: "This is a network/transport fault, not a rejection of the query — one retry is reasonable; if it repeats, treat the search backend as unavailable and continue without it." };
|
|
592
|
+
}
|
|
593
|
+
if (code !== undefined && code >= 500 && code <= 599) {
|
|
594
|
+
return { retryable: true, hint: `The backend reported a server-side fault (HTTP ${code}) — retry once; if it repeats, continue without search results.` };
|
|
595
|
+
}
|
|
596
|
+
if (code !== undefined && code >= 400 && code <= 499) {
|
|
597
|
+
return {
|
|
598
|
+
retryable: false,
|
|
599
|
+
hint: `The backend REJECTED the request (HTTP ${code}) — an identical retry will fail the same way. Change the query or the domain filters, or report that the search backend's configuration/credentials need attention.`,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
retryable: "unknown",
|
|
604
|
+
hint: "Whether a retry helps cannot be determined from the backend's message — at most one retry, then continue without search results.",
|
|
605
|
+
};
|
|
606
|
+
}
|
|
579
607
|
const SEARCH_TITLE_MAX_CHARS = 300;
|
|
580
608
|
const SEARCH_SNIPPET_MAX_CHARS = 1_000;
|
|
581
609
|
const SEARCH_BODY_MAX_CHARS = 100_000;
|
|
@@ -641,7 +669,11 @@ export function createWebSearchTool(config) {
|
|
|
641
669
|
...extra,
|
|
642
670
|
});
|
|
643
671
|
if (allowed_domains?.length && blocked_domains?.length) {
|
|
644
|
-
return errorResult("Error: Cannot specify both allowed_domains and blocked_domains in the same request", failCard());
|
|
672
|
+
return errorResult("Error: Cannot specify both allowed_domains and blocked_domains in the same request", failCard({ retryable: false }));
|
|
673
|
+
}
|
|
674
|
+
const queryChars = [...query].length;
|
|
675
|
+
if (queryChars > SEARCH_QUERY_MAX_CHARS) {
|
|
676
|
+
return errorResult(`Error (WebSearch): the query is ${queryChars} characters, over the ${SEARCH_QUERY_MAX_CHARS}-character limit. Shorten it to the terms that matter — an identical retry will fail the same way.`, failCard({ retryable: false }));
|
|
645
677
|
}
|
|
646
678
|
const timeoutMs = config.timeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS;
|
|
647
679
|
const ac = new AbortController();
|
|
@@ -652,8 +684,8 @@ export function createWebSearchTool(config) {
|
|
|
652
684
|
else
|
|
653
685
|
ctx.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
654
686
|
const TIMED_OUT = Symbol("websearch-timeout");
|
|
655
|
-
const abortedFrame = () => errorResult("Error (WebSearch): the search was interrupted (aborted) before completion. No results were retrieved.", failCard({ aborted: true }));
|
|
656
|
-
const timedOutFrame = () => errorResult(`Error (WebSearch): the search backend did not respond within ${timeoutMs}ms. The tool stopped waiting; the backend may still be executing the request.`, failCard({ timedOut: true }));
|
|
687
|
+
const abortedFrame = () => errorResult("Error (WebSearch): the search was interrupted (aborted) before completion. No results were retrieved. The interruption came from the caller, not the backend — do not retry on your own initiative.", failCard({ aborted: true, retryable: false }));
|
|
688
|
+
const timedOutFrame = () => errorResult(`Error (WebSearch): the search backend did not respond within ${timeoutMs}ms. The tool stopped waiting; the backend may still be executing the request. A retry may succeed — a narrower query, or fewer domain filters, tends to return faster.`, failCard({ timedOut: true, retryable: true }));
|
|
657
689
|
let results;
|
|
658
690
|
try {
|
|
659
691
|
const work = Promise.resolve(config.search(query, ac.signal, { allowedDomains: allowed_domains, blockedDomains: blocked_domains }));
|
|
@@ -682,9 +714,10 @@ export function createWebSearchTool(config) {
|
|
|
682
714
|
return timedOutFrame();
|
|
683
715
|
const msg = redactSecrets(e instanceof Error ? e.message : String(e)).trim();
|
|
684
716
|
const headline = "Error (WebSearch): the search backend failed.";
|
|
685
|
-
|
|
717
|
+
const verdict = classifySearchFailure(msg);
|
|
718
|
+
return errorResult((msg
|
|
686
719
|
? `${headline} The backend's error text follows:\n\n${delimitUntrusted("WebSearch backend error", msg, SEARCH_ERROR_EXCERPT_CHARS)}`
|
|
687
|
-
: headline
|
|
720
|
+
: headline) + `\n\n${verdict.hint}`, failCard({ retryable: verdict.retryable }));
|
|
688
721
|
}
|
|
689
722
|
finally {
|
|
690
723
|
clearTimeout(timer);
|
|
@@ -692,7 +725,9 @@ export function createWebSearchTool(config) {
|
|
|
692
725
|
}
|
|
693
726
|
const usable = results.filter((r) => webSearchResultAllowed(r.url));
|
|
694
727
|
const droppedUnusable = results.length - usable.length;
|
|
695
|
-
|
|
728
|
+
const domainFiltered = usable.filter((r) => webSearchResultAllowed(r.url, allowed_domains, blocked_domains));
|
|
729
|
+
const droppedByDomain = usable.length - domainFiltered.length;
|
|
730
|
+
results = domainFiltered.slice(0, max);
|
|
696
731
|
let clipped = false;
|
|
697
732
|
const shown = results.map((r) => {
|
|
698
733
|
const title = clipCodePoints(r.title, SEARCH_TITLE_MAX_CHARS);
|
|
@@ -709,10 +744,13 @@ export function createWebSearchTool(config) {
|
|
|
709
744
|
const dropNote = droppedUnusable > 0
|
|
710
745
|
? `\n\n[WebSearch: ${droppedUnusable} result(s) returned by the backend were dropped — their URL was not a usable http(s) link]`
|
|
711
746
|
: "";
|
|
747
|
+
const domainNote = droppedByDomain > 0
|
|
748
|
+
? `\n\n[WebSearch: ${droppedByDomain} result(s) were dropped by the ${allowed_domains?.length ? "allowed_domains" : "blocked_domains"} filter passed with this call — the backend returned them, this tool removed them. Relax or drop the filter to see them.]`
|
|
749
|
+
: "";
|
|
712
750
|
const clipNote = clipped
|
|
713
751
|
? "\n\n[WebSearch: one or more result fields exceeded the display limits and were truncated — treat the text above as an excerpt, not the backend's full result.]"
|
|
714
752
|
: "";
|
|
715
|
-
const modelText = `${fenced}${dropNote}${clipNote}\n\nREMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.`;
|
|
753
|
+
const modelText = `${fenced}${dropNote}${domainNote}${clipNote}\n\nREMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.`;
|
|
716
754
|
return {
|
|
717
755
|
content: modelText,
|
|
718
756
|
details: {
|