@sema-agent/core 5.10.0 → 5.12.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/agents/subagent.d.ts +1 -1
  3. package/dist/agents/subagent.js +6 -2
  4. package/dist/brain/anthropic.js +1 -1
  5. package/dist/brain/openai.js +1 -1
  6. package/dist/core/auto-compaction.d.ts +12 -1
  7. package/dist/core/auto-compaction.js +3 -1
  8. package/dist/core/background-agent-store.d.ts +2 -1
  9. package/dist/core/background-agent-store.js +1 -0
  10. package/dist/core/checkpoint-store.d.ts +2 -0
  11. package/dist/core/checkpoint-store.js +1 -0
  12. package/dist/core/exec-gate.js +12 -1
  13. package/dist/core/runner/assemble-result.js +9 -0
  14. package/dist/core/runner/compaction-call-options.d.ts +13 -1
  15. package/dist/core/runner/compaction-call-options.js +85 -0
  16. package/dist/core/runner/prepare-task.d.ts +6 -0
  17. package/dist/core/runner/prepare-task.js +118 -36
  18. package/dist/core/runner/runtask.js +71 -21
  19. package/dist/core/runner/tool-disclosure.d.ts +1 -0
  20. package/dist/core/runner/tool-disclosure.js +24 -9
  21. package/dist/core/runner/turn-attachments.d.ts +2 -0
  22. package/dist/core/runner/turn-attachments.js +17 -6
  23. package/dist/core/task-registry-agent.d.ts +3 -0
  24. package/dist/core/task-registry-agent.js +9 -2
  25. package/dist/core/task-registry-shared.d.ts +1 -0
  26. package/dist/core/task-registry.d.ts +2 -0
  27. package/dist/core/trace.d.ts +1 -0
  28. package/dist/core/types.d.ts +9 -1
  29. package/dist/engine/compaction/compaction.d.ts +11 -2
  30. package/dist/engine/compaction/compaction.js +87 -9
  31. package/dist/index.d.ts +1 -1
  32. package/dist/prompt-assembly/event-registry.js +1 -1
  33. package/dist/prompts/default.d.ts +1 -0
  34. package/dist/prompts/default.js +3 -0
  35. package/dist/tools/fs/fs-bash.js +4 -1
  36. package/dist/tools/fs/index.d.ts +1 -0
  37. package/dist/tools/fs/index.js +7 -4
  38. package/dist/tools/web.d.ts +21 -1
  39. package/dist/tools/web.js +126 -10
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.12.0 — 2026-08-05
4
+
5
+ ### BREAKING
6
+
7
+ - **The compaction summary request FORKS the main conversation (CC form).** When the summary model IS the main model (no `compactionModel`/`summarize` role) and the run's latest main request is a faithful (untrimmed) projection, the summary request is now that request byte-for-byte — same system prompt, same message prefix, same tools — plus ONE appended user-role instruction message, so the provider's prompt cache re-serves the already-paid prefix instead of re-prefilling an independent serialized-conversation request at full price every boundary. Degradations keep the independent form byte-identical: a direct `maybeCompact` caller without `forkContext`; an explicit `compactionModel`; the PTL-recovery lane; a lossy request projection (guard-trim drop, clearStale content blanking, aggregate tool-result/media caps, orphan sweep) clears the fork seam for that boundary; and a fork attempt that fails as prompt-too-long, the empty-summary class (e.g. the model answered the fork with a tool call), OR a non-conforming response (the fork instruction demands the summary inside a closed `<summary>` envelope; a response without one — e.g. an obedient refusal under a BYOM system prompt that forbids summaries — is discarded, never persisted as history) falls back ONCE inside `compact()` to the independent (clamp-capable) form — a generic summarizer failure stays terminal (one call per pass). The fork is also refused when the snapshot's served model differs from the summary model (mid-run budget degradation): the model is part of the provider cache key, so that fork would pay a full prefill for nothing. **Consumer flips**: probes pinning the OLD summary-request shape (independent `SUMMARIZATION_SYSTEM_PROMPT` + single serialized user message) red — detect a summary request as EITHER form (independent system prompt, OR main prefix + trailing user instruction mentioning the structured summary). `MaybeCompactOptions.forkContext` (accessor) and a trailing optional `compact(..., forkContext)` parameter are the additive seams.
8
+
9
+ - **Default `keepRecentTokens` drops 20000 → 0 (full compaction, CC form).** With the knob unset, a landed compaction now keeps NO verbatim token budget — the summary replaces the compacted region entirely (the cut still lands on the nearest valid floor, so tool pairs and an in-progress turn's suffix stay intact; the turn-prefix leg summarizes the rest). Explicit `keepRecentTokens` values keep their exact meaning and are the opt-back. **Consumer flips**: probes pinning "pre-compaction verbatim text survives after a default-settings compaction" red.
10
+
11
+ - **The post-compact background-task restatement is DEFAULT ON** (`attachments.backgroundTasks` becomes `boolean`; explicit `false` is the opt-out — same contract as the listing family). CC hard-codes this behavior; the opt-in default left every non-shell host with a window after a compaction where the model had no context evidence of an in-flight task — duplicate spawns and premature turn ends. Explicit `true`/`false` keep their exact meaning; only the absent case flips. The zero-config fast path (no attachment state at all) now applies only to runs that explicitly opt out — the residual cost on default runs is one cadence-clock pass per message (reducers stay type-gated no-ops). **Consumer flips**: a probe pinning "no `background_tasks` frame without opt-in" reds; the frame itself is byte-unchanged.
12
+
13
+ ### Added
14
+
15
+ - **`compaction cannot help` — honest disclosure for an oversized fixed prefix.** When the fixed per-request prefix (system prompt + tool schemas) alone meets/exceeds the compaction trigger threshold, the run emits one `onError` frame (`phase:"config"`) naming the numbers, at trigger-geometry derivation time — compaction only shrinks conversation history, so such a run re-triggers or overflows no matter how well summaries land. One frame per run; geometry itself unchanged.
16
+
17
+ - **`compaction.staleToolResultOffload` — opt-in stale tool-result offload (request-projection clear).** In the outgoing request, same-tool results older than the most recent `keepRecentPerTool` (default 3) are replaced by a short pointer (persisted under the design/30 tool-result store, readable back via `read_tool_result`) once the swap saves ≥ `minSavingsChars` (default 2000). Refs are content-digested (`<toolCallId>_s<sha256-128bit>`), so recurring tool-call ids — which carry no cross-turn uniqueness contract under BYOM — cannot make one pointer resolve to another result's content (collision-resistant at 128 bits; tool results are untrusted text, so the digest width is chosen adversarially, not for accidental collisions). Replacement decisions are deterministic and one-way (an already-replaced result renders the same pointer bytes on every later request — no flip-flop churn); the verbatim→pointer transition itself is a bounded once-per-result cache break, amortized by the `minSavingsChars` floor. The session transcript is never rewritten. Absent (default) = requests byte-identical to before; knob set while the offload store is disabled = one `config` onError and the knob stays inert.
18
+
19
+ - **A PARKED child joins the post-compact restatement.** The snapshot filter (pending/running) gains `parked`, rendered as `parked awaiting an out-of-band approval — do NOT re-issue the gated call` — a child waiting at an approval gate is exactly the task a post-compact model must not re-issue.
20
+
21
+ - **The walltime axis feeds the limit-approach frames.** A caller-armed hard deadline (`limits.maxWalltimeMs`) now contributes its fill ratio to the design/164 converge/deliver advisories alongside tokens/cost/turns — previously the deadline was the only armed ceiling that never warned, so the model learned it was out of time by being cut. Same thresholds (default 80%/95%), same caller-armed rule, same per-slice clock the enforcement reads; frames name the axis (`walltime budget`). Consumer note: a probe pinning "no `limit_approach` steering on a walltime-only run" reds.
22
+
23
+ ### Fixed
24
+
25
+ - **The summarization instruction carries a fabricated-user-turn guard** (both prompt variants and the fork-form instruction): only real user-role messages count as user messages — a `"user: ..."`/`"Human: ..."` line quoted inside assistant output is the assistant's own text, never attribute it to the user.
26
+
27
+ - **`summarize` role doc corrected**: the main model is the correct default baseline (fork form amortizes the prefix via prompt cache); a separate cheap model is an explicit quality-for-price tradeoff, not the recommended posture.
28
+
29
+ - **The exec-gate's buffered leg keeps ran-then-cut partial output.** A timed-out/aborted setup step on a non-streaming env reported empty stdout/stderr even though the `ExecutionError` carried the captured partials — the streaming leg already kept its chunks. Partials now ride the step result, clamped at `maxOutputBytes`.
30
+
31
+ ## 5.11.0 — 2026-08-04
32
+
33
+ ### BREAKING
34
+
35
+ - **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.
36
+ - **`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.
37
+
38
+ - **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.
39
+
40
+ ### Added
41
+
42
+ - **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.
43
+ - **`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.
44
+
45
+ ### Fixed
46
+
47
+ - **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.
48
+ - **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.
49
+ - **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.
50
+ - **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.
51
+ - **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.
52
+ - **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.
53
+
3
54
  ## 5.10.0 — 2026-08-04
4
55
 
5
56
  ### 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("; ")}`);
@@ -1,5 +1,5 @@
1
1
  import { type AgentMessage, type CompactionSettings, type Session, type ThinkingLevel } from "../internal/harness.js";
2
- import type { Model } from "../internal/llm.js";
2
+ import type { Message, Model, Tool } from "../internal/llm.js";
3
3
  import type { Hooks } from "./hooks.js";
4
4
  import type { TraceEvent } from "./trace.js";
5
5
  import type { Brain } from "./types.js";
@@ -20,6 +20,16 @@ export interface CompactionWindowSafetyInfo {
20
20
  }
21
21
  export declare const STALE_ANCHOR_STRUCTURAL_MARGIN = 2;
22
22
  export declare function sanitizeCompactionSettings(settings: CompactionSettings, contextWindow: number | undefined): CompactionSettings;
23
+ export interface CompactionForkContext {
24
+ systemPrompt?: string;
25
+ systemBlocks?: Array<{
26
+ text: string;
27
+ cacheControlBoundary: boolean;
28
+ }>;
29
+ messages: Message[];
30
+ tools?: Tool[];
31
+ modelId?: string;
32
+ }
23
33
  export interface MaybeCompactOptions {
24
34
  onNotifyError?: (failure: import("./safe-notify.js").SafeNotifyFailure) => void;
25
35
  session: Session;
@@ -34,6 +44,7 @@ export interface MaybeCompactOptions {
34
44
  };
35
45
  model: Model;
36
46
  compactionModel?: Model;
47
+ forkContext?: () => CompactionForkContext | undefined;
37
48
  brain: Brain;
38
49
  getApiKeyAndHeaders?: (model: Model) => Promise<{
39
50
  apiKey: string;
@@ -234,12 +234,14 @@ export async function maybeCompact(opts) {
234
234
  const auth = await opts.getApiKeyAndHeaders?.(summaryModel);
235
235
  const summarySignal = opts.signal;
236
236
  const runtime = brainToRuntime(opts.brain);
237
+ const forkCandidate = opts.compactionModel === undefined ? opts.forkContext?.() : undefined;
238
+ const forkContext = forkCandidate !== undefined && (forkCandidate.modelId === undefined || forkCandidate.modelId === summaryModel.id) ? forkCandidate : undefined;
237
239
  let res;
238
240
  summaryStartAt = Date.now();
239
241
  try {
240
242
  res = await compact(prep.value, summaryModel, auth?.apiKey, auth?.headers, effectiveInstructions, summarySignal, opts.thinking, undefined, runtime, undefined, opts.onInputTruncated, () => {
241
243
  ptlRetries += 1;
242
- });
244
+ }, forkContext);
243
245
  }
244
246
  finally {
245
247
  summaryEndAt = Date.now();
@@ -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
  };
@@ -65,7 +65,18 @@ async function runBufferedStep(env, step, options, maxOutputBytes) {
65
65
  abortSignal: options.signal,
66
66
  });
67
67
  if (!res.ok) {
68
- return { label, command: step.command, exitCode: null, stdout: "", stderr: "", ok: false, errorCode: res.error.code };
68
+ const po = clampBytes(res.error.partialStdout ?? "", maxOutputBytes);
69
+ const pe = clampBytes(res.error.partialStderr ?? "", maxOutputBytes);
70
+ return {
71
+ label,
72
+ command: step.command,
73
+ exitCode: null,
74
+ stdout: po.text,
75
+ stderr: pe.text,
76
+ ok: false,
77
+ errorCode: res.error.code,
78
+ truncated: po.truncated || pe.truncated,
79
+ };
69
80
  }
70
81
  const so = clampBytes(res.value.stdout, maxOutputBytes);
71
82
  const se = clampBytes(res.value.stderr, maxOutputBytes);
@@ -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";
@@ -1,7 +1,19 @@
1
1
  import { type TracerHook } from "../trace.js";
2
2
  import type { MaybeCompactOptions } from "../auto-compaction.js";
3
- import type { TaskSpec } from "../types.js";
3
+ import type { StaleToolResultOffloadOptions, TaskSpec } from "../types.js";
4
+ import type { Context } from "../../internal/llm.js";
5
+ import { type ToolResultStore } from "../tool-result-store.js";
4
6
  import type { Prepared } from "./prepare-task.js";
5
7
  export declare function buildWorkingFileAttachments(spec: TaskSpec, prepared: Prepared): MaybeCompactOptions["workingFileAttachments"];
8
+ export declare function forkContextOption(prepared: Prepared, disable: boolean): Pick<MaybeCompactOptions, "forkContext">;
6
9
  export declare function centerAdoptionOption(prepared: Prepared): Partial<Pick<MaybeCompactOptions, "centerAdoption">>;
10
+ export declare const STALE_OFFLOAD_DEFAULT_KEEP_RECENT_PER_TOOL = 3;
11
+ export declare const STALE_OFFLOAD_DEFAULT_MIN_SAVINGS_CHARS = 2000;
12
+ export interface ResolvedStaleToolResultOffload {
13
+ keepRecentPerTool: number;
14
+ minSavingsChars: number;
15
+ }
16
+ export declare function resolveStaleToolResultOffload(knob: StaleToolResultOffloadOptions | undefined): ResolvedStaleToolResultOffload | undefined;
17
+ export declare function buildStaleOffloadPointer(toolName: string, ref: string, chars: number): string;
18
+ export declare function projectStaleToolResults(context: Context, cfg: ResolvedStaleToolResultOffload, store: ToolResultStore, sessionId: string, writtenRefs: Set<string>): Promise<Context>;
7
19
  export declare function emitInputTruncated(tracer: TracerHook | undefined, taskId: string): NonNullable<MaybeCompactOptions["onInputTruncated"]>;
@@ -1,4 +1,6 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { emitTrace } from "../trace.js";
3
+ import { buildToolResultRef, OFFLOAD_TOOL_NAME, PERSISTED_OUTPUT_PREFIX } from "../tool-result-store.js";
2
4
  export function buildWorkingFileAttachments(spec, prepared) {
3
5
  if (spec.compaction?.attachWorkingFiles === false || !prepared.readTaskFile)
4
6
  return undefined;
@@ -13,10 +15,93 @@ export function buildWorkingFileAttachments(spec, prepared) {
13
15
  ...(typeof spec.compaction?.attachWorkingFiles === "object" ? spec.compaction.attachWorkingFiles : undefined),
14
16
  };
15
17
  }
18
+ export function forkContextOption(prepared, disable) {
19
+ return disable ? {} : { forkContext: prepared.lastBrainContext };
20
+ }
16
21
  export function centerAdoptionOption(prepared) {
17
22
  const ca = prepared.centerCompactionCandidate?.();
18
23
  return ca !== undefined ? { centerAdoption: ca } : {};
19
24
  }
25
+ export const STALE_OFFLOAD_DEFAULT_KEEP_RECENT_PER_TOOL = 3;
26
+ export const STALE_OFFLOAD_DEFAULT_MIN_SAVINGS_CHARS = 2000;
27
+ export function resolveStaleToolResultOffload(knob) {
28
+ if (knob === undefined)
29
+ return undefined;
30
+ const check = (name, v, fallback) => {
31
+ if (v === undefined)
32
+ return fallback;
33
+ if (!Number.isInteger(v) || v < 0) {
34
+ const e = new Error(`compaction.staleToolResultOffload.${name} must be a non-negative integer, got ${String(v)}`);
35
+ e.code = "config.stale_tool_result_offload_invalid";
36
+ throw e;
37
+ }
38
+ return v;
39
+ };
40
+ return {
41
+ keepRecentPerTool: check("keepRecentPerTool", knob.keepRecentPerTool, STALE_OFFLOAD_DEFAULT_KEEP_RECENT_PER_TOOL),
42
+ minSavingsChars: check("minSavingsChars", knob.minSavingsChars, STALE_OFFLOAD_DEFAULT_MIN_SAVINGS_CHARS),
43
+ };
44
+ }
45
+ export function buildStaleOffloadPointer(toolName, ref, chars) {
46
+ return (`[Stale tool result offloaded to save context: ${chars} chars from an earlier "${toolName}" call ` +
47
+ `saved to persisted output ref "${ref}". Newer results of this tool are still shown in full below; ` +
48
+ `use the ${OFFLOAD_TOOL_NAME} tool with this ref if you need the offloaded content again.]`);
49
+ }
50
+ function toolResultText(m) {
51
+ return m.content
52
+ .filter((b) => b.type === "text")
53
+ .map((b) => b.text)
54
+ .join("\n");
55
+ }
56
+ export async function projectStaleToolResults(context, cfg, store, sessionId, writtenRefs) {
57
+ const byTool = new Map();
58
+ context.messages.forEach((m, i) => {
59
+ if (m.role !== "toolResult" || m.isError)
60
+ return;
61
+ const list = byTool.get(m.toolName);
62
+ const row = { idx: i, msg: m };
63
+ if (list === undefined)
64
+ byTool.set(m.toolName, [row]);
65
+ else
66
+ list.push(row);
67
+ });
68
+ const replacements = new Map();
69
+ for (const [toolName, rows] of byTool) {
70
+ const staleCount = rows.length - cfg.keepRecentPerTool;
71
+ for (let k = 0; k < staleCount; k++) {
72
+ const { idx, msg } = rows[k];
73
+ const text = toolResultText(msg);
74
+ if (text.startsWith(PERSISTED_OUTPUT_PREFIX))
75
+ continue;
76
+ const ref = buildToolResultRef(sessionId, `${msg.toolCallId}_s${createHash("sha256").update(text, "utf8").digest("hex").slice(0, 32)}`);
77
+ const pointer = buildStaleOffloadPointer(toolName, ref, text.length);
78
+ if (text.length - pointer.length < cfg.minSavingsChars)
79
+ continue;
80
+ if (!writtenRefs.has(ref)) {
81
+ try {
82
+ await store.put(ref, text);
83
+ writtenRefs.add(ref);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ }
89
+ replacements.set(idx, { msg, pointer });
90
+ }
91
+ }
92
+ if (replacements.size === 0)
93
+ return context;
94
+ return {
95
+ ...context,
96
+ messages: context.messages.map((m, i) => {
97
+ const hit = replacements.get(i);
98
+ if (hit === undefined)
99
+ return m;
100
+ const rest = hit.msg.content.filter((b) => b.type !== "text");
101
+ return { ...hit.msg, content: [{ type: "text", text: hit.pointer }, ...rest] };
102
+ }),
103
+ };
104
+ }
20
105
  export function emitInputTruncated(tracer, taskId) {
21
106
  return (info) => emitTrace(tracer, () => ({
22
107
  kind: "compaction.input_truncated",
@@ -1,5 +1,6 @@
1
1
  import { AgentHarness, type ThinkingLevel } from "../../internal/harness.js";
2
2
  import type { Model } from "../../internal/llm.js";
3
+ import { type CompactionForkContext } from "../auto-compaction.js";
3
4
  import { type MaterializedMcp } from "../mcp.js";
4
5
  import { type MaterializedA2a } from "../a2a.js";
5
6
  import type { HarvestReport, MemorySessionHandle } from "../memory-engine/types.js";
@@ -121,6 +122,7 @@ export interface Prepared {
121
122
  } | undefined;
122
123
  activeTools: Set<string>;
123
124
  deferredToolNames?: ReadonlySet<string>;
125
+ toolMaterializeStatic: boolean;
124
126
  memoryEngineSession?: {
125
127
  engine: MemoryEngine;
126
128
  handle: MemorySessionHandle;
@@ -134,6 +136,9 @@ export interface Prepared {
134
136
  scope?: string;
135
137
  restoreMode?: "snapshot" | "park_only";
136
138
  };
139
+ suspendProgressRef: {
140
+ executedApproved: boolean;
141
+ };
137
142
  reviewRef: {
138
143
  token?: CheckpointToken;
139
144
  gate?: CheckpointGate;
@@ -195,6 +200,7 @@ export interface Prepared {
195
200
  toolEffects: Map<string, ToolEffect>;
196
201
  wakeRecovered: RecoveredOrphan[];
197
202
  promptOverheadTokens: number;
203
+ lastBrainContext: () => CompactionForkContext | undefined;
198
204
  readTaskFile?: (path: string) => Promise<string | null>;
199
205
  recentlyReadFiles?: () => string[];
200
206
  normalizeAttachmentPath?: (raw: string) => Promise<string>;