@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.
@@ -47,6 +47,7 @@ export function defineTool(spec) {
47
47
  content: [{ type: "text", text: `${detail} Fix the arguments and call again.` }],
48
48
  details: { invalidArguments: true },
49
49
  terminate: false,
50
+ isError: true,
50
51
  };
51
52
  }
52
53
  let ret;
@@ -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
- toolCalls.length === 0 &&
380
- state.pendingMessages.length === 0 &&
381
- state.truncatedOutputContinues < (truncated.maxContinues ?? 3)) {
382
- state.truncatedOutputContinues++;
383
- state.midstreamPartialContinues = 0;
384
- const noVisibleText = !message.content.some((c) => c.type === "text" && c.text.trim() !== "");
385
- state.pendingMessages = [...state.pendingMessages, noVisibleText ? createReasoningCutContinueNudge() : createContinueNudge()];
386
- return { kind: "ran", recovered: "max_output_tokens_recovery" };
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 prefix = join(this.dir, sanitizeScope(scope)) + sep;
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: "idempotent",
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
- }, signal?: AbortSignal): Promise<string>;
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
- return info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES && !isNotebookPath(key) ? `${base} ${OVERSIZE_READ_ESCAPE_HINT}` : base;
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, splitLeadingBom } from "./encoding.js";
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: "idempotent",
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(`${violationText("Write", notRead)} ` +
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 outgoing = splitLeadingBom(content);
257
- const writeEncoding = outgoing.hadBom && !decodedPrev.encoding.hadBom ? { ...decodedPrev.encoding, hadBom: true } : decodedPrev.encoding;
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 totalLines = countLines(outgoing.text);
262
- state.set(r.key, { hash: sha256(normalizeFileText(content)), totalLines, truncated: false, lastReadAt: Date.now() });
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: normalizeFileText(content), originalFile },
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;
@@ -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 { code: "not_read", message: "File has not been read yet. Read it first before writing to it." };
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." };
@@ -69,4 +69,5 @@ export declare function runGlobDetailed(env: ExecutionEnv, root: string, pattern
69
69
  durationMs: number;
70
70
  totalMatches: number;
71
71
  countIsComplete: boolean;
72
+ error?: string;
72
73
  }>;
@@ -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 && isDir)
1148
- ignoredDirs++;
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 text = matched.length === 0 ? "No files matched." + ignoreNote + caveat : matched.join("\n") + capNote + caveat;
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
- return errorResult(msg
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, failCard());
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
- results = usable.filter((r) => webSearchResultAllowed(r.url, allowed_domains, blocked_domains)).slice(0, max);
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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",