@oh-my-pi/pi-agent-core 18.3.1 → 18.3.3

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,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.3.3] - 2026-09-27
6
+
7
+ ### Added
8
+
9
+ - Added live steering support, allowing models to receive and act on user steering messages during an active stream.
10
+
11
+ ## [18.3.2] - 2026-09-25
12
+
13
+ ### Fixed
14
+
15
+ - Fixed tool calls that put their payload in the intent field `i` (for example a file body in `write`) silently running with the leftover arguments; they now fail with an error telling the model to retry ([#13140](https://github.com/can1357/oh-my-pi/issues/13140), [#13141](https://github.com/can1357/oh-my-pi/pull/13141) by [@radkawar](https://github.com/radkawar))
16
+ - Fixed the Anthropic compaction failure log omitting why no compaction block came back; it now names the stop reason ([#13300](https://github.com/can1357/oh-my-pi/pull/13300) by [@alphastorm](https://github.com/alphastorm))
17
+
5
18
  ## [18.3.1] - 2026-09-25
6
19
 
7
20
  ### Added
@@ -5,7 +5,7 @@
5
5
  import { type AssistantMessage, type Context, EventStream, type ToolResultMessage } from "@oh-my-pi/pi-ai";
6
6
  import { type Dialect } from "@oh-my-pi/pi-ai/dialect";
7
7
  import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector.js";
8
- import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
8
+ import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, SteeringQueueState, StreamFn } from "./types.js";
9
9
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
10
10
  export declare const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
11
11
  /**
@@ -106,6 +106,13 @@ export interface NormalizeToolsOptions {
106
106
  pruneDescriptions?: boolean;
107
107
  }
108
108
  export declare function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"];
109
+ /**
110
+ * Classify the first `count` steering messages for a tool-batch interrupt:
111
+ * any user-authored message wins, then agent-attributed user messages, else
112
+ * system steering (advisor cards, hidden directives). Shared by the agent's
113
+ * queue peek and the loop's live-taken steering.
114
+ */
115
+ export declare function steeringQueueState(messages: readonly AgentMessage[], count?: number): SteeringQueueState;
109
116
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
110
117
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
111
118
  * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
@@ -411,6 +411,14 @@ export declare class Agent {
411
411
  addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
412
412
  /** Register an independently removable hook that runs immediately before each model call. */
413
413
  addBeforeModelCallHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
414
+ /**
415
+ * Take back live-steered messages ahead of an abort (Esc restores them to the editor):
416
+ * the aborted run then neither records nor requeues them.
417
+ */
418
+ withdrawLiveSteering(): AgentMessage[];
419
+ /** Steering live steering took for the streaming response; the transcript records it once
420
+ * that response (or its tool batch) ends, which is when the model switches to it. */
421
+ peekLiveSteeredMessages(): AgentMessage[];
414
422
  setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void;
415
423
  setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
416
424
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
@@ -476,6 +484,11 @@ export declare class Agent {
476
484
  * single source of truth. Includes exclusively claimed originals while
477
485
  * preparation is pending, so editor restoration can cancel their delivery. */
478
486
  peekSteeringQueue(): readonly AgentMessage[];
487
+ /** Dequeued messages not yet in the transcript, e.g. steering a provider
488
+ * took into its in-flight response via live steering. Aborting the run
489
+ * requeues them, so the session's empty-submit interrupt counts them as
490
+ * pending input even though {@link peekSteeringQueue} no longer does. */
491
+ peekUndeliveredQueuedMessages(): AgentMessage[];
479
492
  /** Non-consuming view of the pending follow-up queue. See
480
493
  * {@link peekSteeringQueue}. */
481
494
  peekFollowUpQueue(): readonly AgentMessage[];
@@ -82,6 +82,15 @@ export interface SupersedePruneConfig {
82
82
  /** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
83
83
  protectedTools: ProtectedToolMatcher[];
84
84
  }
85
+ /**
86
+ * Generic age-based pruning floor. Below this, blanking a result to
87
+ * `[Output truncated - N tokens]` recovers nothing — the placeholder itself
88
+ * costs ~8 tokens, so a sub-floor result grows the context (and churns the
89
+ * prompt cache) instead of shrinking it. Superseded/useless results keep their
90
+ * own rules: useless already drops no-savings candidates, superseded prunes for
91
+ * correctness regardless of size.
92
+ */
93
+ export declare const MIN_PRUNE_TOKENS = 50;
85
94
  /**
86
95
  * Prune superseded tool results (e.g. stale `read` outputs replaced by a newer
87
96
  * read of the same file) and, when `pruneUseless` is set, results their tool
@@ -37,6 +37,14 @@ export interface TranscriptUsageAnchor {
37
37
  * the session-entry walkers.
38
38
  */
39
39
  export declare function isTranscriptUsageAnchor(message: AgentMessage): message is AssistantMessage;
40
+ /** Options for {@link findTranscriptUsageAnchor}. */
41
+ export interface TranscriptUsageAnchorOptions {
42
+ /**
43
+ * Also treat usage reported at or before the newest `prunedAt` as stale,
44
+ * the same rule {@link findRequestUsageAnchor} applies to request contexts.
45
+ */
46
+ skipPrunedAnchors?: boolean;
47
+ }
40
48
  /**
41
49
  * Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
42
50
  * transcript, or `undefined` when none qualifies (fresh context, or every
@@ -45,7 +53,7 @@ export declare function isTranscriptUsageAnchor(message: AgentMessage): message
45
53
  * `fromIndex` excludes turns whose usage is stale — anything a compaction
46
54
  * summarized away describes a prompt that is no longer sent.
47
55
  */
48
- export declare function findTranscriptUsageAnchor(messages: readonly AgentMessage[], fromIndex?: number): TranscriptUsageAnchor | undefined;
56
+ export declare function findTranscriptUsageAnchor(messages: readonly AgentMessage[], fromIndex?: number, options?: TranscriptUsageAnchorOptions): TranscriptUsageAnchor | undefined;
49
57
  /**
50
58
  * Newest assistant turn in a provider request's `messages` whose usage still
51
59
  * describes the prefix it sits on, or `undefined` when none does.
@@ -67,6 +75,8 @@ export interface TranscriptTokenOptions {
67
75
  * accounting is governed separately by {@link countFromIndex}.
68
76
  */
69
77
  anchorFromIndex?: number;
78
+ /** Forwarded to {@link findTranscriptUsageAnchor}. */
79
+ skipPrunedAnchors?: boolean;
70
80
  /**
71
81
  * First message whose content is counted locally when no anchor is found.
72
82
  * Defaults to 0 (count the whole transcript), which is what a floor
@@ -240,6 +240,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
240
240
  * {@link getSteeringMessages}.
241
241
  */
242
242
  waitForSteeringMessages?: (signal?: AbortSignal) => Promise<void>;
243
+ /**
244
+ * Called when live steering dequeues messages via {@link getSteeringMessages}
245
+ * for the response being streamed. The loop records them in the transcript
246
+ * after that response (or its tool batch); an abort before then leaves them
247
+ * unrecorded for the host to requeue.
248
+ */
249
+ onLiveSteeringTaken?: (messages: AgentMessage[]) => void;
243
250
  /**
244
251
  * Peeks whether IRC messages should interrupt an interruptible waiting tool.
245
252
  *
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": "18.3.1",
4
+ "version": "18.3.3",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.3.1",
42
- "@oh-my-pi/pi-catalog": "18.3.1",
43
- "@oh-my-pi/pi-natives": "18.3.1",
44
- "@oh-my-pi/pi-utils": "18.3.1",
45
- "@oh-my-pi/pi-wire": "18.3.1",
46
- "@oh-my-pi/snapcompact": "18.3.1",
41
+ "@oh-my-pi/pi-ai": "18.3.3",
42
+ "@oh-my-pi/pi-catalog": "18.3.3",
43
+ "@oh-my-pi/pi-natives": "18.3.3",
44
+ "@oh-my-pi/pi-utils": "18.3.3",
45
+ "@oh-my-pi/pi-wire": "18.3.3",
46
+ "@oh-my-pi/snapcompact": "18.3.3",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.3.1",
50
+ "@oh-my-pi/omptype": "18.3.3",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  getStreamingPartialJson,
40
40
  kCursorExecResolved,
41
41
  } from "@oh-my-pi/pi-ai/utils/block-symbols";
42
+ import { schemaDefinesProperty } from "@oh-my-pi/pi-ai/utils/schema/json-schema-validator";
42
43
  import { stamp } from "@oh-my-pi/pi-ai/utils/schema/stamps";
43
44
  import {
44
45
  createHarmonyAuditEvent,
@@ -1022,6 +1023,13 @@ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional"
1022
1023
  return "require";
1023
1024
  }
1024
1025
 
1026
+ /**
1027
+ * Longest `i` value accepted as an intent. The injected field is described as
1028
+ * a "concise intent" (INTENT_FIELD_DESCRIPTION); anything past this is a tool
1029
+ * payload the model put in the wrong field, not a label.
1030
+ */
1031
+ const MAX_INTENT_LENGTH = 200;
1032
+
1025
1033
  function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
1026
1034
  const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
1027
1035
  if (typeof intent !== "string") {
@@ -1239,6 +1247,7 @@ async function runLoopBody(
1239
1247
  config,
1240
1248
  telemetry,
1241
1249
  invokeAgentSpan,
1250
+ [],
1242
1251
  );
1243
1252
  for (const result of executionResult.toolResults) {
1244
1253
  currentContext.messages.push(result);
@@ -1611,6 +1620,7 @@ async function runLoopBody(
1611
1620
  config,
1612
1621
  telemetry,
1613
1622
  invokeAgentSpan,
1623
+ [...liveAccepted, ...liveDeferred],
1614
1624
  );
1615
1625
  toolResults.push(...executionResult.toolResults);
1616
1626
 
@@ -1792,6 +1802,27 @@ interface PreparedProviderCall {
1792
1802
  liveSteering?: LiveSteeringChannel;
1793
1803
  }
1794
1804
 
1805
+ /**
1806
+ * Classify the first `count` steering messages for a tool-batch interrupt:
1807
+ * any user-authored message wins, then agent-attributed user messages, else
1808
+ * system steering (advisor cards, hidden directives). Shared by the agent's
1809
+ * queue peek and the loop's live-taken steering.
1810
+ */
1811
+ export function steeringQueueState(messages: readonly AgentMessage[], count = messages.length): SteeringQueueState {
1812
+ if (count === 0) return { queued: false };
1813
+ let hasAgentSteering = false;
1814
+ for (let i = 0; i < count; i++) {
1815
+ const message = messages[i];
1816
+ const role = "role" in message ? message.role : undefined;
1817
+ const attribution = "attribution" in message ? message.attribution : undefined;
1818
+ if (attribution === "user") return { queued: true, source: "user" };
1819
+ if (role !== "user") continue;
1820
+ if (attribution !== "agent") return { queued: true, source: "user" };
1821
+ hasAgentSteering = true;
1822
+ }
1823
+ return { queued: true, source: hasAgentSteering ? "agent" : "system" };
1824
+ }
1825
+
1795
1826
  /**
1796
1827
  * Offer queued steering to a provider that can deliver it into the response it
1797
1828
  * is streaming. Latency decides whether steering lands before the model commits
@@ -1810,7 +1841,11 @@ function openLiveSteering(
1810
1841
  const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
1811
1842
  return new LiveSteeringChannel({
1812
1843
  wait: signal => waitForSteeringMessages(bound(signal)),
1813
- take: signal => getSteeringMessages(bound(signal)),
1844
+ take: async signal => {
1845
+ const messages = await getSteeringMessages(bound(signal));
1846
+ if (messages.length > 0) config.onLiveSteeringTaken?.(messages);
1847
+ return messages;
1848
+ },
1814
1849
  toProvider: async (messages, signal) => {
1815
1850
  const transformed = config.transformContext
1816
1851
  ? await config.transformContext(messages, bound(signal))
@@ -2830,6 +2865,19 @@ async function prepareToolCallDispatch(
2830
2865
  if (intentTracing) {
2831
2866
  const { intent, strippedArgs } = extractIntent(toolCall.arguments);
2832
2867
  argsForExecution = strippedArgs;
2868
+ // A payload in `i` would be stripped and the tool run with the leftover
2869
+ // args. Unknown tools fall through to the not-found error; a tool that
2870
+ // owns `i` as a real parameter has nowhere else to put the value.
2871
+ if (
2872
+ intent !== undefined &&
2873
+ intent.length > MAX_INTENT_LENGTH &&
2874
+ tool &&
2875
+ !schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD)
2876
+ ) {
2877
+ entry.args = strippedArgs;
2878
+ entry.validationErrorMessage = `\`${INTENT_FIELD}\` is a short intent label (at most ${MAX_INTENT_LENGTH} chars); the value you sent is ${intent.length} chars. The tool was not run. Put that content in the tool's own parameters and retry with a brief \`${INTENT_FIELD}\`.`;
2879
+ continue;
2880
+ }
2833
2881
  if (intent) {
2834
2882
  toolCall.intent = intent;
2835
2883
  } else if (typeof tool?.intent === "function") {
@@ -2981,6 +3029,9 @@ async function executeToolCalls(
2981
3029
  config: AgentLoopConfig,
2982
3030
  telemetry: AgentTelemetry | undefined,
2983
3031
  invokeAgentSpan: Span | undefined,
3032
+ // Steering the provider took off the queue during the response that emitted
3033
+ // this batch; it injects at this batch's boundary like queued steering.
3034
+ liveSteering: readonly AgentMessage[],
2984
3035
  ): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
2985
3036
  const tools = currentContext.tools;
2986
3037
  const {
@@ -3122,7 +3173,10 @@ async function executeToolCalls(
3122
3173
  // injection boundary below; polling it here would strand or drop messages.
3123
3174
  let steeringQueued = false;
3124
3175
  let steeringSource: SteeringInterruptSource | undefined;
3125
- if (hasSteeringMessages) {
3176
+ if (liveSteering.length > 0) {
3177
+ steeringQueued = true;
3178
+ steeringSource = steeringQueueState(liveSteering).source;
3179
+ } else if (hasSteeringMessages) {
3126
3180
  const queuedState = await hasSteeringMessages();
3127
3181
  if (typeof queuedState === "boolean") {
3128
3182
  steeringQueued = queuedState;
@@ -3469,6 +3523,9 @@ async function executeToolCalls(
3469
3523
  const watchSteeringWhileRunning =
3470
3524
  (softInterrupts || records.some(record => record.interruptible)) &&
3471
3525
  (hasSteeringMessages !== undefined || hasAsidePeek);
3526
+ // Live-taken steering is already pending: interrupt before any call starts
3527
+ // so not-yet-started interruptible waits are skipped outright.
3528
+ if (liveSteering.length > 0) await checkSteering();
3472
3529
  const eventDrivenSteeringWatch =
3473
3530
  watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
3474
3531
  const steeringWatchAbortController = new AbortController();
package/src/agent.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  normalizeMessagesForProvider,
35
35
  normalizeTools,
36
36
  resolveOwnedDialectFromEnv,
37
+ steeringQueueState,
37
38
  unpairedToolCallTail,
38
39
  } from "./agent-loop";
39
40
  import type { AppendOnlyContextManager } from "./append-only-context";
@@ -411,6 +412,14 @@ export class Agent {
411
412
  #steeringQueue: AgentMessage[] = [];
412
413
  #followUpQueue: AgentMessage[] = [];
413
414
  #queuedMessageClaims: Partial<Record<QueuedMessageQueue, QueuedMessageClaim>> = {};
415
+ /**
416
+ * Steering live steering took for the in-flight response (`onLiveSteeringTaken`) that the
417
+ * transcript has not recorded yet, whether or not the provider accepted it. Kept apart from
418
+ * {@link #queuedMessageDeliveries}: the loop drops it on abort instead of recording it, so queue
419
+ * replacement must not drop it too; the run's end requeues whatever it did not record, and
420
+ * {@link withdrawLiveSteering} takes it back ahead of an abort.
421
+ */
422
+ #liveSteered: { message: AgentMessage; controller: AbortController | undefined }[] = [];
414
423
  /** Dequeued originals remain recoverable until their transcript events arrive. */
415
424
  #queuedMessageDeliveries = new Set<{
416
425
  queue: QueuedMessageQueue;
@@ -992,8 +1001,14 @@ export class Agent {
992
1001
  }
993
1002
 
994
1003
  #restoreUndeliveredQueuedMessages(controller: AbortController): void {
995
- if (this.#queuedMessageDeliveries.size === 0) return;
1004
+ if (this.#queuedMessageDeliveries.size === 0 && this.#liveSteered.length === 0) return;
996
1005
  const restored: Record<QueuedMessageQueue, AgentMessage[]> = { steering: [], followUp: [] };
1006
+ // Live steering was taken before anything the run still holds undelivered.
1007
+ this.#liveSteered = this.#liveSteered.filter(entry => {
1008
+ if (entry.controller !== controller) return true;
1009
+ restored.steering.push(entry.message);
1010
+ return false;
1011
+ });
997
1012
  for (const delivery of this.#queuedMessageDeliveries) {
998
1013
  if (delivery.controller !== controller) continue;
999
1014
  this.#queuedMessageDeliveries.delete(delivery);
@@ -1008,6 +1023,40 @@ export class Agent {
1008
1023
  if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
1009
1024
  }
1010
1025
 
1026
+ /** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
1027
+ #adoptLiveSteering(taken: readonly AgentMessage[]): void {
1028
+ for (const delivery of this.#queuedMessageDeliveries) {
1029
+ const pending = delivery.messages.slice(delivery.next);
1030
+ const kept = pending.filter(message => !taken.includes(message));
1031
+ if (kept.length === pending.length) continue;
1032
+ for (const message of pending) {
1033
+ if (taken.includes(message)) this.#liveSteered.push({ message, controller: delivery.controller });
1034
+ }
1035
+ if (kept.length === 0) {
1036
+ this.#queuedMessageDeliveries.delete(delivery);
1037
+ } else {
1038
+ delivery.messages = kept;
1039
+ delivery.next = 0;
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ /**
1045
+ * Take back live-steered messages ahead of an abort (Esc restores them to the editor):
1046
+ * the aborted run then neither records nor requeues them.
1047
+ */
1048
+ withdrawLiveSteering(): AgentMessage[] {
1049
+ const messages = this.peekLiveSteeredMessages();
1050
+ this.#liveSteered = [];
1051
+ return messages;
1052
+ }
1053
+
1054
+ /** Steering live steering took for the streaming response; the transcript records it once
1055
+ * that response (or its tool batch) ends, which is when the model switches to it. */
1056
+ peekLiveSteeredMessages(): AgentMessage[] {
1057
+ return this.#liveSteered.map(entry => entry.message);
1058
+ }
1059
+
1011
1060
  setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void {
1012
1061
  this.#onResponse = fn;
1013
1062
  }
@@ -1147,6 +1196,11 @@ export class Agent {
1147
1196
 
1148
1197
  appendMessage(m: AgentMessage) {
1149
1198
  this.#state.messages.push(m);
1199
+ const live = this.#liveSteered.findIndex(entry => entry.message === m);
1200
+ if (live >= 0) {
1201
+ this.#liveSteered.splice(live, 1);
1202
+ return;
1203
+ }
1150
1204
  for (const delivery of this.#queuedMessageDeliveries) {
1151
1205
  if (delivery.messages[delivery.next] !== m) continue;
1152
1206
  if (++delivery.next === delivery.messages.length) this.#queuedMessageDeliveries.delete(delivery);
@@ -1202,6 +1256,7 @@ export class Agent {
1202
1256
  clearAllQueues() {
1203
1257
  this.#steeringQueue = [];
1204
1258
  this.#followUpQueue = [];
1259
+ this.#liveSteered = [];
1205
1260
  this.#cancelQueuedMessagePreparation("steering");
1206
1261
  this.#cancelQueuedMessagePreparation("followUp");
1207
1262
  this.#notifySteeringWaiters();
@@ -1227,6 +1282,18 @@ export class Agent {
1227
1282
  return claim ? [...claim.messages, ...this.#steeringQueue] : this.#steeringQueue;
1228
1283
  }
1229
1284
 
1285
+ /** Dequeued messages not yet in the transcript, e.g. steering a provider
1286
+ * took into its in-flight response via live steering. Aborting the run
1287
+ * requeues them, so the session's empty-submit interrupt counts them as
1288
+ * pending input even though {@link peekSteeringQueue} no longer does. */
1289
+ peekUndeliveredQueuedMessages(): AgentMessage[] {
1290
+ const messages = this.peekLiveSteeredMessages();
1291
+ for (const delivery of this.#queuedMessageDeliveries) {
1292
+ for (let i = delivery.next; i < delivery.messages.length; i++) messages.push(delivery.messages[i]);
1293
+ }
1294
+ return messages;
1295
+ }
1296
+
1230
1297
  /** Non-consuming view of the pending follow-up queue. See
1231
1298
  * {@link peekSteeringQueue}. */
1232
1299
  peekFollowUpQueue(): readonly AgentMessage[] {
@@ -1702,28 +1769,15 @@ export class Agent {
1702
1769
  }
1703
1770
  return this.#dequeueSteeringMessagesAfterHooks(signal ?? loopSignal);
1704
1771
  },
1705
- hasSteeringMessages: () => {
1706
- if (this.#steeringQueue.length === 0) {
1707
- return { queued: false };
1708
- }
1709
- const messageCount = this.#steeringMode === "one-at-a-time" ? 1 : this.#steeringQueue.length;
1710
- let hasAgentSteering = false;
1711
- for (let i = 0; i < messageCount; i++) {
1712
- const message = this.#steeringQueue[i];
1713
- const role = "role" in message ? message.role : undefined;
1714
- const attribution = "attribution" in message ? message.attribution : undefined;
1715
- if (attribution === "user") {
1716
- return { queued: true, source: "user" };
1717
- }
1718
- if (role !== "user") continue;
1719
- if (attribution !== "agent") {
1720
- return { queued: true, source: "user" };
1721
- }
1722
- hasAgentSteering = true;
1723
- }
1724
- return { queued: true, source: hasAgentSteering ? "agent" : "system" };
1725
- },
1772
+ hasSteeringMessages: () =>
1773
+ steeringQueueState(
1774
+ this.#steeringQueue,
1775
+ this.#steeringMode === "one-at-a-time"
1776
+ ? Math.min(1, this.#steeringQueue.length)
1777
+ : this.#steeringQueue.length,
1778
+ ),
1726
1779
  waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
1780
+ onLiveSteeringTaken: messages => this.#adoptLiveSteering(messages),
1727
1781
  hasIrcInterrupts: this.hasIrcInterrupts,
1728
1782
  hasBackgroundCompletions: this.hasBackgroundCompletions,
1729
1783
  getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
@@ -255,7 +255,7 @@ export async function requestAnthropicNativeCompaction(
255
255
  throw new Error(
256
256
  response.stopDetails?.type === "compaction"
257
257
  ? "Anthropic compaction returned no signed summary"
258
- : "Anthropic compaction response carried no compaction block",
258
+ : `Anthropic compaction response carried no compaction block (stop reason: ${response.stopDetails?.type ?? response.stopReason})`,
259
259
  );
260
260
  }
261
261
  return {
@@ -120,7 +120,7 @@ function createPrunedNotice(tokens: number): string {
120
120
  * own rules: useless already drops no-savings candidates, superseded prunes for
121
121
  * correctness regardless of size.
122
122
  */
123
- const MIN_PRUNE_TOKENS = 50;
123
+ export const MIN_PRUNE_TOKENS = 50;
124
124
 
125
125
  function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
126
126
  if (entry.type !== "message") return undefined;
@@ -47,6 +47,31 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
47
47
  return assistant.usage !== undefined && hasContextTokenUsage(assistant.usage);
48
48
  }
49
49
 
50
+ /**
51
+ * Newest `prunedAt` in `messages`, or `-Infinity` when nothing was pruned.
52
+ *
53
+ * A pruned tool result was rewritten in place, so every usage report made at
54
+ * or before this time still counts the removed bytes.
55
+ */
56
+ function newestPrunedAt(messages: readonly AgentMessage[]): number {
57
+ let rewriteAt = Number.NEGATIVE_INFINITY;
58
+ for (const message of messages) {
59
+ if (message.role === "toolResult" && message.prunedAt !== undefined) {
60
+ rewriteAt = Math.max(rewriteAt, message.prunedAt);
61
+ }
62
+ }
63
+ return rewriteAt;
64
+ }
65
+
66
+ /** Options for {@link findTranscriptUsageAnchor}. */
67
+ export interface TranscriptUsageAnchorOptions {
68
+ /**
69
+ * Also treat usage reported at or before the newest `prunedAt` as stale,
70
+ * the same rule {@link findRequestUsageAnchor} applies to request contexts.
71
+ */
72
+ skipPrunedAnchors?: boolean;
73
+ }
74
+
50
75
  /**
51
76
  * Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
52
77
  * transcript, or `undefined` when none qualifies (fresh context, or every
@@ -58,10 +83,12 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
58
83
  export function findTranscriptUsageAnchor(
59
84
  messages: readonly AgentMessage[],
60
85
  fromIndex = 0,
86
+ options?: TranscriptUsageAnchorOptions,
61
87
  ): TranscriptUsageAnchor | undefined {
88
+ const rewriteAt = options?.skipPrunedAnchors === true ? newestPrunedAt(messages) : Number.NEGATIVE_INFINITY;
62
89
  for (let index = messages.length - 1; index >= fromIndex; index--) {
63
90
  const message = messages[index];
64
- if (!isTranscriptUsageAnchor(message)) continue;
91
+ if (!isTranscriptUsageAnchor(message) || message.timestamp <= rewriteAt) continue;
65
92
  return { index, message, tokens: calculateContextTokens(message.usage) };
66
93
  }
67
94
  return undefined;
@@ -105,6 +132,8 @@ export interface TranscriptTokenOptions {
105
132
  * accounting is governed separately by {@link countFromIndex}.
106
133
  */
107
134
  anchorFromIndex?: number;
135
+ /** Forwarded to {@link findTranscriptUsageAnchor}. */
136
+ skipPrunedAnchors?: boolean;
108
137
  /**
109
138
  * First message whose content is counted locally when no anchor is found.
110
139
  * Defaults to 0 (count the whole transcript), which is what a floor
@@ -132,7 +161,9 @@ export function estimateTranscriptTokens(
132
161
  ): number {
133
162
  const estimateOptions: MessageCountOptions | undefined =
134
163
  options?.excludeEncryptedReasoning === true ? { excludeEncryptedReasoning: true } : undefined;
135
- const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0);
164
+ const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0, {
165
+ skipPrunedAnchors: options?.skipPrunedAnchors,
166
+ });
136
167
  let total = anchor?.tokens ?? 0;
137
168
  for (let index = anchor ? anchor.index + 1 : (options?.countFromIndex ?? 0); index < messages.length; index++) {
138
169
  total += tokenizer.countMessage(messages[index], estimateOptions);
package/src/types.ts CHANGED
@@ -298,6 +298,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
298
298
  */
299
299
  waitForSteeringMessages?: (signal?: AbortSignal) => Promise<void>;
300
300
 
301
+ /**
302
+ * Called when live steering dequeues messages via {@link getSteeringMessages}
303
+ * for the response being streamed. The loop records them in the transcript
304
+ * after that response (or its tool batch); an abort before then leaves them
305
+ * unrecorded for the host to requeue.
306
+ */
307
+ onLiveSteeringTaken?: (messages: AgentMessage[]) => void;
308
+
301
309
  /**
302
310
  * Peeks whether IRC messages should interrupt an interruptible waiting tool.
303
311
  *