@sema-agent/core 5.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
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
+
3
31
  ## 5.11.0 — 2026-08-04
4
32
 
5
33
  ### BREAKING
@@ -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();
@@ -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);
@@ -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";
@@ -199,6 +200,7 @@ export interface Prepared {
199
200
  toolEffects: Map<string, ToolEffect>;
200
201
  wakeRecovered: RecoveredOrphan[];
201
202
  promptOverheadTokens: number;
203
+ lastBrainContext: () => CompactionForkContext | undefined;
202
204
  readTaskFile?: (path: string) => Promise<string | null>;
203
205
  recentlyReadFiles?: () => string[];
204
206
  normalizeAttachmentPath?: (raw: string) => Promise<string>;
@@ -4,6 +4,7 @@ import { resolve as resolveFsPath } from "node:path";
4
4
  import { AgentHarness, DEFAULT_CHARS_PER_TOKEN, DEFAULT_CLAMP_TOLERANCE, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, readCompactionActiveTools, summaryOutputBudgetTokens } from "../../internal/harness.js";
5
5
  const PROMPT_HASH_SALT = randomBytes(16);
6
6
  import { sanitizeCompactionSettings } from "../auto-compaction.js";
7
+ import { projectStaleToolResults, resolveStaleToolResultOffload } from "./compaction-call-options.js";
7
8
  import { createAutoModeDecider } from "../auto-mode.js";
8
9
  import { buildAutoModePrompt, renderAutoModeAction, renderAutoModeWindow } from "../auto-mode-prompt.js";
9
10
  import { resolveModel, resolveTaskModel, roleModelIfSet } from "../roles.js";
@@ -2275,6 +2276,34 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2275
2276
  const dateChange = renderWithDate
2276
2277
  ? { legDate: envFacts.date, today: () => formatLocalDate(new Date(), tzValid ? userTz : undefined) }
2277
2278
  : undefined;
2279
+ const lastBrainContextRef = {};
2280
+ const requestLossyRef = { current: false };
2281
+ const staleOffloadWrittenRefs = new Set();
2282
+ const lastBrainContext = () => lastBrainContextRef.current;
2283
+ const recordBrainContext = (servedModelId, c) => {
2284
+ if (requestLossyRef.current) {
2285
+ lastBrainContextRef.current = undefined;
2286
+ return;
2287
+ }
2288
+ lastBrainContextRef.current = {
2289
+ ...(c.systemPrompt !== undefined ? { systemPrompt: c.systemPrompt } : {}),
2290
+ ...(c.systemBlocks !== undefined ? { systemBlocks: [...c.systemBlocks] } : {}),
2291
+ messages: [...c.messages],
2292
+ ...(c.tools !== undefined ? { tools: [...c.tools] } : {}),
2293
+ modelId: servedModelId,
2294
+ };
2295
+ };
2296
+ const staleOffloadCfg = resolveStaleToolResultOffload(spec.compaction?.staleToolResultOffload);
2297
+ if (staleOffloadCfg !== undefined && offloadStore === undefined) {
2298
+ deps.onError?.(new Error("compaction.staleToolResultOffload is set but the tool-result offload store is disabled (toolResultThresholdChars ≤ 0/∞) — the knob is inert this run; re-enable offloading or drop the knob"), { phase: "config", sessionId });
2299
+ }
2300
+ const staleOffload = staleOffloadCfg !== undefined && offloadStore !== undefined ? { cfg: staleOffloadCfg, store: offloadStore } : undefined;
2301
+ const guardedBrain = brainCallGuardrailMs === undefined
2302
+ ? deps.brain
2303
+ : {
2304
+ ...deps.brain,
2305
+ stream: withBrainCallGuardrail((m, c, o) => deps.brain.stream(m, c, o), brainCallGuardrailMs, brainCallGuardrailRef),
2306
+ };
2278
2307
  const harness = new AgentHarness({
2279
2308
  abortResultDetails: () => suspendRef.token !== undefined || reviewRef.token !== undefined ? { code: "gate.parked" } : undefined,
2280
2309
  ...(spec.limits?.maxOutputTokens !== undefined && spec.limits.maxOutputTokens > 0
@@ -2308,12 +2337,20 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2308
2337
  systemPrompt,
2309
2338
  ...(assembled.systemBlocks ? { systemBlocks: assembled.systemBlocks } : {}),
2310
2339
  getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
2311
- runtime: brainToRuntime(brainCallGuardrailMs === undefined
2312
- ? deps.brain
2313
- : {
2314
- ...deps.brain,
2315
- stream: withBrainCallGuardrail((m, c, o) => deps.brain.stream(m, c, o), brainCallGuardrailMs, brainCallGuardrailRef),
2316
- }),
2340
+ runtime: brainToRuntime({
2341
+ ...guardedBrain,
2342
+ stream: (m, c, o) => {
2343
+ if (staleOffload === undefined) {
2344
+ recordBrainContext(m.id, c);
2345
+ return guardedBrain.stream(m, c, o);
2346
+ }
2347
+ return (async () => {
2348
+ const projected = await projectStaleToolResults(c, staleOffload.cfg, staleOffload.store, sessionId, staleOffloadWrittenRefs);
2349
+ recordBrainContext(m.id, projected);
2350
+ return await guardedBrain.stream(m, projected, o);
2351
+ })();
2352
+ },
2353
+ }),
2317
2354
  });
2318
2355
  harnessRef.current = harness;
2319
2356
  let releaseSignal = () => undefined;
@@ -3486,7 +3523,8 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3486
3523
  : {}),
3487
3524
  });
3488
3525
  let trimmed = trimToBudget(edited, guardAt, estimateContextTokens(edited, charsPerToken).tokens, charsPerToken);
3489
- if (trimmed.length < edited.length) {
3526
+ const trimDroppedMessages = trimmed.length < edited.length;
3527
+ if (trimDroppedMessages) {
3490
3528
  trimPressureRef.droppedMessages = true;
3491
3529
  const droppedCount = edited.length - trimmed.length;
3492
3530
  trimmed = insertTrimNotice(trimmed);
@@ -3509,6 +3547,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3509
3547
  catch {
3510
3548
  }
3511
3549
  }
3550
+ requestLossyRef.current =
3551
+ capped !== healed ||
3552
+ mediaCapped !== capped ||
3553
+ edited !== mediaCapped ||
3554
+ trimDroppedMessages ||
3555
+ swept.dropped.length > 0;
3512
3556
  return { messages: swept.messages };
3513
3557
  });
3514
3558
  const cacheBreakDetector = deps.cacheBreakDetection === false ? undefined : new CacheBreakDetector();
@@ -3622,7 +3666,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3622
3666
  : undefined;
3623
3667
  const listBackgroundTasks = () => defaultTaskRegistry
3624
3668
  .list({ owner: hostTaskId, scope: taskScope, sessionId })
3625
- .filter((t) => t.status === "pending" || t.status === "running")
3669
+ .filter((t) => t.status === "pending" || t.status === "running" || t.status === "parked")
3626
3670
  .map((t) => ({ id: t.task_id, ...(t.description !== undefined ? { description: t.description } : {}), status: t.status }));
3627
3671
  const overheadState = { promptChars: 0 };
3628
3672
  const centerCompactionCandidate = deps.promptSource !== undefined && centerAdoption !== undefined
@@ -3674,7 +3718,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
3674
3718
  : undefined;
3675
3719
  overheadState.promptChars = systemPrompt.length;
3676
3720
  const preparedHolder = {};
3677
- const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
3721
+ const buildPrepared = () => ({ harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp: mcp, ...(a2a !== undefined && a2a.tools.length > 0 ? { a2a } : {}), blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, ...(rewindNotes.length > 0 ? { rewindNotes } : {}), cwdRef: handsCwdRef, ...(worktreeSessionRef !== undefined ? { worktreeSessionRef } : {}), ...(workspaceStateSettle !== undefined ? { workspaceStateSettle } : {}), denyNarrowingPolicy, ...(basePolicyForResumeEdit !== undefined ? { basePolicyForResumeEdit } : {}), releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, epochDeclaredSections, activeTools, ...(deferred.size > 0 ? { deferredToolNames: deferred } : {}), toolMaterializeStatic, ownedEnv, suspendRef, suspendProgressRef, reviewRef, remoteEnvFailures, reviewRequestRef, suspendLoopRef, suspendForResource, ...(suspendForPlatformLimit !== undefined ? { suspendForPlatformLimit } : {}), ...(envLifetimeSuspendAt !== undefined ? { envLifetimeSuspendAt } : {}), ...(usageGovernance !== undefined ? { usageGovernance } : {}), callIssuedAtRef, brainCallGuardrailRef, suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, wakeRecovered, promptOverheadTokens, lastBrainContext, readTaskFile, recentlyReadFiles, normalizeAttachmentPath, isDedupStubResult, ...(onCompactionApplied ? { onCompactionApplied } : {}), compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit, runIdent: lspRunIdent } } : {}), planModeRef, ...(dateChange ? { dateChange } : {}), ...(instructionSources ? { instructionSources } : {}), ...(workflowSizeGuideline ? { workflowSizeGuideline } : {}), ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), ...(skillsListing ? { skillsListing } : {}), announcedListingsRef, listBackgroundTasks, ...(turnSnapshotRef.current !== undefined ? { turnSnapshot: turnSnapshotRef.current } : {}), ...(centerCompactionCandidate !== undefined ? { centerCompactionCandidate } : {}) });
3678
3722
  const prepared = buildPrepared();
3679
3723
  preparedHolder.current = prepared;
3680
3724
  return prepared;
@@ -3,7 +3,7 @@ import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, MAX_S
3
3
  import { engineVersion } from "../version.js";
4
4
  import { CONFIG_CATALOG_VERSION, declarationReasons, resolveEffectiveConfig } from "../../config/catalog.js";
5
5
  import { eventDefaultOn } from "../../prompt-assembly/event-registry.js";
6
- import { DEFAULT_COMPACTION_INSTRUCTIONS, createRapidRefillState, isCompactionManualCancel, maybeCompact, nextTrimForceBackoff, recordCompactionAndCheckRapidRefill } from "../auto-compaction.js";
6
+ import { DEFAULT_COMPACTION_INSTRUCTIONS, createRapidRefillState, isCompactionManualCancel, maybeCompact, nextTrimForceBackoff, recordCompactionAndCheckRapidRefill, sanitizeCompactionSettings } from "../auto-compaction.js";
7
7
  import { ASK_USER_QUESTION_TOOL_NAME, QUESTION_AWAITS_RESUME } from "../ask-question.js";
8
8
  import { computeCostMicroUsd, modelCostToPricing } from "../pricing.js";
9
9
  import { emitTrace } from "../trace.js";
@@ -23,7 +23,7 @@ import { OUTPUT_TOOL_NAME, SKILLS_LISTING_PROBE_HEADER, resolveOutputRetries } f
23
23
  import { cacheFamilyOf, usageCostMicroUsd } from "./usage-accounting.js";
24
24
  import { assembleResult, errorCodeOf } from "./assemble-result.js";
25
25
  import { ATTACHMENT_BYTE_CAP, CHANGED_FILES_MAX, AGENT_LISTING_REMOVED_HEADER, SKILLS_LISTING_DELTA_HEADER, SKILLS_LISTING_REMOVED_HEADER, advanceCadenceClock, agentListingDeltaHeader, agentListingInitialHeader, replayAnnouncedListing, replayAnnouncedModels, clipToBytes, collectDateChange, collectDueAttachments, collectInstructionsChange, commitAgentListing, commitInstructionsChange, commitSkillsListing, createAttachmentState, rebaseCadenceWindows, reduceToolEnd, renderAgentListingDelta, renderMcpDroppedTools, renderMcpInstructionsDelta, renderOrphanedBackgroundTasks, selectMcpDroppedBatch, renderSkillsListingDelta, renderToolsDelta, stampWriteAnchor } from "./turn-attachments.js";
26
- import { buildWorkingFileAttachments, centerAdoptionOption, emitInputTruncated } from "./compaction-call-options.js";
26
+ import { buildWorkingFileAttachments, centerAdoptionOption, emitInputTruncated, forkContextOption } from "./compaction-call-options.js";
27
27
  import { prepareTask, resolveCheckpointStore } from "./prepare-task.js";
28
28
  import { settleTeardownLeg } from "./teardown-bounded.js";
29
29
  import { TOOL_SEARCH_NAME } from "./tool-disclosure.js";
@@ -426,6 +426,12 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
426
426
  if (declaredMaxTurns !== undefined && declaredMaxTurns > 0) {
427
427
  ratios.push({ axis: "turn budget", ratio: (stats.turns + 1) / declaredMaxTurns });
428
428
  }
429
+ if (walltimeMonotonicDeadline !== undefined) {
430
+ const walltimeWindowMs = walltimeMonotonicDeadline - rs.telemetry.taskStartMonotonic;
431
+ if (walltimeWindowMs > 0) {
432
+ ratios.push({ axis: "walltime budget", ratio: (performance.now() - rs.telemetry.taskStartMonotonic) / walltimeWindowMs });
433
+ }
434
+ }
429
435
  let tightest;
430
436
  for (const r of ratios)
431
437
  if (tightest === undefined || r.ratio > tightest.ratio)
@@ -490,7 +496,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
490
496
  rs.attach.attachState.surfacedMtime.delete(p);
491
497
  changed = scan.changed;
492
498
  }
493
- const backgroundTasksOn = rs.attach.attachmentsCfg?.backgroundTasks === true;
499
+ const backgroundTasksOn = (rs.attach.attachmentsCfg?.backgroundTasks ?? eventDefaultOn("background_tasks")) === true;
494
500
  const bgTasks = backgroundTasksOn && rs.attach.attachState.postCompactPending ? prepared.listBackgroundTasks() : undefined;
495
501
  const toolsDeltaOn = rs.attach.attachmentsCfg?.toolsDelta === true;
496
502
  const tdRef = toolsDeltaOn ? prepared.toolsDeltaRef : undefined;
@@ -760,6 +766,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
760
766
  ...centerAdoptionOption(prepared),
761
767
  model: event.model,
762
768
  compactionModel: prepared.compModel,
769
+ ...forkContextOption(prepared, false),
763
770
  brain: compactionBrain,
764
771
  getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
765
772
  thinking: prepared.thinking,
@@ -1927,7 +1934,8 @@ export class Runner {
1927
1934
  rs.attach.agentListingOn = rs.attach.attachmentsCfg?.agentListing !== false && eventDefaultOn("agent_listing");
1928
1935
  rs.attach.skillsListingOn = rs.attach.attachmentsCfg?.skillsListing !== false && eventDefaultOn("skills_listing");
1929
1936
  const listingsLive = (rs.attach.agentListingOn && prepared.agentListing !== undefined) || (rs.attach.skillsListingOn && prepared.skillsListing !== undefined);
1930
- rs.attach.attachState = rs.attach.attachmentsCfg !== undefined || listingsLive ? createAttachmentState() : undefined;
1937
+ const backgroundTasksLive = (rs.attach.attachmentsCfg?.backgroundTasks ?? eventDefaultOn("background_tasks")) === true;
1938
+ rs.attach.attachState = rs.attach.attachmentsCfg !== undefined || listingsLive || backgroundTasksLive ? createAttachmentState() : undefined;
1931
1939
  rs.attach.dateState = prepared.dateChange !== undefined ? { announcedDate: prepared.dateChange.legDate } : undefined;
1932
1940
  rs.attach.instrProbe = this.deps.probeInstructionSources;
1933
1941
  rs.attach.instrState =
@@ -1940,7 +1948,7 @@ export class Runner {
1940
1948
  : undefined;
1941
1949
  rs.counters.cadenceTurns = 0;
1942
1950
  rs.turn.lastTurnHadToolCalls = false;
1943
- if (rs.attach.attachState !== undefined && rs.attach.attachmentsCfg?.backgroundTasks === true) {
1951
+ if (rs.attach.attachState !== undefined && (rs.attach.attachmentsCfg?.backgroundTasks ?? eventDefaultOn("background_tasks")) === true) {
1944
1952
  try {
1945
1953
  const branch = await prepared.session.getBranch();
1946
1954
  for (let i = branch.length - 1; i >= 0; i--) {
@@ -2264,6 +2272,19 @@ export class Runner {
2264
2272
  };
2265
2273
  const withinTaskCompaction = (spec.compaction?.enabled ?? true) && (spec.compaction?.withinTask ?? true);
2266
2274
  const compactionBreaker = { failures: 0 };
2275
+ if (spec.compaction?.enabled ?? true) {
2276
+ const prefixWindow = prepared.model.autoCompactTokens ?? prepared.model.contextTokens ?? prepared.model.contextWindow;
2277
+ if (Number.isFinite(prefixWindow) && prefixWindow > 0) {
2278
+ const prefixSettings = sanitizeCompactionSettings({ ...DEFAULT_COMPACTION_SETTINGS, ...spec.compaction }, prefixWindow);
2279
+ const prefixCompactAt = prefixWindow - prefixSettings.reserveTokens;
2280
+ if (prepared.promptOverheadTokens >= prefixCompactAt) {
2281
+ this.deps.onError?.(new Error(`compaction cannot help: the fixed request prefix (system prompt + tool schemas, ≈${prepared.promptOverheadTokens} tokens) ` +
2282
+ `already meets or exceeds the compaction threshold (${prefixCompactAt} of a ${prefixWindow}-token window). ` +
2283
+ `Compaction only shrinks conversation history, so this run will re-trigger or overflow regardless — ` +
2284
+ `shrink the system prompt/tool surface or use a larger-window model.`), { phase: "config", sessionId: prepared.sessionId });
2285
+ }
2286
+ }
2287
+ }
2267
2288
  const windowSafetyOptions = (mainModel) => ({
2268
2289
  ...(rs.budget.maxCostMicroUsd !== undefined
2269
2290
  ? {
@@ -2433,6 +2454,7 @@ export class Runner {
2433
2454
  ...centerAdoptionOption(prepared),
2434
2455
  model: prepared.harness.getModel(),
2435
2456
  compactionModel: prepared.compModel,
2457
+ ...forkContextOption(prepared, true),
2436
2458
  brain: compactionBrain,
2437
2459
  getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
2438
2460
  thinking: prepared.thinking,
@@ -2801,7 +2823,7 @@ export class Runner {
2801
2823
  });
2802
2824
  }
2803
2825
  }
2804
- if (rs.attach.attachState?.postCompactPending === true && rs.attach.attachmentsCfg?.backgroundTasks === true) {
2826
+ if (rs.attach.attachState?.postCompactPending === true && (rs.attach.attachmentsCfg?.backgroundTasks ?? eventDefaultOn("background_tasks")) === true) {
2805
2827
  emitTrace(rs.telemetry.tracer, () => ({ kind: "compaction.announce_dropped", version: 1, taskId: rs.telemetry.taskId, ts: Date.now() }));
2806
2828
  }
2807
2829
  const comp = await this.finish(spec, prepared, {
@@ -3649,6 +3671,7 @@ export class Runner {
3649
3671
  ...centerAdoptionOption(prepared),
3650
3672
  model: prepared.model,
3651
3673
  compactionModel: prepared.compModel,
3674
+ ...forkContextOption(prepared, false),
3652
3675
  brain: opts?.brain ?? this.deps.brain,
3653
3676
  getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
3654
3677
  thinking: prepared.thinking,
@@ -367,7 +367,9 @@ function renderBackgroundTasks(tasks) {
367
367
  ? "stopped"
368
368
  : t.status === "running"
369
369
  ? "still running in background"
370
- : t.status;
370
+ : t.status === "parked"
371
+ ? "parked awaiting an out-of-band approval — do NOT re-issue the gated call"
372
+ : t.status;
371
373
  return `- [${t.id}] Task "${(t.description ?? "background task").slice(0, PROJECTION_CONTENT_MAX)}" ${phrase}`;
372
374
  });
373
375
  return ("Context was compacted. Background tasks from before the compaction (check output with TaskOutput, " +
@@ -3,6 +3,10 @@ import type { AgentTool, ThinkingLevel } from "../internal/harness.js";
3
3
  import type { CompleteSimpleFn, DocumentContent, ImageContent, Model, ResilienceOptions, StreamFn, TextContent } from "../internal/llm.js";
4
4
  import type { TaskNotificationPayload } from "./task-notification.js";
5
5
  export type ModelRef = string | Model;
6
+ export interface StaleToolResultOffloadOptions {
7
+ keepRecentPerTool?: number;
8
+ minSavingsChars?: number;
9
+ }
6
10
  export type ModelRole = "default" | "summarize" | "subagent" | "team" | "synthesize" | "advisor" | "verifier" | "classifier";
7
11
  export type RoleSpec = ModelRef | {
8
12
  model?: ModelRef;
@@ -371,6 +375,7 @@ export interface TaskSpec {
371
375
  maxFiles?: number;
372
376
  maxCharsPerFile?: number;
373
377
  };
378
+ staleToolResultOffload?: StaleToolResultOffloadOptions;
374
379
  clampTolerance?: number;
375
380
  };
376
381
  attachments?: {
@@ -382,7 +387,7 @@ export interface TaskSpec {
382
387
  };
383
388
  planModeReminder?: true;
384
389
  budgetUsd?: true;
385
- backgroundTasks?: true;
390
+ backgroundTasks?: boolean;
386
391
  toolsDelta?: true;
387
392
  agentListing?: boolean;
388
393
  skillsListing?: boolean;
@@ -1,4 +1,4 @@
1
- import type { Model, StreamFn, Usage } from "../llm/index.js";
1
+ import type { Context, Message, Model, StreamFn, Tool, Usage } from "../llm/index.js";
2
2
  import { type AgentCoreCompletionRuntimeDeps } from "../loop/runtime-deps.js";
3
3
  import type { AgentMessage, ThinkingLevel } from "../loop/types.js";
4
4
  import { CompactionError, type Result, type SessionTreeEntry } from "../harness/types.js";
@@ -47,6 +47,15 @@ export interface CutPointResult {
47
47
  }
48
48
  export declare function findCutPoint(entries: SessionTreeEntry[], startIndex: number, endIndex: number, keepRecentTokens: number, charsPerToken?: number): CutPointResult;
49
49
  export declare const SUMMARIZATION_SYSTEM_PROMPT = "You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.";
50
+ export interface CompactionForkContext {
51
+ systemPrompt?: string;
52
+ systemBlocks?: Context["systemBlocks"];
53
+ messages: Message[];
54
+ tools?: Tool[];
55
+ modelId?: string;
56
+ }
57
+ export declare function extractForkSummaryEnvelope(text: string): string | undefined;
58
+ export declare function forkSummarizationInstruction(customInstructions?: string): string;
50
59
  export declare function summaryOutputBudgetTokens(model: Model, settings: CompactionSettings): number;
51
60
  export interface SummarizationInputTruncation {
52
61
  label: "history" | "turn_prefix";
@@ -77,5 +86,5 @@ export interface CompactionPreparation {
77
86
  }
78
87
  export declare function prepareCompaction(pathEntries: SessionTreeEntry[], settings: CompactionSettings, charsPerToken?: number, windowTokens?: number): Result<CompactionPreparation | undefined, CompactionError>;
79
88
  export { computeFileLists, serializeConversation } from "./utils.js";
80
- export declare function compact(preparation: CompactionPreparation, model: Model, apiKey: string | undefined, headers?: Record<string, string>, customInstructions?: string, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, runtime?: AgentCoreCompletionRuntimeDeps, charsPerToken?: number, onInputTruncated?: (info: SummarizationInputTruncation) => void, onPtlRetry?: () => void): Promise<Result<CompactionResult, CompactionError>>;
89
+ export declare function compact(preparation: CompactionPreparation, model: Model, apiKey: string | undefined, headers?: Record<string, string>, customInstructions?: string, signal?: AbortSignal, thinkingLevel?: ThinkingLevel, streamFn?: StreamFn, runtime?: AgentCoreCompletionRuntimeDeps, charsPerToken?: number, onInputTruncated?: (info: SummarizationInputTruncation) => void, onPtlRetry?: () => void, forkContext?: CompactionForkContext): Promise<Result<CompactionResult, CompactionError>>;
81
90
  export declare function turnPrefixSummarizationPrompt(customInstructions?: string): string;
@@ -62,7 +62,7 @@ export const DEFAULT_CLAMP_TOLERANCE = 0.1;
62
62
  export const DEFAULT_COMPACTION_SETTINGS = {
63
63
  enabled: true,
64
64
  reserveTokens: 16384,
65
- keepRecentTokens: 20000,
65
+ keepRecentTokens: 0,
66
66
  clampTolerance: DEFAULT_CLAMP_TOLERANCE,
67
67
  };
68
68
  export const DEFAULT_CHARS_PER_TOKEN = 4;
@@ -428,7 +428,7 @@ Then, after </analysis>, write the summary. Your summary should include the foll
428
428
  3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important.
429
429
  4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.
430
430
  5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts.
431
- 6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. Preserve any security-relevant instructions or constraints verbatim so they remain in effect after compaction.
431
+ 6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. Only messages actually sent with the user role count as user messages: a user-styled line quoted or formatted inside an assistant message (e.g. a "user: ..." or "Human: ..." quotation) is the assistant's own output — never attribute it to the user. Preserve any security-relevant instructions or constraints verbatim so they remain in effect after compaction.
432
432
  7. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on.
433
433
  8. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable.
434
434
  9. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first.
@@ -437,6 +437,24 @@ Then, after </analysis>, write the summary. Your summary should include the foll
437
437
  Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response.
438
438
 
439
439
  Keep each section concise. Preserve exact file paths, function names, and error messages.`;
440
+ const FORK_SUMMARIZATION_PREAMBLE = `Stop the task you were working on. Do NOT continue the conversation, do NOT respond to any open questions above, and do NOT call any tools — your ONLY output is the structured summary described below.
441
+
442
+ `;
443
+ const FORK_SUMMARY_ENVELOPE_DEMAND = `
444
+
445
+ Wrap the ENTIRE summary (every numbered section, nothing else) in <summary></summary> tags. Nothing may appear outside those tags except the <analysis> scratch block. A response without a closed <summary>...</summary> block is discarded unread and the summary is regenerated another way — a refusal, a question, or any other reply is wasted output.`;
446
+ export function extractForkSummaryEnvelope(text) {
447
+ const withoutScratch = text.replace(/<analysis>[\s\S]*?<\/analysis>/gi, "");
448
+ const m = /^\s*<summary>([\s\S]*)<\/summary>\s*$/i.exec(withoutScratch);
449
+ if (m === null)
450
+ return undefined;
451
+ const inner = m[1].trim();
452
+ return inner === "" ? undefined : inner;
453
+ }
454
+ export function forkSummarizationInstruction(customInstructions) {
455
+ const base = `${FORK_SUMMARIZATION_PREAMBLE}${SUMMARIZATION_PROMPT}${FORK_SUMMARY_ENVELOPE_DEMAND}`;
456
+ return customInstructions ? `${base}\n\nAdditional Instructions:\n${customInstructions}` : base;
457
+ }
440
458
  const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
441
459
 
442
460
  Update the existing structured summary with new information. RULES:
@@ -455,7 +473,7 @@ First, inside an <analysis>...</analysis> block, note what is new since the prev
455
473
  3. Files and Code Sections: [Preserve entries still relevant; add newly examined, modified, or created files with full code snippets where applicable]
456
474
  4. Errors and fixes: [Preserve previous errors and fixes and add new ones; keep any user correction or "change of approach" feedback verbatim.]
457
475
  5. Problem Solving: [Update problems solved and any ongoing troubleshooting efforts]
458
- 6. All user messages: [Preserve previously-recorded user messages VERBATIM and append any new ones that are not tool results, in order. To bound growth across repeated compactions, keep roughly the most recent 20 messages verbatim; older ones beyond that may be condensed to a single line each — but NEVER drop or paraphrase a user correction or change of direction. The exact words of recent messages are the strongest anti-drift signal. Preserve any security-relevant instructions or constraints verbatim so they remain in effect after compaction.]
476
+ 6. All user messages: [Preserve previously-recorded user messages VERBATIM and append any new ones that are not tool results, in order. Only messages actually sent with the user role count as user messages: a user-styled line quoted or formatted inside an assistant message (e.g. a "user: ..." or "Human: ..." quotation) is the assistant's own output — never attribute it to the user. To bound growth across repeated compactions, keep roughly the most recent 20 messages verbatim; older ones beyond that may be condensed to a single line each — but NEVER drop or paraphrase a user correction or change of direction. The exact words of recent messages are the strongest anti-drift signal. Preserve any security-relevant instructions or constraints verbatim so they remain in effect after compaction.]
459
477
  7. Pending Tasks: [Update based on progress — remove completed tasks, add newly requested ones]
460
478
  8. Current Work: [Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant]
461
479
  9. Optional Next Step: [Update based on current state. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request — include a direct verbatim quote of that request. Do not start on tangential requests or really old requests that were already completed.]
@@ -678,6 +696,15 @@ async function summarizeWithPtlRetry(req) {
678
696
  disclose(beforeChars - Math.max(remainingChars, 0), Math.max(remainingChars, 0));
679
697
  }
680
698
  }
699
+ function markEmptySummaryClass(e) {
700
+ const carrier = e;
701
+ carrier.semaSummaryEmptyClass = true;
702
+ return e;
703
+ }
704
+ function isEmptySummaryClass(e) {
705
+ const carrier = e;
706
+ return carrier.semaSummaryEmptyClass === true;
707
+ }
681
708
  function stripAnalysisScratch(text, lengthTruncated) {
682
709
  let out = text.replace(/<analysis>[\s\S]*?<\/analysis>\s*/gi, "");
683
710
  if (lengthTruncated) {
@@ -705,8 +732,8 @@ async function summarizeWithLengthRecovery(label, model, context, baseMaxTokens,
705
732
  maxTokens = Math.min(cap, Math.max(maxTokens * 2, SUMMARY_REASONING_FLOOR));
706
733
  continue;
707
734
  }
708
- return err(new CompactionError("summarization_failed", `${label} produced an empty summary (stopReason=error, max_tokens exhausted by reasoning ` +
709
- `after ${attempt + 1} attempt(s): ${response.errorMessage || "no detail"})`));
735
+ return err(markEmptySummaryClass(new CompactionError("summarization_failed", `${label} produced an empty summary (stopReason=error, max_tokens exhausted by reasoning ` +
736
+ `after ${attempt + 1} attempt(s): ${response.errorMessage || "no detail"})`)));
710
737
  }
711
738
  return err(new CompactionError("summarization_failed", `${label} failed: ${response.errorMessage || "Unknown error"}`));
712
739
  }
@@ -723,10 +750,43 @@ async function summarizeWithLengthRecovery(label, model, context, baseMaxTokens,
723
750
  maxTokens = Math.min(cap, Math.max(maxTokens * 2, SUMMARY_REASONING_FLOOR));
724
751
  continue;
725
752
  }
726
- return err(new CompactionError("summarization_failed", `${label} produced an empty summary (stopReason=${response.stopReason}` +
727
- `${lengthTruncated ? ", the output was analysis scratch cut at max_tokens" : ""})`));
753
+ return err(markEmptySummaryClass(new CompactionError("summarization_failed", `${label} produced an empty summary (stopReason=${response.stopReason}` +
754
+ `${lengthTruncated ? ", the output was analysis scratch cut at max_tokens" : ""})`)));
728
755
  }
729
756
  }
757
+ async function forkSummarize(fork, model, baseMaxTokens, apiKey, headers, customInstructions, signal, thinkingLevel, streamFn, runtime, charsPerToken) {
758
+ const instruction = forkSummarizationInstruction(customInstructions);
759
+ const context = {
760
+ ...(fork.systemPrompt !== undefined ? { systemPrompt: fork.systemPrompt } : {}),
761
+ ...(fork.systemBlocks !== undefined ? { systemBlocks: fork.systemBlocks } : {}),
762
+ ...(fork.tools !== undefined && fork.tools.length > 0 ? { tools: fork.tools } : {}),
763
+ messages: [
764
+ ...fork.messages,
765
+ { role: "user", content: [{ type: "text", text: instruction }], timestamp: Date.now() },
766
+ ],
767
+ };
768
+ let result;
769
+ try {
770
+ result = await summarizeWithLengthRecovery("Summarization", model, context, baseMaxTokens, apiKey, headers, signal, thinkingLevel, streamFn, runtime, charsPerToken);
771
+ }
772
+ catch (e) {
773
+ const msg = e instanceof Error ? e.message : String(e);
774
+ if (parsePromptTooLong(msg).isPtl)
775
+ return { kind: "fallback", detail: msg };
776
+ throw e;
777
+ }
778
+ if (!result.ok) {
779
+ if (result.error.code === "summarization_failed" && (isEmptySummaryClass(result.error) || parsePromptTooLong(result.error.message).isPtl)) {
780
+ return { kind: "fallback", detail: result.error.message };
781
+ }
782
+ return { kind: "err", error: result.error };
783
+ }
784
+ const enveloped = extractForkSummaryEnvelope(result.value);
785
+ if (enveloped === undefined) {
786
+ return { kind: "fallback", detail: "fork response lacked a closed <summary> envelope (non-conforming output)" };
787
+ }
788
+ return { kind: "ok", summary: enveloped };
789
+ }
730
790
  export async function generateSummary(currentMessages, model, summaryBudgetTokens, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, runtime, charsPerToken, onInputTruncated, onPtlRetry) {
731
791
  let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
732
792
  if (customInstructions) {
@@ -866,7 +926,7 @@ Summarize the prefix to provide context for the retained suffix:
866
926
 
867
927
  Be concise. Focus on what's needed to understand the kept suffix.`;
868
928
  export { computeFileLists, serializeConversation } from "./utils.js";
869
- export async function compact(preparation, model, apiKey, headers, customInstructions, signal, thinkingLevel, streamFn, runtime, charsPerToken, onInputTruncated, onPtlRetry) {
929
+ export async function compact(preparation, model, apiKey, headers, customInstructions, signal, thinkingLevel, streamFn, runtime, charsPerToken, onInputTruncated, onPtlRetry, forkContext) {
870
930
  const { firstKeptEntryId, messagesToSummarize, turnPrefixMessages, isSplitTurn, tokensBefore, previousSummary, fileOps, invokedSkills, persistedOutputRefs, elidedMessages, settings, } = preparation;
871
931
  if (!firstKeptEntryId) {
872
932
  return err(new CompactionError("invalid_session", "First kept entry has no UUID - session may need migration"));
@@ -876,7 +936,25 @@ export async function compact(preparation, model, apiKey, headers, customInstruc
876
936
  }
877
937
  let summary;
878
938
  const summaryBudget = summaryOutputBudgetTokens(model, settings);
879
- if (isSplitTurn && turnPrefixMessages.length > 0) {
939
+ if (forkContext !== undefined && forkContext.messages.length > 0) {
940
+ const forked = await forkSummarize(forkContext, model, Math.floor(0.8 * summaryBudget), apiKey, headers, customInstructions, signal, thinkingLevel, streamFn, runtime, charsPerToken);
941
+ if (forked.kind === "ok") {
942
+ summary = forked.summary;
943
+ }
944
+ else if (forked.kind === "err") {
945
+ return err(forked.error);
946
+ }
947
+ else {
948
+ try {
949
+ onPtlRetry?.();
950
+ }
951
+ catch {
952
+ }
953
+ }
954
+ }
955
+ if (summary !== undefined) {
956
+ }
957
+ else if (isSplitTurn && turnPrefixMessages.length > 0) {
880
958
  const [historyResult, turnPrefixResult] = await Promise.all([
881
959
  messagesToSummarize.length > 0
882
960
  ? generateSummary(messagesToSummarize, model, summaryBudget, apiKey, headers, signal, customInstructions, previousSummary, thinkingLevel, streamFn, runtime, charsPerToken, onInputTruncated, onPtlRetry)
package/dist/index.d.ts CHANGED
@@ -206,7 +206,7 @@ export { retryBackoffMs, parseRetryAfter } from "./brain/retry.js";
206
206
  export { type BrainTimeoutConfig } from "./brain/timeout.js";
207
207
  export { createAssistantMessageEventStream } from "./internal/llm.js";
208
208
  export type { AssistantMessage, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, StopReason, StreamFn, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "./internal/llm.js";
209
- export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, RuntimeCaps, BackgroundChildEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, TaskResult, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolEffect, WorkflowGovernanceBaseline, } from "./core/types.js";
209
+ export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, A2aServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, RuntimeCaps, BackgroundChildEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskLimits, StaleToolResultOffloadOptions, TaskResult, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolEffect, WorkflowGovernanceBaseline, } from "./core/types.js";
210
210
  export { Type } from "typebox";
211
211
  export type { TSchema, Static } from "typebox";
212
212
  export { explainPromptAssembly, describeDefaultPack, type DefaultPackDescription, type ExplainInput } from "./prompt-assembly/explain.js";
@@ -7,7 +7,7 @@ export const EVENT_PROMPT_REGISTRY = new Map([
7
7
  { kind: "instructions_change", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", maxBytes: 512, defaultPolicy: "always", rendererRef: "turn-attachments.ts#collectInstructionsChange" },
8
8
  { kind: "workflow_size_guideline_change", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "always", rendererRef: "runtask.ts#workflowSizeGuidelineChangeNotice" },
9
9
  { kind: "budget_usd", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderBudgetUsd" },
10
- { kind: "background_tasks", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderBackgroundTasks" },
10
+ { kind: "background_tasks", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "on", rendererRef: "turn-attachments.ts#renderBackgroundTasks" },
11
11
  { kind: "tools_delta", carrier: "message.user-prefix", trust: "external", dedupe: "replace-by-key", defaultPolicy: "off", rendererRef: "turn-attachments.ts#renderToolsDelta" },
12
12
  { kind: "agent_listing", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "on", rendererRef: "turn-attachments.ts#renderAgentListingDelta" },
13
13
  { kind: "skills_listing", carrier: "message.user-prefix", trust: "operator", dedupe: "replace-by-key", defaultPolicy: "on", rendererRef: "turn-attachments.ts#renderSkillsListingDelta" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.11.0",
3
+ "version": "5.12.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",