@oh-my-pi/pi-agent-core 18.2.10 → 18.3.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,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.3.0] - 2026-09-24
6
+
7
+ ### Added
8
+
9
+ - Added support for documenting agent tools on demand through the new `AgentTool.docTopics` method.
10
+ - Added `TOOL_INTERRUPT_ABORT_REASON` so interruptible tools can distinguish queued steering, peer messages, or background completions from a full run abort.
11
+
12
+ ### Changed
13
+
14
+ - Improved interrupt handling so tools respect wait mode and can be interrupted when appropriate.
15
+ - Updated Anthropic compaction compatibility with signature verification.
16
+
17
+ ## [18.2.11] - 2026-09-23
18
+
19
+ ### Fixed
20
+
21
+ - Fixed background job completions interrupting foreground Bash and eval calls, which could cause those calls to be repeatedly moved into the background.
22
+
5
23
  ## [18.2.9] - 2026-09-22
6
24
 
7
25
  ### Fixed
@@ -293,7 +293,7 @@ MIT License
293
293
  Copyright (c) 2026 Sander Land
294
294
  (measured tokenizer vocabulary data, reconstruction model, and reference
295
295
  implementation: https://github.com/sanderland/ctok)
296
- Copyright (c) 2026 Can Bölük and the Oh My Pi contributors
296
+ Copyright (c) 2026 Can Bölük and the omp contributors
297
297
  Copyright (c) 2026 Stencil Labs, Inc.
298
298
  (Rust implementation and the compact binary vocabulary encoding in
299
299
  crates/pi-natives/src/utok/claude)
@@ -32,6 +32,12 @@ export declare function createToolScopedAbortReason(message: string, toolCallMes
32
32
  * boundary; this reason stops after persisting the completed tool batch.
33
33
  */
34
34
  export declare const TERMINAL_TOOL_RESULT_ABORT_REASON: unique symbol;
35
+ /**
36
+ * Abort reason carried by an interruptible tool's signal when queued steering,
37
+ * a peer IRC, or a background completion cut it short. Lets a wait tell the
38
+ * designed wake path apart from an external/user abort of the run.
39
+ */
40
+ export declare const TOOL_INTERRUPT_ABORT_REASON: unique symbol;
35
41
  export declare function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined;
36
42
  /**
37
43
  * Start an agent loop with a new prompt message.
@@ -1,33 +1,21 @@
1
1
  /**
2
- * Anthropic server-side compaction (`compact-2026-01-12` beta).
2
+ * Anthropic on-demand compaction (`compact-2026-09-04` beta).
3
3
  *
4
- * The compaction request is the live turn's own request shape — same system
5
- * prompt, tools, and message history — plus the `compact_20260112` edit with
6
- * `pause_after_compaction`. The API summarizes the prompt from the already
7
- * cached prefix and stops; the summary arrives as a `compaction` block that
8
- * the provider surfaces as an `anthropicCompaction` payload. The summary is
9
- * plain text, so it doubles as the compaction entry's readable summary for
10
- * every other provider, while the Anthropic provider replays it as a native
11
- * block (the API drops everything that precedes it). The retained tail after
12
- * the cut point is replayed from session entries exactly like a local summary.
4
+ * The request sends only the prefix to summarize, with the live conversation's
5
+ * system prompt, tools and thinking settings. The returned signed block
6
+ * replaces that prefix; the retained tail is replayed from session entries.
13
7
  */
14
8
  import type { AnthropicCompactionPayload, ApiKey, Effort, Message, Model, SimpleStreamOptions, Tool, Usage } from "@oh-my-pi/pi-ai";
15
9
  import { type InstrumentedChatSpanOptions } from "../telemetry.js";
10
+ import type { AgentMessage } from "../types.js";
16
11
  export declare const ANTHROPIC_COMPACTION_PRESERVE_KEY = "anthropicCompaction";
17
- /** The API rejects a `compact_20260112` trigger below this many input tokens. */
18
- export declare const ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS = 50000;
19
- /**
20
- * Smallest context the native lane accepts. The trigger sits at the API
21
- * floor, so a prompt that lands below it is answered instead of compacted;
22
- * the margin over the floor absorbs the difference between the last reported
23
- * context size and the compaction request's own input.
24
- */
25
- export declare const ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS = 55000;
26
12
  /** Summary persisted under {@link ANTHROPIC_COMPACTION_PRESERVE_KEY}. */
27
13
  export interface AnthropicCompactionPreserveData {
28
14
  provider: string;
29
15
  content: string;
30
- /** Opaque provider state the API attached to the block; replayed verbatim. */
16
+ /** Signature attached to an on-demand block; replayed verbatim. */
17
+ signature?: string;
18
+ /** Legacy threshold block state; replay-only. */
31
19
  encryptedContent?: string;
32
20
  /** Harness file metadata (`<files>` section) replayed after the native block. */
33
21
  filesText?: string;
@@ -51,33 +39,17 @@ export declare function withAnthropicCompactionPreserveData(preserveData: Record
51
39
  /** Replay payload for a compaction summary the active model produced natively. */
52
40
  export declare function getAnthropicCompactionPayload(preserveData: Record<string, unknown> | undefined): AnthropicCompactionPayload | undefined;
53
41
  /**
54
- * The retained tail as the model will see it, for the summarization
55
- * instructions: how many of the conversation's final wire messages stay in
56
- * context verbatim, and the role of the first. The compaction request carries
57
- * the whole conversation so the prompt cache the live turn wrote is read, but
58
- * the summary must cover only the history before that tail — the local
59
- * summarizer never sees the tail, and the rebuilt context replays it after the
60
- * summary. Counting mirrors the provider's message conversion (consecutive
61
- * tool results collapse into one user message; developer messages are user
62
- * messages). Structured for the prompt template, which renders the
63
- * singular/plural wording; the description quotes no content: quoting the
64
- * tail would hand the summarizer the very facts it must leave to the tail.
42
+ * Move the existing keep-tail boundary forward until the summary/tail boundary
43
+ * alternates wire roles and no tool call is separated from its result. If no
44
+ * boundary is safe, the request summarizes the entire snapshot (empty tail).
65
45
  */
66
- export interface RetainedTailScope {
67
- count: number;
68
- role: "assistant" | "user";
69
- }
70
- export declare function describeRetainedTail(messages: readonly Message[]): RetainedTailScope | undefined;
71
- /**
72
- * Summarization prompt sent as the edit's `instructions`, which replace the
73
- * API default entirely. The template lays out the retained-tail boundary
74
- * first, so the summary covers only the history the rebuilt context drops,
75
- * then the caller's extra context, the same structure prompt as the local
76
- * summarizer, the caller's focus, and the tool-abstention clause the API
77
- * recommends when tools are defined (a summarization pass that calls a tool
78
- * yields no summary).
79
- */
80
- export declare function buildAnthropicCompactionInstructions(basePrompt: string, customInstructions: string | undefined, extraContext: string | undefined, retainedTail: RetainedTailScope | undefined): string;
46
+ export declare function findAnthropicCompactionCut(messages: readonly (AgentMessage | {
47
+ role: "system";
48
+ content: string;
49
+ timestamp: number;
50
+ })[], initialCut: number): number;
51
+ /** Instructions replace the API default; the request contains only summarized messages. */
52
+ export declare function buildAnthropicCompactionInstructions(basePrompt: string, customInstructions: string | undefined, extraContext: string | undefined): string;
81
53
  export interface AnthropicNativeCompactionRequest {
82
54
  systemPrompt: string[];
83
55
  messages: Message[];
@@ -88,21 +60,19 @@ export interface AnthropicNativeCompactionRequest {
88
60
  }
89
61
  export interface AnthropicNativeCompactionResponse {
90
62
  content: string;
91
- encryptedContent?: string;
63
+ signature: string;
92
64
  usage: Usage;
93
65
  model: string;
94
66
  }
95
67
  export interface AnthropicNativeCompactionOptions extends Pick<SimpleStreamOptions, "initiatorOverride" | "metadata" | "fetch" | "sessionId" | "promptCacheKey" | "providerSessionState" | "maxInFlightRequests">, Pick<InstrumentedChatSpanOptions, "completeImpl" | "telemetry" | "retry"> {
96
68
  }
97
69
  /**
98
- * Run one compaction request and return the summary the API wrote, with the
99
- * opaque `encrypted_content` the API attached for the replay. `completeSimple`
70
+ * Run one compaction request and return the summary and signature the API wrote. `completeSimple`
100
71
  * resolves terminal failures as messages, so their classification is restored
101
72
  * here: an aborted response is an `AbortError` (a cancellation, never a native
102
73
  * failure) and an error response keeps its HTTP status, so auth and timeout
103
74
  * handling downstream classify it the same way as the OpenAI lanes. A response
104
- * without a summary is a native failure — the API answers the prompt instead
105
- * when its input never reached the trigger, and returns an empty block when
106
- * the model called a tool during summarization.
75
+ * without a summary is a native failure, including tool use, refusals and
76
+ * output limits; the configured method order can then choose a fallback.
107
77
  */
108
78
  export declare function requestAnthropicNativeCompaction(model: Model<"anthropic-messages">, apiKey: ApiKey, request: AnthropicNativeCompactionRequest, signal: AbortSignal | undefined, options: AnthropicNativeCompactionOptions): Promise<AnthropicNativeCompactionResponse>;
@@ -297,6 +297,8 @@ export interface CompactionPreparation {
297
297
  turnPrefixMessages: AgentMessage[];
298
298
  /** Messages kept in full after compaction (recent history) */
299
299
  recentMessages: AgentMessage[];
300
+ /** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
301
+ recentEntryIds?: string[];
300
302
  /** Whether this is a split turn (cut point in middle of turn) */
301
303
  isSplitTurn: boolean;
302
304
  tokensBefore: number;
@@ -6,6 +6,7 @@ export * from "./pause.js";
6
6
  export * from "./proxy.js";
7
7
  export * from "./replay-policy.js";
8
8
  export * from "./run-collector.js";
9
+ export * from "./sent-tool-definitions.js";
9
10
  export * from "./speculative-execution.js";
10
11
  export * from "./telemetry.js";
11
12
  export * from "./thinking.js";
@@ -0,0 +1,17 @@
1
+ import type { Message, Tool } from "@oh-my-pi/pi-ai";
2
+ /**
3
+ * Last wire definition this Agent sent for each tool name, so a provider that keeps
4
+ * withdrawn tools declared (Anthropic `tool_removal`) can re-declare them byte-identically.
5
+ * Used by prepareProviderCall and Agent.buildSideRequestContext.
6
+ */
7
+ export declare class SentToolDefinitions {
8
+ #private;
9
+ /** Remember the definitions a request is about to send. */
10
+ record(tools: readonly Tool[]): void;
11
+ /**
12
+ * Definitions for names the latest `requestControls.tools.declared` in `messages` holds
13
+ * that are not in `active`; undefined when none. Names never sent by this Agent are
14
+ * skipped: the provider drops them from the declaration.
15
+ */
16
+ inactiveFor(messages: readonly Message[], active: readonly Tool[]): Tool[] | undefined;
17
+ }
@@ -3,6 +3,7 @@ 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.js";
5
5
  import type { AgentRunCoverage, AgentRunSummary } from "./run-collector.js";
6
+ import type { SentToolDefinitions } from "./sent-tool-definitions.js";
6
7
  import type { AgentTelemetryConfig } from "./telemetry.js";
7
8
  /** Stream function - can return sync or Promise for async config lookup */
8
9
  export type StreamFn = (...args: Parameters<typeof streamSimple>) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
@@ -117,8 +118,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
117
118
  model: Model;
118
119
  /**
119
120
  * When to interrupt tool execution for steering messages.
120
- * - "immediate" = check after each tool call (default)
121
- * - "wait" = defer steering until the current turn completes
121
+ * - "immediate" = cut interruptible waits short and raise the cooperative
122
+ * `steeringSignal` for other running tools (default)
123
+ * - "wait" = let non-interruptible tools finish undisturbed; interruptible
124
+ * waits are still cut short, since they have no work to complete
122
125
  */
123
126
  interruptMode?: "immediate" | "wait";
124
127
  /**
@@ -185,6 +188,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
185
188
  * and provider send.
186
189
  */
187
190
  transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
191
+ /** Remembers sent tool definitions to fill {@link Context.inactiveTools}. */
192
+ sentToolDefinitions?: SentToolDefinitions;
188
193
  /**
189
194
  * Resolves the API key or resolver for the current model before each LLM call.
190
195
  *
@@ -204,8 +209,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
204
209
  /**
205
210
  * Peeks whether steering messages are queued, without consuming them.
206
211
  *
207
- * Polled while a tool batch runs (unless interruptMode is "wait") to decide
208
- * whether to abort in-flight and skip not-yet-started *interruptible* waits;
212
+ * Polled while a tool batch runs (in "wait" mode, only when the batch holds an
213
+ * interruptible tool) to decide whether to abort in-flight and skip
214
+ * not-yet-started *interruptible* waits;
209
215
  * every other already-emitted call still executes and the message injects
210
216
  * at the batch boundary. The queue keeps
211
217
  * owning its messages until the loop reaches the next injection boundary and
@@ -232,7 +238,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
232
238
  * Peeks whether IRC messages should interrupt an interruptible waiting tool.
233
239
  *
234
240
  * Uses the same delivery rules as steering: the poll is non-consuming, only
235
- * runs for interruptible tools, and is ignored when interruptMode is "wait".
241
+ * runs for interruptible tools, and cuts them short even when interruptMode
242
+ * is "wait".
236
243
  * The host owns message injection at the next boundary.
237
244
  */
238
245
  hasIrcInterrupts?: () => boolean | Promise<boolean>;
@@ -241,9 +248,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
241
248
  * process) is queued for aside injection at the next boundary.
242
249
  *
243
250
  * Same rules as {@link hasIrcInterrupts}: non-consuming, only cuts
244
- * *interruptible* waits short, ignored when interruptMode is "wait". Without
245
- * it a completion notice sits behind an hour-long `hub wait` that the agent
246
- * would have abandoned had it seen the notice.
251
+ * *interruptible* waits short, in either interruptMode. Without
252
+ * it a completion notice sits behind an hour-long `wait` that the agent
253
+ * would have abandoned had it seen the notice. Unlike a peer IRC it never
254
+ * raises {@link ToolCallContext.steeringSignal}: a queued completion must
255
+ * not push ordinary foreground work (auto-background bash/eval) into the
256
+ * background.
247
257
  */
248
258
  hasBackgroundCompletions?: () => boolean | Promise<boolean>;
249
259
  /**
@@ -900,6 +910,12 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
900
910
  loadMode?: ToolLoadMode;
901
911
  /** Short one-line summary used for tool discovery indexes. */
902
912
  summary?: string;
913
+ /**
914
+ * On-demand documentation topics (`topic → markdown`), readable as
915
+ * `xd://<tool>/<topic>`. Lets a tool keep large sub-surfaces out of its
916
+ * description and advertise only a one-line pointer per topic.
917
+ */
918
+ docTopics?(): Readonly<Record<string, string>>;
903
919
  /**
904
920
  * Concurrency mode for tool scheduling when multiple calls are in one turn.
905
921
  * - "shared": can run alongside other shared tools (default)
@@ -923,7 +939,7 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
923
939
  * cleanly (e.g. `job` poll), so the abort surfaces the tool's current
924
940
  * snapshot rather than corrupting a side effect. Every other call runs to
925
941
  * completion even when steering is queued; the message lands at the next
926
- * batch boundary. Honored only when `interruptMode` is "immediate".
942
+ * batch boundary. Honored in both `interruptMode`s.
927
943
  */
928
944
  interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
929
945
  /**
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "18.2.10",
4
+ "version": "18.3.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
- "author": "Stencil Labs, Inc.",
7
+ "author": {
8
+ "name": "Stencil Labs, Inc.",
9
+ "url": "https://stencil.so"
10
+ },
8
11
  "contributors": [
9
12
  "Mario Zechner"
10
13
  ],
@@ -35,16 +38,16 @@
35
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
36
39
  },
37
40
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "18.2.10",
39
- "@oh-my-pi/pi-catalog": "18.2.10",
40
- "@oh-my-pi/pi-natives": "18.2.10",
41
- "@oh-my-pi/pi-utils": "18.2.10",
42
- "@oh-my-pi/pi-wire": "18.2.10",
43
- "@oh-my-pi/snapcompact": "18.2.10",
41
+ "@oh-my-pi/pi-ai": "18.3.0",
42
+ "@oh-my-pi/pi-catalog": "18.3.0",
43
+ "@oh-my-pi/pi-natives": "18.3.0",
44
+ "@oh-my-pi/pi-utils": "18.3.0",
45
+ "@oh-my-pi/pi-wire": "18.3.0",
46
+ "@oh-my-pi/snapcompact": "18.3.0",
44
47
  "@opentelemetry/api": "^1.9.1"
45
48
  },
46
49
  "devDependencies": {
47
- "@oh-my-pi/omptype": "18.2.10",
50
+ "@oh-my-pi/omptype": "18.3.0",
48
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
49
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
50
53
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import {
6
6
  type AssistantMessage,
7
7
  type AssistantMessageEvent,
8
+ type ApiKeyResolution,
8
9
  type ComputerAction,
9
10
  type ComputerSafetyCheck,
10
11
  type Context,
@@ -164,6 +165,13 @@ export function createToolScopedAbortReason(
164
165
  */
165
166
  export const TERMINAL_TOOL_RESULT_ABORT_REASON = Symbol.for("pi-agent-core.terminal-tool-result");
166
167
 
168
+ /**
169
+ * Abort reason carried by an interruptible tool's signal when queued steering,
170
+ * a peer IRC, or a background completion cut it short. Lets a wait tell the
171
+ * designed wake path apart from an external/user abort of the run.
172
+ */
173
+ export const TOOL_INTERRUPT_ABORT_REASON = Symbol.for("pi-agent-core.tool-interrupt");
174
+
167
175
  const STEERING_INTERRUPT_POLL_MS = 250;
168
176
 
169
177
  class HarmonyLeakInterruption extends Error {
@@ -1758,6 +1766,12 @@ async function prepareProviderCall(
1758
1766
  tools: undefined,
1759
1767
  };
1760
1768
  }
1769
+ // After `transformProviderContext`, so the recorded definitions are exactly what the provider receives.
1770
+ if (config.sentToolDefinitions && llmContext.tools) {
1771
+ config.sentToolDefinitions.record(llmContext.tools);
1772
+ const inactiveTools = config.sentToolDefinitions.inactiveFor(llmContext.messages, llmContext.tools);
1773
+ if (inactiveTools) llmContext = { ...llmContext, inactiveTools };
1774
+ }
1761
1775
  return { model, context: llmContext, promptToolWireTools, ownedDialect };
1762
1776
  }
1763
1777
 
@@ -1814,8 +1828,13 @@ async function streamAssistantResponse(
1814
1828
  ? providerAbortSignals[0]!
1815
1829
  : AbortSignal.any(providerAbortSignals);
1816
1830
  const requestApiKey = (config.getApiKey ? await config.getApiKey(model) : undefined) ?? config.apiKey;
1817
- const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
1818
- const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
1831
+ let resolvedCredential: ApiKeyResolution;
1832
+ const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal, resolved => {
1833
+ resolvedCredential = resolved;
1834
+ });
1835
+ const apiKey = isApiKeyResolver(requestApiKey)
1836
+ ? seedApiKeyResolver(resolvedCredential ?? resolvedApiKey, requestApiKey)
1837
+ : requestApiKey;
1819
1838
 
1820
1839
  // Re-resolve metadata after credential selection so the per-request value
1821
1840
  // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
@@ -2866,7 +2885,10 @@ async function executeToolCalls(
2866
2885
  const emittedToolResults: ToolResultMessage[] = [];
2867
2886
  const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
2868
2887
  const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
2869
- const shouldInterruptImmediately = interruptMode !== "wait";
2888
+ // `interruptMode: "wait"` only spares side-effecting work: interruptible
2889
+ // waits are always cut short, since a pure wait has nothing to finish and
2890
+ // would otherwise sit out its full window with a message already queued.
2891
+ const softInterrupts = interruptMode !== "wait";
2870
2892
  const steeringAbortController = new AbortController();
2871
2893
  const ircAbortController = new AbortController();
2872
2894
  // Cooperative channel: aborted when queued steering (or an interrupting
@@ -2875,7 +2897,7 @@ async function executeToolCalls(
2875
2897
  // backgrounds itself so the message injects promptly — but it never kills
2876
2898
  // anything; ignoring it is always safe.
2877
2899
  const steeringSoftController = new AbortController();
2878
- // Interruptible tools (pure waits: hub wait, vibe) observe steering +
2900
+ // Interruptible tools (pure waits: wait, vibe) observe steering +
2879
2901
  // external + IRC aborts. Every other tool sees ONLY the external signal:
2880
2902
  // neither queued steering nor a peer IRC ever hard-kills a partially
2881
2903
  // side-effecting foreground tool (e.g. `bash`) — those get the cooperative
@@ -2938,28 +2960,40 @@ async function executeToolCalls(
2938
2960
  const checkAsideInterrupts = async (): Promise<void> => {
2939
2961
  // Asides only fire once: an interrupt already recorded on interruptState
2940
2962
  // must not re-abort, and (unlike steering) never re-consumes a queue.
2941
- if (!shouldInterruptImmediately || signal?.aborted || interruptState.triggered) return;
2963
+ // A completion-triggered record is the exception — it leaves the
2964
+ // cooperative signal down, so keep polling until a peer IRC escalates
2965
+ // (only when soft interrupts are enabled; otherwise nothing is left).
2966
+ if (signal?.aborted) return;
2967
+ if (interruptState.triggered && (!softInterrupts || steeringSoftController.signal.aborted)) return;
2942
2968
  // Peer IRC and background completions (finished jobs, exited supervised
2943
2969
  // processes) hard-abort interruptible waits only; foreground tools keep
2944
- // running (no partial side effects) but get the cooperative soft signal
2945
- // so backgroundable work can step aside for the queued notice.
2970
+ // running (no partial side effects).
2946
2971
  let source: AsideInterruptSource | undefined;
2947
2972
  if (hasIrcInterrupts && (await hasIrcInterrupts())) source = "irc";
2948
- else if (hasBackgroundCompletions && (await hasBackgroundCompletions())) source = "background";
2949
- if (!source || interruptState.triggered) return;
2950
- interruptState.triggered = true;
2951
- interruptState.source = source;
2952
- ircAbortController.abort();
2953
- steeringSoftController.abort();
2973
+ else if (!interruptState.triggered && hasBackgroundCompletions && (await hasBackgroundCompletions()))
2974
+ source = "background";
2975
+ if (!source) return;
2976
+ if (!interruptState.triggered) {
2977
+ interruptState.triggered = true;
2978
+ interruptState.source = source;
2979
+ ircAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
2980
+ }
2981
+ // Only an urgent aside raises the cooperative signal that makes
2982
+ // backgroundable foreground work (auto-background bash/eval) detach
2983
+ // itself. A peer waiting on an IRC is blocked on this batch; a finished
2984
+ // background job is not — its notice is an aside that injects at the
2985
+ // batch boundary either way. Detaching ordinary foreground work for it
2986
+ // also cascades: the freshly detached job's own completion re-triggers
2987
+ // this check for the next command, so millisecond-long commands chain
2988
+ // into separate background deliveries (#12869).
2989
+ if (source !== "background" && softInterrupts) steeringSoftController.abort();
2954
2990
  };
2955
2991
 
2956
2992
  const checkSteering = async (): Promise<void> => {
2957
2993
  // `signal` (external/user abort) is checked separately from the internal
2958
2994
  // abort controllers: once the run is externally aborted it is unwinding
2959
2995
  // and the interrupt would be redundant.
2960
- if (!shouldInterruptImmediately || signal?.aborted) {
2961
- return;
2962
- }
2996
+ if (signal?.aborted) return;
2963
2997
  // Mid-batch steering detection must be non-consuming. If a direct
2964
2998
  // integration only provides getSteeringMessages(), the queue drains at the
2965
2999
  // injection boundary below; polling it here would strand or drop messages.
@@ -2977,8 +3011,9 @@ async function executeToolCalls(
2977
3011
  }
2978
3012
  }
2979
3013
  if (steeringQueued) {
2980
- // Queued steering hard-aborts only interruptible waits and raises the
2981
- // cooperative soft signal for everything else: the boundary dequeue
3014
+ // Queued steering hard-aborts only interruptible waits and (unless
3015
+ // interruptMode is "wait") raises the cooperative soft signal for
3016
+ // everything else: the boundary dequeue
2982
3017
  // below injects the message as soon as running tools finish (or
2983
3018
  // background themselves), and not-yet-started interruptible waits
2984
3019
  // are skipped. Idempotent — a second steer poll after the abort is
@@ -2986,8 +3021,8 @@ async function executeToolCalls(
2986
3021
  if (!steeringAbortController.signal.aborted) {
2987
3022
  interruptState.triggered = true;
2988
3023
  interruptState.source = steeringSource ?? "unknown";
2989
- steeringAbortController.abort();
2990
- steeringSoftController.abort();
3024
+ steeringAbortController.abort(TOOL_INTERRUPT_ABORT_REASON);
3025
+ if (softInterrupts) steeringSoftController.abort();
2991
3026
  }
2992
3027
  return;
2993
3028
  }
@@ -3037,7 +3072,7 @@ async function executeToolCalls(
3037
3072
 
3038
3073
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
3039
3074
  // A pending interrupt preempts not-yet-started *interruptible* waits so
3040
- // the message injects promptly instead of sitting out a `hub wait`.
3075
+ // the message injects promptly instead of sitting out a `wait`.
3041
3076
  // Non-interruptible work is never skipped, whatever the source: the
3042
3077
  // expensive part — generating the call — is already paid, the tool
3043
3078
  // itself is cheap, and a skip only makes the model re-emit the same
@@ -3296,12 +3331,15 @@ async function executeToolCalls(
3296
3331
 
3297
3332
  // While tool calls are in flight, queued steering or interrupting IRC would
3298
3333
  // otherwise wait out the tools' own window. Poll only non-consuming queues:
3299
- // detection hard-aborts interruptible waits (running or not yet started)
3300
- // and soft-signals cooperative tools (auto-background bash), so the boundary
3301
- // dequeue below injects the message promptly. Gated on immediate-interrupt
3302
- // mode; checkSteering is idempotent (no-op once triggered).
3334
+ // detection hard-aborts interruptible waits (running or not yet started),
3335
+ // and steering/IRC additionally soft-signal cooperative tools
3336
+ // (auto-background bash), so the boundary dequeue below injects the message
3337
+ // promptly. In "wait" mode only a batch holding an interruptible wait needs
3338
+ // the watch; checkSteering is idempotent (no-op once triggered).
3303
3339
  const hasAsidePeek = hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined;
3304
- const watchSteeringWhileRunning = shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasAsidePeek);
3340
+ const watchSteeringWhileRunning =
3341
+ (softInterrupts || records.some(record => record.interruptible)) &&
3342
+ (hasSteeringMessages !== undefined || hasAsidePeek);
3305
3343
  const eventDrivenSteeringWatch =
3306
3344
  watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
3307
3345
  const steeringWatchAbortController = new AbortController();
@@ -3335,7 +3373,17 @@ async function executeToolCalls(
3335
3373
  () => false,
3336
3374
  );
3337
3375
  if (!(await Promise.race([steeringChecked, watchAbortedFalse]))) return;
3338
- if (steeringWatchSignal.aborted || interruptState.triggered) return;
3376
+ // Stop once nothing is left to escalate: the cooperative signal
3377
+ // is up, or (without soft interrupts) the waits are cut. A
3378
+ // completion-only trigger leaves the soft signal down, so keep
3379
+ // watching: a genuine steer arriving afterwards must still
3380
+ // reach foreground tools.
3381
+ if (
3382
+ steeringWatchSignal.aborted ||
3383
+ steeringSoftController.signal.aborted ||
3384
+ (!softInterrupts && interruptState.triggered)
3385
+ )
3386
+ return;
3339
3387
  if (!(await Promise.race([steeringQueued, watchAbortedFalse]))) return;
3340
3388
  }
3341
3389
  })()
package/src/agent.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  } from "./agent-loop";
39
39
  import type { AppendOnlyContextManager } from "./append-only-context";
40
40
  import { isProviderRefusalMessage } from "./replay-policy";
41
+ import { SentToolDefinitions } from "./sent-tool-definitions";
41
42
  import { Tokenizer, tokenizerEncodingForModel } from "./tokenizer";
42
43
  import type {
43
44
  AgentBeforeModelCall,
@@ -389,6 +390,7 @@ export class Agent {
389
390
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
390
391
  #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
391
392
  #transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
393
+ #sentToolDefinitions = new SentToolDefinitions();
392
394
  #steeringQueue: AgentMessage[] = [];
393
395
  #followUpQueue: AgentMessage[] = [];
394
396
  #queuedMessageClaims: Partial<Record<QueuedMessageQueue, QueuedMessageClaim>> = {};
@@ -837,6 +839,11 @@ export class Agent {
837
839
  }) ?? []);
838
840
  let context: Context = { systemPrompt, messages, tools };
839
841
  if (this.#transformProviderContext) context = await this.#transformProviderContext(context, model);
842
+ // Side requests reuse the main loop's sent definitions without recording their own.
843
+ if (context.tools?.length) {
844
+ const inactiveTools = this.#sentToolDefinitions.inactiveFor(context.messages, context.tools);
845
+ if (inactiveTools) context = { ...context, inactiveTools };
846
+ }
840
847
  return context;
841
848
  }
842
849
 
@@ -1579,6 +1586,7 @@ export class Agent {
1579
1586
  preferWebsockets: this.#preferWebsockets,
1580
1587
  convertToLlm: this.#convertToLlm,
1581
1588
  transformProviderContext: this.#transformProviderContext,
1589
+ sentToolDefinitions: this.#sentToolDefinitions,
1582
1590
  transformContext: this.#transformContext,
1583
1591
  onPayload: this.#onPayload,
1584
1592
  onResponse: this.#onResponse,
@@ -1,21 +1,14 @@
1
1
  /**
2
- * Anthropic server-side compaction (`compact-2026-01-12` beta).
2
+ * Anthropic on-demand compaction (`compact-2026-09-04` beta).
3
3
  *
4
- * The compaction request is the live turn's own request shape — same system
5
- * prompt, tools, and message history — plus the `compact_20260112` edit with
6
- * `pause_after_compaction`. The API summarizes the prompt from the already
7
- * cached prefix and stops; the summary arrives as a `compaction` block that
8
- * the provider surfaces as an `anthropicCompaction` payload. The summary is
9
- * plain text, so it doubles as the compaction entry's readable summary for
10
- * every other provider, while the Anthropic provider replays it as a native
11
- * block (the API drops everything that precedes it). The retained tail after
12
- * the cut point is replayed from session entries exactly like a local summary.
4
+ * The request sends only the prefix to summarize, with the live conversation's
5
+ * system prompt, tools and thinking settings. The returned signed block
6
+ * replaces that prefix; the retained tail is replayed from session entries.
13
7
  */
14
8
 
15
9
  import type {
16
10
  AnthropicCompactionPayload,
17
11
  ApiKey,
18
- AssistantMessage,
19
12
  Effort,
20
13
  Message,
21
14
  Model,
@@ -27,26 +20,18 @@ import * as AIError from "@oh-my-pi/pi-ai/error";
27
20
  import { supportsAnthropicCompaction } from "@oh-my-pi/pi-ai/providers/anthropic-compaction";
28
21
  import { isRecord, prompt } from "@oh-my-pi/pi-utils";
29
22
  import { type InstrumentedChatSpanOptions, instrumentedCompleteSimple } from "../telemetry";
23
+ import type { AgentMessage } from "../types";
30
24
  import anthropicCompactionInstructionsPrompt from "./prompts/anthropic-compaction-instructions.md" with { type: "text" };
31
25
 
32
26
  export const ANTHROPIC_COMPACTION_PRESERVE_KEY = "anthropicCompaction";
33
27
 
34
- /** The API rejects a `compact_20260112` trigger below this many input tokens. */
35
- export const ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS = 50_000;
36
-
37
- /**
38
- * Smallest context the native lane accepts. The trigger sits at the API
39
- * floor, so a prompt that lands below it is answered instead of compacted;
40
- * the margin over the floor absorbs the difference between the last reported
41
- * context size and the compaction request's own input.
42
- */
43
- export const ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS = 55_000;
44
-
45
28
  /** Summary persisted under {@link ANTHROPIC_COMPACTION_PRESERVE_KEY}. */
46
29
  export interface AnthropicCompactionPreserveData {
47
30
  provider: string;
48
31
  content: string;
49
- /** Opaque provider state the API attached to the block; replayed verbatim. */
32
+ /** Signature attached to an on-demand block; replayed verbatim. */
33
+ signature?: string;
34
+ /** Legacy threshold block state; replay-only. */
50
35
  encryptedContent?: string;
51
36
  /** Harness file metadata (`<files>` section) replayed after the native block. */
52
37
  filesText?: string;
@@ -82,6 +67,9 @@ export function getPreservedAnthropicCompactionData(
82
67
  return {
83
68
  provider: candidate.provider,
84
69
  content: candidate.content,
70
+ ...(typeof candidate.signature === "string" && candidate.signature.length > 0
71
+ ? { signature: candidate.signature }
72
+ : {}),
85
73
  ...(typeof candidate.encryptedContent === "string" && candidate.encryptedContent.length > 0
86
74
  ? { encryptedContent: candidate.encryptedContent }
87
75
  : {}),
@@ -118,93 +106,63 @@ export function getAnthropicCompactionPayload(
118
106
  type: "anthropicCompaction",
119
107
  provider: preserved.provider,
120
108
  content: preserved.content,
109
+ ...(preserved.signature ? { signature: preserved.signature } : {}),
121
110
  ...(preserved.encryptedContent ? { encryptedContent: preserved.encryptedContent } : {}),
122
111
  ...(preserved.filesText ? { filesText: preserved.filesText } : {}),
123
112
  };
124
113
  }
125
114
 
126
115
  /**
127
- * The retained tail as the model will see it, for the summarization
128
- * instructions: how many of the conversation's final wire messages stay in
129
- * context verbatim, and the role of the first. The compaction request carries
130
- * the whole conversation so the prompt cache the live turn wrote is read, but
131
- * the summary must cover only the history before that tail — the local
132
- * summarizer never sees the tail, and the rebuilt context replays it after the
133
- * summary. Counting mirrors the provider's message conversion (consecutive
134
- * tool results collapse into one user message; developer messages are user
135
- * messages). Structured for the prompt template, which renders the
136
- * singular/plural wording; the description quotes no content: quoting the
137
- * tail would hand the summarizer the very facts it must leave to the tail.
116
+ * Move the existing keep-tail boundary forward until the summary/tail boundary
117
+ * alternates wire roles and no tool call is separated from its result. If no
118
+ * boundary is safe, the request summarizes the entire snapshot (empty tail).
138
119
  */
139
- export interface RetainedTailScope {
140
- count: number;
141
- role: "assistant" | "user";
142
- }
143
-
144
- export function describeRetainedTail(messages: readonly Message[]): RetainedTailScope | undefined {
145
- const first = messages[0];
146
- if (!first) return undefined;
147
- let count = 0;
148
- let previousWasToolResult = false;
149
- for (const message of messages) {
150
- const isToolResult = message.role === "toolResult";
151
- if (!(isToolResult && previousWasToolResult)) count += 1;
152
- previousWasToolResult = isToolResult;
120
+ export function findAnthropicCompactionCut(
121
+ messages: readonly (AgentMessage | { role: "system"; content: string; timestamp: number })[],
122
+ initialCut: number,
123
+ ): number {
124
+ let calls: Map<string, number> | undefined;
125
+ for (let i = 0; i < messages.length; i++) {
126
+ const message = messages[i];
127
+ if (message.role !== "assistant") continue;
128
+ for (const block of message.content) {
129
+ if (block.type === "toolCall") (calls ??= new Map()).set(block.id, i);
130
+ }
153
131
  }
154
- // Mirror the provider's trailing-assistant prefill: a tail ending in a
155
- // live assistant turn gains a synthetic trailing user message on the
156
- // wire, which stays verbatim too. Only blocks the converter emits count —
157
- // blank text never serializes, and images, redacted thinking, and fallback
158
- // markers need target context this scope lacks, so a turn of only those
159
- // emits nothing and draws no pad. Without the pad in
160
- // the scope, the summary could duplicate the tail head.
161
- const last = messages[messages.length - 1];
162
- if (last?.role === "assistant" && last.content.some(emitsWireBlock)) {
163
- count += 1;
132
+ let lastResult: Int32Array | undefined;
133
+ if (calls) {
134
+ lastResult = new Int32Array(messages.length);
135
+ for (let i = 0; i < messages.length; i++) {
136
+ const message = messages[i];
137
+ if (message.role !== "toolResult") continue;
138
+ const callIndex = calls.get(message.toolCallId);
139
+ if (callIndex !== undefined) lastResult[callIndex] = i;
140
+ }
164
141
  }
165
- return { count, role: first.role === "assistant" ? "assistant" : "user" };
166
- }
167
-
168
- /**
169
- * Whether an assistant content block reaches the wire. Server-tool blocks
170
- * serialize unconditionally; blank text never does. Redacted thinking and
171
- * fallback markers replay only for specific deployments, which the scope
172
- * cannot see, so a turn of only those conservatively draws no pad.
173
- */
174
- function emitsWireBlock(block: AssistantMessage["content"][number]): boolean {
175
- switch (block.type) {
176
- case "text":
177
- return block.text.trim().length > 0;
178
- case "toolCall":
179
- case "anthropicServerTool":
180
- return true;
181
- case "thinking":
182
- return block.thinking.trim().length > 0 || (block.thinkingSignature ?? "").trim().length > 0;
183
- default:
184
- return false;
142
+ let protectedThrough = -1;
143
+ for (let i = 0; i < initialCut; i++) protectedThrough = Math.max(protectedThrough, lastResult?.[i] ?? -1);
144
+ for (let cut = initialCut; cut < messages.length; cut++) {
145
+ protectedThrough = Math.max(protectedThrough, lastResult?.[cut - 1] ?? -1);
146
+ if (cut <= protectedThrough) continue;
147
+ const first = messages[cut];
148
+ if (first.role === "system" || first.role === "developer") continue;
149
+ const previous = messages[cut - 1];
150
+ if (!previous) continue;
151
+ if ((previous.role === "assistant") !== (first.role === "assistant")) return cut;
185
152
  }
153
+ return messages.length;
186
154
  }
187
155
 
188
- /**
189
- * Summarization prompt sent as the edit's `instructions`, which replace the
190
- * API default entirely. The template lays out the retained-tail boundary
191
- * first, so the summary covers only the history the rebuilt context drops,
192
- * then the caller's extra context, the same structure prompt as the local
193
- * summarizer, the caller's focus, and the tool-abstention clause the API
194
- * recommends when tools are defined (a summarization pass that calls a tool
195
- * yields no summary).
196
- */
156
+ /** Instructions replace the API default; the request contains only summarized messages. */
197
157
  export function buildAnthropicCompactionInstructions(
198
158
  basePrompt: string,
199
159
  customInstructions: string | undefined,
200
160
  extraContext: string | undefined,
201
- retainedTail: RetainedTailScope | undefined,
202
161
  ): string {
203
162
  return prompt.render(anthropicCompactionInstructionsPrompt, {
204
163
  basePrompt,
205
164
  customInstructions,
206
165
  extraContext,
207
- retainedTail,
208
166
  });
209
167
  }
210
168
 
@@ -219,7 +177,7 @@ export interface AnthropicNativeCompactionRequest {
219
177
 
220
178
  export interface AnthropicNativeCompactionResponse {
221
179
  content: string;
222
- encryptedContent?: string;
180
+ signature: string;
223
181
  usage: Usage;
224
182
  model: string;
225
183
  }
@@ -239,15 +197,13 @@ export interface AnthropicNativeCompactionOptions
239
197
  Pick<InstrumentedChatSpanOptions, "completeImpl" | "telemetry" | "retry"> {}
240
198
 
241
199
  /**
242
- * Run one compaction request and return the summary the API wrote, with the
243
- * opaque `encrypted_content` the API attached for the replay. `completeSimple`
200
+ * Run one compaction request and return the summary and signature the API wrote. `completeSimple`
244
201
  * resolves terminal failures as messages, so their classification is restored
245
202
  * here: an aborted response is an `AbortError` (a cancellation, never a native
246
203
  * failure) and an error response keeps its HTTP status, so auth and timeout
247
204
  * handling downstream classify it the same way as the OpenAI lanes. A response
248
- * without a summary is a native failure — the API answers the prompt instead
249
- * when its input never reached the trigger, and returns an empty block when
250
- * the model called a tool during summarization.
205
+ * without a summary is a native failure, including tool use, refusals and
206
+ * output limits; the configured method order can then choose a fallback.
251
207
  */
252
208
  export async function requestAnthropicNativeCompaction(
253
209
  model: Model<"anthropic-messages">,
@@ -271,11 +227,7 @@ export async function requestAnthropicNativeCompaction(
271
227
  promptCacheKey: options.promptCacheKey,
272
228
  providerSessionState: options.providerSessionState,
273
229
  maxInFlightRequests: options.maxInFlightRequests,
274
- anthropicCompaction: {
275
- triggerInputTokens: ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS,
276
- pauseAfterCompaction: true,
277
- instructions: request.instructions,
278
- },
230
+ anthropicCompaction: { instructions: request.instructions },
279
231
  },
280
232
  {
281
233
  telemetry: options.telemetry,
@@ -294,16 +246,21 @@ export async function requestAnthropicNativeCompaction(
294
246
  : new AIError.ProviderHttpError(message, response.errorStatus);
295
247
  }
296
248
  const payload = response.providerPayload;
297
- if (payload?.type !== "anthropicCompaction" || payload.content.length === 0) {
249
+ if (
250
+ response.stopDetails?.type !== "compaction" ||
251
+ payload?.type !== "anthropicCompaction" ||
252
+ !payload.content ||
253
+ !payload.signature
254
+ ) {
298
255
  throw new Error(
299
256
  response.stopDetails?.type === "compaction"
300
- ? "Anthropic compaction returned no summary"
257
+ ? "Anthropic compaction returned no signed summary"
301
258
  : "Anthropic compaction response carried no compaction block",
302
259
  );
303
260
  }
304
261
  return {
305
262
  content: payload.content,
306
- encryptedContent: payload.encryptedContent,
263
+ signature: payload.signature,
307
264
  usage: response.usage,
308
265
  model: response.model,
309
266
  };
@@ -43,9 +43,8 @@ import { ThinkingLevel } from "../thinking";
43
43
  import { Tokenizer } from "../tokenizer";
44
44
  import type { AgentMessage } from "../types";
45
45
  import {
46
- ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS,
47
46
  buildAnthropicCompactionInstructions,
48
- describeRetainedTail,
47
+ findAnthropicCompactionCut,
49
48
  getPreservedAnthropicCompactionData,
50
49
  requestAnthropicNativeCompaction,
51
50
  shouldUseAnthropicNativeCompaction,
@@ -1252,6 +1251,8 @@ export interface CompactionPreparation {
1252
1251
  turnPrefixMessages: AgentMessage[];
1253
1252
  /** Messages kept in full after compaction (recent history) */
1254
1253
  recentMessages: AgentMessage[];
1254
+ /** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
1255
+ recentEntryIds?: string[];
1255
1256
  /** Whether this is a split turn (cut point in middle of turn) */
1256
1257
  isSplitTurn: boolean;
1257
1258
  tokensBefore: number;
@@ -1380,16 +1381,22 @@ export function prepareCompaction(
1380
1381
  }
1381
1382
  }
1382
1383
  } else if (previousCompaction) {
1383
- // Local summaries exclude the retained tail, whose original entries precede
1384
- // the compaction record. Native replay already carries that tail. Only look
1385
- // backwards: advisor snapshots put all retained messages after the summary
1386
- // and may carry a keep ID from their previous, differently indexed snapshot.
1384
+ // Local and Anthropic summaries exclude the retained tail, whose
1385
+ // original entries precede the compaction record.
1387
1386
  for (let i = resetBoundaryIndex + 1; i < prevCompactionIndex; i++) {
1388
1387
  if (pathEntries[i].id === previousCompaction.firstKeptEntryId) {
1389
1388
  boundaryStart = i;
1390
1389
  break;
1391
1390
  }
1392
1391
  }
1392
+ if (previousCompaction.firstKeptEntryId === "" && previousCompaction.providerReplayThroughEntryId) {
1393
+ // An empty snapshot tail can still have turns appended during
1394
+ // background compaction; those start after the summarized snapshot.
1395
+ const snapshotIdx = pathEntries.findIndex(
1396
+ entry => entry.id === previousCompaction.providerReplayThroughEntryId,
1397
+ );
1398
+ if (snapshotIdx >= 0 && snapshotIdx < prevCompactionIndex) boundaryStart = snapshotIdx + 1;
1399
+ }
1393
1400
  }
1394
1401
 
1395
1402
  // Keep original IDs beside the converted messages so estimation, cutting,
@@ -1437,6 +1444,11 @@ export function prepareCompaction(
1437
1444
  return undefined;
1438
1445
  }
1439
1446
 
1447
+ const recentEntryIds: string[] = [];
1448
+ for (let i = cutPoint.firstKeptEntryIndex; i < compactionEntries.length; i++) {
1449
+ recentEntryIds.push(compactionEntries[i].id);
1450
+ }
1451
+
1440
1452
  // Extract file operations from messages and previous compaction
1441
1453
  const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
1442
1454
 
@@ -1452,6 +1464,7 @@ export function prepareCompaction(
1452
1464
  messagesToSummarize,
1453
1465
  turnPrefixMessages,
1454
1466
  recentMessages,
1467
+ recentEntryIds,
1455
1468
  isSplitTurn: cutPoint.isSplitTurn,
1456
1469
  tokensBefore,
1457
1470
  previousSummary: previousCompaction?.summary,
@@ -1579,6 +1592,7 @@ export async function compact(
1579
1592
  messagesToSummarize,
1580
1593
  turnPrefixMessages,
1581
1594
  recentMessages,
1595
+ recentEntryIds,
1582
1596
  isSplitTurn,
1583
1597
  tokensBefore,
1584
1598
  previousSummary,
@@ -1826,35 +1840,19 @@ export async function compact(
1826
1840
  }
1827
1841
  }
1828
1842
 
1829
- // Anthropic server-side compaction: the live turn's request shape plus the
1830
- // compact edit, so the API summarizes from its cached prefix. The summary is
1831
- // real text, persisted both as the entry summary and as the native replay
1832
- // payload. A context below the API's trigger floor cannot compact remotely
1833
- // and takes the local summarizer instead — an eligibility boundary, not a
1834
- // failure.
1843
+ // On-demand compaction summarizes only the prefix; the tail is never sent
1844
+ // to this request and is replayed after the returned signed block.
1835
1845
  let nativeSummary: string | undefined;
1836
- let nativeEncryptedContent: string | undefined;
1846
+ let nativeSignature: string | undefined;
1847
+ let nativeFirstKeptEntryId = firstKeptEntryId;
1837
1848
  let nativeUsedTokens: number | undefined;
1838
- if (
1839
- !usedRemoteCompaction &&
1840
- settings.remoteEnabled !== false &&
1841
- shouldUseAnthropicNativeCompaction(model) &&
1842
- tokensBefore >= ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS
1843
- ) {
1849
+ if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseAnthropicNativeCompaction(model)) {
1844
1850
  const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
1845
- // Lead with the previous summary exactly as the live context renders it:
1846
- // natively when this provider wrote it, as text otherwise. The request
1847
- // then shares the live turn's prefix byte-for-byte. A prior snapcompact
1848
- // archive is already merged into that summary text, so the archive
1849
- // migration message the OpenAI lanes carry is omitted here.
1850
- // The rewrite marker must precede every message this request replays —
1851
- // summarized history and retained tail alike — exactly like the live
1852
- // context rebuild predates its tail. A previous compaction's commit
1853
- // timestamp is newer than re-retained or re-summarized turns, so
1854
- // reusing it would strip their bound thinking only in this request,
1855
- // diverging from the cached live prefix (and possibly dropping below
1856
- // the trigger). Manually built preparations with no input at all fall
1857
- // back to the current time.
1851
+ // Lead with the previous summary as the live context renders it:
1852
+ // natively when this provider wrote it, as text otherwise.
1853
+ // A prior snapcompact archive is already merged into that summary
1854
+ // text. Predate the rewrite marker before all replayed messages so
1855
+ // retained thinking stays bound to the original prefix.
1858
1856
  const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
1859
1857
  const previousSummaryAt =
1860
1858
  firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
@@ -1866,6 +1864,7 @@ export async function compact(
1866
1864
  type: "anthropicCompaction",
1867
1865
  provider: previousNative.provider,
1868
1866
  content: previousNative.content,
1867
+ ...(previousNative.signature ? { signature: previousNative.signature } : {}),
1869
1868
  ...(previousNative.encryptedContent
1870
1869
  ? { encryptedContent: previousNative.encryptedContent }
1871
1870
  : {}),
@@ -1874,29 +1873,40 @@ export async function compact(
1874
1873
  : undefined,
1875
1874
  })
1876
1875
  : undefined;
1877
- const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1878
- const retainedTail = convertToLlm(recentMessages);
1879
- const messages = [
1880
- ...convertToLlm([
1881
- ...(previousSummaryMessage ? [previousSummaryMessage] : []),
1882
- ...messagesToSummarize,
1883
- ...turnPrefixMessages,
1884
- ]),
1885
- ...retainedTail,
1886
- ];
1876
+ const allMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
1877
+ const originalCut = messagesToSummarize.length + turnPrefixMessages.length;
1878
+ const nativeCut = findAnthropicCompactionCut(allMessages, originalCut);
1879
+ // Hand-built preparations lacking entry IDs cannot move their persisted
1880
+ // boundary into the tail. Summarizing all is still safe.
1881
+ const safeCut =
1882
+ nativeCut > originalCut && nativeCut < allMessages.length && !recentEntryIds?.[nativeCut - originalCut]
1883
+ ? allMessages.length
1884
+ : nativeCut;
1885
+ nativeFirstKeptEntryId =
1886
+ safeCut === allMessages.length
1887
+ ? ""
1888
+ : safeCut === originalCut
1889
+ ? firstKeptEntryId
1890
+ : (recentEntryIds?.[safeCut - originalCut] ?? "");
1891
+ for (let i = originalCut; i < safeCut; i++) extractFileOpsFromMessage(allMessages[i], fileOps);
1892
+ const messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)([
1893
+ ...(previousSummaryMessage ? [previousSummaryMessage] : []),
1894
+ ...allMessages.slice(0, safeCut),
1895
+ ]);
1887
1896
  try {
1888
1897
  const remote = await requestAnthropicNativeCompaction(
1889
1898
  model,
1890
1899
  apiKey,
1891
1900
  {
1892
- systemPrompt: summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT],
1901
+ // The live prompt, not the local summarizer's synthetic system
1902
+ // prompt: kept thinking remains valid only under identical controls.
1903
+ systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
1893
1904
  messages,
1894
1905
  tools: summaryOptions.tools,
1895
1906
  instructions: buildAnthropicCompactionInstructions(
1896
1907
  summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
1897
1908
  customInstructions,
1898
1909
  formatAdditionalContext(summaryOptions.extraContext).trim() || undefined,
1899
- describeRetainedTail(retainedTail),
1900
1910
  ),
1901
1911
  maxTokens: Math.min(Math.floor(0.8 * reserveTokens), MAX_SUMMARY_TOKENS),
1902
1912
  reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
@@ -1915,7 +1925,7 @@ export async function compact(
1915
1925
  },
1916
1926
  );
1917
1927
  nativeSummary = remote.content;
1918
- nativeEncryptedContent = remote.encryptedContent;
1928
+ nativeSignature = remote.signature;
1919
1929
  nativeUsedTokens = calculatePromptTokens(remote.usage);
1920
1930
  usedRemoteCompaction = true;
1921
1931
  } catch (err) {
@@ -2004,7 +2014,7 @@ export async function compact(
2004
2014
  summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
2005
2015
  if (nativeSummary !== undefined) {
2006
2016
  // The replayed block stays byte-identical to the API's summary so it
2007
- // matches `encryptedContent`. The harness file lists above travel
2017
+ // matches its signature. The harness file lists above travel
2008
2018
  // separately: the converter replaces the summary message with the
2009
2019
  // block and skips its text, so they would otherwise be invisible to
2010
2020
  // this provider. Every other provider keeps reading the entry text.
@@ -2012,7 +2022,7 @@ export async function compact(
2012
2022
  preserveData = withAnthropicCompactionPreserveData(preserveData, {
2013
2023
  provider: model.provider,
2014
2024
  content: nativeSummary,
2015
- ...(nativeEncryptedContent ? { encryptedContent: nativeEncryptedContent } : {}),
2025
+ ...(nativeSignature ? { signature: nativeSignature } : {}),
2016
2026
  ...(filesText ? { filesText } : {}),
2017
2027
  model: model.id,
2018
2028
  usedTokens: nativeUsedTokens,
@@ -2034,7 +2044,7 @@ export async function compact(
2034
2044
  return {
2035
2045
  summary,
2036
2046
  shortSummary,
2037
- firstKeptEntryId,
2047
+ firstKeptEntryId: nativeSummary !== undefined ? nativeFirstKeptEntryId : firstKeptEntryId,
2038
2048
  tokensBefore,
2039
2049
  details: { readFiles, modifiedFiles } as CompactionDetails,
2040
2050
  preserveData: finalPreserveData,
@@ -1,8 +1,4 @@
1
- {{#if retainedTail}}
2
- SCOPE: The conversation's final {{#when retainedTail.count "==" 1}}{{retainedTail.role}} message stays{{else}}{{retainedTail.count}} messages, starting with a {{retainedTail.role}} message, stay{{/when}} in context verbatim after your summary. Summarize ONLY the history before those messages. You MUST NOT restate anything from those final messages — the reader sees them right after the summary — and you MUST treat them as the most recent state when describing progress and next steps.
3
- {{else}}
4
- SCOPE: The conversation above is the transcript to summarize. The API replaces everything before your summary with it, so nothing you leave out survives into the next context window.
5
- {{/if}}
1
+ The conversation above is the complete history to summarize. The API replaces every message in this request with your summary; no message you leave out survives.
6
2
 
7
3
  {{#if extraContext}}
8
4
  {{extraContext}}
@@ -14,4 +10,4 @@ SCOPE: The conversation above is the transcript to summarize. The API replaces e
14
10
  Additional focus: {{customInstructions}}
15
11
  {{/if}}
16
12
 
17
- You MUST NOT call any tools while writing the summary; respond with the summary text only.
13
+ You MUST NOT call any tools while writing the summary; respond with the summary text only.
package/src/index.ts CHANGED
@@ -14,6 +14,8 @@ export * from "./proxy";
14
14
  export * from "./replay-policy";
15
15
  // Run-level telemetry collector + aggregators
16
16
  export * from "./run-collector";
17
+ // Tool definitions remembered for Anthropic inactive-tool re-declaration
18
+ export * from "./sent-tool-definitions";
17
19
  // Speculative execution coordinator
18
20
  export * from "./speculative-execution";
19
21
  // Telemetry
@@ -0,0 +1,40 @@
1
+ import type { Message, Tool } from "@oh-my-pi/pi-ai";
2
+
3
+ /**
4
+ * Last wire definition this Agent sent for each tool name, so a provider that keeps
5
+ * withdrawn tools declared (Anthropic `tool_removal`) can re-declare them byte-identically.
6
+ * Used by prepareProviderCall and Agent.buildSideRequestContext.
7
+ */
8
+ export class SentToolDefinitions {
9
+ #byName = new Map<string, Tool>();
10
+
11
+ /** Remember the definitions a request is about to send. */
12
+ record(tools: readonly Tool[]): void {
13
+ for (const tool of tools) this.#byName.set(tool.name, tool);
14
+ }
15
+
16
+ /**
17
+ * Definitions for names the latest `requestControls.tools.declared` in `messages` holds
18
+ * that are not in `active`; undefined when none. Names never sent by this Agent are
19
+ * skipped: the provider drops them from the declaration.
20
+ */
21
+ inactiveFor(messages: readonly Message[], active: readonly Tool[]): Tool[] | undefined {
22
+ let declared: readonly string[] | undefined;
23
+ for (let index = messages.length - 1; index >= 0; index--) {
24
+ const message = messages[index];
25
+ if (message?.role === "assistant" && message.requestControls?.tools) {
26
+ declared = message.requestControls.tools.declared;
27
+ break;
28
+ }
29
+ }
30
+ if (!declared) return undefined;
31
+ const activeNames = new Set(active.map(tool => tool.name));
32
+ const inactive: Tool[] = [];
33
+ for (const name of declared) {
34
+ if (activeNames.has(name)) continue;
35
+ const tool = this.#byName.get(name);
36
+ if (tool) inactive.push(tool);
37
+ }
38
+ return inactive.length > 0 ? inactive : undefined;
39
+ }
40
+ }
package/src/types.ts CHANGED
@@ -24,6 +24,7 @@ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
24
24
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
25
25
  import type { AppendOnlyContextManager } from "./append-only-context";
26
26
  import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
27
+ import type { SentToolDefinitions } from "./sent-tool-definitions";
27
28
  import type { AgentTelemetryConfig } from "./telemetry";
28
29
 
29
30
  /** Stream function - can return sync or Promise for async config lookup */
@@ -163,8 +164,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
163
164
 
164
165
  /**
165
166
  * When to interrupt tool execution for steering messages.
166
- * - "immediate" = check after each tool call (default)
167
- * - "wait" = defer steering until the current turn completes
167
+ * - "immediate" = cut interruptible waits short and raise the cooperative
168
+ * `steeringSignal` for other running tools (default)
169
+ * - "wait" = let non-interruptible tools finish undisturbed; interruptible
170
+ * waits are still cut short, since they have no work to complete
168
171
  */
169
172
  interruptMode?: "immediate" | "wait";
170
173
 
@@ -238,6 +241,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
238
241
  */
239
242
  transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
240
243
 
244
+ /** Remembers sent tool definitions to fill {@link Context.inactiveTools}. */
245
+ sentToolDefinitions?: SentToolDefinitions;
246
+
241
247
  /**
242
248
  * Resolves the API key or resolver for the current model before each LLM call.
243
249
  *
@@ -259,8 +265,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
259
265
  /**
260
266
  * Peeks whether steering messages are queued, without consuming them.
261
267
  *
262
- * Polled while a tool batch runs (unless interruptMode is "wait") to decide
263
- * whether to abort in-flight and skip not-yet-started *interruptible* waits;
268
+ * Polled while a tool batch runs (in "wait" mode, only when the batch holds an
269
+ * interruptible tool) to decide whether to abort in-flight and skip
270
+ * not-yet-started *interruptible* waits;
264
271
  * every other already-emitted call still executes and the message injects
265
272
  * at the batch boundary. The queue keeps
266
273
  * owning its messages until the loop reaches the next injection boundary and
@@ -289,7 +296,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
289
296
  * Peeks whether IRC messages should interrupt an interruptible waiting tool.
290
297
  *
291
298
  * Uses the same delivery rules as steering: the poll is non-consuming, only
292
- * runs for interruptible tools, and is ignored when interruptMode is "wait".
299
+ * runs for interruptible tools, and cuts them short even when interruptMode
300
+ * is "wait".
293
301
  * The host owns message injection at the next boundary.
294
302
  */
295
303
  hasIrcInterrupts?: () => boolean | Promise<boolean>;
@@ -298,9 +306,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
298
306
  * process) is queued for aside injection at the next boundary.
299
307
  *
300
308
  * Same rules as {@link hasIrcInterrupts}: non-consuming, only cuts
301
- * *interruptible* waits short, ignored when interruptMode is "wait". Without
302
- * it a completion notice sits behind an hour-long `hub wait` that the agent
303
- * would have abandoned had it seen the notice.
309
+ * *interruptible* waits short, in either interruptMode. Without
310
+ * it a completion notice sits behind an hour-long `wait` that the agent
311
+ * would have abandoned had it seen the notice. Unlike a peer IRC it never
312
+ * raises {@link ToolCallContext.steeringSignal}: a queued completion must
313
+ * not push ordinary foreground work (auto-background bash/eval) into the
314
+ * background.
304
315
  */
305
316
  hasBackgroundCompletions?: () => boolean | Promise<boolean>;
306
317
 
@@ -1031,6 +1042,12 @@ export interface AgentTool<
1031
1042
  loadMode?: ToolLoadMode;
1032
1043
  /** Short one-line summary used for tool discovery indexes. */
1033
1044
  summary?: string;
1045
+ /**
1046
+ * On-demand documentation topics (`topic → markdown`), readable as
1047
+ * `xd://<tool>/<topic>`. Lets a tool keep large sub-surfaces out of its
1048
+ * description and advertise only a one-line pointer per topic.
1049
+ */
1050
+ docTopics?(): Readonly<Record<string, string>>;
1034
1051
  /**
1035
1052
  * Concurrency mode for tool scheduling when multiple calls are in one turn.
1036
1053
  * - "shared": can run alongside other shared tools (default)
@@ -1055,7 +1072,7 @@ export interface AgentTool<
1055
1072
  * cleanly (e.g. `job` poll), so the abort surfaces the tool's current
1056
1073
  * snapshot rather than corrupting a side effect. Every other call runs to
1057
1074
  * completion even when steering is queued; the message lands at the next
1058
- * batch boundary. Honored only when `interruptMode` is "immediate".
1075
+ * batch boundary. Honored in both `interruptMode`s.
1059
1076
  */
1060
1077
  interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
1061
1078
  /**