@oh-my-pi/pi-agent-core 17.2.5 → 17.2.7

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,13 @@
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
+
5
12
  ## [17.2.5] - 2026-08-03
6
13
 
7
14
  ### Breaking Changes
@@ -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.5",
4
+ "version": "17.2.7",
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,15 +35,16 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "17.2.5",
39
- "@oh-my-pi/pi-catalog": "17.2.5",
40
- "@oh-my-pi/pi-natives": "17.2.5",
41
- "@oh-my-pi/pi-utils": "17.2.5",
42
- "@oh-my-pi/pi-wire": "17.2.5",
43
- "@oh-my-pi/snapcompact": "17.2.5",
38
+ "@oh-my-pi/pi-ai": "17.2.7",
39
+ "@oh-my-pi/pi-catalog": "17.2.7",
40
+ "@oh-my-pi/pi-natives": "17.2.7",
41
+ "@oh-my-pi/pi-utils": "17.2.7",
42
+ "@oh-my-pi/pi-wire": "17.2.7",
43
+ "@oh-my-pi/snapcompact": "17.2.7",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
47
+ "@oh-my-pi/omptype": "17.2.7",
47
48
  "@opentelemetry/context-async-hooks": "^2.9.0",
48
49
  "@opentelemetry/sdk-trace-base": "^2.9.0",
49
50
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -74,12 +74,13 @@ import type {
74
74
  AgentTurnEndContext,
75
75
  AsideMessage,
76
76
  BeforeToolCallResult,
77
+ CommittableAsideMessage,
77
78
  SoftToolRequirement,
78
79
  SteeringInterruptSource,
79
80
  SteeringQueueState,
80
81
  StreamFn,
81
82
  } from "./types";
82
- import { isSoftToolRequirement } from "./types";
83
+ import { ASIDE_MESSAGE_COMMIT, ASIDE_MESSAGE_DISCARD, isSoftToolRequirement } from "./types";
83
84
  import { yieldIfDue } from "./utils/yield";
84
85
 
85
86
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
@@ -527,6 +528,9 @@ export function agentLoop(
527
528
  ...context,
528
529
  messages: [...context.messages, ...prompts],
529
530
  };
531
+ for (const prompt of prompts) {
532
+ (prompt as CommittableAsideMessage)[ASIDE_MESSAGE_COMMIT]?.();
533
+ }
530
534
 
531
535
  stream.push({ type: "agent_start" });
532
536
 
@@ -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;
@@ -2393,7 +2410,16 @@ async function executeToolCalls(
2393
2410
  };
2394
2411
 
2395
2412
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
2396
- 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")) {
2397
2423
  // Skip both span emission and the collector orphan record here. The
2398
2424
  // tail sweep below (after `Promise.allSettled`) is the single path
2399
2425
  // that handles "no result message was produced" — it calls
@@ -2875,6 +2901,9 @@ function createSkippedToolResult(
2875
2901
  if (source === "user") {
2876
2902
  reason = "queued user message";
2877
2903
  blocker = "queued message";
2904
+ } else if (source === "agent") {
2905
+ reason = "pending parent steering message";
2906
+ blocker = "steering message";
2878
2907
  } else if (source === "system") {
2879
2908
  reason = "pending system advisory";
2880
2909
  blocker = "advisory";
package/src/agent.ts CHANGED
@@ -1373,14 +1373,22 @@ export class Agent {
1373
1373
  if (this.#steeringQueue.length === 0) {
1374
1374
  return { queued: false };
1375
1375
  }
1376
- 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];
1377
1380
  const role = "role" in message ? message.role : undefined;
1378
1381
  const attribution = "attribution" in message ? message.attribution : undefined;
1379
- if (role === "user" && attribution !== "agent") {
1382
+ if (attribution === "user") {
1380
1383
  return { queued: true, source: "user" };
1381
1384
  }
1385
+ if (role !== "user") continue;
1386
+ if (attribution !== "agent") {
1387
+ return { queued: true, source: "user" };
1388
+ }
1389
+ hasAgentSteering = true;
1382
1390
  }
1383
- return { queued: true, source: "system" };
1391
+ return { queued: true, source: hasAgentSteering ? "agent" : "system" };
1384
1392
  },
1385
1393
  waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
1386
1394
  hasIrcInterrupts: this.hasIrcInterrupts,
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 {