@oh-my-pi/pi-agent-core 16.0.10 → 16.1.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
@@ -2,6 +2,23 @@
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
+
16
+ ## [16.0.11] - 2026-06-19
17
+
18
+ ### Changed
19
+
20
+ - Updated the display format for truncated file operation summaries
21
+
5
22
  ## [16.0.8] - 2026-06-18
6
23
 
7
24
  ### Fixed
@@ -54,8 +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 const INTENT_FIELD = "_i";
58
- 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"];
59
58
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
60
59
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
61
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.10",
4
+ "version": "16.1.0",
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,11 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.0.10",
39
- "@oh-my-pi/pi-catalog": "16.0.10",
40
- "@oh-my-pi/pi-natives": "16.0.10",
41
- "@oh-my-pi/pi-utils": "16.0.10",
42
- "@oh-my-pi/snapcompact": "16.0.10",
38
+ "@oh-my-pi/pi-ai": "16.1.0",
39
+ "@oh-my-pi/pi-catalog": "16.1.0",
40
+ "@oh-my-pi/pi-natives": "16.1.0",
41
+ "@oh-my-pi/pi-utils": "16.1.0",
42
+ "@oh-my-pi/pi-wire": "16.1.0",
43
+ "@oh-my-pi/snapcompact": "16.1.0",
43
44
  "@opentelemetry/api": "^1.9.1"
44
45
  },
45
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";
@@ -38,6 +41,7 @@ import {
38
41
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
39
42
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
40
43
  import { sanitizeText } from "@oh-my-pi/pi-utils";
44
+ import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
41
45
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
42
46
  import {
43
47
  type AgentTelemetry,
@@ -65,6 +69,7 @@ import type {
65
69
  AsideMessage,
66
70
  StreamFn,
67
71
  } from "./types";
72
+ import { isSoftToolRequirement } from "./types";
68
73
  import { yieldIfDue } from "./utils/yield";
69
74
 
70
75
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
@@ -81,6 +86,27 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
81
86
  */
82
87
  const MAX_PAUSED_TURN_CONTINUATIONS = 8;
83
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
+
84
110
  /**
85
111
  * Cadence (ms) for polling queued steering while an `interruptible` tool is in
86
112
  * flight, so a steer cuts the wait short instead of sitting idle until the
@@ -517,9 +543,13 @@ function normalizeMessagesForProvider(
517
543
  });
518
544
  }
519
545
 
520
- export const INTENT_FIELD = "_i";
546
+ const INTENT_FIELD_DESCRIPTION = "concise intent";
521
547
 
522
- function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" = "require"): unknown {
548
+ function injectIntentIntoSchema(
549
+ schema: unknown,
550
+ mode: "require" | "optional" = "require",
551
+ describeIntent = true,
552
+ ): unknown {
523
553
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
524
554
  const schemaRecord = schema as Record<string, unknown>;
525
555
  const propertiesValue = schemaRecord.properties;
@@ -545,10 +575,9 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
545
575
  return {
546
576
  ...schemaRecord,
547
577
  properties: {
548
- [INTENT_FIELD]: {
549
- type: "string",
550
- description: "Concise intent in present participle form (2-6 words) strictly on a single line, no newlines",
551
- },
578
+ [INTENT_FIELD]: describeIntent
579
+ ? { type: "string", description: INTENT_FIELD_DESCRIPTION }
580
+ : { type: "string" },
552
581
  ...properties,
553
582
  },
554
583
  ...(mode === "require" ? { required: [...required, INTENT_FIELD] } : {}),
@@ -559,26 +588,36 @@ export function normalizeTools(
559
588
  tools: AgentContext["tools"],
560
589
  injectIntent: boolean,
561
590
  exampleDialect?: Dialect,
591
+ pruneDescriptions = false,
562
592
  ): Context["tools"] {
563
593
  injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
564
594
  return tools?.map(t => {
565
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
+ }
566
608
  let parameters: TSchema = t.parameters;
567
- if (injectIntent && intentMode !== "omit") {
609
+ if (doInjectIntent) {
568
610
  if (isZodSchema(parameters)) {
569
- const wired = zodToWireSchema(parameters);
570
- parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
611
+ parameters = injectIntentIntoSchema(zodToWireSchema(parameters), intentMode) as TSchema;
571
612
  } else if (isArkSchema(parameters)) {
572
- const wired = arkToWireSchema(parameters);
573
- parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
613
+ parameters = injectIntentIntoSchema(arkToWireSchema(parameters), intentMode) as TSchema;
574
614
  } else {
575
615
  parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
576
616
  }
577
617
  }
578
618
  const description = t.description ?? "";
579
- const injectExampleIntent = injectIntent && intentMode !== "omit";
580
619
  const examplesBlock = exampleDialect
581
- ? renderToolExamples({ ...t, parameters }, exampleDialect, injectExampleIntent ? INTENT_FIELD : undefined)
620
+ ? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined)
582
621
  : "";
583
622
  const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
584
623
  return { ...t, parameters, description: finalDescription };
@@ -714,6 +753,19 @@ async function runLoopBody(
714
753
  let harmonyTruncateResumeCount = 0;
715
754
  let pausedTurnContinuations = 0;
716
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
+
717
769
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
718
770
  while (true) {
719
771
  let hasMoreToolCalls = true;
@@ -749,6 +801,41 @@ async function runLoopBody(
749
801
  await config.syncContextBeforeModelCall(currentContext);
750
802
  }
751
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
+
752
839
  // Stream assistant response
753
840
  let recovered: HarmonyRecoveredToolCall | undefined;
754
841
  let message: AssistantMessage;
@@ -763,6 +850,8 @@ async function runLoopBody(
763
850
  stepCounter,
764
851
  streamFn,
765
852
  harmonyRetryAttempt,
853
+ hostToolChoice,
854
+ forcedToolChoice,
766
855
  );
767
856
  harmonyRetryAttempt = 0;
768
857
  harmonyTruncateResumeCount = 0;
@@ -779,6 +868,10 @@ async function runLoopBody(
779
868
  recovered = err.recovered;
780
869
  message = recovered.message;
781
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;
782
875
  } else {
783
876
  if (harmonyRetryAttempt >= 2) {
784
877
  await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
@@ -799,6 +892,14 @@ async function runLoopBody(
799
892
  }
800
893
  newMessages.push(message);
801
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
+
802
903
  if (message.stopReason === "error" || message.stopReason === "aborted") {
803
904
  // Create placeholder tool results for any tool calls in the aborted message
804
905
  // This maintains the tool_use/tool_result pairing that the API requires
@@ -851,8 +952,51 @@ async function runLoopBody(
851
952
  hasMoreToolCalls = false;
852
953
  }
853
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
+
854
967
  const toolResults: ToolResultMessage[] = [];
855
- 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) {
856
1000
  const executionResult = await executeToolCalls(
857
1001
  currentContext,
858
1002
  message,
@@ -1000,6 +1144,8 @@ async function streamAssistantResponse(
1000
1144
  stepCounter: StepCounter,
1001
1145
  streamFn?: StreamFn,
1002
1146
  harmonyRetryAttempt = 0,
1147
+ hostToolChoice?: ToolChoice,
1148
+ forcedToolChoice?: ToolChoice,
1003
1149
  ): Promise<AssistantMessage> {
1004
1150
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1005
1151
  let messages = context.messages;
@@ -1013,6 +1159,9 @@ async function streamAssistantResponse(
1013
1159
 
1014
1160
  const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1015
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;
1016
1165
  // Build LLM context — append-only mode caches system prompt + tools
1017
1166
  // AND keeps an append-only message log so prior-turn bytes are stable.
1018
1167
  let llmContext: Context;
@@ -1021,12 +1170,13 @@ async function streamAssistantResponse(
1021
1170
  llmContext = config.appendOnlyContext.build(context, {
1022
1171
  intentTracing: !!config.intentTracing,
1023
1172
  exampleDialect,
1173
+ pruneToolDescriptions,
1024
1174
  });
1025
1175
  } else {
1026
1176
  llmContext = {
1027
1177
  systemPrompt: context.systemPrompt,
1028
1178
  messages: normalizedMessages,
1029
- tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect),
1179
+ tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect, pruneToolDescriptions),
1030
1180
  };
1031
1181
  }
1032
1182
  if (config.transformProviderContext) {
@@ -1049,7 +1199,6 @@ async function streamAssistantResponse(
1049
1199
 
1050
1200
  const streamFunction = streamFn || streamSimple;
1051
1201
 
1052
- const dynamicToolChoice = config.getToolChoice?.();
1053
1202
  const dynamicReasoning = config.getReasoning?.();
1054
1203
  const dynamicDisableReasoning = config.getDisableReasoning?.();
1055
1204
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
@@ -1084,7 +1233,7 @@ async function streamAssistantResponse(
1084
1233
  const effectiveTemperature =
1085
1234
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1086
1235
  // Owned tool calling sends no native tools, so any tool_choice would error.
1087
- const effectiveToolChoice = ownedDialect ? undefined : (dynamicToolChoice ?? config.toolChoice);
1236
+ const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice);
1088
1237
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1089
1238
  const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
1090
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.
@@ -173,7 +173,7 @@ export function formatFileOperations(
173
173
  const all = [...mode.keys()].sort();
174
174
  let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
175
175
  if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
176
- files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`;
176
+ files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
177
177
  }
178
178
  return prompt.render(fileOperationsTemplate, { files });
179
179
  }
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.