@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 +11 -0
- package/dist/types/agent-loop.d.ts +1 -1
- package/dist/types/agent.d.ts +9 -3
- package/dist/types/append-only-context.d.ts +2 -0
- package/dist/types/compaction/messages.d.ts +6 -2
- package/dist/types/compaction/pruning.d.ts +33 -2
- package/dist/types/compaction/shake.d.ts +8 -0
- package/dist/types/types.d.ts +45 -3
- package/package.json +7 -7
- package/src/agent-loop.ts +166 -16
- package/src/agent.ts +21 -7
- package/src/append-only-context.ts +5 -1
- package/src/compaction/compaction.ts +10 -3
- package/src/compaction/messages.ts +21 -9
- package/src/compaction/pruning.ts +97 -15
- package/src/compaction/shake.ts +19 -0
- package/src/types.ts +50 -3
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
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -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
|
|
142
|
-
getToolChoice?: () =>
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
|
209
|
-
*
|
|
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?: () =>
|
|
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.
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
609
|
+
if (doInjectIntent) {
|
|
567
610
|
if (isZodSchema(parameters)) {
|
|
568
|
-
|
|
569
|
-
parameters = injectIntentIntoSchema(wired, intentMode) as TSchema;
|
|
611
|
+
parameters = injectIntentIntoSchema(zodToWireSchema(parameters), intentMode) as TSchema;
|
|
570
612
|
} else if (isArkSchema(parameters)) {
|
|
571
|
-
|
|
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,
|
|
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 (
|
|
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 : (
|
|
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
|
|
236
|
-
getToolChoice?: () =>
|
|
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?: () =>
|
|
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
|
|
1014
|
-
if (
|
|
1015
|
-
|
|
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 =
|
|
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"
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
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;
|
package/src/compaction/shake.ts
CHANGED
|
@@ -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
|
|
250
|
-
*
|
|
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?: () =>
|
|
299
|
+
getToolChoice?: () => ToolChoiceDirective | undefined;
|
|
253
300
|
|
|
254
301
|
/**
|
|
255
302
|
* Dynamic reasoning effort override, resolved per LLM call.
|