@oh-my-pi/pi-agent-core 16.0.11 → 16.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.0] - 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - Added `SoftToolRequirement` support to `getToolChoice`: a host can require a tool by returning a soft requirement instead of a hard `ToolChoice`. The loop injects the supplied reminder once (leaving `tool_choice` on auto), and escalates to a one-turn forced choice — skipping any detour tool batch — only if the model fails to call the required tool, avoiding the provider message-cache invalidation of forcing every turn.
10
+ - Added `pruneToolDescriptions` option to reduce token usage by stripping tool descriptions from provider-bound specs
11
+
12
+ ### Fixed
13
+
14
+ - Improved token estimation accuracy for compaction summaries containing multi-block content
15
+
5
16
  ## [16.0.11] - 2026-06-19
6
17
 
7
18
  ### Changed
@@ -54,7 +54,7 @@ export declare function agentLoopContinueDetailed(context: AgentContext, config:
54
54
  readonly stream: EventStream<AgentEvent, AgentMessage[]>;
55
55
  readonly detailed: () => Promise<AgentLoopDetailedResult>;
56
56
  };
57
- export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean, exampleDialect?: Dialect): Context["tools"];
57
+ export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean, exampleDialect?: Dialect, pruneDescriptions?: boolean): Context["tools"];
58
58
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
59
59
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
60
60
  * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
@@ -2,7 +2,7 @@ import { type ApiKey, type AssistantMessage, type AssistantMessageEvent, type Co
2
2
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
3
3
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context";
5
- import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AsideMessage, StreamFn, ToolCallContext } from "./types";
5
+ import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types";
6
6
  export declare class AgentBusyError extends Error {
7
7
  constructor(message?: string);
8
8
  }
@@ -129,6 +129,12 @@ export interface AgentOptions {
129
129
  transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
130
130
  /** Enable intent tracing schema injection/stripping in the harness. */
131
131
  intentTracing?: boolean;
132
+ /**
133
+ * Strip tool descriptions from provider-bound tool specs (top-level + nested
134
+ * schema annotations). Use when the full catalog is rendered into the system
135
+ * prompt so descriptions are not duplicated on the wire. Native tool calling only.
136
+ */
137
+ pruneToolDescriptions?: boolean;
132
138
  /** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
133
139
  dialect?: Dialect;
134
140
  /**
@@ -138,8 +144,8 @@ export interface AgentOptions {
138
144
  * the loop's {@link AgentLoopConfig.abortOnFabricatedToolResult}.
139
145
  */
140
146
  abortOnFabricatedToolResult?: boolean;
141
- /** Dynamic tool choice override, resolved per LLM call. */
142
- getToolChoice?: () => ToolChoice | undefined;
147
+ /** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */
148
+ getToolChoice?: () => ToolChoiceDirective | undefined;
143
149
  /**
144
150
  * Cursor exec handlers for local tool execution.
145
151
  */
@@ -27,6 +27,8 @@ export interface BuildOptions {
27
27
  /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
28
28
  intentTracing: boolean;
29
29
  exampleDialect?: Dialect;
30
+ /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
31
+ pruneToolDescriptions?: boolean;
30
32
  }
31
33
  /**
32
34
  * A frozen prefix (system prompt + tools) that produces stable byte
@@ -33,7 +33,11 @@ export interface CompactionSummaryMessage {
33
33
  shortSummary?: string;
34
34
  tokensBefore: number;
35
35
  providerPayload?: ProviderPayload;
36
- /** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */
36
+ /** Runtime-only ordered archive blocks for snapcompact: old text region,
37
+ * imaged middle, then new text region. When present, `summary` is already
38
+ * the final lead-in text (no legacy wrapper applied). */
39
+ blocks?: (TextContent | ImageContent)[];
40
+ /** Snapcompact image blocks, kept for display counts / legacy consumers. */
37
41
  images?: ImageContent[];
38
42
  timestamp: number;
39
43
  }
@@ -50,7 +54,7 @@ export type ConvertToLlm = (messages: AgentMessage[]) => Message[];
50
54
  export declare function renderBranchSummaryContext(summary: string): string;
51
55
  export declare function renderCompactionSummaryContext(summary: string): string;
52
56
  export declare function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage;
53
- export declare function createCompactionSummaryMessage(summary: string, tokensBefore: number, timestamp: string, shortSummary?: string, providerPayload?: ProviderPayload, images?: ImageContent[]): CompactionSummaryMessage;
57
+ export declare function createCompactionSummaryMessage(summary: string, tokensBefore: number, timestamp: string, shortSummary?: string, providerPayload?: ProviderPayload, images?: ImageContent[], blocks?: (TextContent | ImageContent)[]): CompactionSummaryMessage;
54
58
  export declare function createCustomMessage(customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details: unknown | undefined, timestamp: string, attribution?: MessageAttribution): CustomMessage;
55
59
  /**
56
60
  * Transform a single core-domain agent message to its LLM form; `undefined`
@@ -19,6 +19,24 @@ export interface PruneConfig {
19
19
  supersedeKey?: SupersedeKeyFn;
20
20
  /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
21
21
  pruneUseless?: boolean;
22
+ /**
23
+ * Compaction boundary: the `firstKeptEntryId` of the latest compaction on
24
+ * the branch. Entries at indices BEFORE this id are summarized away and never
25
+ * sent to the model, so mutating them only churns persisted history without
26
+ * shrinking the prompt — they are skipped. Undefined = no compaction (the
27
+ * whole branch is sent).
28
+ */
29
+ keepBoundaryId?: string;
30
+ /**
31
+ * Prompt-cache guard. When set, a tool result whose all-message suffix
32
+ * (tokens of every message after it) EXCEEDS this is part of the warm,
33
+ * already-sent cache prefix: mutating it forces the provider to re-write the
34
+ * whole suffix (cacheWrite premium). Such results — including superseded and
35
+ * useless ones, which otherwise bypass {@link protectTokens} — are left for
36
+ * compaction/shake (which rebuild the cache anyway) to reclaim. Undefined =
37
+ * no cache guard (legacy: superseded/useless prune at any depth).
38
+ */
39
+ cacheWarmSuffixTokens?: number;
22
40
  }
23
41
  export declare const DEFAULT_PRUNE_CONFIG: PruneConfig;
24
42
  export interface PruneResult {
@@ -44,10 +62,22 @@ export interface SupersedePruneConfig {
44
62
  pruneUseless?: boolean;
45
63
  /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
46
64
  suffixTokenLimit?: number;
47
- /** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */
65
+ /**
66
+ * Prune all candidates when the last message is at least this old: the
67
+ * provider prompt cache is then cold, so re-writing it is free. MUST exceed
68
+ * the cache retention (Anthropic "long" = 1h) or a still-warm prefix is busted
69
+ * by the flush. Default 30 min — callers on long retention override it.
70
+ */
48
71
  idleFlushMs?: number;
49
72
  /** Clock override for tests. */
50
73
  now?: number;
74
+ /**
75
+ * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries
76
+ * before it are summarized away and never sent, so they are skipped in every
77
+ * path — including the idle flush — to avoid pointless history churn.
78
+ * Undefined = no compaction (the whole branch is sent).
79
+ */
80
+ keepBoundaryId?: string;
51
81
  /** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
52
82
  protectedTools: ProtectedToolMatcher[];
53
83
  }
@@ -57,7 +87,8 @@ export interface SupersedePruneConfig {
57
87
  * flagged contextually useless. Cheap, incremental, and prompt-cache-aware: a
58
88
  * candidate is pruned now only when the suffix after it is small (tail case —
59
89
  * the read→edit→read loop) or when the context has been idle long enough that
60
- * the provider cache is cold anyway (then ALL candidates flush).
90
+ * the provider cache is cold anyway (then all still-sent candidates flush).
91
+ * Never mutates entries before `keepBoundaryId` (summarized away — not sent).
61
92
  */
62
93
  export declare function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult;
63
94
  export declare function pruneToolOutputs(entries: SessionEntry[], config?: PruneConfig): PruneResult;
@@ -20,6 +20,14 @@ export interface ShakeConfig {
20
20
  protectedTools: ProtectedToolMatcher[];
21
21
  /** Minimum token size for a fenced/XML block to be eligible. */
22
22
  fenceMinTokens: number;
23
+ /**
24
+ * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries
25
+ * before it are summarized away and never sent, so they are skipped — shaking
26
+ * them only churns persisted history. Undefined = no compaction (whole branch
27
+ * is sent). Note: shake still elides the warm cached prefix at/after the
28
+ * boundary — that is its job as a compaction-class reducer.
29
+ */
30
+ keepBoundaryId?: string;
23
31
  }
24
32
  /** Auto-shake config: protects the live tail, conservative thresholds. */
25
33
  export declare const DEFAULT_SHAKE_CONFIG: ShakeConfig;
@@ -13,6 +13,38 @@ export type StreamFn = (...args: Parameters<typeof streamSimple>) => AssistantMe
13
13
  * (e.g. dropping late diagnostics a newer edit superseded).
14
14
  */
15
15
  export type AsideMessage = AgentMessage | (() => AgentMessage | null);
16
+ /**
17
+ * A soft tool requirement: the host wants `toolName` called before the loop
18
+ * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
19
+ * up front (changing `tool_choice` invalidates the provider message cache).
20
+ * Returned from {@link AgentLoopConfig.getToolChoice} in place of a hard
21
+ * {@link ToolChoice}: the loop injects `reminder` once when a new `id` becomes
22
+ * active, runs with `toolChoice` unchanged, and escalates to a one-turn forced
23
+ * choice only if the model fails to call `toolName`. Auto-clears when the host
24
+ * stops returning it or `toolName` is no longer an active tool.
25
+ */
26
+ export interface SoftToolRequirement {
27
+ /** Discriminates a soft requirement from a hard {@link ToolChoice}. */
28
+ soft: true;
29
+ /**
30
+ * Stable id of the *current* requirement. The loop injects `reminder` when
31
+ * this id first becomes active and again whenever it changes (e.g. one
32
+ * stacked preview resolves and the next becomes the head), but never
33
+ * re-injects for an unchanged id across turns.
34
+ */
35
+ id: string;
36
+ /** Tool that must be called before the loop runs other tools or yields. */
37
+ toolName: string;
38
+ /** Host-owned reminder messages, injected once per `id` activation. */
39
+ reminder: AgentMessage[];
40
+ }
41
+ /**
42
+ * A per-turn tool-choice directive: either a hard provider {@link ToolChoice}
43
+ * (applied verbatim) or a {@link SoftToolRequirement} (remind-then-escalate).
44
+ */
45
+ export type ToolChoiceDirective = ToolChoice | SoftToolRequirement;
46
+ /** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
47
+ export declare function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement;
16
48
  /**
17
49
  * Configuration for the agent loop.
18
50
  */
@@ -165,6 +197,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
165
197
  * then strips from arguments before executing tools.
166
198
  */
167
199
  intentTracing?: boolean;
200
+ /**
201
+ * Strip tool descriptions (top-level + nested schema annotations) from the
202
+ * provider-bound tool specs. Use when the full catalog is rendered into the
203
+ * system prompt instead, so descriptions are not duplicated on the wire.
204
+ */
205
+ pruneToolDescriptions?: boolean;
168
206
  /**
169
207
  * Owned tool calling dialect.
170
208
  *
@@ -205,10 +243,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
205
243
  */
206
244
  onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
207
245
  /**
208
- * Dynamic tool choice override, resolved per LLM call.
209
- * When set and returns a value, overrides the static `toolChoice`.
246
+ * Dynamic tool-choice directive, resolved once per turn. Returns a hard
247
+ * {@link ToolChoice} (applied verbatim, overriding the static `toolChoice`),
248
+ * a {@link SoftToolRequirement} (the loop reminds-then-escalates instead of
249
+ * forcing `tool_choice` immediately, so a model that complies with the
250
+ * reminder pays no message-cache invalidation), or `undefined` to fall back
251
+ * to the static `toolChoice`.
210
252
  */
211
- getToolChoice?: () => ToolChoice | undefined;
253
+ getToolChoice?: () => ToolChoiceDirective | undefined;
212
254
  /**
213
255
  * Dynamic reasoning effort override, resolved per LLM call.
214
256
  * When set and returns a value, overrides the static `reasoning` captured
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "16.0.11",
4
+ "version": "16.1.1",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.0.11",
39
- "@oh-my-pi/pi-catalog": "16.0.11",
40
- "@oh-my-pi/pi-natives": "16.0.11",
41
- "@oh-my-pi/pi-utils": "16.0.11",
42
- "@oh-my-pi/pi-wire": "16.0.11",
43
- "@oh-my-pi/snapcompact": "16.0.11",
38
+ "@oh-my-pi/pi-ai": "16.1.1",
39
+ "@oh-my-pi/pi-catalog": "16.1.1",
40
+ "@oh-my-pi/pi-natives": "16.1.1",
41
+ "@oh-my-pi/pi-utils": "16.1.1",
42
+ "@oh-my-pi/pi-wire": "16.1.1",
43
+ "@oh-my-pi/snapcompact": "16.1.1",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -14,8 +14,11 @@ import {
14
14
  resolveApiKeyOnce,
15
15
  seedApiKeyResolver,
16
16
  streamSimple,
17
+ stripSchemaDescriptions,
18
+ type ToolChoice,
17
19
  type ToolResultMessage,
18
20
  type TSchema,
21
+ toolWireSchema,
19
22
  validateToolArguments,
20
23
  zodToWireSchema,
21
24
  } from "@oh-my-pi/pi-ai";
@@ -66,6 +69,7 @@ import type {
66
69
  AsideMessage,
67
70
  StreamFn,
68
71
  } from "./types";
72
+ import { isSoftToolRequirement } from "./types";
69
73
  import { yieldIfDue } from "./utils/yield";
70
74
 
71
75
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
@@ -82,6 +86,27 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
82
86
  */
83
87
  const MAX_PAUSED_TURN_CONTINUATIONS = 8;
84
88
 
89
+ /**
90
+ * Cap on consecutive forced escalations for a single soft tool requirement.
91
+ * A forced `toolChoice` guarantees the call, so this is purely defensive: if a
92
+ * model somehow never satisfies the requirement, give up forcing rather than
93
+ * spin the loop. Reset whenever the requirement id changes or clears.
94
+ */
95
+ const MAX_SOFT_TOOL_ESCALATIONS = 3;
96
+
97
+ /**
98
+ * Whether a hard `toolChoice` for a turn conflicts with a pending soft tool
99
+ * requirement — i.e. forbids tools (`"none"`) or forces a *different* specific
100
+ * tool. `"auto"`/`"required"`/`"any"` and a same-tool force still let the model
101
+ * satisfy the requirement, so they do not conflict and the soft gate stays active.
102
+ */
103
+ function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: string): boolean {
104
+ if (choice === undefined) return false;
105
+ if (typeof choice === "string") return choice === "none";
106
+ const name = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name;
107
+ return name !== requiredTool;
108
+ }
109
+
85
110
  /**
86
111
  * Cadence (ms) for polling queued steering while an `interruptible` tool is in
87
112
  * flight, so a steer cuts the wait short instead of sitting idle until the
@@ -518,7 +543,13 @@ function normalizeMessagesForProvider(
518
543
  });
519
544
  }
520
545
 
521
- function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "require"): unknown {
546
+ const INTENT_FIELD_DESCRIPTION = "concise intent";
547
+
548
+ function injectIntentIntoSchema(
549
+ schema: unknown,
550
+ mode: "require" | "optional" = "require",
551
+ describeIntent = true,
552
+ ): unknown {
522
553
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
523
554
  const schemaRecord = schema as Record<string, unknown>;
524
555
  const propertiesValue = schemaRecord.properties;
@@ -544,10 +575,9 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
544
575
  return {
545
576
  ...schemaRecord,
546
577
  properties: {
547
- [INTENT_FIELD]: {
548
- type: "string",
549
- description: "Concise intent in present participle form (2-6 words) strictly on a single line, no newlines",
550
- },
578
+ [INTENT_FIELD]: describeIntent
579
+ ? { type: "string", description: INTENT_FIELD_DESCRIPTION }
580
+ : { type: "string" },
551
581
  ...properties,
552
582
  },
553
583
  ...(mode === "require" ? { required: [...required, INTENT_FIELD] } : {}),
@@ -558,26 +588,36 @@ export function normalizeTools(
558
588
  tools: AgentContext["tools"],
559
589
  injectIntent: boolean,
560
590
  exampleDialect?: Dialect,
591
+ pruneDescriptions = false,
561
592
  ): Context["tools"] {
562
593
  injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
563
594
  return tools?.map(t => {
564
595
  const intentMode = resolveIntentMode(t.intent);
596
+ const doInjectIntent = injectIntent && intentMode !== "omit";
597
+ // When the full catalog is rendered into the system prompt, ship the tool
598
+ // specs without their descriptions (top-level + nested schema annotations)
599
+ // so they are not duplicated on the wire. Strip the STABLE wire schema (the
600
+ // memoized `stripSchemaDescriptions` result is reused across requests), then
601
+ // re-inject `_i` (without its hint, which `describeIntent: false` omits) so
602
+ // intent tracing keeps the field while no descriptions ride the wire.
603
+ if (pruneDescriptions) {
604
+ let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema;
605
+ if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode, false) as TSchema;
606
+ return { ...t, parameters, description: "" };
607
+ }
565
608
  let parameters: TSchema = t.parameters;
566
- if (injectIntent && intentMode !== "omit") {
609
+ if (doInjectIntent) {
567
610
  if (isZodSchema(parameters)) {
568
- const wired = zodToWireSchema(parameters);
569
- parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
611
+ parameters = injectIntentIntoSchema(zodToWireSchema(parameters), intentMode) as TSchema;
570
612
  } else if (isArkSchema(parameters)) {
571
- const wired = arkToWireSchema(parameters);
572
- parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
613
+ parameters = injectIntentIntoSchema(arkToWireSchema(parameters), intentMode) as TSchema;
573
614
  } else {
574
615
  parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
575
616
  }
576
617
  }
577
618
  const description = t.description ?? "";
578
- const injectExampleIntent = injectIntent && intentMode !== "omit";
579
619
  const examplesBlock = exampleDialect
580
- ? renderToolExamples({ ...t, parameters }, exampleDialect, injectExampleIntent ? INTENT_FIELD : undefined)
620
+ ? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined)
581
621
  : "";
582
622
  const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
583
623
  return { ...t, parameters, description: finalDescription };
@@ -713,6 +753,19 @@ async function runLoopBody(
713
753
  let harmonyTruncateResumeCount = 0;
714
754
  let pausedTurnContinuations = 0;
715
755
 
756
+ // Soft tool requirement lifecycle (reminder → escalate; see SoftToolRequirement).
757
+ // `forcedToolChoice` carries a one-turn escalation into the next model call. It
758
+ // overrides the static toolChoice but NEVER the host's hard getToolChoice().
759
+ let softRequirementId: string | undefined;
760
+ let forcedToolChoice: ToolChoice | undefined;
761
+ let softEscalations = 0;
762
+ // Resolved once per logical turn at the fetch site below and reused across
763
+ // Harmony-leak re-samples (which re-enter the same turn) so the consuming
764
+ // getToolChoice is never advanced twice; the flag resets at the message boundary.
765
+ let hostToolChoice: ToolChoice | undefined;
766
+ let softRequiredTool: string | undefined;
767
+ let directiveResolvedForTurn = false;
768
+
716
769
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
717
770
  while (true) {
718
771
  let hasMoreToolCalls = true;
@@ -748,6 +801,41 @@ async function runLoopBody(
748
801
  await config.syncContextBeforeModelCall(currentContext);
749
802
  }
750
803
 
804
+ // Resolve the per-turn tool-choice directive ONCE per logical turn. The
805
+ // host hard-choice path (getToolChoice → nextToolChoice) is CONSUMING — it
806
+ // advances a generator on every call — so Harmony-leak retries, which
807
+ // re-sample the same turn via `continue` without a turn_end, must reuse the
808
+ // values fetched on the first attempt rather than double-advancing it.
809
+ // Fetched here (after pending-message flush + context sync, immediately
810
+ // before the call) so a throw in between cannot wedge an in-flight
811
+ // directive. A hard ToolChoice is applied verbatim; a SoftToolRequirement
812
+ // triggers the remind-then-escalate lifecycle: inject its reminder inline
813
+ // once per new id (toolChoice stays auto), and the gate below escalates to
814
+ // a forced choice only if the model declines. The host wrapper already
815
+ // dropped a soft requirement whose tool is inactive.
816
+ if (!directiveResolvedForTurn) {
817
+ const directive = signal?.aborted ? undefined : config.getToolChoice?.();
818
+ const softReq = isSoftToolRequirement(directive) ? directive : undefined;
819
+ hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
820
+ softRequiredTool = softReq?.toolName;
821
+ if (softReq !== undefined) {
822
+ if (softReq.id !== softRequirementId) {
823
+ softRequirementId = softReq.id;
824
+ softEscalations = 0;
825
+ for (const reminder of softReq.reminder) {
826
+ stream.push({ type: "message_start", message: reminder });
827
+ stream.push({ type: "message_end", message: reminder });
828
+ currentContext.messages.push(reminder);
829
+ newMessages.push(reminder);
830
+ }
831
+ }
832
+ } else {
833
+ softRequirementId = undefined;
834
+ softEscalations = 0;
835
+ }
836
+ directiveResolvedForTurn = true;
837
+ }
838
+
751
839
  // Stream assistant response
752
840
  let recovered: HarmonyRecoveredToolCall | undefined;
753
841
  let message: AssistantMessage;
@@ -762,6 +850,8 @@ async function runLoopBody(
762
850
  stepCounter,
763
851
  streamFn,
764
852
  harmonyRetryAttempt,
853
+ hostToolChoice,
854
+ forcedToolChoice,
765
855
  );
766
856
  harmonyRetryAttempt = 0;
767
857
  harmonyTruncateResumeCount = 0;
@@ -778,6 +868,10 @@ async function runLoopBody(
778
868
  recovered = err.recovered;
779
869
  message = recovered.message;
780
870
  await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
871
+ // A recovered message completes the turn, so the abort-retry counter
872
+ // resets like the normal success path (the truncate-resume counter
873
+ // keeps accumulating for its cross-turn cap).
874
+ harmonyRetryAttempt = 0;
781
875
  } else {
782
876
  if (harmonyRetryAttempt >= 2) {
783
877
  await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
@@ -798,6 +892,14 @@ async function runLoopBody(
798
892
  }
799
893
  newMessages.push(message);
800
894
 
895
+ // The escalation choice (if any) applied to the call above; clear it so
896
+ // only the single escalation turn carries the forced choice.
897
+ forcedToolChoice = undefined;
898
+
899
+ // A fresh logical turn re-resolves the directive next iteration; a Harmony
900
+ // retry `continue`s before this line and keeps the cached value.
901
+ directiveResolvedForTurn = false;
902
+
801
903
  if (message.stopReason === "error" || message.stopReason === "aborted") {
802
904
  // Create placeholder tool results for any tool calls in the aborted message
803
905
  // This maintains the tool_use/tool_result pairing that the API requires
@@ -850,8 +952,51 @@ async function runLoopBody(
850
952
  hasMoreToolCalls = false;
851
953
  }
852
954
 
955
+ // A turn is compliant ONLY when it calls the required tool and nothing
956
+ // else — mirroring the forced-tool_choice turn, which can emit only that
957
+ // tool. A required+detour batch is treated as non-compliant so detour
958
+ // tools never run side effects while the requirement is still pending.
959
+ const calledOnlyRequiredTool =
960
+ softRequiredTool !== undefined &&
961
+ toolCalls.length > 0 &&
962
+ toolCalls.every(toolCall => toolCall.name === softRequiredTool);
963
+ const softGateActive =
964
+ softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool);
965
+ const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
966
+
853
967
  const toolResults: ToolResultMessage[] = [];
854
- if (hasMoreToolCalls) {
968
+ if (softNonCompliant && softRequiredTool !== undefined) {
969
+ if (softEscalations >= MAX_SOFT_TOOL_ESCALATIONS) {
970
+ throw new Error(
971
+ `Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
972
+ );
973
+ }
974
+ // A soft-required tool is pending but the model called something else
975
+ // (or yielded). Do NOT execute the detour — pair each call with a
976
+ // skipped result and force the required tool next turn. This is the
977
+ // only turn that changes toolChoice; a model that complies with the
978
+ // reminder pays no message-cache invalidation. Re-engage so the loop
979
+ // never yields while the requirement is unmet.
980
+ for (const toolCall of toolCalls) {
981
+ const result = createAbortedToolResult(
982
+ toolCall,
983
+ stream,
984
+ "skipped",
985
+ `Not executed: call the \`${softRequiredTool}\` tool to resolve the pending action before using other tools.`,
986
+ );
987
+ currentContext.messages.push(result);
988
+ newMessages.push(result);
989
+ toolResults.push(result);
990
+ recordSkippedTool(telemetry, {
991
+ toolCallId: toolCall.id,
992
+ toolName: toolCall.name,
993
+ status: "skipped",
994
+ });
995
+ }
996
+ forcedToolChoice = { type: "tool", name: softRequiredTool };
997
+ softEscalations++;
998
+ hasMoreToolCalls = true;
999
+ } else if (hasMoreToolCalls) {
855
1000
  const executionResult = await executeToolCalls(
856
1001
  currentContext,
857
1002
  message,
@@ -999,6 +1144,8 @@ async function streamAssistantResponse(
999
1144
  stepCounter: StepCounter,
1000
1145
  streamFn?: StreamFn,
1001
1146
  harmonyRetryAttempt = 0,
1147
+ hostToolChoice?: ToolChoice,
1148
+ forcedToolChoice?: ToolChoice,
1002
1149
  ): Promise<AssistantMessage> {
1003
1150
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1004
1151
  let messages = context.messages;
@@ -1012,6 +1159,9 @@ async function streamAssistantResponse(
1012
1159
 
1013
1160
  const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1014
1161
  const exampleDialect = ownedDialect ?? preferredDialect(config.model.id);
1162
+ // Owned/in-band dialects carry the catalog in the prompt as text and send no
1163
+ // native `tools`, so description pruning only applies to native tool calling.
1164
+ const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1015
1165
  // Build LLM context — append-only mode caches system prompt + tools
1016
1166
  // AND keeps an append-only message log so prior-turn bytes are stable.
1017
1167
  let llmContext: Context;
@@ -1020,12 +1170,13 @@ async function streamAssistantResponse(
1020
1170
  llmContext = config.appendOnlyContext.build(context, {
1021
1171
  intentTracing: !!config.intentTracing,
1022
1172
  exampleDialect,
1173
+ pruneToolDescriptions,
1023
1174
  });
1024
1175
  } else {
1025
1176
  llmContext = {
1026
1177
  systemPrompt: context.systemPrompt,
1027
1178
  messages: normalizedMessages,
1028
- tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect),
1179
+ tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect, pruneToolDescriptions),
1029
1180
  };
1030
1181
  }
1031
1182
  if (config.transformProviderContext) {
@@ -1048,7 +1199,6 @@ async function streamAssistantResponse(
1048
1199
 
1049
1200
  const streamFunction = streamFn || streamSimple;
1050
1201
 
1051
- const dynamicToolChoice = config.getToolChoice?.();
1052
1202
  const dynamicReasoning = config.getReasoning?.();
1053
1203
  const dynamicDisableReasoning = config.getDisableReasoning?.();
1054
1204
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
@@ -1083,7 +1233,7 @@ async function streamAssistantResponse(
1083
1233
  const effectiveTemperature =
1084
1234
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1085
1235
  // Owned tool calling sends no native tools, so any tool_choice would error.
1086
- const effectiveToolChoice = ownedDialect ? undefined : (dynamicToolChoice ?? config.toolChoice);
1236
+ const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice);
1087
1237
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1088
1238
  const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
1089
1239
 
package/src/agent.ts CHANGED
@@ -39,7 +39,9 @@ import type {
39
39
  AsideMessage,
40
40
  StreamFn,
41
41
  ToolCallContext,
42
+ ToolChoiceDirective,
42
43
  } from "./types";
44
+ import { isSoftToolRequirement } from "./types";
43
45
  import { EventLoopKeepalive } from "./utils/yield";
44
46
 
45
47
  /**
@@ -223,6 +225,12 @@ export interface AgentOptions {
223
225
 
224
226
  /** Enable intent tracing schema injection/stripping in the harness. */
225
227
  intentTracing?: boolean;
228
+ /**
229
+ * Strip tool descriptions from provider-bound tool specs (top-level + nested
230
+ * schema annotations). Use when the full catalog is rendered into the system
231
+ * prompt so descriptions are not duplicated on the wire. Native tool calling only.
232
+ */
233
+ pruneToolDescriptions?: boolean;
226
234
  /** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
227
235
  dialect?: Dialect;
228
236
  /**
@@ -232,8 +240,8 @@ export interface AgentOptions {
232
240
  * the loop's {@link AgentLoopConfig.abortOnFabricatedToolResult}.
233
241
  */
234
242
  abortOnFabricatedToolResult?: boolean;
235
- /** Dynamic tool choice override, resolved per LLM call. */
236
- getToolChoice?: () => ToolChoice | undefined;
243
+ /** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */
244
+ getToolChoice?: () => ToolChoiceDirective | undefined;
237
245
 
238
246
  /**
239
247
  * Cursor exec handlers for local tool execution.
@@ -336,9 +344,10 @@ export class Agent {
336
344
  #preferWebsockets?: boolean;
337
345
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
338
346
  #intentTracing: boolean;
347
+ #pruneToolDescriptions: boolean;
339
348
  #dialect?: Dialect;
340
349
  #abortOnFabricatedToolResult?: boolean;
341
- #getToolChoice?: () => ToolChoice | undefined;
350
+ #getToolChoice?: () => ToolChoiceDirective | undefined;
342
351
  #onPayload?: SimpleStreamOptions["onPayload"];
343
352
  #onResponse?: SimpleStreamOptions["onResponse"];
344
353
  #onSseEvent?: SimpleStreamOptions["onSseEvent"];
@@ -407,6 +416,7 @@ export class Agent {
407
416
  this.#preferWebsockets = opts.preferWebsockets;
408
417
  this.#transformToolCallArguments = opts.transformToolCallArguments;
409
418
  this.#intentTracing = opts.intentTracing === true;
419
+ this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
410
420
  this.#dialect = opts.dialect;
411
421
  this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
412
422
  this.#getToolChoice = opts.getToolChoice;
@@ -1009,10 +1019,13 @@ export class Agent {
1009
1019
  }
1010
1020
  : undefined;
1011
1021
 
1012
- const getToolChoice = () => {
1013
- const queuedToolChoice = this.#getToolChoice?.();
1014
- if (queuedToolChoice !== undefined) {
1015
- return refreshToolChoiceForActiveTools(queuedToolChoice, this.#state.tools);
1022
+ const getToolChoice = (): ToolChoiceDirective | undefined => {
1023
+ const queued = this.#getToolChoice?.();
1024
+ if (queued !== undefined) {
1025
+ if (isSoftToolRequirement(queued)) {
1026
+ return (this.#state.tools ?? []).some(tool => tool.name === queued.toolName) ? queued : undefined;
1027
+ }
1028
+ return refreshToolChoiceForActiveTools(queued, this.#state.tools);
1016
1029
  }
1017
1030
  return refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools);
1018
1031
  };
@@ -1059,6 +1072,7 @@ export class Agent {
1059
1072
  cursorOnToolResult,
1060
1073
  transformToolCallArguments: this.#transformToolCallArguments,
1061
1074
  intentTracing: this.#intentTracing,
1075
+ pruneToolDescriptions: this.#pruneToolDescriptions,
1062
1076
  dialect: this.#dialect,
1063
1077
  abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult,
1064
1078
  appendOnlyContext: this.#appendOnlyContext,
@@ -35,6 +35,8 @@ export interface BuildOptions {
35
35
  /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
36
36
  intentTracing: boolean;
37
37
  exampleDialect?: Dialect;
38
+ /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
39
+ pruneToolDescriptions?: boolean;
38
40
  }
39
41
 
40
42
  /**
@@ -270,7 +272,8 @@ export class AppendOnlyContextManager {
270
272
 
271
273
  function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
272
274
  const systemPrompt = [...context.systemPrompt];
273
- const tools = normalizeTools(context.tools, options.intentTracing, options.exampleDialect) ?? [];
275
+ const tools =
276
+ normalizeTools(context.tools, options.intentTracing, options.exampleDialect, options.pruneToolDescriptions) ?? [];
274
277
  return {
275
278
  systemPrompt,
276
279
  tools,
@@ -291,6 +294,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil
291
294
  })),
292
295
  i: options.intentTracing,
293
296
  ex: options.exampleDialect,
297
+ pd: options.pruneToolDescriptions,
294
298
  });
295
299
  let hash = 0;
296
300
  for (let i = 0; i < payload.length; i++) {
@@ -326,9 +326,16 @@ export function estimateTokens(message: AgentMessage): number {
326
326
  case "branchSummary":
327
327
  case "compactionSummary": {
328
328
  fragments.push(message.summary);
329
- if (message.role === "compactionSummary" && message.images) {
330
- // Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
331
- extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
329
+ if (message.role === "compactionSummary") {
330
+ if (message.blocks) {
331
+ for (const block of message.blocks) {
332
+ if (block.type === "text") fragments.push(block.text);
333
+ else extra += snapcompact.FRAME_TOKEN_ESTIMATE;
334
+ }
335
+ } else if (message.images) {
336
+ // Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
337
+ extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
338
+ }
332
339
  }
333
340
  break;
334
341
  }
@@ -51,7 +51,11 @@ export interface CompactionSummaryMessage {
51
51
  shortSummary?: string;
52
52
  tokensBefore: number;
53
53
  providerPayload?: ProviderPayload;
54
- /** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */
54
+ /** Runtime-only ordered archive blocks for snapcompact: old text region,
55
+ * imaged middle, then new text region. When present, `summary` is already
56
+ * the final lead-in text (no legacy wrapper applied). */
57
+ blocks?: (TextContent | ImageContent)[];
58
+ /** Snapcompact image blocks, kept for display counts / legacy consumers. */
55
59
  images?: ImageContent[];
56
60
  timestamp: number;
57
61
  }
@@ -101,14 +105,19 @@ export function createCompactionSummaryMessage(
101
105
  shortSummary?: string,
102
106
  providerPayload?: ProviderPayload,
103
107
  images?: ImageContent[],
108
+ blocks?: (TextContent | ImageContent)[],
104
109
  ): CompactionSummaryMessage {
110
+ const imageBlocks =
111
+ blocks?.filter((block): block is ImageContent => block.type === "image") ??
112
+ (images && images.length > 0 ? images : undefined);
105
113
  return {
106
114
  role: "compactionSummary",
107
115
  summary,
108
116
  shortSummary,
109
117
  tokensBefore,
110
118
  providerPayload,
111
- images: images && images.length > 0 ? images : undefined,
119
+ blocks: blocks && blocks.length > 0 ? blocks : undefined,
120
+ images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined,
112
121
  timestamp: new Date(timestamp).getTime(),
113
122
  };
114
123
  }
@@ -182,13 +191,16 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
182
191
  case "compactionSummary":
183
192
  return {
184
193
  role: "user",
185
- content: [
186
- {
187
- type: "text" as const,
188
- text: renderCompactionSummaryContext(message.summary),
189
- },
190
- ...(message.images ?? []),
191
- ],
194
+ content:
195
+ message.blocks !== undefined
196
+ ? [{ type: "text" as const, text: message.summary }, ...message.blocks]
197
+ : [
198
+ {
199
+ type: "text" as const,
200
+ text: renderCompactionSummaryContext(message.summary),
201
+ },
202
+ ...(message.images ?? []),
203
+ ],
192
204
  attribution: "agent",
193
205
  providerPayload: message.providerPayload,
194
206
  timestamp: message.timestamp,
@@ -30,6 +30,24 @@ export interface PruneConfig {
30
30
  supersedeKey?: SupersedeKeyFn;
31
31
  /** Useless-flagged results bypass the protect window (see {@link USELESS_NOTICE}). Default true. */
32
32
  pruneUseless?: boolean;
33
+ /**
34
+ * Compaction boundary: the `firstKeptEntryId` of the latest compaction on
35
+ * the branch. Entries at indices BEFORE this id are summarized away and never
36
+ * sent to the model, so mutating them only churns persisted history without
37
+ * shrinking the prompt — they are skipped. Undefined = no compaction (the
38
+ * whole branch is sent).
39
+ */
40
+ keepBoundaryId?: string;
41
+ /**
42
+ * Prompt-cache guard. When set, a tool result whose all-message suffix
43
+ * (tokens of every message after it) EXCEEDS this is part of the warm,
44
+ * already-sent cache prefix: mutating it forces the provider to re-write the
45
+ * whole suffix (cacheWrite premium). Such results — including superseded and
46
+ * useless ones, which otherwise bypass {@link protectTokens} — are left for
47
+ * compaction/shake (which rebuild the cache anyway) to reclaim. Undefined =
48
+ * no cache guard (legacy: superseded/useless prune at any depth).
49
+ */
50
+ cacheWarmSuffixTokens?: number;
33
51
  }
34
52
 
35
53
  export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
@@ -66,10 +84,22 @@ export interface SupersedePruneConfig {
66
84
  pruneUseless?: boolean;
67
85
  /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
68
86
  suffixTokenLimit?: number;
69
- /** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */
87
+ /**
88
+ * Prune all candidates when the last message is at least this old: the
89
+ * provider prompt cache is then cold, so re-writing it is free. MUST exceed
90
+ * the cache retention (Anthropic "long" = 1h) or a still-warm prefix is busted
91
+ * by the flush. Default 30 min — callers on long retention override it.
92
+ */
70
93
  idleFlushMs?: number;
71
94
  /** Clock override for tests. */
72
95
  now?: number;
96
+ /**
97
+ * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries
98
+ * before it are summarized away and never sent, so they are skipped in every
99
+ * path — including the idle flush — to avoid pointless history churn.
100
+ * Undefined = no compaction (the whole branch is sent).
101
+ */
102
+ keepBoundaryId?: string;
73
103
  /** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
74
104
  protectedTools: ProtectedToolMatcher[];
75
105
  }
@@ -103,6 +133,35 @@ function estimatePrunedSavings(tokens: number, notice: string): number {
103
133
  return Math.max(0, tokens - noticeTokens);
104
134
  }
105
135
 
136
+ /**
137
+ * For each entry index, the estimated token total of all *message* entries
138
+ * strictly after it — how much prompt-cache content the provider must re-write
139
+ * (cacheWrite premium) if that entry is mutated in place. Used to keep prune
140
+ * mutations inside the cheap-to-recache tail.
141
+ */
142
+ function computeMessageSuffixTokens(entries: readonly SessionEntry[]): number[] {
143
+ const suffix = new Array<number>(entries.length);
144
+ let accumulated = 0;
145
+ for (let i = entries.length - 1; i >= 0; i--) {
146
+ suffix[i] = accumulated;
147
+ const entry = entries[i];
148
+ if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage);
149
+ }
150
+ return suffix;
151
+ }
152
+
153
+ /**
154
+ * Resolve the array index of the compaction boundary (`keepBoundaryId`). Entries
155
+ * before this index are summarized away by the latest compaction and never sent,
156
+ * so prune passes must not mutate them. Returns 0 when there is no boundary (no
157
+ * compaction → whole branch is sent) or the id is absent from `entries`.
158
+ */
159
+ function resolveBoundaryIndex(entries: readonly SessionEntry[], keepBoundaryId: string | undefined): number {
160
+ if (keepBoundaryId === undefined) return 0;
161
+ const index = entries.findIndex(entry => entry.id === keepBoundaryId);
162
+ return index < 0 ? 0 : index;
163
+ }
164
+
106
165
  interface SupersedeCandidate {
107
166
  entry: SessionMessageEntry;
108
167
  message: ToolResultMessage;
@@ -183,7 +242,8 @@ function collectUselessResults(
183
242
  * flagged contextually useless. Cheap, incremental, and prompt-cache-aware: a
184
243
  * candidate is pruned now only when the suffix after it is small (tail case —
185
244
  * the read→edit→read loop) or when the context has been idle long enough that
186
- * the provider cache is cold anyway (then ALL candidates flush).
245
+ * the provider cache is cold anyway (then all still-sent candidates flush).
246
+ * Never mutates entries before `keepBoundaryId` (summarized away — not sent).
187
247
  */
188
248
  export function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult {
189
249
  const toolCallsById = collectToolCallsById(entries);
@@ -209,20 +269,24 @@ export function pruneSupersededToolResults(entries: SessionEntry[], config: Supe
209
269
  const idle =
210
270
  lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
211
271
 
272
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
273
+
212
274
  let toPrune: SupersedeCandidate[];
213
275
  if (idle) {
214
- toPrune = candidates;
276
+ // Provider cache is cold (idle exceeds the retention TTL), so re-writing
277
+ // the sent region costs nothing. Entries before the compaction boundary
278
+ // are summarized away and never sent — skip them to avoid pointless churn.
279
+ toPrune = candidates.filter(candidate => candidate.index >= boundaryIndex);
215
280
  } else {
216
281
  const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
217
282
  // suffixTokens[i] = estimated tokens of all messages strictly after entry i.
218
- const suffixTokens = new Array<number>(entries.length);
219
- let accumulated = 0;
220
- for (let i = entries.length - 1; i >= 0; i--) {
221
- suffixTokens[i] = accumulated;
222
- const entry = entries[i];
223
- if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage);
224
- }
225
- toPrune = candidates.filter(candidate => suffixTokens[candidate.index] <= suffixTokenLimit);
283
+ // Mutating a candidate re-writes its suffix in the warm cache, so prune only
284
+ // when that suffix is small (cheap-to-recache tail) and the candidate sits
285
+ // at/after the compaction boundary.
286
+ const suffixTokens = computeMessageSuffixTokens(entries);
287
+ toPrune = candidates.filter(
288
+ candidate => candidate.index >= boundaryIndex && suffixTokens[candidate.index] <= suffixTokenLimit,
289
+ );
226
290
  }
227
291
  if (toPrune.length === 0) return { prunedCount: 0, tokensSaved: 0 };
228
292
 
@@ -262,6 +326,11 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
262
326
  )
263
327
  : undefined;
264
328
 
329
+ const boundaryIndex = resolveBoundaryIndex(entries, config.keepBoundaryId);
330
+ const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
331
+ // All-message suffix per index, only when the cache guard is armed.
332
+ const messageSuffix = cacheWarmSuffixTokens === undefined ? undefined : computeMessageSuffixTokens(entries);
333
+
265
334
  for (let i = entries.length - 1; i >= 0; i--) {
266
335
  const entry = entries[i];
267
336
  const message = getToolResultMessage(entry);
@@ -275,10 +344,23 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
275
344
  continue;
276
345
  }
277
346
 
278
- // Superseded and useless results are pruned first: they bypass the
279
- // protect window (a stale copy of re-read contentor a result the
280
- // tool itself flagged as carrying no information is dead weight at
281
- // any age).
347
+ // Prompt-cache guard: a result whose all-message suffix exceeds the
348
+ // warm-cache window sits in the already-sent cached prefix mutating it
349
+ // re-writes the whole suffix (cacheWrite premium). Entries before the
350
+ // compaction boundary are summarized away (never sent). Both are skipped
351
+ // before any prune decision, so superseded/useless cannot reach a deep,
352
+ // still-cached copy; compaction/shake reclaim those when they rebuild.
353
+ const inWarmPrefix =
354
+ messageSuffix !== undefined && cacheWarmSuffixTokens !== undefined && messageSuffix[i] > cacheWarmSuffixTokens;
355
+ if (inWarmPrefix || i < boundaryIndex) {
356
+ accumulatedTokens += tokens;
357
+ continue;
358
+ }
359
+
360
+ // Superseded and useless results bypass the age-based protect window
361
+ // (a stale re-read copy, or a result the tool flagged as uninformative,
362
+ // is dead weight at any age) — but only within the cache-warm tail: the
363
+ // guard above already excluded deeper, still-cached copies.
282
364
  const superseded = supersededMessages?.has(message) ?? false;
283
365
  const useless = uselessMessages?.has(message) ?? false;
284
366
  const tooSmall = tokens < MIN_PRUNE_TOKENS;
@@ -31,6 +31,14 @@ export interface ShakeConfig {
31
31
  protectedTools: ProtectedToolMatcher[];
32
32
  /** Minimum token size for a fenced/XML block to be eligible. */
33
33
  fenceMinTokens: number;
34
+ /**
35
+ * Compaction boundary (`firstKeptEntryId` of the latest compaction). Entries
36
+ * before it are summarized away and never sent, so they are skipped — shaking
37
+ * them only churns persisted history. Undefined = no compaction (whole branch
38
+ * is sent). Note: shake still elides the warm cached prefix at/after the
39
+ * boundary — that is its job as a compaction-class reducer.
40
+ */
41
+ keepBoundaryId?: string;
34
42
  }
35
43
 
36
44
  /** Auto-shake config: protects the live tail, conservative thresholds. */
@@ -289,9 +297,20 @@ export function collectShakeRegions(entries: SessionEntry[], config: ShakeConfig
289
297
 
290
298
  const toolCallsById = collectToolCallsById(entries);
291
299
 
300
+ // Entries before the compaction boundary are summarized away and never sent —
301
+ // shaking them only churns persisted history (no prompt/cache effect).
302
+ const boundaryIndex =
303
+ config.keepBoundaryId === undefined
304
+ ? 0
305
+ : Math.max(
306
+ 0,
307
+ entries.findIndex(entry => entry.id === config.keepBoundaryId),
308
+ );
309
+
292
310
  const regions: ShakeRegion[] = [];
293
311
  for (let i = 0; i < n; i++) {
294
312
  const entry = entries[i];
313
+ if (i < boundaryIndex) continue;
295
314
  const toolResult = getToolResultMessage(entry);
296
315
  // Useless-flagged results carry no information once consumed; they are
297
316
  // eligible even inside the protect-recent window.
package/src/types.ts CHANGED
@@ -36,6 +36,43 @@ export type StreamFn = (
36
36
  */
37
37
  export type AsideMessage = AgentMessage | (() => AgentMessage | null);
38
38
 
39
+ /**
40
+ * A soft tool requirement: the host wants `toolName` called before the loop
41
+ * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
42
+ * up front (changing `tool_choice` invalidates the provider message cache).
43
+ * Returned from {@link AgentLoopConfig.getToolChoice} in place of a hard
44
+ * {@link ToolChoice}: the loop injects `reminder` once when a new `id` becomes
45
+ * active, runs with `toolChoice` unchanged, and escalates to a one-turn forced
46
+ * choice only if the model fails to call `toolName`. Auto-clears when the host
47
+ * stops returning it or `toolName` is no longer an active tool.
48
+ */
49
+ export interface SoftToolRequirement {
50
+ /** Discriminates a soft requirement from a hard {@link ToolChoice}. */
51
+ soft: true;
52
+ /**
53
+ * Stable id of the *current* requirement. The loop injects `reminder` when
54
+ * this id first becomes active and again whenever it changes (e.g. one
55
+ * stacked preview resolves and the next becomes the head), but never
56
+ * re-injects for an unchanged id across turns.
57
+ */
58
+ id: string;
59
+ /** Tool that must be called before the loop runs other tools or yields. */
60
+ toolName: string;
61
+ /** Host-owned reminder messages, injected once per `id` activation. */
62
+ reminder: AgentMessage[];
63
+ }
64
+
65
+ /**
66
+ * A per-turn tool-choice directive: either a hard provider {@link ToolChoice}
67
+ * (applied verbatim) or a {@link SoftToolRequirement} (remind-then-escalate).
68
+ */
69
+ export type ToolChoiceDirective = ToolChoice | SoftToolRequirement;
70
+
71
+ /** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
72
+ export function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement {
73
+ return typeof directive === "object" && directive !== null && (directive as SoftToolRequirement).soft === true;
74
+ }
75
+
39
76
  /**
40
77
  * Configuration for the agent loop.
41
78
  */
@@ -203,6 +240,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
203
240
  * then strips from arguments before executing tools.
204
241
  */
205
242
  intentTracing?: boolean;
243
+ /**
244
+ * Strip tool descriptions (top-level + nested schema annotations) from the
245
+ * provider-bound tool specs. Use when the full catalog is rendered into the
246
+ * system prompt instead, so descriptions are not duplicated on the wire.
247
+ */
248
+ pruneToolDescriptions?: boolean;
206
249
  /**
207
250
  * Owned tool calling dialect.
208
251
  *
@@ -246,10 +289,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
246
289
  onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
247
290
 
248
291
  /**
249
- * Dynamic tool choice override, resolved per LLM call.
250
- * When set and returns a value, overrides the static `toolChoice`.
292
+ * Dynamic tool-choice directive, resolved once per turn. Returns a hard
293
+ * {@link ToolChoice} (applied verbatim, overriding the static `toolChoice`),
294
+ * a {@link SoftToolRequirement} (the loop reminds-then-escalates instead of
295
+ * forcing `tool_choice` immediately, so a model that complies with the
296
+ * reminder pays no message-cache invalidation), or `undefined` to fall back
297
+ * to the static `toolChoice`.
251
298
  */
252
- getToolChoice?: () => ToolChoice | undefined;
299
+ getToolChoice?: () => ToolChoiceDirective | undefined;
253
300
 
254
301
  /**
255
302
  * Dynamic reasoning effort override, resolved per LLM call.