@sema-agent/core 5.10.0 → 5.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.11.0 — 2026-08-04
4
+
5
+ ### BREAKING
6
+
7
+ - **The restart-loop suspend cap counts CONSECUTIVE NO-PROGRESS suspends, not total approvals.** The cap's stated purpose is catching a restart-prone model re-issuing the same gated call forever — yet it counted distinct approved-and-executed gates identically, so under a doctrine that asks per call (`shellGate:"classify"` + out-of-root reads) a fully cooperating approver was killed on the 6th legitimate Yes (`suspend.loop`, default `maxSuspends: 5`). The chain base now resets the moment the resume leg starts EXECUTING the approved pending call (an execution that then errors still consumed the approval — it reset the chain); a park whose approved call never ran keeps accumulating. **Consumer flips**: checkpoint `suspendCount` restarts at 1 after each executed approval — probes pinning a monotonically increasing `suspendCount` across approve-execute cycles red; `maxSuspends` now bounds no-progress chains, never the total number of approvals a run may receive.
8
+ - **`SubagentErrorKind` closed set gains a sixth member: `"governance"`.** A FAILED child whose `errorCode` carries the `usage.*` prefix (deployment usage window refused/exhausted) now classifies as `governance` with `retryable: false` — it is DEFERRED-retryable: re-issuing NOW gets the same refusal, but the refusal lifts at the card's `retryAfterMs`. Previously these folded into `logic` ("re-delegating unchanged won't help"), which told a scheduler to abandon a subtask that merely needed to wait. The transient retry set (`rate_limit`/`overloaded`/`timeout`/`network` → `retryable: true`) is untouched. **Consumer flips**: switches over the closed five-member kind set add a `governance` arm; the completed agent card now pairs `error_kind: "governance"` with `retryAfterMs` on window-exhausted children.
9
+
10
+ - **Deferred-tool activation no longer rewrites the provider tools block — `toolMaterializeStrategy` defaults to `"static"`.** Tools serialize FIRST in the provider prompt-cache hierarchy, so the old activation swap (placeholder → full schema via setTools) invalidated the entire conversation cache — 29% of benchmarked trials paid an average ~31k tokens per break. Under the new default the placeholder bytes persist for the whole run; the full schema reaches the model through the ToolSearch result text (existing carrier) and engine-side validation against the REAL schema is unchanged. Ruled GO by a live A/B on the weakest BYOM model (15/15 argument instances correct, three scenario classes incl. nested/optional params). `"swap"` is the explicit opt-back (spec or `SEMA_TOOL_MATERIALIZE_STRATEGY`, invalid env value refused loudly as `config.tool_materialize_invalid`). **Consumer flips**: probes pinning the post-activation full schema in the tools block red — the activation announcement frames and genuine roster changes (MCP refresh) are unchanged.
11
+
12
+ ### Added
13
+
14
+ - **A shell-gated ask persists the live shellGate doctrine on its risk descriptor.** Field forensics could not answer "was `classify` or `always` live when this gate fired" from any persisted artifact. The mint now stamps `RiskDescriptor.shellGateDoctrine` (`"classify" | "always"`, present only on shell-gated asks — `"off"` never mints) so the checkpoint row self-reports its provenance. Additive optional field.
15
+ - **`TaskSpec.additionalReadDirectories` — a read-only fence widening.** Extra directories the READ faces admit and nothing else: the `shellGate:"classify"` read boundary (a provably read-only command like `head <dir>/settings.json` auto-allows instead of consulting the approver on every call — the interactive kill shape this exists for), plus read_file/grep/glob/repo_map and `bash_readonly` containment. The write faces (edit_file/write_file/notebook_edit) refuse these directories exactly as before — a read grant never silently becomes a write grant (`additionalDirectories` remains the read+write widening). Same canonicalization/fail-closed-skip rules as `additionalDirectories` (the skip trace gains an optional `field` discriminator); announced in `# Environment` as `Additional read-only directories:`; inherited down the delegation tree on the same seat as `additionalDirectories`. Additive — absent ⇒ byte-identical behavior.
16
+
17
+ ### Fixed
18
+
19
+ - **WebFetch stops laundering emptiness into authority (grounding contract).** A shell page (tiny extracted text, or near-zero text ratio) now gets a TRUSTED grounding verdict outside the untrusted fence, the full extracted text appended for verification, and additive `details.grounding{level,textChars,bytes,textRatio}`; an incomplete body is called a retrieved prefix. The summarizer input budget derives from the summary model's window (flat 100k retired; trimmed input disclosed structurally and in the rendered head; NaN model metadata refused loudly). Recovery prose names Bash/curl only when reachable in THIS run. The summary prompt gains a grounding clause (report absence rather than infer; a filename is only evidence about naming) ahead of the byte-fixed CC guidelines tail.
20
+ - **A truncated-tool-arguments disclosure names the request output cap when one was armed** (`request output cap in effect: N tokens`) on both brain lanes — a cut mid-arguments at the cap read as a generic provider error with no budget clue.
21
+ - **agentStream's birth window is steerable**: an immediate `steer()` after `agentStream()` waits (bounded) for the loop's first prompt and lands on turn 1; a finished run keeps the honest refusal.
22
+ - **A long-window zero-byte Bash timeout names its two honest readings** (stalled before producing output / holding it in a block buffer); short windows and abort arms stay bare.
23
+ - **The background faces carry the governance wait hint.** A `usage.window_exhausted` background child's settle dropped `retryAfterMs` — every bg face (poll text, poll details, notification-adjacent durable row) said `retryable:false` with no way to know when the refusal lifts, while the sync report card carried the hint. The classification family gains its fourth seat end-to-end: settle mint → registry row → durable archival row → poll `details.retryAfterMs` → the poll text clause (`(error_kind: governance, retryable: false, retry_after_ms: N)`); a revive clears it with its three siblings. Additive on every face.
24
+ - **The `suspend.loop` terminal diagnoses the every-approval-was-Yes kill.** When every recorded gate decision on the run was ALLOW, the message now names the shape (a gate that keeps asking × an approver that keeps approving consumed the suspend allowance on legitimate work) and the remedies (a live `onAsk` parks nothing; keep benign commands inside the read boundary; raise `maxSuspends` for approval-heavy tasks) instead of reporting a bare restart-loop mystery.
25
+
3
26
  ## 5.10.0 — 2026-08-04
4
27
 
5
28
  ### Added
@@ -34,7 +34,7 @@ export interface SubagentWorktreeIsolation {
34
34
  }
35
35
  export declare function createSubagentWorktreeHelper(baseEnv: ExecutionEnv, repoRoot: string): SubagentWorktreeIsolation;
36
36
  export declare const REPORT_FIELD_MAX = 300;
37
- export type SubagentErrorKind = "rate_limit" | "overloaded" | "timeout" | "network" | "logic";
37
+ export type SubagentErrorKind = "rate_limit" | "overloaded" | "timeout" | "network" | "logic" | "governance";
38
38
  export declare function classifySubagentError(child: {
39
39
  status: string;
40
40
  errorCode?: string;
@@ -229,8 +229,10 @@ export function classifySubagentError(child) {
229
229
  ? "timeout"
230
230
  : code === "network" || code === "server"
231
231
  ? "network"
232
- : "logic";
233
- return { errorKind, retryable: errorKind !== "logic" };
232
+ : code !== undefined && code.startsWith("usage.")
233
+ ? "governance"
234
+ : "logic";
235
+ return { errorKind, retryable: errorKind !== "logic" && errorKind !== "governance" };
234
236
  }
235
237
  export function completedAgentCard(child, extras) {
236
238
  return {
@@ -1502,6 +1504,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
1502
1504
  ...(ctx.alwaysLoadTools !== undefined ? { alwaysLoadTools: [...ctx.alwaysLoadTools] } : {}),
1503
1505
  ...(ctx.promptProfile !== undefined ? { promptProfile: ctx.promptProfile } : {}),
1504
1506
  ...(ctx.additionalDirectories !== undefined ? { additionalDirectories: [...ctx.additionalDirectories] } : {}),
1507
+ ...(ctx.additionalReadDirectories !== undefined ? { additionalReadDirectories: [...ctx.additionalReadDirectories] } : {}),
1505
1508
  ...(ctx.envFacts !== undefined ? { envFacts: { ...ctx.envFacts } } : {}),
1506
1509
  ...(ctx.getApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: ctx.getApiKeyAndHeaders } : {}),
1507
1510
  enableBlockedReport: true,
@@ -2567,6 +2570,7 @@ function createSubagentToolNode(opts, depth, excluded, extraToolsBudget) {
2567
2570
  ...(!ok ? { error: reaped ? (collateral ? BG_AGENT_COLLATERAL_REAP_REASON : BG_AGENT_REAP_STOP_ERROR) : unparkedPauseReason ?? child.errorMessage ?? String(child.status) } : {}),
2568
2571
  ...(errCodeBg !== undefined ? { errorCode: errCodeBg } : {}),
2569
2572
  ...(errClassBg !== undefined ? { retryable: errClassBg.retryable, errorKind: errClassBg.errorKind } : {}),
2573
+ ...(failedBg && child.retryAfterMs !== undefined ? { retryAfterMs: child.retryAfterMs } : {}),
2570
2574
  }) ??
2571
2575
  (abort.signal.aborted ? "killed" : ok ? "completed" : "failed");
2572
2576
  if (!bgRetain && reviveRow === undefined && !(await bgRowConfirmed())) {
@@ -558,7 +558,7 @@ export function createAnthropicBrain(config = {}) {
558
558
  const noUsableContent = toolCalls.length === 0 && !anyText;
559
559
  const toolErrorParts = [];
560
560
  if (malformed.length > 0) {
561
- toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, stop_reason="${stopReason ?? "?"}"): ${malformed.join("; ")}`);
561
+ toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, stop_reason="${stopReason ?? "?"}"${sentMaxTokens !== undefined ? `; request output cap in effect: ${sentMaxTokens} tokens — a cut mid-arguments commonly means the cap was hit` : ""}): ${malformed.join("; ")}`);
562
562
  }
563
563
  if (unnamed.length > 0) {
564
564
  toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (stop_reason="${stopReason ?? "?"}"): ${unnamed.join("; ")}`);
@@ -526,7 +526,7 @@ export function createOpenAIBrain(config = {}) {
526
526
  }
527
527
  const toolErrorParts = [];
528
528
  if (malformed.length > 0) {
529
- toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, finish_reason="${finishReason ?? "?"}"): ${malformed.join("; ")}`);
529
+ toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, finish_reason="${finishReason ?? "?"}"${sentMaxTokens !== undefined ? `; request output cap in effect: ${sentMaxTokens} tokens — a cut mid-arguments commonly means the cap was hit` : ""}): ${malformed.join("; ")}`);
530
530
  }
531
531
  if (unnamed.length > 0) {
532
532
  toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (finish_reason="${finishReason ?? "?"}"): ${unnamed.join("; ")}`);
@@ -40,13 +40,14 @@ export interface BackgroundAgentRecord {
40
40
  errorCode?: string;
41
41
  errorRetryable?: boolean;
42
42
  errorKind?: string;
43
+ errorRetryAfterMs?: number;
43
44
  resultIsPartial?: boolean;
44
45
  recentSteps?: SubagentStep[];
45
46
  editedFiles?: SubagentEditedFile[];
46
47
  usage?: BackgroundAgentUsage;
47
48
  rev: number;
48
49
  }
49
- export declare const REVIVED_ROW_CLEARED_FIELDS: readonly ["settledAt", "stoppedBy", "completionId", "finalOutput", "finalOutputFull", "error", "errorCode", "errorRetryable", "errorKind", "resultIsPartial", "summary", "recentSteps", "editedFiles", "usage"];
50
+ export declare const REVIVED_ROW_CLEARED_FIELDS: readonly ["settledAt", "stoppedBy", "completionId", "finalOutput", "finalOutputFull", "error", "errorCode", "errorRetryable", "errorKind", "errorRetryAfterMs", "resultIsPartial", "summary", "recentSteps", "editedFiles", "usage"];
50
51
  export declare function clearRevivedRowTerminalPayload(record: BackgroundAgentRecord): void;
51
52
  export interface BackgroundAgentRowSummary {
52
53
  handle: string;
@@ -9,6 +9,7 @@ export const REVIVED_ROW_CLEARED_FIELDS = [
9
9
  "errorCode",
10
10
  "errorRetryable",
11
11
  "errorKind",
12
+ "errorRetryAfterMs",
12
13
  "resultIsPartial",
13
14
  "summary",
14
15
  "recentSteps",
@@ -24,6 +24,7 @@ export interface RiskDescriptor {
24
24
  shell?: boolean;
25
25
  };
26
26
  toolName: string;
27
+ shellGateDoctrine?: "classify" | "always";
27
28
  summary?: string;
28
29
  touchedPaths?: string[];
29
30
  }
@@ -39,6 +40,7 @@ export declare function buildRiskDescriptor(input: {
39
40
  args: unknown;
40
41
  safety?: SafetyAxis;
41
42
  shellGated?: boolean;
43
+ shellGateDoctrine?: "classify" | "always";
42
44
  }): RiskDescriptor;
43
45
  export type CheckpointGate = {
44
46
  kind: "human";
@@ -115,6 +115,7 @@ export function buildRiskDescriptor(input) {
115
115
  severity: riskSeverity(axes),
116
116
  axes,
117
117
  toolName,
118
+ ...(input.shellGated && input.shellGateDoctrine !== undefined ? { shellGateDoctrine: input.shellGateDoctrine } : {}),
118
119
  ...(summary !== undefined ? { summary } : {}),
119
120
  ...(touchedPaths !== undefined ? { touchedPaths } : {}),
120
121
  };
@@ -86,6 +86,15 @@ export function assembleResult(spec, sessionId, final, stats, flags) {
86
86
  status = "failed";
87
87
  errorCode = "suspend.loop";
88
88
  errorMessage = "task suspended too many times (resume/restart loop) — exceeded the suspend limit";
89
+ const gates = stats.humanReview?.gates ?? [];
90
+ if (gates.length > 0 && gates.every((g) => g.decision === "allow")) {
91
+ errorMessage +=
92
+ `. Diagnosis: all ${gates.length} recorded gate decision(s) on this run were ALLOW — a gate that keeps ` +
93
+ `asking combined with an approver that keeps approving consumes the suspend allowance on legitimate ` +
94
+ `work. Remedies: answer asks at a LIVE onAsk (a synchronous allow parks nothing and consumes no ` +
95
+ `suspend), keep provably-benign commands inside the read boundary so the classifier auto-allows them, ` +
96
+ `or raise maxSuspends for genuinely approval-heavy tasks.`;
97
+ }
89
98
  }
90
99
  else if (flags.threw) {
91
100
  status = "failed";
@@ -121,6 +121,7 @@ export interface Prepared {
121
121
  } | undefined;
122
122
  activeTools: Set<string>;
123
123
  deferredToolNames?: ReadonlySet<string>;
124
+ toolMaterializeStatic: boolean;
124
125
  memoryEngineSession?: {
125
126
  engine: MemoryEngine;
126
127
  handle: MemorySessionHandle;
@@ -134,6 +135,9 @@ export interface Prepared {
134
135
  scope?: string;
135
136
  restoreMode?: "snapshot" | "park_only";
136
137
  };
138
+ suspendProgressRef: {
139
+ executedApproved: boolean;
140
+ };
137
141
  reviewRef: {
138
142
  token?: CheckpointToken;
139
143
  gate?: CheckpointGate;
@@ -319,6 +319,18 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
319
319
  throw e;
320
320
  }
321
321
  }
322
+ if (spec.toolMaterializeStrategy !== undefined && spec.toolMaterializeStrategy !== "swap" && spec.toolMaterializeStrategy !== "static") {
323
+ const e = new Error(`toolMaterializeStrategy must be "swap" or "static" (got ${JSON.stringify(spec.toolMaterializeStrategy)}).`);
324
+ e.code = "config.tool_materialize_invalid";
325
+ throw e;
326
+ }
327
+ if (spec.toolMaterializeStrategy === "static" && spec.deferSelfResolve === false) {
328
+ const e = new Error(`toolMaterializeStrategy "static" cannot be combined with deferSelfResolve: false — with the direct-call ` +
329
+ `lane disabled a placeholder is never swapped and never self-resolves, so no deferred tool could ever be ` +
330
+ `called. Use "swap", or leave deferSelfResolve on.`);
331
+ e.code = "config.tool_materialize_unreachable";
332
+ throw e;
333
+ }
322
334
  if (resume === undefined && spec.objective.trim().length === 0) {
323
335
  const e = new Error("TaskSpec.objective is empty — a task needs an instruction (an empty user message is rejected by strict model endpoints and would fail every later request of the session).");
324
336
  e.code = "config.empty_objective";
@@ -977,6 +989,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
977
989
  alwaysLoadTools: toolFaceSnapshot.alwaysLoad,
978
990
  promptProfile,
979
991
  ...(spec.additionalDirectories !== undefined ? { additionalDirectories: Object.freeze([...spec.additionalDirectories]) } : {}),
992
+ ...(spec.additionalReadDirectories !== undefined ? { additionalReadDirectories: Object.freeze([...spec.additionalReadDirectories]) } : {}),
980
993
  ...(spec.envFacts !== undefined ? { envFacts: { ...spec.envFacts } } : {}),
981
994
  getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
982
995
  parentCwd: taskRootPath,
@@ -1151,6 +1164,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1151
1164
  gates: resume?.priorHumanReview?.gates ? [...resume.priorHumanReview.gates] : [],
1152
1165
  };
1153
1166
  const priorSuspendCount = resume?.priorSuspendCount ?? 0;
1167
+ const suspendProgressRef = { executedApproved: false };
1168
+ const suspendChainBase = () => (suspendProgressRef.executedApproved ? 0 : priorSuspendCount);
1154
1169
  const maxSuspends = spec.maxSuspends ?? deps.maxSuspends ?? DEFAULT_MAX_SUSPENDS;
1155
1170
  const maxSlices = spec.resourceSuspend?.maxSlices;
1156
1171
  const priorLedger = resume?.priorLedger;
@@ -1334,34 +1349,40 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1334
1349
  const callIssuedAtRef = {};
1335
1350
  const memoryWriteGateRef = {};
1336
1351
  const additionalRootsCanonical = [];
1352
+ const additionalReadRootsCanonical = [];
1337
1353
  let attachmentRootCanonical;
1338
1354
  if (handsEnabled) {
1339
1355
  const rootRaw = taskRootPath;
1340
1356
  const canon = await executionEnv.canonicalPath(rootRaw);
1341
1357
  const rootCanonical = canon.ok ? canon.value : rootRaw;
1342
1358
  attachmentRootCanonical = rootCanonical;
1343
- for (const dir of spec.additionalDirectories ?? []) {
1344
- if (!dir || !dir.trim())
1345
- continue;
1346
- const c = await executionEnv.canonicalPath(dir);
1347
- if (c.ok) {
1348
- additionalRootsCanonical.push(c.value);
1349
- }
1350
- else {
1351
- deps.onError?.(new Error(`additionalDirectories entry skipped (cannot canonicalize): ${dir}`), {
1352
- phase: "config",
1353
- sessionId,
1354
- });
1355
- emitTrace(deps.tracer, () => ({
1356
- kind: "config.additional_directory_skipped",
1357
- version: 1,
1358
- taskId: hostTaskId,
1359
- entry: dir,
1360
- reason: `${c.error.code}: ${c.error.message}`,
1361
- ts: Date.now(),
1362
- }));
1359
+ const canonicalizeExtraDirs = async (dirs, field, sink) => {
1360
+ for (const dir of dirs ?? []) {
1361
+ if (!dir || !dir.trim())
1362
+ continue;
1363
+ const c = await executionEnv.canonicalPath(dir);
1364
+ if (c.ok) {
1365
+ sink.push(c.value);
1366
+ }
1367
+ else {
1368
+ deps.onError?.(new Error(`${field} entry skipped (cannot canonicalize): ${dir}`), {
1369
+ phase: "config",
1370
+ sessionId,
1371
+ });
1372
+ emitTrace(deps.tracer, () => ({
1373
+ kind: "config.additional_directory_skipped",
1374
+ version: 1,
1375
+ taskId: hostTaskId,
1376
+ entry: dir,
1377
+ ...(field !== "additionalDirectories" ? { field } : {}),
1378
+ reason: `${c.error.code}: ${c.error.message}`,
1379
+ ts: Date.now(),
1380
+ }));
1381
+ }
1363
1382
  }
1364
- }
1383
+ };
1384
+ await canonicalizeExtraDirs(spec.additionalDirectories, "additionalDirectories", additionalRootsCanonical);
1385
+ await canonicalizeExtraDirs(spec.additionalReadDirectories, "additionalReadDirectories", additionalReadRootsCanonical);
1365
1386
  if (spec.envFacts?.scratchpadDir) {
1366
1387
  let c = await executionEnv.canonicalPath(spec.envFacts.scratchpadDir);
1367
1388
  if (!c.ok && c.error.code === "not_found") {
@@ -1412,6 +1433,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1412
1433
  }
1413
1434
  const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
1414
1435
  ...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}),
1436
+ ...(additionalReadRootsCanonical.length > 0 ? { additionalReadRoots: additionalReadRootsCanonical } : {}),
1415
1437
  includeShell: handsIncludeShell,
1416
1438
  readOnly: handsReadOnly,
1417
1439
  ...(handsCwdRef ? { cwdRef: handsCwdRef } : {}),
@@ -1453,7 +1475,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1453
1475
  irreversibilityTier.set("Bash", shellGate === "always" ? "always" : "maybe");
1454
1476
  irreversibleTools.add("Bash");
1455
1477
  const shellReadBoundary = () => ({
1456
- roots: [rootCanonical, ...additionalRootsCanonical],
1478
+ roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical],
1457
1479
  ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}),
1458
1480
  });
1459
1481
  if (shellGate === "classify")
@@ -1777,6 +1799,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1777
1799
  if (additionalRootsCanonical.length > 0) {
1778
1800
  envFacts.additionalDirectories = [...additionalRootsCanonical];
1779
1801
  }
1802
+ if (additionalReadRootsCanonical.length > 0) {
1803
+ envFacts.additionalReadDirectories = [...additionalReadRootsCanonical];
1804
+ }
1780
1805
  try {
1781
1806
  const probe = await executionEnv.exec('uname -s; uname -r; (git rev-parse --is-inside-work-tree 2>/dev/null || echo false); (git symbolic-ref --short -q HEAD 2>/dev/null || echo "HEAD (detached)"); (git rev-parse --show-toplevel 2>/dev/null || echo); (test -n "$(git status --porcelain 2>/dev/null | head -1)" && echo dirty || echo clean); (s=$(ps -p $$ -o comm= 2>/dev/null); s=${s##*/}; echo "${s#-}"); (test "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" && echo linked || echo main); (pwd -P 2>/dev/null || pwd)', { cwd: envFacts.cwd, timeout: 10 });
1782
1807
  if (probe.ok && probe.value.exitCode === 0) {
@@ -2021,6 +2046,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2021
2046
  .filter((s) => s.status === "failed")
2022
2047
  .map((s) => ({ name: inlineUntrusted(s.name, 160), ...(s.error !== undefined ? { error: inlineUntrusted(s.error, 240) } : {}) }));
2023
2048
  let toolsDeltaRef;
2049
+ let toolMaterializeStatic = false;
2024
2050
  if (deferred.size > 0 || failedMcpServers.length > 0) {
2025
2051
  toolsDeltaRef = { pending: [], pendingRemoved: [], pendingReadded: [], pendingFailed: failedMcpServers };
2026
2052
  }
@@ -2097,8 +2123,16 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2097
2123
  };
2098
2124
  offloadReachableToolsRef.current = callableToolNames;
2099
2125
  let toolSearch;
2126
+ const envStrategy = process.env.SEMA_TOOL_MATERIALIZE_STRATEGY;
2127
+ if (envStrategy !== undefined && envStrategy !== "swap" && envStrategy !== "static") {
2128
+ const e = new Error(`SEMA_TOOL_MATERIALIZE_STRATEGY must be "swap" or "static" (got ${JSON.stringify(envStrategy)}).`);
2129
+ e.code = "config.tool_materialize_invalid";
2130
+ throw e;
2131
+ }
2132
+ const materializeStatic = (spec.toolMaterializeStrategy ?? envStrategy ?? "static") === "static" && spec.deferSelfResolve !== false;
2133
+ toolMaterializeStatic = materializeStatic;
2100
2134
  const buildToolList = (active) => {
2101
- const list = tools.map((t) => (deferred.has(t.name) && !active.has(t.name) ? placeholders.get(t.name) : t));
2135
+ const list = tools.map((t) => (deferred.has(t.name) && (materializeStatic || !active.has(t.name)) ? placeholders.get(t.name) : t));
2102
2136
  list.push(toolSearch);
2103
2137
  return list;
2104
2138
  };
@@ -2139,6 +2173,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2139
2173
  listingRide: (newly) => listingRideRef.current?.(newly),
2140
2174
  mountedNames: callableToolNames,
2141
2175
  directCallEnabled: spec.deferSelfResolve !== false,
2176
+ ...(materializeStatic
2177
+ ? { staticSchemaFor: (name) => tools.find((t) => t.name === name)?.parameters }
2178
+ : {}),
2142
2179
  serializeActivation,
2143
2180
  });
2144
2181
  harnessTools = buildToolList(activeTools);
@@ -3083,7 +3120,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3083
3120
  }
3084
3121
  return false;
3085
3122
  }
3086
- if (suspendLoopCapHit(priorSuspendCount, maxSuspends, " for a plan_review (likely a resume/restart loop)."))
3123
+ if (suspendLoopCapHit(suspendChainBase(), maxSuspends, " for a plan_review (likely a resume/restart loop)."))
3087
3124
  return false;
3088
3125
  const leafId = await session.getLeafId();
3089
3126
  if (!leafId) {
@@ -3131,7 +3168,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3131
3168
  createdAt: mintedAt,
3132
3169
  suspendedAt: now(),
3133
3170
  deadline: mintedAt + (sanitizedTtlMs(spec.durableApproval?.ttlMs) ?? DEFAULT_RESOURCE_TTL_MS),
3134
- suspendCount: priorSuspendCount + 1,
3171
+ suspendCount: suspendChainBase() + 1,
3135
3172
  humanReview: humanReviewRef.count > 0
3136
3173
  ? { count: humanReviewRef.count, totalWaitMs: humanReviewRef.totalWaitMs, gates: [...humanReviewRef.gates] }
3137
3174
  : undefined,
@@ -3192,7 +3229,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3192
3229
  if (abortController.signal.aborted) {
3193
3230
  return undefined;
3194
3231
  }
3195
- if (suspendLoopCapHit(priorSuspendCount, maxSuspends, ` for tool "${req.toolName}" — likely a resume/restart loop.`))
3232
+ if (suspendLoopCapHit(suspendChainBase(), maxSuspends, ` for tool "${req.toolName}" — likely a resume/restart loop.`))
3196
3233
  return undefined;
3197
3234
  if (remoteEnv !== undefined) {
3198
3235
  if (hasBackgroundShell(remoteEnv))
@@ -3215,6 +3252,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3215
3252
  args: postHookArgs,
3216
3253
  safety,
3217
3254
  shellGated: (req.toolName === "Bash" && shellGatedBash) || (req.toolName === "Monitor" && shellGatedMonitor),
3255
+ ...(effectiveShellGate !== "off" ? { shellGateDoctrine: effectiveShellGate } : {}),
3218
3256
  });
3219
3257
  gate =
3220
3258
  safety !== undefined
@@ -3275,7 +3313,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3275
3313
  humanReview: humanReviewRef.count > 0
3276
3314
  ? { count: humanReviewRef.count, totalWaitMs: humanReviewRef.totalWaitMs, gates: [...humanReviewRef.gates] }
3277
3315
  : undefined,
3278
- suspendCount: priorSuspendCount + 1,
3316
+ suspendCount: suspendChainBase() + 1,
3279
3317
  resourceLedger: approvalLedger,
3280
3318
  sourceTaskId: sessionId,
3281
3319
  ...(spec.principal ? { principal: spec.principal } : {}),
@@ -3636,7 +3674,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3636
3674
  : undefined;
3637
3675
  overheadState.promptChars = systemPrompt.length;
3638
3676
  const preparedHolder = {};
3639
- const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), ownedEnv, suspendRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
3677
+ const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
3640
3678
  const prepared = buildPrepared();
3641
3679
  preparedHolder.current = prepared;
3642
3680
  return prepared;
@@ -541,6 +541,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
541
541
  : {}),
542
542
  ...(bgTasks !== undefined ? { backgroundTasks: bgTasks } : {}),
543
543
  ...(pendingTools !== undefined ? { newTools: pendingTools } : {}),
544
+ ...(prepared.toolMaterializeStatic ? { newToolsStaticFace: true } : {}),
544
545
  ...(mcpToolsDelta !== undefined ? { mcpToolsDelta } : {}),
545
546
  ...(rs.attach.agentListingOn && prepared.agentListing !== undefined
546
547
  ? {
@@ -556,7 +557,11 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
556
557
  ...(mcpDropped !== undefined ? { mcpDroppedTools: mcpDropped } : {}),
557
558
  }));
558
559
  if (pendingTools !== undefined || mcpToolsDelta !== undefined) {
559
- const exact = renderToolsDelta({ ...(pendingTools !== undefined ? { added: pendingTools } : {}), ...(mcpToolsDelta ?? {}) });
560
+ const exact = renderToolsDelta({
561
+ ...(pendingTools !== undefined ? { added: pendingTools } : {}),
562
+ ...(prepared.toolMaterializeStatic ? { staticFace: true } : {}),
563
+ ...(mcpToolsDelta ?? {}),
564
+ });
560
565
  if (exact !== undefined && due.some((a) => a.source === "tools_delta" && a.body === exact)) {
561
566
  const ref = prepared.toolsDeltaRef;
562
567
  if (pendingTools !== undefined)
@@ -1338,6 +1343,7 @@ export class Runner {
1338
1343
  return Promise.race([p, timeout]).finally(() => clearTimeout(t));
1339
1344
  };
1340
1345
  let reapHandle;
1346
+ let steerChain = Promise.resolve();
1341
1347
  const notifyRef = {};
1342
1348
  const manualCompactRef = { requested: false, waiters: [] };
1343
1349
  const drainManualCompactWaiters = (outcome) => {
@@ -1501,24 +1507,41 @@ export class Runner {
1501
1507
  return suggestionsDone.catch(() => []);
1502
1508
  },
1503
1509
  steer: async (text, options) => {
1504
- if (resultValue)
1505
- throw steeringError("the task has already finished");
1506
- const h = handle ?? (await orTimeout(ready));
1507
- if (!h)
1508
- throw steeringError("the task is not running");
1509
1510
  if (options?.trusted && sanitizeUntrustedText(text) !== text) {
1510
1511
  throw steeringError("trusted steering text must not contain a </system-reminder> tag", "steering.invalid_content");
1511
1512
  }
1512
1513
  const payload = options?.trusted ? formatHookFeedback(text) : text;
1513
- try {
1514
- await h.harness.steer(payload, { provenance: "engine-note" });
1515
- }
1516
- catch (e) {
1517
- if (e instanceof Error && e.code === "invalid_state") {
1518
- throw steeringError("the task is no longer running");
1514
+ const deliver = async () => {
1515
+ if (resultValue)
1516
+ throw steeringError("the task has already finished");
1517
+ const h = handle ?? (await orTimeout(ready));
1518
+ if (!h)
1519
+ throw steeringError("the task is not running");
1520
+ try {
1521
+ await h.harness.steer(payload, { provenance: "engine-note" });
1522
+ return;
1519
1523
  }
1520
- throw e;
1521
- }
1524
+ catch (e) {
1525
+ if (!(e instanceof Error && e.code === "invalid_state"))
1526
+ throw e;
1527
+ }
1528
+ const birthDeadline = Date.now() + READY_TIMEOUT_MS;
1529
+ while (resultValue === undefined && !h.loop.ended && Date.now() < birthDeadline) {
1530
+ try {
1531
+ await h.harness.steer(payload, { provenance: "engine-note" });
1532
+ return;
1533
+ }
1534
+ catch (e2) {
1535
+ if (!(e2 instanceof Error && e2.code === "invalid_state"))
1536
+ throw e2;
1537
+ }
1538
+ await new Promise((r) => setTimeout(r, 10));
1539
+ }
1540
+ throw steeringError("the task is no longer running");
1541
+ };
1542
+ const p = steerChain.then(deliver);
1543
+ steerChain = p.then(() => undefined, () => undefined);
1544
+ return p;
1522
1545
  },
1523
1546
  notify: async (input, opts) => {
1524
1547
  const notifyError = (msg, code) => {
@@ -1753,7 +1776,8 @@ export class Runner {
1753
1776
  }
1754
1777
  }
1755
1778
  }
1756
- onReady({ harness: prepared.harness, abortController: prepared.abortController });
1779
+ const loopLatch = { ended: false };
1780
+ onReady({ harness: prepared.harness, abortController: prepared.abortController, loop: loopLatch });
1757
1781
  const stats = { turns: 0, tokens: 0, toolCalls: 0, promptTokens: 0, totalInputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, cacheWriteTokensLong: 0, outputTokens: 0, costMicroUsd: 0 };
1758
1782
  prepared.liveSpendRef.get = () => ({ costMicroUsd: stats.costMicroUsd, tokens: stats.tokens, turns: stats.turns, walltimeMs: Math.round(performance.now() - rs.telemetry.taskStartMonotonic) });
1759
1783
  if (resume &&
@@ -2705,9 +2729,11 @@ export class Runner {
2705
2729
  }));
2706
2730
  }
2707
2731
  }
2732
+ loopLatch.ended = true;
2708
2733
  abortedLive = prepared.abortController.signal.aborted;
2709
2734
  }
2710
2735
  catch (err) {
2736
+ loopLatch.ended = true;
2711
2737
  if (errorCodeOf(err) === "resume.tool_unavailable") {
2712
2738
  await settleTeardownLeg(() => prepared.mcp.dispose(), "mcp.dispose (tool_unavailable leg)", (e) => this.deps.onError?.(e, { phase: "config", sessionId: prepared.sessionId }));
2713
2739
  await settleTeardownLeg(() => prepared.a2a?.dispose(), "a2a.dispose (tool_unavailable leg)", (e) => this.deps.onError?.(e, { phase: "config", sessionId: prepared.sessionId }));
@@ -3512,6 +3538,7 @@ export class Runner {
3512
3538
  }
3513
3539
  const args = resolvedArgs;
3514
3540
  onExecuteStart?.(pendingAction.toolCallId);
3541
+ prepared.suspendProgressRef.executedApproved = true;
3515
3542
  let res;
3516
3543
  try {
3517
3544
  res = await tool.execute(pendingAction.toolCallId, args, prepared.abortController.signal);
@@ -54,4 +54,5 @@ export declare function createToolSearchTool(opts: {
54
54
  mountedNames?: () => ReadonlySet<string>;
55
55
  directCallEnabled?: boolean;
56
56
  serializeActivation?: <T>(section: () => Promise<T>) => Promise<T>;
57
+ staticSchemaFor?: (name: string) => TSchema | undefined;
57
58
  }): AgentTool;
@@ -80,7 +80,7 @@ export function createPlaceholderTool(info, direct) {
80
80
  };
81
81
  const invalidArgumentsRejection = (target, params, schemaJson, ride) => {
82
82
  const text = `Invalid arguments for \`${sn}\`: ${formatZodValidationError(target.parameters, params)} ` +
83
- `\`${sn}\` is now active — its full parameter schema is below (and rides the next request). ` +
83
+ `\`${sn}\` is now active — its full parameter schema is below; use it for this and later calls. ` +
84
84
  `Call \`${sn}\` again with arguments matching it.\nParameter schema: ${schemaJson}`;
85
85
  return {
86
86
  content: ride === undefined || ride === "" ? [{ type: "text", text }] : [{ type: "text", text }, { type: "text", text: ride }],
@@ -236,11 +236,15 @@ export function extractDiscoveredToolNames(messages, registry) {
236
236
  return [...names];
237
237
  }
238
238
  export function createToolSearchTool(opts) {
239
- const { registry, active, rematerialize, listingRide, mountedNames } = opts;
239
+ const { registry, active, rematerialize, listingRide, mountedNames, staticSchemaFor } = opts;
240
240
  const directCallEnabled = opts.directCallEnabled !== false;
241
241
  const activationPosture = directCallEnabled
242
- ? "Most tools start as name-only placeholders to keep requests small; activating one here loads its full " +
243
- "parameter schema. Until you have that schema you cannot reliably form a call, so activate a tool rather " +
242
+ ? (staticSchemaFor !== undefined
243
+ ? "Most tools start as name-only placeholders to keep requests small; activating one here returns its full " +
244
+ "parameter schema in the result (the tools list keeps the compact placeholder entry). "
245
+ : "Most tools start as name-only placeholders to keep requests small; activating one here loads its full " +
246
+ "parameter schema. ") +
247
+ "Until you have that schema you cannot reliably form a call, so activate a tool rather " +
244
248
  "than guessing its arguments — a call that does match the real schema executes and activates the tool. " +
245
249
  "When any instruction, reminder, or another tool's description names a deferred tool, activate it here " +
246
250
  'with query "select:<name>". '
@@ -265,7 +269,9 @@ export function createToolSearchTool(opts) {
265
269
  "a bare tool name — activates that tool directly. " +
266
270
  "Activate every tool you expect to need in one call (select accepts a comma-separated list) " +
267
271
  "rather than one at a time. " +
268
- "Activated tools become callable with their full parameters on your next turn.",
272
+ (staticSchemaFor !== undefined
273
+ ? "Activation returns each tool's full parameter schema in this result — call the tool directly with arguments matching it."
274
+ : "Activated tools become callable with their full parameters on your next turn."),
269
275
  parameters: Type.Object({
270
276
  query: Type.Optional(Type.String({
271
277
  description: 'Query to find deferred tools. Use "select:<tool_name>" for direct selection, or keywords to search.',
@@ -329,11 +335,20 @@ export function createToolSearchTool(opts) {
329
335
  const lines = matched.map((n) => {
330
336
  const info = registry.get(n);
331
337
  const tag = newly.includes(n) ? "activated" : "already active";
332
- return `- ${safeName(n)} (${tag})${info ? ` — ${info.hint}` : ""}`;
338
+ const base = `- ${safeName(n)} (${tag})${info ? ` — ${info.hint}` : ""}`;
339
+ if (staticSchemaFor === undefined)
340
+ return base;
341
+ const schema = staticSchemaFor(n);
342
+ const json = schema === undefined ? undefined : renderSchemaForModel(schema);
343
+ return json === undefined ? base : `${base}\n parameters: ${json}`;
333
344
  });
334
- const head = newly.length > 0
335
- ? `Activated ${newly.length} tool(s); they are now available with full parameters — call them directly:`
336
- : "These tools are already active — call them directly:";
345
+ const head = staticSchemaFor !== undefined
346
+ ? newly.length > 0
347
+ ? `Activated ${newly.length} tool(s) — call them directly with arguments matching the parameter schemas below (the tools list keeps compact placeholder entries):`
348
+ : "These tools are already active — call them directly; their parameter schemas are repeated below:"
349
+ : newly.length > 0
350
+ ? `Activated ${newly.length} tool(s); they are now available with full parameters — call them directly:`
351
+ : "These tools are already active — call them directly:";
337
352
  return {
338
353
  content: `${head}\n${lines.join("\n")}${missingNote}${ride !== undefined ? `\n\n${ride}` : ""}`,
339
354
  details: {
@@ -100,6 +100,7 @@ export interface AttachmentInputs {
100
100
  }>;
101
101
  backgroundTasks?: ReadonlyArray<BackgroundTaskSnapshot>;
102
102
  newTools?: readonly string[];
103
+ newToolsStaticFace?: boolean;
103
104
  mcpToolsDelta?: McpToolsDeltaFacts;
104
105
  agentListing?: ReadonlyArray<AgentListingEntry>;
105
106
  agentToolName?: string;
@@ -148,6 +149,7 @@ export interface McpToolsDeltaFacts {
148
149
  }
149
150
  export declare function renderToolsDelta(input: {
150
151
  added?: readonly string[];
152
+ staticFace?: boolean;
151
153
  } & McpToolsDeltaFacts): string | undefined;
152
154
  export declare const AGENT_TOOLS_NOTE_DEFAULT = "All tools";
153
155
  export declare const AGENT_CONCURRENCY_NOTE = "When you launch multiple agents for independent work, send them in a single message with multiple tool uses so they run concurrently.";
@@ -168,7 +168,11 @@ export function collectDueAttachments(state, inp) {
168
168
  }
169
169
  }
170
170
  if (inp.config.toolsDelta) {
171
- const body = renderToolsDelta({ ...(inp.newTools !== undefined ? { added: inp.newTools } : {}), ...(inp.mcpToolsDelta ?? {}) });
171
+ const body = renderToolsDelta({
172
+ ...(inp.newTools !== undefined ? { added: inp.newTools } : {}),
173
+ ...(inp.newToolsStaticFace === true ? { staticFace: true } : {}),
174
+ ...(inp.mcpToolsDelta ?? {}),
175
+ });
172
176
  if (body !== undefined)
173
177
  (out ??= []).push({ source: "tools_delta", body });
174
178
  }
@@ -386,15 +390,20 @@ export function renderToolsDelta(input) {
386
390
  const blocks = [];
387
391
  const added = input.added ?? [];
388
392
  if (added.length > 0) {
389
- blocks.push("The following deferred tools are now available. Their full schemas are loaded — call them " +
390
- "directly like any other tool:\n" +
393
+ blocks.push((input.staticFace === true
394
+ ? "The following deferred tools are now active — call them directly. Their parameter schemas were " +
395
+ "provided in the ToolSearch result (the tools list itself keeps compact placeholder entries):\n"
396
+ : "The following deferred tools are now available. Their full schemas are loaded — call them " +
397
+ "directly like any other tool:\n") +
391
398
  added.map((n) => `- ${n}`).join("\n"));
392
399
  }
393
400
  const readded = input.readded ?? [];
394
401
  if (readded.length > 0) {
395
402
  blocks.push(`${readded.length} deferred tool${readded.length === 1 ? " is" : "s are"} available again (MCP server reconnected — ` +
396
- `names announced earlier in this conversation): ${groupByMcpServer(readded)}. Their schemas are loaded again — ` +
397
- `call them directly.`);
403
+ `names announced earlier in this conversation): ${groupByMcpServer(readded)}. ` +
404
+ (input.staticFace === true
405
+ ? `The tools list keeps compact placeholder entries — re-run ToolSearch ("select:<name>") if you need their current parameter schemas.`
406
+ : `Their schemas are loaded again — call them directly.`));
398
407
  }
399
408
  const removed = input.removed ?? [];
400
409
  if (removed.length > 0) {
@@ -69,6 +69,7 @@ export declare function settleBackgroundAgentLane(core: DurableAgentCore, id: st
69
69
  errorCode?: string;
70
70
  retryable?: boolean;
71
71
  errorKind?: string;
72
+ retryAfterMs?: number;
72
73
  stoppedBy?: StopSource;
73
74
  seq?: number;
74
75
  cycle?: number;
@@ -105,6 +106,7 @@ export declare function settleRevivedAgentLane(core: DurableAgentCore, id: strin
105
106
  errorCode?: string;
106
107
  retryable?: boolean;
107
108
  errorKind?: string;
109
+ retryAfterMs?: number;
108
110
  }): "completed" | "failed" | "killed" | undefined;
109
111
  export declare function noteBackgroundAgentActivityLane(core: DurableAgentCore, id: string, now?: number): void;
110
112
  export declare function reapStaleSessionBackgroundAgentsLane(core: DurableAgentCore, staleMs: number, now?: number, onTerminal?: (note: () => void) => void): number;
@@ -137,6 +139,7 @@ export interface AgentPollDetailsInput {
137
139
  error?: string;
138
140
  errorCode?: string;
139
141
  errorRetryable?: boolean;
142
+ errorRetryAfterMs?: number;
140
143
  resultIsPartial?: boolean;
141
144
  completionId?: string;
142
145
  }
@@ -713,6 +713,8 @@ export function settleBackgroundAgentLane(core, id, outcome) {
713
713
  handle.errorRetryable = outcome.retryable;
714
714
  if (outcome.errorKind !== undefined)
715
715
  handle.errorKind = outcome.errorKind;
716
+ if (outcome.retryAfterMs !== undefined)
717
+ handle.errorRetryAfterMs = outcome.retryAfterMs;
716
718
  }
717
719
  handle.updatedAt = Date.now();
718
720
  if (outcome.seq !== undefined)
@@ -731,6 +733,7 @@ export function settleBackgroundAgentLane(core, id, outcome) {
731
733
  ...(handle.errorCode !== undefined ? { errorCode: handle.errorCode } : {}),
732
734
  ...(handle.errorRetryable !== undefined ? { errorRetryable: handle.errorRetryable } : {}),
733
735
  ...(handle.errorKind !== undefined ? { errorKind: handle.errorKind } : {}),
736
+ ...(handle.errorRetryAfterMs !== undefined ? { errorRetryAfterMs: handle.errorRetryAfterMs } : {}),
734
737
  }, ["parkedCheckpointToken", "parkClaimId", "parkedAt"]);
735
738
  return outcome.status;
736
739
  }
@@ -857,6 +860,7 @@ export function reviveBackgroundAgentLane(core, id, access, abort) {
857
860
  handle.errorCode = undefined;
858
861
  handle.errorRetryable = undefined;
859
862
  handle.errorKind = undefined;
863
+ handle.errorRetryAfterMs = undefined;
860
864
  handle.resultIsPartial = undefined;
861
865
  handle.stopSource = undefined;
862
866
  handle.completionId = undefined;
@@ -1052,6 +1056,7 @@ export function buildAgentPollDetails(input) {
1052
1056
  ...(failed && input.error !== undefined ? { error: delimitUntrusted("agent error", boundedRedactedSummary(input.error, 300)) } : {}),
1053
1057
  ...(failed && input.errorCode !== undefined ? { errorCode: input.errorCode } : {}),
1054
1058
  ...(failed && input.errorRetryable !== undefined ? { retryable: input.errorRetryable } : {}),
1059
+ ...(failed && input.errorRetryAfterMs !== undefined ? { retryAfterMs: input.errorRetryAfterMs } : {}),
1055
1060
  ...(input.resultIsPartial === true ? { partial_result: true } : {}),
1056
1061
  ...(input.completionId !== undefined ? { completionId: input.completionId } : {}),
1057
1062
  };
@@ -1070,7 +1075,7 @@ The agent is durably suspended, waiting for an approval decision. It resumes whe
1070
1075
  };
1071
1076
  }
1072
1077
  const kindClause = row.status === "failed" && row.errorKind !== undefined && row.errorRetryable !== undefined
1073
- ? ` (error_kind: ${row.errorKind}, retryable: ${row.errorRetryable})`
1078
+ ? ` (error_kind: ${row.errorKind}, retryable: ${row.errorRetryable}${row.errorRetryAfterMs !== undefined ? `, retry_after_ms: ${row.errorRetryAfterMs}` : ""})`
1074
1079
  : "";
1075
1080
  const body = `status: ${row.status}
1076
1081
  ${row.error ? `error: ${row.error}${kindClause}
@@ -1087,6 +1092,7 @@ ${clipTaskOutput(row.finalOutputFull ?? row.finalOutput)}` : "(no result text)"}
1087
1092
  ...(row.error !== undefined ? { error: row.error } : {}),
1088
1093
  ...(row.errorCode !== undefined ? { errorCode: row.errorCode } : {}),
1089
1094
  ...(row.errorRetryable !== undefined ? { errorRetryable: row.errorRetryable } : {}),
1095
+ ...(row.errorRetryAfterMs !== undefined ? { errorRetryAfterMs: row.errorRetryAfterMs } : {}),
1090
1096
  ...(row.resultIsPartial === true ? { resultIsPartial: true } : {}),
1091
1097
  ...(row.completionId !== undefined ? { completionId: row.completionId } : {}),
1092
1098
  }),
@@ -1126,7 +1132,7 @@ The agent is durably suspended, waiting for an approval decision. It resumes whe
1126
1132
  const fullResult = handle.result ? (handle.resultFull ?? handle.result) : undefined;
1127
1133
  const resultText = fullResult !== undefined ? await spillClippedAgentResult(handle, fullResult, clipTaskOutput(fullResult, handle.outputFile), store, sessionId) : undefined;
1128
1134
  const kindClause = handle.status === "failed" && handle.errorKind !== undefined && handle.errorRetryable !== undefined
1129
- ? ` (error_kind: ${handle.errorKind}, retryable: ${handle.errorRetryable})`
1135
+ ? ` (error_kind: ${handle.errorKind}, retryable: ${handle.errorRetryable}${handle.errorRetryAfterMs !== undefined ? `, retry_after_ms: ${handle.errorRetryAfterMs}` : ""})`
1130
1136
  : "";
1131
1137
  const body = running
1132
1138
  ? oneShot === true
@@ -1149,6 +1155,7 @@ ${resultText}` : "(no result text)"}`;
1149
1155
  ...(handle.error !== undefined ? { error: handle.error } : {}),
1150
1156
  ...(handle.errorCode !== undefined ? { errorCode: handle.errorCode } : {}),
1151
1157
  ...(handle.errorRetryable !== undefined ? { errorRetryable: handle.errorRetryable } : {}),
1158
+ ...(handle.errorRetryAfterMs !== undefined ? { errorRetryAfterMs: handle.errorRetryAfterMs } : {}),
1152
1159
  ...(handle.resultIsPartial === true ? { resultIsPartial: true } : {}),
1153
1160
  ...(handle.completionId !== undefined ? { completionId: handle.completionId } : {}),
1154
1161
  }),
@@ -139,6 +139,7 @@ export interface BackgroundAgentTaskHandle extends SemaTaskHandle {
139
139
  errorCode?: string;
140
140
  errorRetryable?: boolean;
141
141
  errorKind?: string;
142
+ errorRetryAfterMs?: number;
142
143
  resultIsPartial?: boolean;
143
144
  stopSource?: StopSource;
144
145
  stoppedBy?: StopSource;
@@ -141,6 +141,7 @@ export declare class TaskRegistry {
141
141
  errorCode?: string;
142
142
  retryable?: boolean;
143
143
  errorKind?: string;
144
+ retryAfterMs?: number;
144
145
  stoppedBy?: StopSource;
145
146
  seq?: number;
146
147
  cycle?: number;
@@ -180,6 +181,7 @@ export declare class TaskRegistry {
180
181
  errorCode?: string;
181
182
  retryable?: boolean;
182
183
  errorKind?: string;
184
+ retryAfterMs?: number;
183
185
  }): "completed" | "failed" | "killed" | undefined;
184
186
  unmarkRetainedContinuation(id: string): void;
185
187
  attachAgentNotify(id: string, notify: NonNullable<BackgroundAgentTaskHandle["notify"]>, cycle?: number): void;
@@ -94,6 +94,7 @@ export type TraceEvent = {
94
94
  kind: "config.additional_directory_skipped";
95
95
  version: 1;
96
96
  taskId: string;
97
+ field?: "additionalReadDirectories";
97
98
  entry: string;
98
99
  reason: string;
99
100
  ts: number;
@@ -109,6 +109,7 @@ export interface ToolExecuteContext {
109
109
  alwaysLoadTools?: readonly string[];
110
110
  promptProfile?: "simple" | "classic";
111
111
  additionalDirectories?: readonly string[];
112
+ additionalReadDirectories?: readonly string[];
112
113
  envFacts?: TaskSpec["envFacts"];
113
114
  getApiKeyAndHeaders?: TaskSpec["getApiKeyAndHeaders"];
114
115
  activeSkillScope?: () => readonly unknown[];
@@ -297,6 +298,7 @@ export interface TaskSpec {
297
298
  tools?: ToolSpec[];
298
299
  excludeTools?: string[];
299
300
  deferTools?: string[];
301
+ toolMaterializeStrategy?: "swap" | "static";
300
302
  alwaysLoadTools?: string[];
301
303
  deferSelfResolve?: boolean;
302
304
  promptProfile?: "simple" | "classic";
@@ -325,6 +327,7 @@ export interface TaskSpec {
325
327
  checkpointStore?: import("./checkpoint-store.js").CheckpointStore | null;
326
328
  handsReadOnly?: boolean;
327
329
  additionalDirectories?: string[];
330
+ additionalReadDirectories?: string[];
328
331
  enablePlanMode?: boolean;
329
332
  interactiveTools?: boolean;
330
333
  enableFork?: boolean;
@@ -27,6 +27,7 @@ export interface EnvironmentFacts {
27
27
  gitWorktreeRoot?: string;
28
28
  isLinkedWorktree?: boolean;
29
29
  additionalDirectories?: readonly string[];
30
+ additionalReadDirectories?: readonly string[];
30
31
  platform?: string;
31
32
  osVersion?: string;
32
33
  shell?: string;
@@ -237,6 +237,9 @@ export function buildEnvironmentContext(facts) {
237
237
  if (facts.additionalDirectories && facts.additionalDirectories.length > 0) {
238
238
  lines.push(`Additional working directories: ${facts.additionalDirectories.map((d) => inlineUntrusted(d)).join(", ")}`);
239
239
  }
240
+ if (facts.additionalReadDirectories && facts.additionalReadDirectories.length > 0) {
241
+ lines.push(`Additional read-only directories: ${facts.additionalReadDirectories.map((d) => inlineUntrusted(d)).join(", ")}`);
242
+ }
240
243
  if (facts.isGitRepo !== undefined)
241
244
  lines.push(`Is a git repository: ${facts.isGitRepo ? "yes" : "no"}`);
242
245
  if (facts.gitBranch)
@@ -296,9 +296,12 @@ async function runShell(env, cwd, toolName, command, timeoutSec, caps, signal, c
296
296
  const captured = (pStdout ? `--- partial stdout ---\n${pStdout}` : "") +
297
297
  (pStderr ? `${pStdout ? "\n" : ""}--- partial stderr ---\n${pStderr}` : "");
298
298
  const zeroOutput = captured.length === 0;
299
+ const zeroOutputHint = res.error.code === "timeout" && timeout >= 60
300
+ ? ` — the process ran the full ${timeout}s without writing to its stdio; it may have been stalled before producing output, or holding it in a block buffer`
301
+ : "";
299
302
  const body = !zeroOutput
300
303
  ? `\n${delimitUntrusted("partial command output", captured)}`
301
- : `\n(no output was produced before the cutoff)`;
304
+ : `\n(no output was produced before the cutoff${zeroOutputHint})`;
302
305
  const overflowNote = cutOverflowFile !== undefined ? shellRecoveryHint(cutOverflowFile, readOnly) : "";
303
306
  return {
304
307
  content: `Error (${toolName}): ${headline}${body}${overflowNote}`,
@@ -13,6 +13,7 @@ export * from "./fs-bash.js";
13
13
  import { type CwdRef, type ReadImageDownsamplerOption } from "./fs-shared.js";
14
14
  export interface HandsToolkitOptions {
15
15
  additionalRoots?: readonly string[];
16
+ additionalReadRoots?: readonly string[];
16
17
  includeShell?: boolean;
17
18
  readOnly?: boolean;
18
19
  bashReadonlyAllow?: readonly string[];
@@ -16,7 +16,10 @@ import { createEditFileTool, createWriteFileTool, createNotebookEditTool } from
16
16
  import { createGrepTool, createGlobTool } from "./fs-search-tools.js";
17
17
  import { createBashTool, createBashReadonlyTool, createEnvTaskOutputTool, createEnvTaskStopTool } from "./fs-bash.js";
18
18
  export function createHandsToolkit(env, readFileState, rootCanonical, opts = {}) {
19
- const { includeShell = false, readOnly = false, bashReadonlyAllow, commitCoAuthor = false, mountBackgroundTaskTools = true, additionalRoots, } = opts;
19
+ const { includeShell = false, readOnly = false, bashReadonlyAllow, commitCoAuthor = false, mountBackgroundTaskTools = true, additionalRoots, additionalReadRoots, } = opts;
20
+ const readFaceRoots = additionalReadRoots === undefined || additionalReadRoots.length === 0
21
+ ? additionalRoots
22
+ : [...(additionalRoots ?? []), ...additionalReadRoots];
20
23
  const cwdRef = opts.cwdRef ?? { current: rootCanonical };
21
24
  const bgReadRegistry = opts.taskRegistry;
22
25
  const bgOutputReadExemption = bgReadRegistry === undefined
@@ -27,18 +30,18 @@ export function createHandsToolkit(env, readFileState, rootCanonical, opts = {})
27
30
  ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
28
31
  }, env);
29
32
  const tools = [
30
- createReadFileTool(env, readFileState, rootCanonical, readOnly ? undefined : cwdRef, additionalRoots, opts.readImageDownsampler, opts.pdfModelCapabilities, bgOutputReadExemption),
33
+ createReadFileTool(env, readFileState, rootCanonical, readOnly ? undefined : cwdRef, readFaceRoots, opts.readImageDownsampler, opts.pdfModelCapabilities, bgOutputReadExemption),
31
34
  ];
32
35
  if (!readOnly) {
33
36
  tools.push(createEditFileTool(env, readFileState, rootCanonical, cwdRef, additionalRoots, opts.beforeWrite), createWriteFileTool(env, readFileState, rootCanonical, cwdRef, additionalRoots, opts.beforeWrite), createNotebookEditTool(env, readFileState, rootCanonical, cwdRef, additionalRoots, opts.beforeWrite));
34
37
  }
35
- tools.push(createGrepTool(env, rootCanonical, additionalRoots), createGlobTool(env, rootCanonical, additionalRoots), createRepoMapTool(env, rootCanonical, additionalRoots));
38
+ tools.push(createGrepTool(env, rootCanonical, readFaceRoots), createGlobTool(env, rootCanonical, readFaceRoots), createRepoMapTool(env, rootCanonical, readFaceRoots));
36
39
  if (includeShell) {
37
40
  tools.push(readOnly
38
41
  ? createBashReadonlyTool(env, rootCanonical, new Set(bashReadonlyAllow ?? BASH_READONLY_DEFAULT_ALLOW), {
39
42
  ...(opts.bashDefaultTimeoutMs !== undefined ? { bashDefaultTimeoutMs: opts.bashDefaultTimeoutMs } : {}),
40
43
  ...(opts.bashMaxTimeoutMs !== undefined ? { bashMaxTimeoutMs: opts.bashMaxTimeoutMs } : {}),
41
- ...(additionalRoots !== undefined ? { additionalRoots } : {}),
44
+ ...(readFaceRoots !== undefined ? { additionalRoots: readFaceRoots } : {}),
42
45
  })
43
46
  : createBashTool(env, rootCanonical, commitCoAuthor, cwdRef, {
44
47
  taskRegistry: opts.taskRegistry,
@@ -9,17 +9,37 @@ export interface WebFetchConfig {
9
9
  summarize?: (content: string, prompt: string, signal?: AbortSignal) => Promise<string | {
10
10
  text: string;
11
11
  truncated?: boolean;
12
+ inputTruncated?: boolean;
13
+ inputChars?: number;
14
+ usedChars?: number;
12
15
  }>;
13
16
  userAgent?: string;
14
17
  }
18
+ export declare const WEBFETCH_GROUNDING_MIN_TEXT_CHARS = 200;
19
+ export interface WebFetchGrounding {
20
+ level: "ok" | "low";
21
+ textChars: number;
22
+ bytes: number;
23
+ textRatio: number;
24
+ }
15
25
  export declare function htmlToText(html: string): string;
16
26
  export declare function webFetchToolSpec(config?: WebFetchConfig): ToolSpec;
17
27
  export declare function createWebFetchTool(config?: WebFetchConfig): AgentTool;
18
28
  export declare const WEBFETCH_SUMMARY_MAX_CONTENT = 100000;
19
29
  export declare const WEBFETCH_SUMMARY_GUIDELINES: string;
20
- export declare function createWebFetchSummarizer(brain: Brain, model: Model): (content: string, prompt: string, signal?: AbortSignal) => Promise<string | {
30
+ export declare const WEBFETCH_SUMMARY_GROUNDING_CLAUSE: string;
31
+ export declare const WEBFETCH_SUMMARY_INPUT_HEADROOM = 0.8;
32
+ export declare const WEBFETCH_SUMMARY_MIN_CONTENT = 4000;
33
+ export declare function resolveSummaryInputChars(model: Model, override?: number): number;
34
+ export interface WebFetchSummarizerOptions {
35
+ maxContentChars?: number;
36
+ }
37
+ export declare function createWebFetchSummarizer(brain: Brain, model: Model, options?: WebFetchSummarizerOptions): (content: string, prompt: string, signal?: AbortSignal) => Promise<string | {
21
38
  text: string;
22
39
  truncated?: boolean;
40
+ inputTruncated?: boolean;
41
+ inputChars?: number;
42
+ usedChars?: number;
23
43
  }>;
24
44
  export interface WebSearchConfig {
25
45
  search: (query: string, signal?: AbortSignal, opts?: {
package/dist/tools/web.js CHANGED
@@ -25,6 +25,25 @@ function resolveWebMaxBytes(value) {
25
25
  }
26
26
  return value;
27
27
  }
28
+ export const WEBFETCH_GROUNDING_MIN_TEXT_CHARS = 200;
29
+ const GROUNDING_MIN_TEXT_RATIO = 0.01;
30
+ const GROUNDING_RATIO_MAX_TEXT_CHARS = 2_000;
31
+ const GROUNDING_ECHO_MAX_CHARS = GROUNDING_RATIO_MAX_TEXT_CHARS + 100;
32
+ const GROUNDING_SHELL_MIN_BYTES = 500;
33
+ function assessGrounding(text, bytes) {
34
+ const textChars = text.replace(/\s+/g, " ").trim().length;
35
+ const textRatio = bytes > 0 ? (textChars / bytes) : 0;
36
+ const low = textChars < WEBFETCH_GROUNDING_MIN_TEXT_CHARS ||
37
+ (textChars < GROUNDING_RATIO_MAX_TEXT_CHARS && bytes > 0 && textRatio < GROUNDING_MIN_TEXT_RATIO);
38
+ return { level: low ? "low" : "ok", textChars, bytes, textRatio: Math.round(textRatio * 1000) / 1000 };
39
+ }
40
+ function recoveryToolRuledOut(ctx, toolName) {
41
+ if (ctx.excludeTools?.includes(toolName) ?? false)
42
+ return true;
43
+ const deferred = ctx.deferTools?.includes(toolName) ?? false;
44
+ const pinnedInline = ctx.alwaysLoadTools?.includes(toolName) ?? false;
45
+ return deferred && !pinnedInline;
46
+ }
28
47
  const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
29
48
  const ERROR_BODY_EXCERPT_CHARS = 2048;
30
49
  const ERROR_BODY_CONVERT_MAX_CHARS = 64 * 1024;
@@ -471,10 +490,14 @@ export function webFetchToolSpec(config = {}) {
471
490
  : bodyCut
472
491
  ? `\n\n[WebFetch: ${cutPhrase} — the ${bodyBytes.length} bytes are a partial prefix received before the cutoff, NOT the object's full size]`
473
492
  : "";
493
+ const shellRecoveryRuledOut = recoveryToolRuledOut(ctx, "Bash") || ctx.handsReadOnly === true;
494
+ const recoveryLine = shellRecoveryRuledOut
495
+ ? "Retrieve it outside this tool — this run has no shell capability that can download it to a file."
496
+ : `Download and inspect it with Bash instead, e.g.: curl -L -o /tmp/download "${parsed.toString()}"`;
474
497
  return {
475
498
  content: `Error (WebFetch): binary content detected (${kind}${mt && signature ? `, content-type: ${mt}` : ""}, ` +
476
499
  `${bodyBytes.length} bytes) — the body was NOT added to the context (it does not decode as text). ` +
477
- `Download and inspect it with Bash instead, e.g.: curl -L -o /tmp/download "${parsed.toString()}"` +
500
+ recoveryLine +
478
501
  binaryStateNote,
479
502
  details: {
480
503
  type: "web-fetch",
@@ -503,8 +526,11 @@ export function webFetchToolSpec(config = {}) {
503
526
  raw = raw.slice(0, maxBytes);
504
527
  }
505
528
  const text = /html/i.test(contentType) || /^\s*</.test(raw) ? htmlToText(raw) : raw;
529
+ const grounding = assessGrounding(text, bodyBytes ? bodyBytes.length : raw.length);
506
530
  let out = text;
507
531
  let summaryTruncated = false;
532
+ let summaryApplied = false;
533
+ let summaryInputNote;
508
534
  let note;
509
535
  if (prompt && bodyCut) {
510
536
  note =
@@ -516,13 +542,24 @@ export function webFetchToolSpec(config = {}) {
516
542
  const summarized = await config.summarize(text, prompt, ctx.signal);
517
543
  if (typeof summarized === "string") {
518
544
  out = summarized;
545
+ summaryApplied = true;
519
546
  }
520
547
  else {
521
548
  out = summarized.text;
549
+ summaryApplied = true;
522
550
  if (summarized.truncated) {
523
551
  summaryTruncated = true;
524
552
  note = `[note: the summary below is INCOMPLETE — the summarizer hit its output limit before finishing]`;
525
553
  }
554
+ if (summarized.inputTruncated) {
555
+ const sizes = summarized.usedChars !== undefined && summarized.inputChars !== undefined
556
+ ? ` (only the first ${summarized.usedChars} of ${summarized.inputChars} characters were read)`
557
+ : "";
558
+ summaryInputNote =
559
+ `[note: the summary below covers only the BEGINNING of the page${sizes} — the page exceeded what the ` +
560
+ `summarizer could feed its model, so the rest was never read. Absence of something from the summary ` +
561
+ `does NOT mean it is absent from the page.]`;
562
+ }
526
563
  }
527
564
  }
528
565
  catch (e) {
@@ -537,12 +574,44 @@ export function webFetchToolSpec(config = {}) {
537
574
  `[note: summarization unavailable in this deployment — raw page content follows; ` +
538
575
  `the requested analysis ("${inlineUntrusted(prompt, 120)}") was NOT applied]`;
539
576
  }
577
+ const bodyIncomplete = truncationNote !== "" || bodyCut !== undefined;
578
+ let groundingNote;
579
+ let sourceEcho = "";
580
+ if (grounding.level === "low") {
581
+ const shellShape = /html/i.test(contentType) && grounding.bytes >= GROUNDING_SHELL_MIN_BYTES
582
+ ? bodyIncomplete
583
+ ? " The retrieved bytes are almost entirely markup/script — which is either a client-rendered shell or " +
584
+ "simply the head of a document whose content sits past the retrieved prefix; a larger byte limit (or a " +
585
+ "completed transfer) would distinguish the two."
586
+ : " The retrieved bytes are almost entirely markup/script, which is the shape of a page whose content is " +
587
+ "loaded by JavaScript after the document — this tool does not run JavaScript, so that content was never present."
588
+ : "";
589
+ const nothingExtractable = grounding.textChars === 0;
590
+ const sourceLabel = bodyIncomplete ? "extracted text of the retrieved prefix" : "complete extracted page text";
591
+ const head = `[WebFetch grounding: LOW — the ${bodyIncomplete ? "retrieved prefix" : "page"} yielded ` +
592
+ (nothingExtractable ? "NO extractable text at all" : `only ${grounding.textChars} characters of extractable text`) +
593
+ ` out of ${grounding.bytes} retrieved bytes`;
594
+ groundingNote = summaryApplied
595
+ ? `${head}. ` +
596
+ (nothingExtractable
597
+ ? `There is no source text at all, so every specific claim in the summary below is unsourced — do not use it as a factual source.`
598
+ : `That may be all this URL serves, or it may be a page whose content is not present in the fetched bytes — this tool ` +
599
+ `cannot tell the two apart, so the summary below is NOT usable as a source on its own: check every specific claim ` +
600
+ `in it against the ${sourceLabel} reproduced after it.`) +
601
+ shellShape +
602
+ `]`
603
+ : `${head}; the content below is ${bodyIncomplete ? "all the retrieved prefix yielded" : "all of it"}.${shellShape}]`;
604
+ if (summaryApplied && !nothingExtractable) {
605
+ sourceEcho = `\n\n${delimitUntrusted(`WebFetch ${parsed.hostname} — ${sourceLabel}`, text.replace(/\s+/g, " ").trim(), GROUNDING_ECHO_MAX_CHARS)}`;
606
+ }
607
+ }
540
608
  const fenced = delimitUntrusted(`WebFetch ${parsed.hostname}`, out);
541
- const withNote = note ? `${note}\n\n${fenced}` : fenced;
609
+ const headNotes = [groundingNote, note, summaryInputNote].filter((n) => n !== undefined);
610
+ const withNote = headNotes.length > 0 ? `${headNotes.join("\n\n")}\n\n${fenced}` : fenced;
542
611
  const partialNote = bodyCut
543
612
  ? `\n\n[WebFetch: ${cutPhrase} — the content above is PARTIAL: ${bodyBytes?.length ?? 0} bytes were received before the cutoff and the tail is missing. Treat absent information as unfetched, not absent from the source.]`
544
613
  : "";
545
- const modelText = withNote + truncationNote + partialNote;
614
+ const modelText = withNote + truncationNote + partialNote + sourceEcho;
546
615
  const RESULT_PREVIEW_CHARS = 8_000;
547
616
  return {
548
617
  content: modelText,
@@ -554,7 +623,9 @@ export function webFetchToolSpec(config = {}) {
554
623
  bytes: bodyBytes ? bodyBytes.length : raw.length,
555
624
  result: out.length > RESULT_PREVIEW_CHARS ? `${out.slice(0, RESULT_PREVIEW_CHARS)}\n…[${out.length - RESULT_PREVIEW_CHARS} chars truncated — full text in the tool output]` : out,
556
625
  durationMs: Date.now() - startedAt,
557
- ...(truncationNote || summaryTruncated ? { truncated: true } : {}),
626
+ ...(truncationNote || summaryTruncated || summaryInputNote ? { truncated: true } : {}),
627
+ grounding,
628
+ ...(summaryInputNote ? { summaryInputTruncated: true } : {}),
558
629
  ...transferStateDetails,
559
630
  },
560
631
  };
@@ -570,12 +641,50 @@ export const WEBFETCH_SUMMARY_GUIDELINES = `Provide a concise response based onl
570
641
  ` - Use quotation marks for exact language from articles; any language outside of the quotation should never be word-for-word the same.\n` +
571
642
  ` - You are not a lawyer and never comment on the legality of your own prompts and responses.\n` +
572
643
  ` - Never produce or reproduce exact song lyrics.\n`;
573
- export function createWebFetchSummarizer(brain, model) {
644
+ export const WEBFETCH_SUMMARY_GROUNDING_CLAUSE = `Ground every statement in the content above:\n` +
645
+ ` - If the content does not contain what was asked for, say exactly that and describe what the content IS instead. Never fill the gap with general knowledge or plausible inference.\n` +
646
+ ` - If the content is a listing (file names, links, titles) rather than the data itself, a name is evidence only about naming — do not conclude from names alone that the underlying resource does or does not contain something; report what the listing shows and what would have to be opened to answer.\n`;
647
+ export const WEBFETCH_SUMMARY_INPUT_HEADROOM = 0.8;
648
+ export const WEBFETCH_SUMMARY_MIN_CONTENT = 4_000;
649
+ const SUMMARY_PROMPT_OVERHEAD_TOKENS = 1_000;
650
+ const SUMMARY_PROMPT_ALLOWANCE_CHARS = 2_000;
651
+ const SUMMARY_MIN_CONTENT_PER_CALL = 1_024;
652
+ function invalidSummaryBudget(message) {
653
+ const e = new Error(message);
654
+ e.code = "config.web_summary_max_content_invalid";
655
+ return e;
656
+ }
657
+ export function resolveSummaryInputChars(model, override) {
658
+ if (override !== undefined) {
659
+ if (!Number.isFinite(override) || override < 1) {
660
+ throw invalidSummaryBudget(`WebFetch summarizer maxContentChars must be a finite number of characters >= 1 (got ${String(override)})`);
661
+ }
662
+ return Math.floor(override);
663
+ }
664
+ const window = model.contextTokens ?? model.contextWindow;
665
+ const charsPerToken = model.charsPerToken ?? 4;
666
+ for (const [name, value] of [
667
+ ["contextTokens/contextWindow", window],
668
+ ["maxTokens", model.maxTokens],
669
+ ["charsPerToken", charsPerToken],
670
+ ]) {
671
+ if (!Number.isFinite(value) || value <= 0) {
672
+ throw invalidSummaryBudget(`WebFetch summarizer cannot size its input: model "${model.id}" declares a non-finite or non-positive ${name} (got ${String(value)})`);
673
+ }
674
+ }
675
+ const reservedOutputTokens = Math.min(model.maxTokens, Math.floor(window / 4));
676
+ const inputTokens = window - reservedOutputTokens - SUMMARY_PROMPT_OVERHEAD_TOKENS;
677
+ const derived = Math.floor(inputTokens * charsPerToken * WEBFETCH_SUMMARY_INPUT_HEADROOM);
678
+ return Math.min(WEBFETCH_SUMMARY_MAX_CONTENT, Math.max(WEBFETCH_SUMMARY_MIN_CONTENT, derived));
679
+ }
680
+ export function createWebFetchSummarizer(brain, model, options = {}) {
681
+ const maxContentChars = resolveSummaryInputChars(model, options.maxContentChars);
574
682
  return async (content, prompt, signal) => {
575
- const truncated = content.length > WEBFETCH_SUMMARY_MAX_CONTENT
576
- ? content.slice(0, WEBFETCH_SUMMARY_MAX_CONTENT) + "\n\n[Content truncated due to length...]"
577
- : content;
578
- const userPrompt = `\nWeb page content:\n---\n${truncated}\n---\n\n${prompt}\n\n` + WEBFETCH_SUMMARY_GUIDELINES;
683
+ const promptOverflow = Math.max(0, prompt.length - SUMMARY_PROMPT_ALLOWANCE_CHARS);
684
+ const budget = promptOverflow === 0 ? maxContentChars : Math.max(SUMMARY_MIN_CONTENT_PER_CALL, maxContentChars - promptOverflow);
685
+ const inputTruncated = content.length > budget;
686
+ const truncated = inputTruncated ? content.slice(0, budget) + "\n\n[Content truncated due to length...]" : content;
687
+ const userPrompt = `\nWeb page content:\n---\n${truncated}\n---\n\n${prompt}\n\n` + WEBFETCH_SUMMARY_GROUNDING_CLAUSE + "\n" + WEBFETCH_SUMMARY_GUIDELINES;
579
688
  const context = { messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] };
580
689
  const msg = brain.complete
581
690
  ? await brain.complete(model, context, { signal })
@@ -590,7 +699,14 @@ export function createWebFetchSummarizer(brain, model) {
590
699
  .trim();
591
700
  if (!text)
592
701
  throw new Error("summarizer returned no text");
593
- return msg.stopReason === "length" || msg.partialFinalized === true ? { text, truncated: true } : text;
702
+ const outputTruncated = msg.stopReason === "length" || msg.partialFinalized === true;
703
+ if (!outputTruncated && !inputTruncated)
704
+ return text;
705
+ return {
706
+ text,
707
+ ...(outputTruncated ? { truncated: true } : {}),
708
+ ...(inputTruncated ? { inputTruncated: true, inputChars: content.length, usedChars: budget } : {}),
709
+ };
594
710
  };
595
711
  }
596
712
  const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.10.0",
3
+ "version": "5.11.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",