@oh-my-pi/pi-agent-core 17.2.4 → 17.2.6

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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.6] - 2026-08-03
6
+
7
+ ### Fixed
8
+
9
+ - Fixed an issue where peer-IRC interrupts (such as subagent messages) incorrectly skipped non-interruptible tool calls queued in the same batch.
10
+ - Improved interruption messaging to clearly distinguish between parent-agent steering and system-advisory interruptions.
11
+
12
+ ## [17.2.5] - 2026-08-03
13
+
14
+ ### Breaking Changes
15
+
16
+ - Tool examples embedded in tool descriptions now always render in Python call syntax, and the `exampleDialect` option has been removed from `AppendOnlyContextManager` build options.
17
+ - Updated `normalizeTools` to accept a `NormalizeToolsOptions` configuration object (`{ injectIntent, pruneDescriptions }`) instead of positional booleans.
18
+
19
+ ### Fixed
20
+
21
+ - Fixed an issue where runs would fail with an error if an Anthropic stream was truncated after complete tool calls were streamed; the agent now recovers and executes those tool calls.
22
+ - Fixed an issue where artifact recovery reads could be incorrectly elided during compaction.
23
+
5
24
  ## [17.2.4] - 2026-08-01
6
25
 
7
26
  ### Fixed
@@ -80,7 +80,13 @@ export declare function agentLoopContinueDetailed(context: AgentContext, config:
80
80
  readonly detailed: () => Promise<AgentLoopDetailedResult>;
81
81
  };
82
82
  export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
83
- export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean, exampleDialect?: Dialect, pruneDescriptions?: boolean): Context["tools"];
83
+ export interface NormalizeToolsOptions {
84
+ /** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
85
+ injectIntent: boolean;
86
+ /** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
87
+ pruneDescriptions?: boolean;
88
+ }
89
+ export declare function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"];
84
90
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
85
91
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
86
92
  * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
@@ -14,7 +14,6 @@
14
14
  * message delta is a cache miss each turn.
15
15
  */
16
16
  import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
17
- import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
18
17
  import type { AgentContext } from "./types.js";
19
18
  /** Frozen system prompt + tool spec snapshot. */
20
19
  export interface StablePrefixSnapshot {
@@ -26,7 +25,6 @@ export interface StablePrefixSnapshot {
26
25
  export interface BuildOptions {
27
26
  /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
28
27
  intentTracing: boolean;
29
- exampleDialect?: Dialect;
30
28
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
31
29
  pruneToolDescriptions?: boolean;
32
30
  }
@@ -31,8 +31,13 @@ export interface ShakeConfig {
31
31
  }
32
32
  /** Auto-shake config: protects the live tail, conservative thresholds. */
33
33
  export declare const DEFAULT_SHAKE_CONFIG: ShakeConfig;
34
- /** Manual `/shake`: aggressive — drops every eligible region across history. */
34
+ /**
35
+ * Manual `/shake`: aggressive — drops every eligible region across history,
36
+ * artifact recovery reads included (the user's full escape hatch).
37
+ */
35
38
  export declare const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig;
39
+ /** Compaction dead-end rescue: aggressive reach, but artifact recovery reads stay protected. */
40
+ export declare const RESCUE_SHAKE_CONFIG: ShakeConfig;
36
41
  /** A located eligible region. */
37
42
  export interface ToolResultShakeRegion {
38
43
  kind: "toolResult";
@@ -14,4 +14,6 @@ export declare function collectToolCallsById(entries: readonly SessionEntry[]):
14
14
  */
15
15
  export declare function getReadToolPath({ toolResult, toolCall }: ProtectedToolContext): string | undefined;
16
16
  export declare function isSkillReadToolResult(context: ProtectedToolContext): boolean;
17
+ /** Recovery reads of session artifacts — eliding one only mints another artifact and can repeat indefinitely. */
18
+ export declare function isArtifactRecoveryToolResult(context: ProtectedToolContext): boolean;
17
19
  export declare function isProtectedToolResult(toolResult: ToolResultMessage, toolCall: AgentToolCall | undefined, matchers: readonly ProtectedToolMatcher[]): boolean;
@@ -6,13 +6,21 @@ import type { AgentRunCoverage, AgentRunSummary } from "./run-collector.js";
6
6
  import type { AgentTelemetryConfig } from "./telemetry.js";
7
7
  /** Stream function - can return sync or Promise for async config lookup */
8
8
  export type StreamFn = (...args: Parameters<typeof streamSimple>) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
9
+ /** Called once an aside has been inserted into the agent's live context. */
10
+ export declare const ASIDE_MESSAGE_COMMIT: unique symbol;
11
+ /** Called when an aside was drained but the agent loop ended before inserting it. */
12
+ export declare const ASIDE_MESSAGE_DISCARD: unique symbol;
13
+ export type CommittableAsideMessage = AgentMessage & {
14
+ [ASIDE_MESSAGE_COMMIT]?: () => void;
15
+ [ASIDE_MESSAGE_DISCARD]?: (error: Error) => void;
16
+ };
9
17
  /**
10
18
  * An aside entry: a ready {@link AgentMessage}, or a sync thunk evaluated at
11
19
  * injection time that returns the message to inject or `null` to skip it. Thunks
12
20
  * let the producer make the final inject-or-drop decision against current state
13
21
  * (e.g. dropping late diagnostics a newer edit superseded).
14
22
  */
15
- export type AsideMessage = AgentMessage | (() => AgentMessage | null);
23
+ export type AsideMessage = CommittableAsideMessage | (() => CommittableAsideMessage | null);
16
24
  export interface AgentTurnEndContext {
17
25
  /** Assistant/user message that just completed this turn boundary. */
18
26
  message: AgentMessage;
@@ -81,8 +89,11 @@ export interface SoftToolRequirementState {
81
89
  }
82
90
  /** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
83
91
  export declare function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement;
84
- /** Source category for a queued steering interrupt observed without consuming the queue. */
85
- export type SteeringInterruptSource = "user" | "system" | "unknown";
92
+ /**
93
+ * Source category for a queued steering interrupt observed without consuming the queue.
94
+ * Distinguishes real-user, agent-authored, system/advisor, and unknown steering.
95
+ */
96
+ export type SteeringInterruptSource = "user" | "agent" | "system" | "unknown";
86
97
  /** Non-consuming summary of whether queued steering should interrupt a tool batch. */
87
98
  export interface SteeringQueueState {
88
99
  /** True when at least one steering message is queued. */
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": "17.2.4",
4
+ "version": "17.2.6",
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": "17.2.4",
39
- "@oh-my-pi/pi-catalog": "17.2.4",
40
- "@oh-my-pi/pi-natives": "17.2.4",
41
- "@oh-my-pi/pi-utils": "17.2.4",
42
- "@oh-my-pi/pi-wire": "17.2.4",
43
- "@oh-my-pi/snapcompact": "17.2.4",
38
+ "@oh-my-pi/pi-ai": "17.2.6",
39
+ "@oh-my-pi/pi-catalog": "17.2.6",
40
+ "@oh-my-pi/pi-natives": "17.2.6",
41
+ "@oh-my-pi/pi-utils": "17.2.6",
42
+ "@oh-my-pi/pi-wire": "17.2.6",
43
+ "@oh-my-pi/snapcompact": "17.2.6",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -42,7 +42,6 @@ import {
42
42
  recoverHarmonyToolCall,
43
43
  signalListLabel,
44
44
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
45
- import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
46
45
  import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
47
46
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
48
47
  import { agentPauseGate } from "./pause";
@@ -75,12 +74,13 @@ import type {
75
74
  AgentTurnEndContext,
76
75
  AsideMessage,
77
76
  BeforeToolCallResult,
77
+ CommittableAsideMessage,
78
78
  SoftToolRequirement,
79
79
  SteeringInterruptSource,
80
80
  SteeringQueueState,
81
81
  StreamFn,
82
82
  } from "./types";
83
- import { isSoftToolRequirement } from "./types";
83
+ import { ASIDE_MESSAGE_COMMIT, ASIDE_MESSAGE_DISCARD, isSoftToolRequirement } from "./types";
84
84
  import { yieldIfDue } from "./utils/yield";
85
85
 
86
86
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
@@ -528,6 +528,9 @@ export function agentLoop(
528
528
  ...context,
529
529
  messages: [...context.messages, ...prompts],
530
530
  };
531
+ for (const prompt of prompts) {
532
+ (prompt as CommittableAsideMessage)[ASIDE_MESSAGE_COMMIT]?.();
533
+ }
531
534
 
532
535
  stream.push({ type: "agent_start" });
533
536
 
@@ -829,13 +832,16 @@ function injectIntentIntoSchema(
829
832
  };
830
833
  }
831
834
 
832
- export function normalizeTools(
833
- tools: AgentContext["tools"],
834
- injectIntent: boolean,
835
- exampleDialect?: Dialect,
836
- pruneDescriptions = false,
837
- ): Context["tools"] {
838
- injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
835
+ export interface NormalizeToolsOptions {
836
+ /** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
837
+ injectIntent: boolean;
838
+ /** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
839
+ pruneDescriptions?: boolean;
840
+ }
841
+
842
+ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"] {
843
+ const pruneDescriptions = options.pruneDescriptions === true;
844
+ const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
839
845
  return tools?.map(t => {
840
846
  const intentMode = resolveIntentMode(t.intent);
841
847
  const doInjectIntent = injectIntent && intentMode !== "omit";
@@ -853,9 +859,7 @@ export function normalizeTools(
853
859
  let parameters = toolWireSchema(t) as TSchema;
854
860
  if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
855
861
  const description = t.description ?? "";
856
- const examplesBlock = exampleDialect
857
- ? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined)
858
- : "";
862
+ const examplesBlock = renderToolExamples({ ...t, parameters }, doInjectIntent ? INTENT_FIELD : undefined);
859
863
  const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
860
864
  return { ...t, parameters, description: finalDescription };
861
865
  });
@@ -952,13 +956,24 @@ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, mess
952
956
  function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
953
957
  if (!entries || entries.length === 0) return [];
954
958
  const out: AgentMessage[] = [];
955
- for (const entry of entries) {
956
- const message = typeof entry === "function" ? entry() : entry;
957
- if (message) out.push(message);
959
+ try {
960
+ for (const entry of entries) {
961
+ const message = typeof entry === "function" ? entry() : entry;
962
+ if (message) out.push(message);
963
+ }
964
+ } catch (error) {
965
+ discardAsides(out, error instanceof Error ? error : new Error(String(error)));
966
+ throw error;
958
967
  }
959
968
  return out;
960
969
  }
961
970
 
971
+ function discardAsides(messages: readonly AgentMessage[], error: Error): void {
972
+ for (const message of messages) {
973
+ (message as CommittableAsideMessage)[ASIDE_MESSAGE_DISCARD]?.(error);
974
+ }
975
+ }
976
+
962
977
  async function runLoopBody(
963
978
  currentContext: AgentContext,
964
979
  newMessages: AgentMessage[],
@@ -989,6 +1004,7 @@ async function runLoopBody(
989
1004
  const softRequirementState = config.softToolRequirementState ?? { escalations: 0 };
990
1005
  let preserveSoftRequirementState = false;
991
1006
 
1007
+ let pendingMessages: AgentMessage[] = [];
992
1008
  try {
993
1009
  let messagesToEmit = [...initialMessages];
994
1010
  if (isDeadlineExceeded(config.deadline)) {
@@ -999,7 +1015,6 @@ async function runLoopBody(
999
1015
  // Check for steering messages at start (user may have typed while waiting).
1000
1016
  // Skip when the run is already externally aborted — dequeuing would strand
1001
1017
  // the messages in a run that is about to die.
1002
- let pendingMessages: AgentMessage[];
1003
1018
  try {
1004
1019
  pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
1005
1020
  } catch (error) {
@@ -1051,6 +1066,7 @@ async function runLoopBody(
1051
1066
  currentContext.messages.push(message);
1052
1067
  newMessages.push(message);
1053
1068
  turnMessages.push(message);
1069
+ (message as CommittableAsideMessage)[ASIDE_MESSAGE_COMMIT]?.();
1054
1070
  }
1055
1071
  pendingMessages = [];
1056
1072
  }
@@ -1449,6 +1465,7 @@ async function runLoopBody(
1449
1465
 
1450
1466
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1451
1467
  } finally {
1468
+ discardAsides(pendingMessages, new Error("Aside message was not committed before the agent loop ended"));
1452
1469
  if (!preserveSoftRequirementState) {
1453
1470
  softRequirementState.id = undefined;
1454
1471
  softRequirementState.forcedToolChoice = undefined;
@@ -1498,21 +1515,22 @@ async function prepareProviderCall(
1498
1515
  const llmMessages = await config.convertToLlm(messages);
1499
1516
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1500
1517
  const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1501
- const exampleDialect = ownedDialect ?? preferredDialect(model.id);
1502
1518
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1503
1519
  let llmContext: Context;
1504
1520
  if (config.appendOnlyContext) {
1505
1521
  config.appendOnlyContext.syncMessages(normalizedMessages);
1506
1522
  llmContext = config.appendOnlyContext.build(context, {
1507
1523
  intentTracing: !!config.intentTracing,
1508
- exampleDialect,
1509
1524
  pruneToolDescriptions,
1510
1525
  });
1511
1526
  } else {
1512
1527
  llmContext = {
1513
1528
  systemPrompt: context.systemPrompt,
1514
1529
  messages: normalizedMessages,
1515
- tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect, pruneToolDescriptions),
1530
+ tools: normalizeTools(context.tools, {
1531
+ injectIntent: !!config.intentTracing,
1532
+ pruneDescriptions: pruneToolDescriptions,
1533
+ }),
1516
1534
  };
1517
1535
  }
1518
1536
  if (config.transformProviderContext) {
@@ -1928,8 +1946,10 @@ function recoverTransientErrorToolTurn(
1928
1946
  if (tool.customWireName !== undefined) availableToolNames.add(tool.customWireName);
1929
1947
  }
1930
1948
  if (!toolCalls.every(toolCall => availableToolNames.has(toolCall.name))) return message;
1949
+ const errorText = `${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`;
1931
1950
  if (
1932
- !AIError.isStreamReadErrorText(`${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`) &&
1951
+ !AIError.isStreamReadErrorText(errorText) &&
1952
+ !AIError.isStreamEnvelopeErrorText(errorText) &&
1933
1953
  !AIError.isTransientStreamParseError(message.errorMessage) &&
1934
1954
  !AIError.isTransientStreamParseError(message.stopDetails?.explanation)
1935
1955
  )
@@ -2390,7 +2410,16 @@ async function executeToolCalls(
2390
2410
  };
2391
2411
 
2392
2412
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
2393
- if (interruptState.triggered) {
2413
+ // A pending interrupt preempts not-yet-started tools so the message
2414
+ // injects promptly. A peer-IRC interrupt is the exception: it aborts
2415
+ // interruptible waits only and leaves non-interruptible foreground work
2416
+ // untouched (see the emit branch below and the `does not abort a
2417
+ // non-interruptible foreground tool` case). That guarantee must hold for
2418
+ // work still queued behind the aborted wait too — otherwise a batched
2419
+ // `todo`/`write` gets dropped as "Skipped due to pending peer interrupt"
2420
+ // purely for being ordered after the wait (#7493). User/system steering
2421
+ // still preempts everything queued.
2422
+ if (interruptState.triggered && (record.interruptible || interruptState.source !== "irc")) {
2394
2423
  // Skip both span emission and the collector orphan record here. The
2395
2424
  // tail sweep below (after `Promise.allSettled`) is the single path
2396
2425
  // that handles "no result message was produced" — it calls
@@ -2872,6 +2901,9 @@ function createSkippedToolResult(
2872
2901
  if (source === "user") {
2873
2902
  reason = "queued user message";
2874
2903
  blocker = "queued message";
2904
+ } else if (source === "agent") {
2905
+ reason = "pending parent steering message";
2906
+ blocker = "steering message";
2875
2907
  } else if (source === "system") {
2876
2908
  reason = "pending system advisory";
2877
2909
  blocker = "advisory";
package/src/agent.ts CHANGED
@@ -24,7 +24,6 @@ import {
24
24
  } from "@oh-my-pi/pi-ai";
25
25
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
26
26
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
27
- import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
28
27
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
29
28
  import { logger } from "@oh-my-pi/pi-utils";
30
29
  import {
@@ -771,12 +770,10 @@ export class Agent {
771
770
  const messages = normalizeMessagesForProvider(llmMessages, model);
772
771
  const tools = ownedDialect
773
772
  ? []
774
- : (normalizeTools(
775
- this.#toolsForModel(model),
776
- this.#intentTracing,
777
- preferredDialect(model.id),
778
- this.#pruneToolDescriptions,
779
- ) ?? []);
773
+ : (normalizeTools(this.#toolsForModel(model), {
774
+ injectIntent: this.#intentTracing,
775
+ pruneDescriptions: this.#pruneToolDescriptions,
776
+ }) ?? []);
780
777
  let context: Context = { systemPrompt, messages, tools };
781
778
  if (this.#transformProviderContext) context = await this.#transformProviderContext(context, model);
782
779
  return context;
@@ -1376,14 +1373,22 @@ export class Agent {
1376
1373
  if (this.#steeringQueue.length === 0) {
1377
1374
  return { queued: false };
1378
1375
  }
1379
- for (const message of this.#steeringQueue) {
1376
+ const messageCount = this.#steeringMode === "one-at-a-time" ? 1 : this.#steeringQueue.length;
1377
+ let hasAgentSteering = false;
1378
+ for (let i = 0; i < messageCount; i++) {
1379
+ const message = this.#steeringQueue[i];
1380
1380
  const role = "role" in message ? message.role : undefined;
1381
1381
  const attribution = "attribution" in message ? message.attribution : undefined;
1382
- if (role === "user" && attribution !== "agent") {
1382
+ if (attribution === "user") {
1383
1383
  return { queued: true, source: "user" };
1384
1384
  }
1385
+ if (role !== "user") continue;
1386
+ if (attribution !== "agent") {
1387
+ return { queued: true, source: "user" };
1388
+ }
1389
+ hasAgentSteering = true;
1385
1390
  }
1386
- return { queued: true, source: "system" };
1391
+ return { queued: true, source: hasAgentSteering ? "agent" : "system" };
1387
1392
  },
1388
1393
  waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
1389
1394
  hasIrcInterrupts: this.hasIrcInterrupts,
@@ -15,7 +15,6 @@
15
15
  */
16
16
 
17
17
  import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
18
- import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
19
18
  import { normalizeTools } from "./agent-loop";
20
19
  import type { AgentContext } from "./types";
21
20
 
@@ -34,7 +33,6 @@ export interface StablePrefixSnapshot {
34
33
  export interface BuildOptions {
35
34
  /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
36
35
  intentTracing: boolean;
37
- exampleDialect?: Dialect;
38
36
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
39
37
  pruneToolDescriptions?: boolean;
40
38
  }
@@ -317,7 +315,10 @@ export class AppendOnlyContextManager {
317
315
  function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
318
316
  const systemPrompt = [...context.systemPrompt];
319
317
  const tools =
320
- normalizeTools(context.tools, options.intentTracing, options.exampleDialect, options.pruneToolDescriptions) ?? [];
318
+ normalizeTools(context.tools, {
319
+ injectIntent: options.intentTracing,
320
+ pruneDescriptions: options.pruneToolDescriptions,
321
+ }) ?? [];
321
322
  return {
322
323
  systemPrompt,
323
324
  tools,
@@ -337,7 +338,6 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil
337
338
  cw: t.customWireName,
338
339
  })),
339
340
  i: options.intentTracing,
340
- ex: options.exampleDialect,
341
341
  pd: options.pruneToolDescriptions,
342
342
  });
343
343
  let hash = 0;
@@ -18,6 +18,7 @@ import type { CustomMessageEntry, SessionEntry, SessionMessageEntry } from "./en
18
18
  import { invalidateMessageCache } from "./message-cache";
19
19
  import {
20
20
  collectToolCallsById,
21
+ isArtifactRecoveryToolResult,
21
22
  isProtectedToolResult,
22
23
  isSkillReadToolResult,
23
24
  type ProtectedToolMatcher,
@@ -46,11 +47,14 @@ export interface ShakeConfig {
46
47
  export const DEFAULT_SHAKE_CONFIG: ShakeConfig = {
47
48
  protectTokens: 16_000,
48
49
  minSavings: 4_000,
49
- protectedTools: ["skill", isSkillReadToolResult],
50
+ protectedTools: ["skill", isSkillReadToolResult, isArtifactRecoveryToolResult],
50
51
  fenceMinTokens: 400,
51
52
  };
52
53
 
53
- /** Manual `/shake`: aggressive — drops every eligible region across history. */
54
+ /**
55
+ * Manual `/shake`: aggressive — drops every eligible region across history,
56
+ * artifact recovery reads included (the user's full escape hatch).
57
+ */
54
58
  export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
55
59
  protectTokens: 0,
56
60
  minSavings: 0,
@@ -58,6 +62,12 @@ export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
58
62
  fenceMinTokens: 400,
59
63
  };
60
64
 
65
+ /** Compaction dead-end rescue: aggressive reach, but artifact recovery reads stay protected. */
66
+ export const RESCUE_SHAKE_CONFIG: ShakeConfig = {
67
+ ...AGGRESSIVE_SHAKE_CONFIG,
68
+ protectedTools: [...AGGRESSIVE_SHAKE_CONFIG.protectedTools, isArtifactRecoveryToolResult],
69
+ };
70
+
61
71
  /** Rough token cost of a placeholder line; used only for the savings gate. */
62
72
  const PLACEHOLDER_TOKEN_ESTIMATE = 16;
63
73
 
@@ -39,6 +39,16 @@ export function isSkillReadToolResult(context: ProtectedToolContext): boolean {
39
39
  return getReadToolPath(context)?.startsWith(SKILL_INTERNAL_URL_PREFIX) ?? false;
40
40
  }
41
41
 
42
+ const ARTIFACT_INTERNAL_URL_PREFIX = "artifact://";
43
+
44
+ /** Recovery reads of session artifacts — eliding one only mints another artifact and can repeat indefinitely. */
45
+ export function isArtifactRecoveryToolResult(context: ProtectedToolContext): boolean {
46
+ if (getReadToolPath(context)?.startsWith(ARTIFACT_INTERNAL_URL_PREFIX)) return true;
47
+ const meta = (context.toolResult.details as { meta?: { source?: { type?: string; value?: string } } } | undefined)
48
+ ?.meta;
49
+ return meta?.source?.type === "internal" && (meta.source.value?.startsWith(ARTIFACT_INTERNAL_URL_PREFIX) ?? false);
50
+ }
51
+
42
52
  export function isProtectedToolResult(
43
53
  toolResult: ToolResultMessage,
44
54
  toolCall: AgentToolCall | undefined,
package/src/types.ts CHANGED
@@ -31,13 +31,23 @@ export type StreamFn = (
31
31
  ...args: Parameters<typeof streamSimple>
32
32
  ) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
33
33
 
34
+ /** Called once an aside has been inserted into the agent's live context. */
35
+ export const ASIDE_MESSAGE_COMMIT = Symbol("aside-message-commit");
36
+ /** Called when an aside was drained but the agent loop ended before inserting it. */
37
+ export const ASIDE_MESSAGE_DISCARD = Symbol("aside-message-discard");
38
+
39
+ export type CommittableAsideMessage = AgentMessage & {
40
+ [ASIDE_MESSAGE_COMMIT]?: () => void;
41
+ [ASIDE_MESSAGE_DISCARD]?: (error: Error) => void;
42
+ };
43
+
34
44
  /**
35
45
  * An aside entry: a ready {@link AgentMessage}, or a sync thunk evaluated at
36
46
  * injection time that returns the message to inject or `null` to skip it. Thunks
37
47
  * let the producer make the final inject-or-drop decision against current state
38
48
  * (e.g. dropping late diagnostics a newer edit superseded).
39
49
  */
40
- export type AsideMessage = AgentMessage | (() => AgentMessage | null);
50
+ export type AsideMessage = CommittableAsideMessage | (() => CommittableAsideMessage | null);
41
51
 
42
52
  export interface AgentTurnEndContext {
43
53
  /** Assistant/user message that just completed this turn boundary. */
@@ -117,8 +127,11 @@ export function isSoftToolRequirement(directive: ToolChoiceDirective | undefined
117
127
  return typeof directive === "object" && directive !== null && (directive as SoftToolRequirement).soft === true;
118
128
  }
119
129
 
120
- /** Source category for a queued steering interrupt observed without consuming the queue. */
121
- export type SteeringInterruptSource = "user" | "system" | "unknown";
130
+ /**
131
+ * Source category for a queued steering interrupt observed without consuming the queue.
132
+ * Distinguishes real-user, agent-authored, system/advisor, and unknown steering.
133
+ */
134
+ export type SteeringInterruptSource = "user" | "agent" | "system" | "unknown";
122
135
 
123
136
  /** Non-consuming summary of whether queued steering should interrupt a tool batch. */
124
137
  export interface SteeringQueueState {