@oh-my-pi/pi-agent-core 18.3.2 → 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,12 @@
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
+
5
11
  ## [18.3.2] - 2026-09-25
6
12
 
7
13
  ### Fixed
@@ -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[];
@@ -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.2",
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.2",
42
- "@oh-my-pi/pi-catalog": "18.3.2",
43
- "@oh-my-pi/pi-natives": "18.3.2",
44
- "@oh-my-pi/pi-utils": "18.3.2",
45
- "@oh-my-pi/pi-wire": "18.3.2",
46
- "@oh-my-pi/snapcompact": "18.3.2",
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.2",
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
@@ -1247,6 +1247,7 @@ async function runLoopBody(
1247
1247
  config,
1248
1248
  telemetry,
1249
1249
  invokeAgentSpan,
1250
+ [],
1250
1251
  );
1251
1252
  for (const result of executionResult.toolResults) {
1252
1253
  currentContext.messages.push(result);
@@ -1619,6 +1620,7 @@ async function runLoopBody(
1619
1620
  config,
1620
1621
  telemetry,
1621
1622
  invokeAgentSpan,
1623
+ [...liveAccepted, ...liveDeferred],
1622
1624
  );
1623
1625
  toolResults.push(...executionResult.toolResults);
1624
1626
 
@@ -1800,6 +1802,27 @@ interface PreparedProviderCall {
1800
1802
  liveSteering?: LiveSteeringChannel;
1801
1803
  }
1802
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
+
1803
1826
  /**
1804
1827
  * Offer queued steering to a provider that can deliver it into the response it
1805
1828
  * is streaming. Latency decides whether steering lands before the model commits
@@ -1818,7 +1841,11 @@ function openLiveSteering(
1818
1841
  const bound = (signal: AbortSignal): AbortSignal => (loopSignal ? AbortSignal.any([signal, loopSignal]) : signal);
1819
1842
  return new LiveSteeringChannel({
1820
1843
  wait: signal => waitForSteeringMessages(bound(signal)),
1821
- 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
+ },
1822
1849
  toProvider: async (messages, signal) => {
1823
1850
  const transformed = config.transformContext
1824
1851
  ? await config.transformContext(messages, bound(signal))
@@ -3002,6 +3029,9 @@ async function executeToolCalls(
3002
3029
  config: AgentLoopConfig,
3003
3030
  telemetry: AgentTelemetry | undefined,
3004
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[],
3005
3035
  ): Promise<{ toolResults: ToolResultMessage[]; additionalContext?: string }> {
3006
3036
  const tools = currentContext.tools;
3007
3037
  const {
@@ -3143,7 +3173,10 @@ async function executeToolCalls(
3143
3173
  // injection boundary below; polling it here would strand or drop messages.
3144
3174
  let steeringQueued = false;
3145
3175
  let steeringSource: SteeringInterruptSource | undefined;
3146
- if (hasSteeringMessages) {
3176
+ if (liveSteering.length > 0) {
3177
+ steeringQueued = true;
3178
+ steeringSource = steeringQueueState(liveSteering).source;
3179
+ } else if (hasSteeringMessages) {
3147
3180
  const queuedState = await hasSteeringMessages();
3148
3181
  if (typeof queuedState === "boolean") {
3149
3182
  steeringQueued = queuedState;
@@ -3490,6 +3523,9 @@ async function executeToolCalls(
3490
3523
  const watchSteeringWhileRunning =
3491
3524
  (softInterrupts || records.some(record => record.interruptible)) &&
3492
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();
3493
3529
  const eventDrivenSteeringWatch =
3494
3530
  watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
3495
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),
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
  *