@sema-agent/core 5.3.0 → 5.5.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.5.0 (2026-08-03)
4
+
5
+ _Three follow-through items: the deferred-placeholder wrong-guess round trip halves, durable cron jobs survive their creating session, and the redaction findings channel closes its last three audit gaps._
6
+
7
+ ### Fixed
8
+
9
+ - Deferred-tool placeholders answer a wrong guess with the real schema. A placeholder card carries a name and one hint line, so a model that has to guess a shape copies the local pattern of the sibling tools it can see — and a wrong guess used to cost four rounds (call → a rejection teaching a `ToolSearch` round trip → search → real call). A shape-invalid direct call now comes back as structured invalid-arguments **with the real declared parameter schema inlined**, and activates the tool on the spot — the same activation the `ToolSearch` `select:` lane performs (full schema materialized into the next request, fingerprint/boundary-announce refresh, listing ride), because both lanes run one activation closure. Wrong guess: four rounds → two. Right guess: still zero — the schema-valid direct lane is untouched, the shape gate still refuses to run the real tool body on a call it never declared, and adjudication is unchanged (policy/approval decide before execute, so a denied call activates nothing). Two consequences stated plainly: the rejection is flagged as an error, so a later leg's transcript replay does not credit that activation (the name reverts to a placeholder and the corrected call re-activates it — self-healing, never a widening); and a withdrawn name, or a declaration that cannot be serialized, keeps the previous "activate it first" rejection.
10
+ - Scheduler isolation keys fork by lifetime. A durable cron job was filed under the same key as a session-scoped one — minted `sessionId ?? principal ?? taskId ?? "default"` — so on any host with sessions a durable job belonged to the session that created it: it kept firing, but every later session got `not_found` from `CronList`/`CronDelete` and could not even learn it existed. Durable jobs are now isolated by the stable cross-session identity (`principal`, or a single `"default"` bucket where the deployment carries no principal); session-scoped jobs keep the session chain unchanged. `CronList` reads both lanes (deduplicated, and a lane whose listing fails surfaces the error instead of a half-empty list), `CronDelete` acts on the lane that actually holds the id, and `ScheduleWakeup` stays on the session lane (one session's `stop: true` never touches another's pending wakeup). The cross-tenant refusal is unchanged: another principal's durable job answers `not_found`, never an existence leak. **Deployment note**: durable rows written by earlier versions are still keyed by their originating session — a one-time re-key of existing rows belongs to the scheduler backend; the engine does no migration.
11
+
12
+ ### Added
13
+
14
+ - The redaction findings channel completes (external-audit architecture item): findings now carry `source` (a closed provenance vocabulary — `arg-summary` / `untrusted-egress` / `secret-env` — stamped by the minting rule table, with no public finding constructor to forge); `summarizeRedactions(findings)` renders the one-line disclosure a host can announce ("3 redactions: prefixed-token (high) x1, …" — pure function, never auto-injected); and the env arm joins the channel (`scrubSecretEnv(env, findings?)` reports each deleted entry as a `SecretEnvFinding {key, kind, confidence, source}` — deletion, not replacement, so it is its own shape; one summarizer renders both). Absent the optional collector every path is byte-identical to before.
15
+
16
+ ## 5.4.0 (2026-08-03)
17
+
18
+ _The external-audit dunning batch closes end-to-end (small cases + all three cleared A-group debts), and the engine grows its outer brain-call guardrail. All additive or louder; major stays locked at 5._
19
+
20
+ ### Fixed
21
+
22
+ - webfetch: the tool card follows the wired capability (a mount without a summarizer no longer advertises `prompt` in prose or schema), and an error response stops silently eating the caller's `prompt` (the error leg now carries the same "was NOT applied" disclosure family, naming the HTTP status). Error bodies are deliberately NOT fed to the summarizer (a 404 page dressed up as "the requested analysis" would be worse than the raw dump) — pinned as a negative control.
23
+ - Background-agent poll projection: stop-attribution rides whenever the ROW carries it — the stale-running reaper settles rows as `failed` with `stoppedBy:"system"`, and the previous killed-only gate dropped that fact, making a reaped row indistinguishable from a natural failure. A row without the field still projects nothing.
24
+ - Brains: an ERROR tool result now discloses the media it had to drop (`[N images omitted — …]`, both wire formats) instead of silently presenting an attachment-free error; the vision-degrade predicates and placeholder wording collapse into one construction point (`media-degrade.ts`).
25
+ - Image read cap: the refusal for an oversized image without a downsampler now names `sharp` (declared in `optionalDependencies`) and the 64MB ceiling it unlocks — the cap itself is unchanged.
26
+
27
+ - Background-command spool (RB-235 close-out): a LIVE writer's spool file is append-only — the reclaim truncate now waits for the writer to be terminal, closing the stat→truncate race window that silently destroyed whole buffered write blocks (external audit reproduced 8192-multiple losses on 5.3.0; the fix's own red-first run recorded 65536/720896/294912-byte ledger gaps). Cost, stated honestly: a live writer past the hard cap no longer has physical space reclaimed until it dies (the reader stays bounded and byte-accounted as before); the file is reclaimed wholesale at writer terminal. The live-rotation disclosure marker is retired with the race (structurally impossible now).
28
+
29
+ ### Added
30
+
31
+ - `STALE_RUNNING_REAP_ATTRIBUTION` (exported): the reaper's settle wording reaches the package root, so an out-of-repo store twin can import the attribution instead of value-copying it against the contract kit (downstream pickup request).
32
+ - `TaskSpec.limits.brainCallGuardrailMs` + `RunnerDeps.brainCallGuardrailMs` (RB-458): an OUTER per-call guardrail on how long the engine waits for one brain call — default 1 hour, `false`/`0` switches it off, invalid values refuse loudly at prepare. Independent of brain cooperation: expiry aborts the brain's signal AND stops the engine's own waiting at every await point, so a custom Brain that ignores `options.signal` can no longer wedge a run forever (previously the de-facto bound was an accidental property of the bundled fetch stack). A run stopped this way terminates loud with `TaskResult.errorCode = "brain.call_guardrail_timeout"`; walltime-budgeted runs keep their own (usually tighter) clocks — the two race and the earlier one wins.
33
+
3
34
  ## 5.3.0 (2026-08-03)
4
35
 
5
36
  _The A2A arc: client+server legs land end-to-end (all additive), the workflow-size notice lane closes its channel, and three naming/containment defects fix. Major stays locked at 5._
@@ -5,6 +5,7 @@ import { createRepetitionPoll, createWalltimeGate, parseStreamedToolArgs } from
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
7
7
  import { emitBrainTelemetry } from "./status-sink.js";
8
+ import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
9
  import { ANTHROPIC_RESERVED, applyExtraBody, stripAuthHeaders } from "./request-params.js";
9
10
  import { isThinkingLevel, reasoningBudgetShare, resolveEffort } from "./reasoning.js";
10
11
  import { runStreamingBrain } from "./stream-engine.js";
@@ -37,7 +38,7 @@ function toUserBlocks(content, supportsVision = true) {
37
38
  blocks.push({ type: "image", source: { type: "base64", media_type: c.mimeType, data: c.data } });
38
39
  else {
39
40
  omitted++;
40
- blocks.push({ type: "text", text: "[image omitted: the serving model does not support image input]" });
41
+ blocks.push({ type: "text", text: IMAGE_OMITTED_NO_VISION });
41
42
  }
42
43
  }
43
44
  }
@@ -46,7 +47,7 @@ function toUserBlocks(content, supportsVision = true) {
46
47
  return blocks;
47
48
  }
48
49
  function toAnthropicMessages(ctx, model) {
49
- const supportsVision = model?.input === undefined ? true : model.input.includes("image");
50
+ const supportsVision = modelSupportsVision(model);
50
51
  const raw = [];
51
52
  let pendingDocs = [];
52
53
  const flushDocs = () => {
@@ -112,6 +113,15 @@ function toAnthropicMessages(ctx, model) {
112
113
  inner.push({ type: "text", text: note });
113
114
  }
114
115
  }
116
+ if (m.isError) {
117
+ const note = errorResultMediaNote({ images: sendableImages(Array.isArray(rest) ? rest : []).length, documents: docs.length });
118
+ if (note !== undefined) {
119
+ if (typeof inner === "string")
120
+ inner = inner ? `${inner}\n${note}` : note;
121
+ else
122
+ inner.push({ type: "text", text: note });
123
+ }
124
+ }
115
125
  if (typeof inner === "string" ? inner.length === 0 : inner.length === 0)
116
126
  inner = NO_CONTENT;
117
127
  raw.push({
@@ -0,0 +1,14 @@
1
+ import type { Model } from "../internal/llm.js";
2
+ export declare function modelSupportsVision(model?: Model): boolean;
3
+ export declare const IMAGE_OMITTED_NO_VISION = "[image omitted: the serving model does not support image input]";
4
+ export declare function imagesOmittedNoVisionNote(count: number): string;
5
+ export interface SendableImage {
6
+ type: "image";
7
+ data: string;
8
+ mimeType: string;
9
+ }
10
+ export declare function sendableImages(blocks: readonly unknown[]): SendableImage[];
11
+ export declare function errorResultMediaNote(counts: {
12
+ images: number;
13
+ documents: number;
14
+ }): string | undefined;
@@ -0,0 +1,30 @@
1
+ export function modelSupportsVision(model) {
2
+ return model?.input === undefined ? true : model.input.includes("image");
3
+ }
4
+ export const IMAGE_OMITTED_NO_VISION = "[image omitted: the serving model does not support image input]";
5
+ export function imagesOmittedNoVisionNote(count) {
6
+ return count > 1 ? `[${count} images omitted: the serving model does not support image input]` : IMAGE_OMITTED_NO_VISION;
7
+ }
8
+ export function sendableImages(blocks) {
9
+ const out = [];
10
+ for (const b of blocks) {
11
+ const c = b;
12
+ if (c.type === "image" && typeof c.data === "string" && typeof c.mimeType === "string") {
13
+ out.push({ type: "image", data: c.data, mimeType: c.mimeType });
14
+ }
15
+ }
16
+ return out;
17
+ }
18
+ function plural(count, noun) {
19
+ return `${count} ${noun}${count > 1 ? "s" : ""}`;
20
+ }
21
+ export function errorResultMediaNote(counts) {
22
+ const dropped = [];
23
+ if (counts.images > 0)
24
+ dropped.push(plural(counts.images, "image"));
25
+ if (counts.documents > 0)
26
+ dropped.push(plural(counts.documents, "document"));
27
+ if (dropped.length === 0)
28
+ return undefined;
29
+ return `[${dropped.join(" and ")} omitted — an error tool result must be text-only; re-run the tool successfully if you need the attachment]`;
30
+ }
@@ -5,6 +5,7 @@ import { createRepetitionPoll, createWalltimeGate, parseStreamedToolArgs } from
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
7
7
  import { emitBrainTelemetry } from "./status-sink.js";
8
+ import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
9
  import { OPENAI_RESERVED, applyExtraBody, stripAuthHeaders } from "./request-params.js";
9
10
  import { DEFAULT_EFFORT_LEVELS, isThinkingLevel, resolveEffort } from "./reasoning.js";
10
11
  import { runStreamingBrain } from "./stream-engine.js";
@@ -30,10 +31,6 @@ function textOf(content) {
30
31
  .map((c) => c.text)
31
32
  .join("");
32
33
  }
33
- function visionOf(model) {
34
- return model?.input === undefined ? true : model.input.includes("image");
35
- }
36
- const IMAGE_OMITTED = "[image omitted: the serving model does not support image input]";
37
34
  const NO_CONTENT = "(no content)";
38
35
  const DOCUMENT_OMITTED = "[PDF document omitted: PDF direct read requires an Anthropic-family model; re-Read the file (the engine will extract text/render pages when possible) or extract the text via Bash instead (e.g. `pdftotext`).]";
39
36
  function toUserContent(content, supportsVision = true) {
@@ -54,7 +51,7 @@ function toUserContent(content, supportsVision = true) {
54
51
  const n = content.filter((c) => c.type === "image").length;
55
52
  emitBrainTelemetry({ kind: "vision_placeholder", count: n });
56
53
  const base = textOf(content);
57
- const note = n > 1 ? `[${n} images omitted: the serving model does not support image input]` : IMAGE_OMITTED;
54
+ const note = imagesOmittedNoVisionNote(n);
58
55
  const withNote = base ? `${base}\n${note}` : note;
59
56
  return nDocs > 0 ? `${withNote}\n${DOCUMENT_OMITTED}` : withNote;
60
57
  }
@@ -139,7 +136,7 @@ function applyThinking(body, model, reasoning) {
139
136
  }
140
137
  }
141
138
  function toOpenAIMessages(ctx, replayThinking = true, model) {
142
- const supportsVision = visionOf(model);
139
+ const supportsVision = modelSupportsVision(model);
143
140
  const out = [];
144
141
  const needsEmptyReasoningField = !!model?.reasoning && thinkingCompat(model).requiresReasoningContentOnAssistantMessages === true;
145
142
  if (ctx.systemPrompt) {
@@ -194,9 +191,8 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
194
191
  out.push(msg);
195
192
  }
196
193
  else if (m.role === "toolResult") {
197
- const rawImages = !m.isError && Array.isArray(m.content)
198
- ? m.content.filter((c) => c.type === "image" && typeof c.data === "string" && typeof c.mimeType === "string")
199
- : [];
194
+ const mediaImages = sendableImages(Array.isArray(m.content) ? m.content : []);
195
+ const rawImages = m.isError ? [] : mediaImages;
200
196
  const images = supportsVision ? rawImages : [];
201
197
  if (!supportsVision && rawImages.length > 0)
202
198
  emitBrainTelemetry({ kind: "vision_placeholder", count: rawImages.length });
@@ -205,11 +201,13 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
205
201
  emitBrainTelemetry({ kind: "document_placeholder", count: nDocs });
206
202
  const baseText0 = textOf(m.content);
207
203
  const baseText = nDocs > 0 ? (baseText0 ? `${baseText0}\n${DOCUMENT_OMITTED}` : DOCUMENT_OMITTED) : baseText0;
208
- const text = !supportsVision && rawImages.length > 0 ? (baseText ? `${baseText}\n${IMAGE_OMITTED}` : IMAGE_OMITTED) : baseText;
204
+ const text = !supportsVision && rawImages.length > 0 ? (baseText ? `${baseText}\n${IMAGE_OMITTED_NO_VISION}` : IMAGE_OMITTED_NO_VISION) : baseText;
205
+ const errorNote = m.isError ? errorResultMediaNote({ images: mediaImages.length, documents: 0 }) : undefined;
206
+ const finalText = errorNote === undefined ? text : text ? `${text}\n${errorNote}` : errorNote;
209
207
  out.push({
210
208
  role: "tool",
211
209
  tool_call_id: m.toolCallId,
212
- content: toolResultText(text, images.length > 0, m.content),
210
+ content: toolResultText(finalText, images.length > 0, m.content),
213
211
  });
214
212
  for (const img of images)
215
213
  pendingImages.push({ toolCallId: m.toolCallId, data: img.data, mimeType: img.mimeType });
@@ -1,3 +1,4 @@
1
+ import type { StreamFn } from "../internal/llm.js";
1
2
  export interface BrainTimeoutConfig {
2
3
  connectTimeoutMs?: number;
3
4
  firstTokenTimeoutMs?: number;
@@ -10,3 +11,16 @@ export declare function createConnectController(connectTimeoutMs: number | undef
10
11
  clearTimer: () => void;
11
12
  dispose: () => void;
12
13
  };
14
+ export declare const BRAIN_CALL_GUARDRAIL_DEFAULT_MS = 3600000;
15
+ export declare const BRAIN_CALL_GUARDRAIL_ERROR_CODE = "brain.call_guardrail_timeout";
16
+ export declare class BrainCallGuardrailTimeoutError extends Error {
17
+ readonly code = "brain.call_guardrail_timeout";
18
+ readonly limitMs: number;
19
+ constructor(limitMs: number);
20
+ }
21
+ export type BrainCallGuardrailKnob = number | false;
22
+ export declare function resolveBrainCallGuardrailMs(knob: BrainCallGuardrailKnob | undefined): number | undefined;
23
+ export interface BrainCallGuardrailRef {
24
+ timedOut?: BrainCallGuardrailTimeoutError;
25
+ }
26
+ export declare function withBrainCallGuardrail(streamFn: StreamFn, limitMs: number, ref: BrainCallGuardrailRef): StreamFn;
@@ -44,3 +44,117 @@ export function createConnectController(connectTimeoutMs, outerSignal, deadlineA
44
44
  },
45
45
  };
46
46
  }
47
+ export const BRAIN_CALL_GUARDRAIL_DEFAULT_MS = 3_600_000;
48
+ export const BRAIN_CALL_GUARDRAIL_ERROR_CODE = "brain.call_guardrail_timeout";
49
+ export class BrainCallGuardrailTimeoutError extends Error {
50
+ code = BRAIN_CALL_GUARDRAIL_ERROR_CODE;
51
+ limitMs;
52
+ constructor(limitMs) {
53
+ super(`the engine stopped waiting for a brain call that ran past its ${limitMs}ms guardrail — the call was abandoned (a Brain that ignores options.signal may still be running)`);
54
+ this.name = "BrainCallGuardrailTimeoutError";
55
+ this.limitMs = limitMs;
56
+ }
57
+ }
58
+ export function resolveBrainCallGuardrailMs(knob) {
59
+ if (knob === undefined)
60
+ return BRAIN_CALL_GUARDRAIL_DEFAULT_MS;
61
+ if (knob === false || knob === 0)
62
+ return undefined;
63
+ if (typeof knob !== "number" || !Number.isFinite(knob) || knob < 0) {
64
+ const e = new Error(`brainCallGuardrailMs must be a positive number of milliseconds, 0, or false (got ${String(knob)})`);
65
+ e.code = "config.brain_call_guardrail_invalid";
66
+ throw e;
67
+ }
68
+ return knob;
69
+ }
70
+ function armCallGuardrail(limitMs, outerSignal, ref) {
71
+ const ac = new AbortController();
72
+ if (outerSignal !== undefined) {
73
+ if (outerSignal.aborted)
74
+ ac.abort(outerSignal.reason);
75
+ }
76
+ const forwardAbort = () => {
77
+ ac.abort(outerSignal === undefined ? undefined : outerSignal.reason);
78
+ };
79
+ if (outerSignal !== undefined && !outerSignal.aborted) {
80
+ outerSignal.addEventListener("abort", forwardAbort, { once: true });
81
+ }
82
+ let expired;
83
+ let rejectExpiry = () => undefined;
84
+ const expiry = new Promise((_resolve, reject) => {
85
+ rejectExpiry = reject;
86
+ });
87
+ expiry.catch(() => undefined);
88
+ const timer = setTimeout(() => {
89
+ const err = new BrainCallGuardrailTimeoutError(limitMs);
90
+ expired = err;
91
+ ref.timedOut ??= err;
92
+ ac.abort(err);
93
+ rejectExpiry(err);
94
+ }, limitMs);
95
+ return {
96
+ signal: ac.signal,
97
+ race: async (p) => {
98
+ if (expired !== undefined)
99
+ throw expired;
100
+ const value = await Promise.race([p, expiry]);
101
+ if (expired !== undefined)
102
+ throw expired;
103
+ return value;
104
+ },
105
+ disarm: () => {
106
+ clearTimeout(timer);
107
+ if (outerSignal !== undefined)
108
+ outerSignal.removeEventListener("abort", forwardAbort);
109
+ },
110
+ };
111
+ }
112
+ export function withBrainCallGuardrail(streamFn, limitMs, ref) {
113
+ return async (model, context, options) => {
114
+ const gate = armCallGuardrail(limitMs, options?.signal, ref);
115
+ let inner;
116
+ try {
117
+ inner = await gate.race(Promise.resolve(streamFn(model, context, { ...options, signal: gate.signal })));
118
+ }
119
+ catch (err) {
120
+ gate.disarm();
121
+ throw err;
122
+ }
123
+ return {
124
+ [Symbol.asyncIterator]() {
125
+ const it = inner[Symbol.asyncIterator]();
126
+ return {
127
+ next: async () => {
128
+ let step;
129
+ try {
130
+ step = await gate.race(Promise.resolve(it.next()));
131
+ }
132
+ catch (err) {
133
+ gate.disarm();
134
+ throw err;
135
+ }
136
+ if (step.done === true)
137
+ gate.disarm();
138
+ return step;
139
+ },
140
+ return: async () => {
141
+ gate.disarm();
142
+ const release = it.return;
143
+ if (release !== undefined) {
144
+ Promise.resolve(release.call(it)).catch(() => undefined);
145
+ }
146
+ return { value: undefined, done: true };
147
+ },
148
+ };
149
+ },
150
+ result: async () => {
151
+ try {
152
+ return await gate.race(Promise.resolve(inner.result()));
153
+ }
154
+ finally {
155
+ gate.disarm();
156
+ }
157
+ },
158
+ };
159
+ };
160
+ }
@@ -1,10 +1,17 @@
1
1
  export type RedactionConfidence = "high" | "medium" | "low";
2
+ export type RedactionSource = "arg-summary" | "untrusted-egress" | "secret-env";
2
3
  export interface RedactionFinding {
3
4
  kind: string;
4
5
  span: readonly [number, number];
5
6
  confidence: RedactionConfidence;
6
7
  marker: string;
8
+ source: RedactionSource;
7
9
  }
10
+ export interface SummarizableFinding {
11
+ kind: string;
12
+ confidence: RedactionConfidence;
13
+ }
14
+ export declare function summarizeRedactions(findings: readonly SummarizableFinding[]): string;
8
15
  export interface RedactionReport {
9
16
  findings: RedactionFinding[];
10
17
  preexistingMarkers?: number;
@@ -13,6 +20,7 @@ export interface RedactionPass {
13
20
  kind: string;
14
21
  confidence: RedactionConfidence;
15
22
  marker: string;
23
+ source: RedactionSource;
16
24
  re: RegExp;
17
25
  replace: string | ((match: string, ...groups: string[]) => string);
18
26
  }
@@ -1,5 +1,16 @@
1
1
  const ACTIVITY_ARG_MAX = 80;
2
2
  const SENSITIVE_KEY = /token|secret|key|password|passwd|credential|auth/i;
3
+ export function summarizeRedactions(findings) {
4
+ if (findings.length === 0)
5
+ return "no redactions";
6
+ const counts = new Map();
7
+ for (const f of findings) {
8
+ const label = `${f.kind} (${f.confidence})`;
9
+ counts.set(label, (counts.get(label) ?? 0) + 1);
10
+ }
11
+ const parts = [...counts].map(([label, n]) => `${label} x${n}`);
12
+ return `${findings.length} redaction${findings.length === 1 ? "" : "s"}: ${parts.join(", ")}`;
13
+ }
3
14
  function mapBackOnePass(edits, pos) {
4
15
  let delta = 0;
5
16
  for (const e of edits) {
@@ -37,7 +48,13 @@ export function runRedactionPasses(input, passes, report) {
37
48
  s0 = mapBackOnePass(batches[i], s0);
38
49
  e0 = mapBackOnePass(batches[i], e0);
39
50
  }
40
- report.findings.push({ kind: pass.kind, confidence: pass.confidence, span: [s0, e0], marker: pass.marker });
51
+ report.findings.push({
52
+ kind: pass.kind,
53
+ confidence: pass.confidence,
54
+ span: [s0, e0],
55
+ marker: pass.marker,
56
+ source: pass.source,
57
+ });
41
58
  }
42
59
  edits.push({ at: offset, removedLen: match.length, insertedLen: inserted.length });
43
60
  }
@@ -48,18 +65,19 @@ export function runRedactionPasses(input, passes, report) {
48
65
  return cur;
49
66
  }
50
67
  export const SECRET_PASSES = [
51
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])(?:sk|pk|rk|gh[opsur])[-_][A-Za-z0-9_-]{8,}/g, replace: "[redacted]" },
52
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])AIza[A-Za-z0-9_-]{20,}/g, replace: "[redacted]" },
53
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: "[redacted]" },
54
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{12,}/g, replace: "[redacted]" },
55
- { kind: "prefixed-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}/g, replace: "[redacted]" },
56
- { kind: "jwt", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9._-]{20,}/g, replace: "[redacted]" },
57
- { kind: "private-key-block", confidence: "high", marker: "[redacted]", re: /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?(?:-----END[ A-Z]*PRIVATE KEY-----|$)/g, replace: "[redacted]" },
58
- { kind: "bearer-token", confidence: "high", marker: "[redacted]", re: /(?<![A-Za-z0-9])Bearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, replace: "[redacted]" },
68
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])(?:sk|pk|rk|gh[opsur])[-_][A-Za-z0-9_-]{8,}/g, replace: "[redacted]" },
69
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])AIza[A-Za-z0-9_-]{20,}/g, replace: "[redacted]" },
70
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: "[redacted]" },
71
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])AKIA[0-9A-Z]{12,}/g, replace: "[redacted]" },
72
+ { kind: "prefixed-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])hf_[A-Za-z0-9]{20,}/g, replace: "[redacted]" },
73
+ { kind: "jwt", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])eyJ[A-Za-z0-9._-]{20,}/g, replace: "[redacted]" },
74
+ { kind: "private-key-block", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?(?:-----END[ A-Z]*PRIVATE KEY-----|$)/g, replace: "[redacted]" },
75
+ { kind: "bearer-token", confidence: "high", marker: "[redacted]", source: "arg-summary", re: /(?<![A-Za-z0-9])Bearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi, replace: "[redacted]" },
59
76
  {
60
77
  kind: "keyword-value",
61
78
  confidence: "medium",
62
79
  marker: "[redacted]",
80
+ source: "arg-summary",
63
81
  re: /(?<![A-Za-z0-9])((?:token|secret|key|password|passwd|credential|authorization)["']?\s*[=:]\s*["']?)[^\s"';|&]{4,}/gi,
64
82
  replace: "$1[redacted]",
65
83
  },
@@ -30,4 +30,3 @@ export declare function sliceStreamIncrement(s: StreamCursorState, terminal: boo
30
30
  droppedBeforeCursor: number;
31
31
  };
32
32
  export declare function markTruncated(text: string, droppedBytes: number): string;
33
- export declare function markLiveSpoolRotations(text: string, liveRotations: number): string;
@@ -73,9 +73,3 @@ export function markTruncated(text, droppedBytes) {
73
73
  ? `[... ${droppedBytes} earlier byte(s) truncated; showing last ${Buffer.byteLength(text, "utf8")} ...]\n${text}`
74
74
  : text;
75
75
  }
76
- export function markLiveSpoolRotations(text, liveRotations) {
77
- if (liveRotations <= 0)
78
- return text;
79
- const note = `[spool reclaimed ${liveRotations} time(s) while the process was still writing; bytes appended during those windows may be missing beyond any counted truncation]`;
80
- return text === "" ? note : `${text}${text.endsWith("\n") ? "" : "\n"}${note}`;
81
- }
@@ -11,6 +11,7 @@ import { type ActiveSkillFrame } from "./active-skill-scope.js";
11
11
  import { type CutKillRegistry } from "./cut-kill.js";
12
12
  import type { SessionPermissionRules } from "../session-policy-store.js";
13
13
  import { CacheBreakDetector, type ToolFingerprintInput } from "../cache-break-detector.js";
14
+ import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
14
15
  import { type OutputRef, type BlockedRef, type SkillListingEntry } from "./synthetic-tools.js";
15
16
  import type { MemoryEngine } from "../memory-engine/engine.js";
16
17
  import { type ToolManifestRow } from "../../prompt-assembly/tool-catalog.js";
@@ -136,6 +137,7 @@ export interface Prepared {
136
137
  callIssuedAtRef?: {
137
138
  current?: number;
138
139
  };
140
+ brainCallGuardrailRef: BrainCallGuardrailRef;
139
141
  reviewRequestRef: {
140
142
  pending?: {
141
143
  reason?: string;
@@ -29,6 +29,7 @@ import { cloneObserverInput, formatHookFeedback, runToolGate } from "../hooks.js
29
29
  import { reconcileInterruptedSession } from "../session-reconcile.js";
30
30
  import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
31
31
  import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
32
+ import { resolveBrainCallGuardrailMs, withBrainCallGuardrail } from "../../brain/timeout.js";
32
33
  import { defineTool, isDefineToolProduct } from "../tools.js";
33
34
  import { RETIRED_TOOL_NAMES } from "../tool-name-aliases.js";
34
35
  import { protocolOf, PROTOCOL_TABLE } from "../protocol-table.js";
@@ -236,6 +237,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
236
237
  e.code = "config.empty_objective";
237
238
  throw e;
238
239
  }
240
+ const brainCallGuardrailRef = {};
241
+ const brainCallGuardrailMs = resolveBrainCallGuardrailMs(spec.limits?.brainCallGuardrailMs ?? deps.brainCallGuardrailMs);
239
242
  if (spec.agents !== undefined && spec.agents.length > 0) {
240
243
  const pool = spec.tools ?? [];
241
244
  if (!pool.some((t) => typeof t.withAgents === "function")) {
@@ -1466,9 +1469,9 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1466
1469
  if (spec.interactiveTools === true || (spec.interactiveTools !== false && (onQuestion !== undefined || durableQuestionFace)))
1467
1470
  tools.push(createAskUserQuestionTool(onQuestion, { principal: spec.principal, sourceTaskId: sessionId }));
1468
1471
  if (spec.handsReadOnly !== true) {
1469
- const schedScope = sessionId ?? spec.principal ?? spec.taskId ?? "default";
1472
+ const sessionScope = sessionId ?? spec.principal ?? spec.taskId ?? "default";
1470
1473
  tools.push(...createSchedulerTools(executionEnv, {
1471
- scope: schedScope,
1474
+ scope: sessionScope,
1472
1475
  ...(spec.principal !== undefined ? { principal: spec.principal } : {}),
1473
1476
  ...(sessionId !== undefined ? { sessionId } : {}),
1474
1477
  requestStopAfterTurn,
@@ -2198,7 +2201,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2198
2201
  systemPrompt,
2199
2202
  ...(assembled.systemBlocks ? { systemBlocks: assembled.systemBlocks } : {}),
2200
2203
  getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
2201
- runtime: brainToRuntime(deps.brain),
2204
+ runtime: brainToRuntime(brainCallGuardrailMs === undefined
2205
+ ? deps.brain
2206
+ : {
2207
+ ...deps.brain,
2208
+ stream: withBrainCallGuardrail((m, c, o) => deps.brain.stream(m, c, o), brainCallGuardrailMs, brainCallGuardrailRef),
2209
+ }),
2202
2210
  });
2203
2211
  harnessRef.current = harness;
2204
2212
  let releaseSignal = () => undefined;
@@ -3529,7 +3537,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3529
3537
  : undefined;
3530
3538
  overheadState.promptChars = systemPrompt.length;
3531
3539
  const preparedHolder = {};
3532
- 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, ...(callCapRef ? { callCapRef } : {}), ...(cutKills ? { cutKills } : {}), ...(callCapRef ? { callIssuedAtRef } : {}), suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit } } : {}), 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 } : {}) });
3540
+ 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, ...(callCapRef ? { callCapRef } : {}), ...(cutKills ? { cutKills } : {}), ...(callCapRef ? { callIssuedAtRef } : {}), brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit } } : {}), 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 } : {}) });
3533
3541
  const prepared = buildPrepared();
3534
3542
  preparedHolder.current = prepared;
3535
3543
  return prepared;
@@ -2659,6 +2659,9 @@ export class Runner {
2659
2659
  unsubBoundary();
2660
2660
  unsub();
2661
2661
  }
2662
+ if (threw === undefined && prepared.brainCallGuardrailRef.timedOut !== undefined) {
2663
+ threw = prepared.brainCallGuardrailRef.timedOut;
2664
+ }
2662
2665
  const suspended = prepared.suspendRef.token !== undefined && prepared.suspendRef.gate !== undefined;
2663
2666
  const reviewPaused = prepared.reviewRef.token !== undefined && prepared.reviewRef.gate !== undefined;
2664
2667
  const durablyPaused = suspended || reviewPaused;
@@ -1,6 +1,7 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { defineTool, errorResult } from "../tools.js";
4
+ import { formatZodValidationError, truncateError } from "../tool-errors.js";
4
5
  export const TOOL_SEARCH_NAME = "ToolSearch";
5
6
  const DEFER_AUTO_FRACTION = 0.1;
6
7
  const CHARS_PER_TOKEN = 4;
@@ -61,12 +62,32 @@ export function buildDeferredRegistry(deferred, tools) {
61
62
  }
62
63
  return reg;
63
64
  }
65
+ function renderSchemaForModel(schema) {
66
+ let json;
67
+ try {
68
+ json = JSON.stringify(schema);
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ return json === undefined ? undefined : truncateError(json);
74
+ }
64
75
  export function createPlaceholderTool(info, direct) {
65
76
  const sn = safeName(info.name);
66
77
  const teachingRejection = () => {
67
78
  throw new Error(`Tool "${sn}" is not active yet. Call ${TOOL_SEARCH_NAME} with {"query":"select:${sn}"} ` +
68
79
  `(or a keyword \`query\`) to load its full schema, then call ${sn} with the proper arguments.`);
69
80
  };
81
+ const invalidArgumentsRejection = (target, params, schemaJson, ride) => {
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). ` +
84
+ `Call \`${sn}\` again with arguments matching it.\nParameter schema: ${schemaJson}`;
85
+ return {
86
+ content: ride === undefined || ride === "" ? [{ type: "text", text }] : [{ type: "text", text }, { type: "text", text: ride }],
87
+ details: { invalidArguments: true },
88
+ isError: true,
89
+ };
90
+ };
70
91
  if (direct !== undefined) {
71
92
  return {
72
93
  name: info.name,
@@ -78,14 +99,20 @@ export function createPlaceholderTool(info, direct) {
78
99
  ...(direct.executionMode !== undefined ? { executionMode: direct.executionMode } : {}),
79
100
  execute: async (toolCallId, params, signal, onUpdate) => {
80
101
  const real = direct.resolveReal();
81
- if (real !== undefined && Value.Check(real.parameters, params)) {
102
+ if (real === undefined)
103
+ return teachingRejection();
104
+ if (Value.Check(real.parameters, params)) {
82
105
  const ride = await direct.activate();
83
106
  const result = await real.invoke(toolCallId, params, signal, onUpdate);
84
107
  if (ride === undefined || ride === "")
85
108
  return result;
86
109
  return { ...result, content: [...result.content, { type: "text", text: ride }] };
87
110
  }
88
- return teachingRejection();
111
+ const schemaJson = renderSchemaForModel(real.parameters);
112
+ if (schemaJson === undefined)
113
+ return teachingRejection();
114
+ const ride = await direct.activate();
115
+ return invalidArgumentsRejection(real, params, schemaJson, ride);
89
116
  },
90
117
  };
91
118
  }
@@ -1,2 +1,10 @@
1
+ import type { RedactionConfidence, RedactionSource } from "./arg-summary.js";
2
+ export type SecretEnvFindingKind = "suffix-rule" | "exact-name";
3
+ export interface SecretEnvFinding {
4
+ key: string;
5
+ kind: SecretEnvFindingKind;
6
+ confidence: RedactionConfidence;
7
+ source: RedactionSource;
8
+ }
1
9
  export declare function isSecretEnvKey(key: string): boolean;
2
- export declare function scrubSecretEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
10
+ export declare function scrubSecretEnv(env: NodeJS.ProcessEnv, findings?: SecretEnvFinding[]): NodeJS.ProcessEnv;
@@ -4,14 +4,31 @@ const SECRET_ENV_EXACT_NAMES = new Set([
4
4
  "PGPASSWORD", "MYSQL_PWD", "SSHPASS", "GPG_PASSPHRASE", "GH_PAT", "GITHUB_PAT",
5
5
  "NPM_CONFIG__AUTH", "NPM_CONFIG__AUTHTOKEN",
6
6
  ]);
7
+ const SECRET_ENV_KIND_CONFIDENCE = {
8
+ "exact-name": "high",
9
+ "suffix-rule": "medium",
10
+ };
11
+ function classifySecretEnvKey(key) {
12
+ if (SECRET_ENV_RE.test(key))
13
+ return "suffix-rule";
14
+ if (SECRET_ENV_EXACT_NAMES.has(key.toUpperCase()))
15
+ return "exact-name";
16
+ return undefined;
17
+ }
7
18
  export function isSecretEnvKey(key) {
8
- return SECRET_ENV_RE.test(key) || SECRET_ENV_EXACT_NAMES.has(key.toUpperCase());
19
+ return classifySecretEnvKey(key) !== undefined;
9
20
  }
10
- export function scrubSecretEnv(env) {
21
+ export function scrubSecretEnv(env, findings) {
11
22
  const out = {};
12
23
  for (const k of Object.keys(env)) {
13
- if (!isSecretEnvKey(k))
24
+ const kind = classifySecretEnvKey(k);
25
+ if (kind === undefined) {
14
26
  out[k] = env[k];
27
+ continue;
28
+ }
29
+ if (findings !== undefined) {
30
+ findings.push({ key: k, kind, confidence: SECRET_ENV_KIND_CONFIDENCE[kind], source: "secret-env" });
31
+ }
15
32
  }
16
33
  return out;
17
34
  }
@@ -1016,7 +1016,7 @@ export function buildAgentPollDetails(input) {
1016
1016
  status: input.status,
1017
1017
  retrieval_status: input.retrievalStatus,
1018
1018
  ...(input.seq !== undefined ? { seq: input.seq } : {}),
1019
- ...(input.status === "killed" && input.stoppedBy !== undefined ? { stoppedBy: input.stoppedBy } : {}),
1019
+ ...(input.stoppedBy !== undefined ? { stoppedBy: input.stoppedBy } : {}),
1020
1020
  ...(failed && input.error !== undefined ? { error: delimitUntrusted("agent error", boundedRedactedSummary(input.error, 300)) } : {}),
1021
1021
  ...(failed && input.errorCode !== undefined ? { errorCode: input.errorCode } : {}),
1022
1022
  ...(failed && input.errorRetryable !== undefined ? { retryable: input.errorRetryable } : {}),
@@ -333,6 +333,7 @@ export interface TaskSpec {
333
333
  };
334
334
  callCapByDeadline?: false;
335
335
  gracefulFinalize?: false;
336
+ brainCallGuardrailMs?: import("../brain/timeout.js").BrainCallGuardrailKnob;
336
337
  };
337
338
  configOverrides?: ReadonlyArray<import("../config/catalog.js").ConfigOverrideDeclaration>;
338
339
  forwardSubagentEvents?: boolean;
@@ -754,6 +755,7 @@ export interface RunnerDeps {
754
755
  builtinWorkflows?: boolean;
755
756
  onWorkflowAgentSpawn?: (handle: import("../orchestration/workflow.js").WorkflowAgentHandle) => void;
756
757
  maxSuspends?: number;
758
+ brainCallGuardrailMs?: import("../brain/timeout.js").BrainCallGuardrailKnob;
757
759
  now?: () => number;
758
760
  memoryBackend?: import("./memory-engine/types.js").MemoryBackend;
759
761
  memoryEngineDir?: string;
@@ -1,5 +1,6 @@
1
1
  import { type RedactionReport } from "./arg-summary.js";
2
- export type { RedactionFinding, RedactionReport, RedactionConfidence } from "./arg-summary.js";
2
+ export type { RedactionFinding, RedactionReport, RedactionConfidence, RedactionSource, SummarizableFinding, } from "./arg-summary.js";
3
+ export { summarizeRedactions } from "./arg-summary.js";
3
4
  export declare function redactSecrets(s: string, report?: RedactionReport): string;
4
5
  export declare function redactHostLeaks(s: string, report?: RedactionReport): string;
5
6
  export declare function boundedRedactedSummary(value: unknown, max: number): string;
@@ -1,6 +1,7 @@
1
1
  import { delimitUntrusted } from "./untrusted-text.js";
2
2
  import { scrubSecrets, SECRET_PASSES, runRedactionPasses } from "./arg-summary.js";
3
3
  import { sliceHeadSafe } from "./surrogate-safe-slice.js";
4
+ export { summarizeRedactions } from "./arg-summary.js";
4
5
  const URI_SCHEME = String.raw `[A-Za-z][A-Za-z0-9+.\-_]{0,63}`;
5
6
  const CREDENTIALS_IN_AUTHORITY = new RegExp(String.raw `(${URI_SCHEME}:\/\/)((?:[^/\s"'@]*@)+)`, "gi");
6
7
  const URL_OF_ANY_PROTOCOL = new RegExp(String.raw `${URI_SCHEME}:\/\/[^\s"'\`]+`, "gi");
@@ -21,18 +22,19 @@ const SECRET_TIER_PASSES = [
21
22
  kind: "url-credentials",
22
23
  confidence: "medium",
23
24
  marker: "[redacted-credentials]",
25
+ source: "untrusted-egress",
24
26
  re: CREDENTIALS_IN_AUTHORITY,
25
27
  replace: (_m, scheme, userinfo) => redactUserinfo(scheme, userinfo),
26
28
  },
27
- { kind: "signed-url-param", confidence: "high", marker: "[redacted]", re: /([?&#](?:sig|signature|sas|code)=)[^&\s"'#]+/gi, replace: "$1[redacted]" },
29
+ { kind: "signed-url-param", confidence: "high", marker: "[redacted]", source: "untrusted-egress", re: /([?&#](?:sig|signature|sas|code)=)[^&\s"'#]+/gi, replace: "$1[redacted]" },
28
30
  ];
29
31
  export function redactSecrets(s, report) {
30
32
  return runRedactionPasses(s, SECRET_TIER_PASSES, report);
31
33
  }
32
34
  const HOST_LEAK_PASSES = [
33
35
  ...SECRET_TIER_PASSES,
34
- { kind: "url", confidence: "medium", marker: "[redacted-url]", re: URL_OF_ANY_PROTOCOL, replace: "[redacted-url]" },
35
- { kind: "fs-path", confidence: "low", marker: "[redacted-path]", re: /(?:\/[A-Za-z0-9_.-]+){2,}/g, replace: "[redacted-path]" },
36
+ { kind: "url", confidence: "medium", marker: "[redacted-url]", source: "untrusted-egress", re: URL_OF_ANY_PROTOCOL, replace: "[redacted-url]" },
37
+ { kind: "fs-path", confidence: "low", marker: "[redacted-path]", source: "untrusted-egress", re: /(?:\/[A-Za-z0-9_.-]+){2,}/g, replace: "[redacted-path]" },
36
38
  ];
37
39
  export function redactHostLeaks(s, report) {
38
40
  return runRedactionPasses(s, HOST_LEAK_PASSES, report);
@@ -19,7 +19,6 @@ export declare function decideSpoolReclaim(args: {
19
19
  reclaim: false;
20
20
  } | {
21
21
  reclaim: true;
22
- live: boolean;
23
22
  skippedBytes: number;
24
23
  };
25
24
  export declare function openSpoolPair(base: string): {
@@ -8,7 +8,7 @@ import { createInterface } from "node:readline";
8
8
  import { ExecutionError, err, FileError, ok, toError, } from "../harness/types.js";
9
9
  import { killProcessTree, shutdownDebug } from "./kill-tree.js";
10
10
  import { scrubSecretEnv } from "../../core/secret-env.js";
11
- import { RollingTailBuffer, markLiveSpoolRotations, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
11
+ import { RollingTailBuffer, markTruncated, newStreamCursorState, sliceStreamIncrement, } from "../../core/exec-output-tail.js";
12
12
  import { BackgroundShellError } from "../../core/background-shell.js";
13
13
  import { SchedulerError } from "../../core/scheduler.js";
14
14
  const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
@@ -216,18 +216,14 @@ const EXEC_SPOOL_HARD_CAP_BYTES = 16 * 1024 * 1024;
216
216
  export function decideSpoolReclaim(args) {
217
217
  const rotate = args.rotateBytes ?? EXEC_SPOOL_ROTATE_BYTES;
218
218
  const hardCap = args.hardCapBytes ?? EXEC_SPOOL_HARD_CAP_BYTES;
219
- if (args.cursor < rotate)
219
+ if (args.writerLive)
220
220
  return { reclaim: false };
221
- if (args.size === args.cursor) {
222
- if (!args.writerLive)
223
- return { reclaim: true, live: false, skippedBytes: 0 };
224
- if (args.size >= hardCap)
225
- return { reclaim: true, live: true, skippedBytes: 0 };
221
+ if (args.cursor < rotate)
226
222
  return { reclaim: false };
227
- }
228
- if (args.size > args.cursor && args.size >= hardCap) {
229
- return { reclaim: true, live: args.writerLive, skippedBytes: args.size - args.cursor };
230
- }
223
+ if (args.size === args.cursor)
224
+ return { reclaim: true, skippedBytes: 0 };
225
+ if (args.size > args.cursor && args.size >= hardCap)
226
+ return { reclaim: true, skippedBytes: args.size - args.cursor };
231
227
  return { reclaim: false };
232
228
  }
233
229
  const SIGNUM = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
@@ -330,8 +326,6 @@ export class NodeExecutionEnv {
330
326
  const adoptOnTimeoutRef = {};
331
327
  const pumpSpoolRef = {};
332
328
  const drainSpoolRef = {};
333
- const liveRotations = { out: 0, err: 0 };
334
- const liveRotationsTotal = () => liveRotations.out + liveRotations.err;
335
329
  const writerLive = () => !settled && child !== undefined && child.exitCode === null && child.signalCode === null;
336
330
  const armForceSettle = (result) => {
337
331
  if (forceSettleRef.current || settled)
@@ -351,7 +345,7 @@ export class NodeExecutionEnv {
351
345
  drainSpoolRef.current?.();
352
346
  const so = stdoutTail.result();
353
347
  const se = stderrTail.result();
354
- return { stdout: markTruncated(so.text, so.droppedBytes), stderr: markLiveSpoolRotations(markTruncated(se.text, se.droppedBytes), liveRotationsTotal()) };
348
+ return { stdout: markTruncated(so.text, so.droppedBytes), stderr: markTruncated(se.text, se.droppedBytes) };
355
349
  };
356
350
  const onAbort = () => {
357
351
  abortKillInFlight = true;
@@ -545,8 +539,6 @@ export class NodeExecutionEnv {
545
539
  return;
546
540
  if (d.skippedBytes > 0)
547
541
  lane.tail.recordSkippedBytes(d.skippedBytes);
548
- if (d.live)
549
- liveRotations[lane.cursor]++;
550
542
  truncateSync(lane.path, 0);
551
543
  spoolCursor[lane.cursor] = 0;
552
544
  }
@@ -583,9 +575,7 @@ export class NodeExecutionEnv {
583
575
  if (settled || !child)
584
576
  return false;
585
577
  pumpSpoolRef.current?.();
586
- const shellId = this.adoptRunningChild(child, stdoutTail.result(), stderrTail.result(), execSpoolRef.current !== undefined
587
- ? { spool: execSpoolRef.current, cursors: { out: spoolCursor.out, err: spoolCursor.err }, liveRotations: liveRotationsTotal() }
588
- : undefined);
578
+ const shellId = this.adoptRunningChild(child, stdoutTail.result(), stderrTail.result(), execSpoolRef.current !== undefined ? { spool: execSpoolRef.current, cursors: { out: spoolCursor.out, err: spoolCursor.err } } : undefined);
589
579
  if (shellId === undefined)
590
580
  return false;
591
581
  if (spoolPumpRef.current)
@@ -678,12 +668,12 @@ export class NodeExecutionEnv {
678
668
  const signum = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGABRT: 6, SIGKILL: 9, SIGSEGV: 11, SIGPIPE: 13, SIGTERM: 15 };
679
669
  settle(ok({
680
670
  stdout: markTruncated(so.text, so.droppedBytes),
681
- stderr: markLiveSpoolRotations(`${markTruncated(se.text, se.droppedBytes)}${se.text.endsWith("\n") || se.text === "" ? "" : "\n"}[process terminated by ${signal}]`, liveRotationsTotal()),
671
+ stderr: `${markTruncated(se.text, se.droppedBytes)}${se.text.endsWith("\n") || se.text === "" ? "" : "\n"}[process terminated by ${signal}]`,
682
672
  exitCode: 128 + (signum[signal] ?? 9),
683
673
  }));
684
674
  return;
685
675
  }
686
- settle(ok({ stdout: markTruncated(so.text, so.droppedBytes), stderr: markLiveSpoolRotations(markTruncated(se.text, se.droppedBytes), liveRotationsTotal()), exitCode: code ?? 0 }));
676
+ settle(ok({ stdout: markTruncated(so.text, so.droppedBytes), stderr: markTruncated(se.text, se.droppedBytes), exitCode: code ?? 0 }));
687
677
  };
688
678
  child.on("close", (code, signal) => settleFromTermination(code, signal));
689
679
  child.on("exit", (code, signal) => {
@@ -1051,7 +1041,6 @@ export class NodeExecutionEnv {
1051
1041
  ...(handover !== undefined
1052
1042
  ? { spool: { outPath: handover.spool.outPath, errPath: handover.spool.errPath, fileCursor: { ...handover.cursors }, ephemeral: true } }
1053
1043
  : {}),
1054
- ...(handover !== undefined && (handover.liveRotations ?? 0) > 0 ? { liveRotations: handover.liveRotations } : {}),
1055
1044
  };
1056
1045
  const seedOut = Buffer.from(stdoutSoFar.text, "utf8");
1057
1046
  const seedErr = Buffer.from(stderrSoFar.text, "utf8");
@@ -1241,14 +1230,9 @@ export class NodeExecutionEnv {
1241
1230
  const out = sliceStreamIncrement(entry.stdout, terminal);
1242
1231
  const er = sliceStreamIncrement(entry.stderr, terminal);
1243
1232
  const droppedBeforeCursor = out.droppedBeforeCursor + er.droppedBeforeCursor;
1244
- let stderrInc = er.inc;
1245
- if (terminal && (entry.liveRotations ?? 0) > 0 && entry.liveRotationsDisclosed !== true) {
1246
- entry.liveRotationsDisclosed = true;
1247
- stderrInc = markLiveSpoolRotations(er.inc, entry.liveRotations ?? 0);
1248
- }
1249
1233
  return ok({
1250
1234
  stdout: out.inc,
1251
- stderr: stderrInc,
1235
+ stderr: er.inc,
1252
1236
  status: entry.status,
1253
1237
  ...(entry.exitCode !== undefined ? { exitCode: entry.exitCode } : {}),
1254
1238
  ...(droppedBeforeCursor > 0 ? { truncated: true, bytesDroppedBeforeCursor: droppedBeforeCursor } : {}),
@@ -1320,8 +1304,6 @@ export class NodeExecutionEnv {
1320
1304
  stream.tail.recordSkippedBytes(d.skippedBytes);
1321
1305
  stream.totalBytes += d.skippedBytes;
1322
1306
  }
1323
- if (d.live)
1324
- entry.liveRotations = (entry.liveRotations ?? 0) + 1;
1325
1307
  truncateSync(path, 0);
1326
1308
  spool.fileCursor[lane] = 0;
1327
1309
  }
package/dist/index.d.ts CHANGED
@@ -57,6 +57,7 @@ export { killProcessTree, signalProcessTree } from "./engine/execution-env/kill-
57
57
  export type { KillProcessTreeOptions } from "./engine/execution-env/kill-tree.js";
58
58
  export { getShellConfig, isWslBashLauncher } from "./engine/execution-env/node-execution-env.js";
59
59
  export { isSecretEnvKey, scrubSecretEnv } from "./core/secret-env.js";
60
+ export type { SecretEnvFinding, SecretEnvFindingKind } from "./core/secret-env.js";
60
61
  export { MAX_EXEC_OUTPUT_BYTES, RollingTailBuffer, markTruncated } from "./core/exec-output-tail.js";
61
62
  export type { ExecutionEnv, FileInfo, Result, FileErrorCode, ExecutionErrorCode } from "./internal/harness.js";
62
63
  export type { ExecResult } from "./internal/harness.js";
@@ -165,14 +166,15 @@ export { toolResultStoreContract } from "./core/store-contracts/tool-result-stor
165
166
  export { fileSnapshotStoreContract } from "./core/store-contracts/file-snapshot-store-contract.js";
166
167
  export { MAILBOX_CONTRACT_SCOPE, mailboxStoreContract, mailboxAckOwnershipContract, mailboxBundledOnlyContract, } from "./core/store-contracts/mailbox-store-contract.js";
167
168
  export { BACKGROUND_AGENT_CONTRACT_SCOPE, backgroundAgentStoreContract, backgroundAgentStoreScopesContract, } from "./core/store-contracts/background-agent-store-contract.js";
168
- export { type BackgroundAgentStore, type BackgroundAgentRecord, type BackgroundAgentRowSummary, type BackgroundAgentUsage, canAccessAgentRecord, BackgroundAgentStoreError, InMemoryBackgroundAgentStore, reconcileParkedAgents, queryBackgroundAgents, type BackgroundAgentQuery, } from "./core/background-agent-store.js";
169
+ export { type BackgroundAgentStore, type BackgroundAgentRecord, type BackgroundAgentRowSummary, type BackgroundAgentUsage, canAccessAgentRecord, STALE_RUNNING_REAP_ATTRIBUTION, BackgroundAgentStoreError, InMemoryBackgroundAgentStore, reconcileParkedAgents, queryBackgroundAgents, type BackgroundAgentQuery, } from "./core/background-agent-store.js";
169
170
  export { serveDurableAgentRowLane, buildAgentPollDetails, type AgentPollDetailsInput, } from "./core/task-registry-agent.js";
170
171
  export type { UnifiedTaskResult } from "./core/task-registry-shared.js";
171
172
  export { FileBackgroundAgentStore, type FileBackgroundAgentStoreOptions } from "./stores/file/background-agent-store.js";
172
173
  export { A2A_TASK_STATES, type A2ATaskState, type A2ATaskStateReversal, type A2ATaskStateReversalFaithful, type A2ATaskStateReversalLossy, toA2ATaskState, fromA2ATaskState, } from "./core/a2a-task-state.js";
173
174
  export { type WorkflowJournalStore, type WorkflowJournalEntry, InMemoryWorkflowJournalStore, callKeyOrdinal, JOURNAL_OVERSIZE_ERROR_CODE, } from "./core/workflow-journal-store.js";
174
175
  export { untrustedEgressForHuman, redactHostLeaks, redactSecrets, boundedRedactedSummary } from "./core/untrusted-egress.js";
175
- export type { RedactionFinding, RedactionReport, RedactionConfidence } from "./core/untrusted-egress.js";
176
+ export type { RedactionFinding, RedactionReport, RedactionConfidence, RedactionSource, SummarizableFinding, } from "./core/untrusted-egress.js";
177
+ export { summarizeRedactions } from "./core/untrusted-egress.js";
176
178
  export { MemoryRosterStore, FileRosterStore, type RosterStore, type RosterEntry, type RosterAccess, type RosterGcOptions } from "./agents/roster-store.js";
177
179
  export { CONFIG_CATALOG_VERSION, describeConfigCatalog, resolveEffectiveConfig, type ConfigKnob, type ConfigOverrideDeclaration, type ConfigProvenance, type EffectiveConfigField, } from "./config/catalog.js";
178
180
  export { normalizeAgentName } from "./core/task-registry.js";
package/dist/index.js CHANGED
@@ -151,12 +151,13 @@ export { toolResultStoreContract } from "./core/store-contracts/tool-result-stor
151
151
  export { fileSnapshotStoreContract } from "./core/store-contracts/file-snapshot-store-contract.js";
152
152
  export { MAILBOX_CONTRACT_SCOPE, mailboxStoreContract, mailboxAckOwnershipContract, mailboxBundledOnlyContract, } from "./core/store-contracts/mailbox-store-contract.js";
153
153
  export { BACKGROUND_AGENT_CONTRACT_SCOPE, backgroundAgentStoreContract, backgroundAgentStoreScopesContract, } from "./core/store-contracts/background-agent-store-contract.js";
154
- export { canAccessAgentRecord, BackgroundAgentStoreError, InMemoryBackgroundAgentStore, reconcileParkedAgents, queryBackgroundAgents, } from "./core/background-agent-store.js";
154
+ export { canAccessAgentRecord, STALE_RUNNING_REAP_ATTRIBUTION, BackgroundAgentStoreError, InMemoryBackgroundAgentStore, reconcileParkedAgents, queryBackgroundAgents, } from "./core/background-agent-store.js";
155
155
  export { serveDurableAgentRowLane, buildAgentPollDetails, } from "./core/task-registry-agent.js";
156
156
  export { FileBackgroundAgentStore } from "./stores/file/background-agent-store.js";
157
157
  export { A2A_TASK_STATES, toA2ATaskState, fromA2ATaskState, } from "./core/a2a-task-state.js";
158
158
  export { InMemoryWorkflowJournalStore, callKeyOrdinal, JOURNAL_OVERSIZE_ERROR_CODE, } from "./core/workflow-journal-store.js";
159
159
  export { untrustedEgressForHuman, redactHostLeaks, redactSecrets, boundedRedactedSummary } from "./core/untrusted-egress.js";
160
+ export { summarizeRedactions } from "./core/untrusted-egress.js";
160
161
  export { MemoryRosterStore, FileRosterStore } from "./agents/roster-store.js";
161
162
  export { CONFIG_CATALOG_VERSION, describeConfigCatalog, resolveEffectiveConfig, } from "./config/catalog.js";
162
163
  export { normalizeAgentName } from "./core/task-registry.js";
@@ -5,7 +5,7 @@ import { decodeTextBytes } from "./encoding.js";
5
5
  import { isNotebookPath, parseNotebookCells, renderNotebookCells, stripNotebookImageData, NOTEBOOK_IMAGE_BASE64_BUDGET } from "./notebook.js";
6
6
  import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE } from "../../core/mcp.js";
7
7
  import { PDF_MAX_PAGES_PER_READ, pdfMagicMatches } from "./pdf.js";
8
- import { MAX_READ_BYTES, SLICED_READ_MAX_BYTES, MAX_IMAGE_READ_BYTES, MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES, resolveAutoDownsampler, MAX_READ_OUTPUT_CHARS, READ_CYBER_REMINDER, FILE_PATH_PARAMS, countLines, seededFileUnchangedReminder, enoentMessage, } from "./fs-shared.js";
8
+ import { MAX_READ_BYTES, SLICED_READ_MAX_BYTES, MAX_IMAGE_READ_BYTES, MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES, NO_DOWNSAMPLER_IMAGE_CAP_HINT, resolveAutoDownsampler, MAX_READ_OUTPUT_CHARS, READ_CYBER_REMINDER, FILE_PATH_PARAMS, countLines, seededFileUnchangedReminder, enoentMessage, } from "./fs-shared.js";
9
9
  import { readPdfFile, pdfResultToToolReturn } from "./fs-pdf.js";
10
10
  export function createReadFileTool(env, state, rootCanonical, cwdRef, additionalRoots, imageDownsampler, pdfCapabilities, bgOutputReadExemption) {
11
11
  return defineTool({
@@ -70,14 +70,16 @@ export function createReadFileTool(env, state, rootCanonical, cwdRef, additional
70
70
  }
71
71
  const downsampler = imageDownsampler === false ? undefined : (imageDownsampler ?? (await resolveAutoDownsampler()));
72
72
  const readCap = downsampler ? MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES : MAX_IMAGE_READ_BYTES;
73
+ const tooLargeToRead = (bytes) => `Error (Read): image "${path}" is too large to read (${bytes} bytes > ${readCap}-byte cap).` +
74
+ (downsampler ? "" : ` ${NO_DOWNSAMPLER_IMAGE_CAP_HINT}`);
73
75
  if (meta.value.size > readCap) {
74
- return errorResult(`Error (Read): image "${path}" is too large to read (${meta.value.size} bytes > ${readCap}-byte cap).`);
76
+ return errorResult(tooLargeToRead(meta.value.size));
75
77
  }
76
78
  const bin = await env.readBinaryFile(r.key, ctx.signal);
77
79
  if (!bin.ok)
78
80
  return errorResult(`Error (Read): cannot read image "${path}": ${bin.error.message}`);
79
81
  if (bin.value.byteLength > readCap) {
80
- return errorResult(`Error (Read): image "${path}" is too large to read (${bin.value.byteLength} bytes > ${readCap}-byte cap).`);
82
+ return errorResult(tooLargeToRead(bin.value.byteLength));
81
83
  }
82
84
  if (!imageMagicMatches(bin.value, imageMime)) {
83
85
  return errorResult(`Error (Read): "${path}" has an image extension but its content is not a valid ${imageMime} (empty, truncated, or mis-named).`);
@@ -18,6 +18,7 @@ export declare function persistedTextOf(encoded: string | Uint8Array): string;
18
18
  export declare function notReadRefusalText(env: ExecutionEnv, toolName: string, key: string, v: Pick<FsViolation, "code" | "message" | "partialView">, signal?: AbortSignal, fallbackHint?: string): Promise<string>;
19
19
  export declare const MAX_IMAGE_READ_BYTES: number;
20
20
  export declare const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES: number;
21
+ export declare const NO_DOWNSAMPLER_IMAGE_CAP_HINT = "Install the optional \"sharp\" dependency (declared in this package's optionalDependencies) to read images up to 67108864 bytes \u2014 it downsamples them to fit the API image limit.";
21
22
  export type ReadImageDownsamplerOption = ImageDownsampler | false | undefined;
22
23
  export declare function resolveAutoDownsampler(): Promise<ImageDownsampler | undefined>;
23
24
  export declare const MAX_READ_OUTPUT_CHARS = 100000;
@@ -41,6 +41,7 @@ export async function notReadRefusalText(env, toolName, key, v, signal, fallback
41
41
  }
42
42
  export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024;
43
43
  export const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES = 64 * 1024 * 1024;
44
+ export const NO_DOWNSAMPLER_IMAGE_CAP_HINT = `Install the optional "sharp" dependency (declared in this package's optionalDependencies) to read images up to ${MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES} bytes — it downsamples them to fit the API image limit.`;
44
45
  let autoDownsampler;
45
46
  export function resolveAutoDownsampler() {
46
47
  return (autoDownsampler ??= sharpImageDownsampler());
@@ -27,9 +27,12 @@ The runtime clamps to [60, 3600], so you don't need to clamp yourself.
27
27
 
28
28
  One short sentence on what you chose and why. Goes to telemetry and is shown back to the user. "watching CI run" beats "waiting." The user reads this to understand what you're doing without having to predict your cadence in advance — make it specific.
29
29
  `;
30
- function toSchedulerContext(c) {
30
+ function durableScopeOf(c) {
31
+ return c.principal ?? "default";
32
+ }
33
+ function toSchedulerContext(c, scope) {
31
34
  return {
32
- scope: c.scope,
35
+ scope,
33
36
  ...(c.principal !== undefined ? { principal: c.principal } : {}),
34
37
  ...(c.sessionId !== undefined ? { sessionId: c.sessionId } : {}),
35
38
  ...(c.taskConfig !== undefined ? { taskConfig: c.taskConfig } : {}),
@@ -157,7 +160,9 @@ export function createSchedulerTools(env, ctx) {
157
160
  if (!hasScheduler(env))
158
161
  return [];
159
162
  const sched = env;
160
- const schedCtx = toSchedulerContext(ctx);
163
+ const sessionCtx = toSchedulerContext(ctx, ctx.scope);
164
+ const durableCtx = toSchedulerContext(ctx, durableScopeOf(ctx));
165
+ const lanesDiffer = sessionCtx.scope !== durableCtx.scope;
161
166
  let cronCreateChain = Promise.resolve();
162
167
  const serializedCronCreateOp = (fn) => {
163
168
  const next = cronCreateChain.then(fn, fn);
@@ -255,9 +260,10 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
255
260
  ...(!durable ? { lifetime: "session" } : {}),
256
261
  ...(a.recurring !== undefined ? { recurring: a.recurring } : {}),
257
262
  };
258
- const before = await sched.list(schedCtx);
263
+ const opCtx = durable ? durableCtx : sessionCtx;
264
+ const before = await sched.list(opCtx);
259
265
  const knownIds = before.ok ? new Set(before.value.map((s) => s.id)) : undefined;
260
- const r = await sched.schedule(intent, schedCtx);
266
+ const r = await sched.schedule(intent, opCtx);
261
267
  if (!r.ok)
262
268
  return errorResult(`Error (CronCreate): ${r.error.message}`);
263
269
  const replaced = knownIds?.has(r.value.id);
@@ -285,6 +291,16 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
285
291
  };
286
292
  }),
287
293
  });
294
+ const cancelLanesFor = async (id) => {
295
+ if (!lanesDiffer)
296
+ return [sessionCtx];
297
+ for (const lane of [sessionCtx, durableCtx]) {
298
+ const listed = await sched.list(lane);
299
+ if (listed.ok && listed.value.some((s) => s.id === id))
300
+ return [lane];
301
+ }
302
+ return [sessionCtx, durableCtx];
303
+ };
288
304
  const cronCancel = defineTool({
289
305
  name: "CronDelete",
290
306
  contract: { contractId: "core.cron_delete@1", implementationRevision: "1" },
@@ -293,7 +309,14 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
293
309
  effect: "idempotent",
294
310
  execute: async (args) => {
295
311
  const { id } = args;
296
- const r = await sched.cancel(id, schedCtx);
312
+ const taskId = id;
313
+ const [firstLane, ...otherLanes] = await cancelLanesFor(taskId);
314
+ let r = await sched.cancel(taskId, firstLane);
315
+ for (const lane of otherLanes) {
316
+ if (r.ok || r.error.code !== "not_found")
317
+ break;
318
+ r = await sched.cancel(taskId, lane);
319
+ }
297
320
  if (!r.ok) {
298
321
  if (r.error.code === "not_found")
299
322
  return `CronDelete: no scheduled task "${id}" (already gone or not yours).`;
@@ -309,13 +332,23 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
309
332
  parameters: Type.Object({}),
310
333
  effect: "read",
311
334
  execute: async () => {
312
- const r = await sched.list(schedCtx);
313
- if (!r.ok)
314
- return errorResult(`Error (CronList): ${r.error.message}`);
315
- if (r.value.length === 0) {
335
+ const rows = [];
336
+ const seen = new Set();
337
+ for (const lane of lanesDiffer ? [sessionCtx, durableCtx] : [sessionCtx]) {
338
+ const listed = await sched.list(lane);
339
+ if (!listed.ok)
340
+ return errorResult(`Error (CronList): ${listed.error.message}`);
341
+ for (const s of listed.value) {
342
+ if (seen.has(s.id))
343
+ continue;
344
+ seen.add(s.id);
345
+ rows.push(s);
346
+ }
347
+ }
348
+ if (rows.length === 0) {
316
349
  return { content: "No scheduled tasks.", details: { type: "cron-list", jobs: [] } };
317
350
  }
318
- const content = r.value
351
+ const content = rows
319
352
  .map((s) => {
320
353
  const shown = cronExprFromSummary(s.when);
321
354
  const human = cronToHuman(s.when);
@@ -324,7 +357,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
324
357
  return `- ${s.id}: ${shown}${human !== s.when ? ` — ${human}` : ""}${s.label ? ` (${s.label})` : ""}${tier}${once}`;
325
358
  })
326
359
  .join("\n");
327
- const jobs = r.value.map((s) => {
360
+ const jobs = rows.map((s) => {
328
361
  const human = cronToHuman(s.when);
329
362
  return {
330
363
  id: s.id,
@@ -339,7 +372,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
339
372
  },
340
373
  });
341
374
  const listPendingWakeups = async () => {
342
- const r = await sched.list(schedCtx);
375
+ const r = await sched.list(sessionCtx);
343
376
  if (!r.ok)
344
377
  return { err: r.error.message };
345
378
  return {
@@ -351,7 +384,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
351
384
  const cancelWakeups = async (ids) => {
352
385
  let n = 0;
353
386
  for (const id of ids) {
354
- const r = await sched.cancel(id, schedCtx);
387
+ const r = await sched.cancel(id, sessionCtx);
355
388
  if (r.ok)
356
389
  n += 1;
357
390
  }
@@ -405,7 +438,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
405
438
  const pending = await listPendingWakeups();
406
439
  if (!("err" in pending))
407
440
  await cancelWakeups(pending.ids);
408
- const r = await sched.schedule({ prompt: a.prompt, when: { kind: "delay", delaySec: clampedDelaySeconds }, label: WAKEUP_LABEL, mode: "session-wakeup" }, schedCtx);
441
+ const r = await sched.schedule({ prompt: a.prompt, when: { kind: "delay", delaySec: clampedDelaySeconds }, label: WAKEUP_LABEL, mode: "session-wakeup" }, sessionCtx);
409
442
  if (!r.ok)
410
443
  return errorResult(`Error (${SCHEDULE_WAKEUP_TOOL_NAME}): ${r.error.message}`);
411
444
  const scheduledFor = Date.now() + clampedDelaySeconds * 1000;
package/dist/tools/web.js CHANGED
@@ -211,11 +211,14 @@ async function fetchAllowlisted(doFetch, start, allowHosts, userAgent, signal, o
211
211
  export function webFetchToolSpec(config = {}) {
212
212
  const maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;
213
213
  const doFetch = config.fetchImpl ?? globalThis.fetch;
214
+ const canSummarize = config.summarize !== undefined;
214
215
  return {
215
216
  name: "WebFetch",
216
217
  contract: { contractId: "core.web_fetch@1", implementationRevision: "1" },
217
- description: "Fetches a URL, converts the page to markdown, and answers `prompt` against it when a sub-model is " +
218
- "configured (otherwise returns the markdown).\n" +
218
+ description: (canSummarize
219
+ ? "Fetches a URL, converts the page to markdown, and answers `prompt` against it with a sub-model.\n"
220
+ : "Fetches a URL, converts the page to markdown, and returns the markdown. This deployment has no " +
221
+ "extraction sub-model wired, so pages come back in full for you to read.\n") +
219
222
  "\n" +
220
223
  "- Fails on authenticated/private URLs — use an authenticated MCP tool or `gh` for those instead.\n" +
221
224
  "- Cross-host redirects are returned to you rather than followed; call again with the redirect URL.\n" +
@@ -223,7 +226,9 @@ export function webFetchToolSpec(config = {}) {
223
226
  "hosts are always refused; if an allowlist is configured, only those hosts are reachable.",
224
227
  parameters: Type.Object({
225
228
  url: Type.String({ description: "The URL to fetch content from" }),
226
- prompt: Type.Optional(Type.String({ description: "The prompt to run on the fetched content (uses a sub-model if configured)." })),
229
+ ...(canSummarize
230
+ ? { prompt: Type.Optional(Type.String({ description: "The prompt to run on the fetched content (answered by the configured sub-model)." })) }
231
+ : {}),
227
232
  }),
228
233
  effect: "read",
229
234
  execute: async (args, ctx) => {
@@ -434,9 +439,12 @@ export function webFetchToolSpec(config = {}) {
434
439
  : bodyCut
435
440
  ? `\n\n[WebFetch: ${cutPhrase} — the error body itself was cut off mid-transfer: ${bodyBytes?.length ?? 0} bytes were received before the cutoff and the tail is missing]`
436
441
  : "";
442
+ const promptNote = prompt
443
+ ? `\n\n[note: the requested analysis ("${inlineUntrusted(prompt, 120)}") was NOT applied — the fetch failed with HTTP ${res.status}, so there is no page content to run it against; anything below is the server's error response, not an answer]`
444
+ : "";
437
445
  const content = (excerpt
438
446
  ? `${headline}\nThe error response body${trimmed.length > excerpt.length ? ` (first ${ERROR_BODY_EXCERPT_CHARS} chars)` : ""} follows:\n\n${delimitUntrusted(`WebFetch ${parsed.hostname}`, excerpt)}`
439
- : headline) + bodyStateNote;
447
+ : headline) + bodyStateNote + promptNote;
440
448
  return {
441
449
  content,
442
450
  details: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.3.0",
3
+ "version": "5.5.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",