@oh-my-pi/pi-agent-core 18.13.1 → 18.13.2

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.13.2] - 2026-10-27
6
+
7
+ ### Added
8
+
9
+ - `AgentTool` gained the optional `handlesToolInterrupt` flag: an interruptible tool that sets it (such as `wait`) still runs, without blocking, when a loop-owned interrupt is already queued, so it can drain the queued wake event instead of being skipped; user cancellation still skips it ([#17174](https://github.com/can1357/oh-my-pi/pull/17174) by [@alphastorm](https://github.com/alphastorm))
10
+
5
11
  ## [18.13.1] - 2026-10-27
6
12
 
7
13
  ### Added
@@ -223,7 +223,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
223
223
  *
224
224
  * Polled while a tool batch runs (in "wait" mode, only when the batch holds an
225
225
  * interruptible tool) to decide whether to abort in-flight and skip
226
- * not-yet-started *interruptible* waits;
226
+ * not-yet-started *interruptible* waits, unless they handle queued interrupts;
227
227
  * every other already-emitted call still executes and the message injects
228
228
  * at the batch boundary. The queue keeps
229
229
  * owning its messages until the loop reaches the next injection boundary and
@@ -1056,6 +1056,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
1056
1056
  * batch boundary. Honored in both `interruptMode`s.
1057
1057
  */
1058
1058
  interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
1059
+ /**
1060
+ * An interruptible tool can execute with an already-aborted
1061
+ * `TOOL_INTERRUPT_ABORT_REASON` signal to drain caller-owned wake events.
1062
+ * It must not block when that signal is aborted. External/user cancellation
1063
+ * still prevents execution. Omitted tools keep the usual pre-start skip.
1064
+ */
1065
+ handlesToolInterrupt?: boolean;
1059
1066
  /**
1060
1067
  * Controls how the INTENT_FIELD (`i`) is handled for this tool.
1061
1068
  * - `"require"` (default): `i` is injected and required in the parameter schema.
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.13.1",
4
+ "version": "18.13.2",
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.13.1",
42
- "@oh-my-pi/pi-catalog": "18.13.1",
43
- "@oh-my-pi/pi-natives": "18.13.1",
44
- "@oh-my-pi/pi-utils": "18.13.1",
45
- "@oh-my-pi/pi-wire": "18.13.1",
46
- "@oh-my-pi/snapcompact": "18.13.1",
41
+ "@oh-my-pi/pi-ai": "18.13.2",
42
+ "@oh-my-pi/pi-catalog": "18.13.2",
43
+ "@oh-my-pi/pi-natives": "18.13.2",
44
+ "@oh-my-pi/pi-utils": "18.13.2",
45
+ "@oh-my-pi/pi-wire": "18.13.2",
46
+ "@oh-my-pi/snapcompact": "18.13.2",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.13.1",
50
+ "@oh-my-pi/omptype": "18.13.2",
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
@@ -3361,6 +3361,7 @@ async function executeToolCalls(
3361
3361
  tool,
3362
3362
  args,
3363
3363
  interruptible,
3364
+ handlesToolInterrupt: interruptible && tool?.handlesToolInterrupt === true,
3364
3365
  signal: interruptible ? interruptibleSignal : nonInterruptibleSignal,
3365
3366
  started: false,
3366
3367
  result: undefined as AgentToolResult<any> | undefined,
@@ -3391,6 +3392,10 @@ async function executeToolCalls(
3391
3392
  };
3392
3393
  if (speculationCoordinator) speculationCoordinator.onIdle = refreshPauseState;
3393
3394
 
3395
+ const isToolAborted = (record: (typeof records)[number]): boolean =>
3396
+ signal?.aborted === true ||
3397
+ (record.signal.aborted && !(record.handlesToolInterrupt && record.signal.reason === TOOL_INTERRUPT_ABORT_REASON));
3398
+
3394
3399
  const checkAsideInterrupts = async (): Promise<void> => {
3395
3400
  // Asides only fire once: an interrupt already recorded on interruptState
3396
3401
  // must not re-abort, and (unlike steering) never re-consumes a queue.
@@ -3453,9 +3458,9 @@ async function executeToolCalls(
3453
3458
  // interruptMode is "wait") raises the cooperative soft signal for
3454
3459
  // everything else: the boundary dequeue
3455
3460
  // below injects the message as soon as running tools finish (or
3456
- // background themselves), and not-yet-started interruptible waits
3457
- // are skipped. Idempotent — a second steer poll after the abort is
3458
- // a no-op.
3461
+ // background themselves). Not-yet-started interruptible tools are
3462
+ // skipped unless they can drain the queued wake. Idempotent — a
3463
+ // second steer poll after the abort is a no-op.
3459
3464
  if (!steeringAbortController.signal.aborted) {
3460
3465
  interruptState.triggered = true;
3461
3466
  interruptState.source = steeringSource ?? "unknown";
@@ -3518,8 +3523,8 @@ async function executeToolCalls(
3518
3523
  };
3519
3524
 
3520
3525
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
3521
- // A pending interrupt preempts not-yet-started *interruptible* waits so
3522
- // the message injects promptly instead of sitting out a `wait`.
3526
+ // A pending interrupt preempts not-yet-started interruptible tools,
3527
+ // except receivers that drain the wake with an already-aborted signal.
3523
3528
  // Non-interruptible work is never skipped, whatever the source: the
3524
3529
  // expensive part — generating the call — is already paid, the tool
3525
3530
  // itself is cheap, and a skip only makes the model re-emit the same
@@ -3529,7 +3534,7 @@ async function executeToolCalls(
3529
3534
  // the parent steers mid-stream (#10645). The steer still injects at the
3530
3535
  // batch boundary; the cooperative soft signal lets long-running tools
3531
3536
  // step aside on their own.
3532
- if (interruptState.triggered && record.interruptible) {
3537
+ if (interruptState.triggered && record.interruptible && !record.handlesToolInterrupt) {
3533
3538
  // Skip both span emission and the collector orphan record here. The
3534
3539
  // tail sweep below (after `Promise.allSettled`) is the single path
3535
3540
  // that handles "no result message was produced" — it calls
@@ -3563,14 +3568,14 @@ async function executeToolCalls(
3563
3568
  return;
3564
3569
  }
3565
3570
  const effectiveArgs = record.args;
3566
- if (record.signal.aborted) {
3571
+ if (isToolAborted(record)) {
3567
3572
  record.skipped = true;
3568
3573
  recordSkippedTool(telemetry, {
3569
3574
  toolCallId: toolCall.id,
3570
3575
  toolName: toolCall.name,
3571
3576
  status: "aborted",
3572
3577
  });
3573
- emitToolResult(record, createToolSignalAbortedResult(record.signal), true);
3578
+ emitToolResult(record, createToolSignalAbortedResult(signal?.aborted ? signal : record.signal), true);
3574
3579
  return;
3575
3580
  }
3576
3581
  record.started = true;
@@ -3598,17 +3603,19 @@ async function executeToolCalls(
3598
3603
  let caughtError: unknown;
3599
3604
  let completedToolExecution = false;
3600
3605
  let executionStarted = false;
3606
+ const abortBeforeToolExecution = (): boolean => {
3607
+ if (!isToolAborted(record)) return false;
3608
+ result = createToolSignalAbortedResult(signal?.aborted ? signal : record.signal);
3609
+ isError = true;
3610
+ return true;
3611
+ };
3601
3612
 
3602
3613
  await runInActiveSpan(toolSpan, async () => {
3603
3614
  try {
3604
3615
  if (!tool) {
3605
3616
  throw new Error(formatToolNotFoundMessage(toolCall.name, tools, suggestFallbackToolNames?.()));
3606
3617
  }
3607
- if (record.signal.aborted) {
3608
- result = createToolSignalAbortedResult(record.signal);
3609
- isError = true;
3610
- return;
3611
- }
3618
+ if (abortBeforeToolExecution()) return;
3612
3619
 
3613
3620
  if (record.prepareError !== undefined) throw record.prepareError;
3614
3621
  if (record.blocked) {
@@ -3619,6 +3626,9 @@ async function executeToolCalls(
3619
3626
  record.executionArgs ??
3620
3627
  (transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs);
3621
3628
  record.args = executionArgs;
3629
+ // A transform or awaited claim can cancel the external run after
3630
+ // a queued wake already fixed the combined signal's reason.
3631
+ if (abortBeforeToolExecution()) return;
3622
3632
 
3623
3633
  const speculativeOutcome = speculationCoordinator
3624
3634
  ? await speculationCoordinator.claim(tool, toolCall, executionArgs)
@@ -3635,6 +3645,7 @@ async function executeToolCalls(
3635
3645
  }
3636
3646
 
3637
3647
  if (!completedToolExecution) {
3648
+ if (abortBeforeToolExecution()) return;
3638
3649
  // The cooperative steering signal and the passive-context sink
3639
3650
  // ride the loop-owned ToolCallContext (surfacing as
3640
3651
  // `ctx.toolCall.*`); the host surfaces the sink on the context it
@@ -3659,9 +3670,12 @@ async function executeToolCalls(
3659
3670
  } else if (streamSession && !streamSession.contextIndependent) {
3660
3671
  await streamSession.discard("outer tool context cannot carry stream speculation");
3661
3672
  }
3662
- executionStarted = true;
3663
3673
  let rawResult: unknown;
3664
3674
  try {
3675
+ // Context construction and stream cleanup can also cancel;
3676
+ // nothing may await or call host code after this final check.
3677
+ if (abortBeforeToolExecution()) return;
3678
+ executionStarted = true;
3665
3679
  rawResult = await tool.execute(
3666
3680
  toolCall.id,
3667
3681
  executionArgs,
@@ -3741,10 +3755,10 @@ async function executeToolCalls(
3741
3755
  const interrupted = interruptState.triggered;
3742
3756
  const perToolAborted = record.signal.aborted;
3743
3757
  const abortedDuringExecution = perToolAborted && isError && !completedToolExecution;
3744
- if (record.interruptible && interrupted && abortedDuringExecution) {
3758
+ if (record.interruptible && interrupted && abortedDuringExecution && !signal?.aborted) {
3745
3759
  // Queued steering/asides abort only interruptible tools. An external
3746
- // user abort can also fail a non-interruptible tool (e.g. cancelled
3747
- // ask); keep its actual error instead of attributing it to the queue.
3760
+ // user abort takes priority even if a queued interrupt fixed the
3761
+ // combined signal's reason first; keep the actual cancellation error.
3748
3762
  record.skipped = true;
3749
3763
  emitToolResult(record, createSkippedToolResult(interruptState.source, executionStarted), true);
3750
3764
  } else {
@@ -3801,7 +3815,7 @@ async function executeToolCalls(
3801
3815
  (softInterrupts || records.some(record => record.interruptible)) &&
3802
3816
  (hasSteeringMessages !== undefined || hasAsidePeek);
3803
3817
  // Live-taken steering is already pending: interrupt before any call starts
3804
- // so not-yet-started interruptible waits are skipped outright.
3818
+ // so interruptible tools skip or drain the queued wake without blocking.
3805
3819
  if (liveSteering.length > 0) await checkSteering();
3806
3820
  const eventDrivenSteeringWatch =
3807
3821
  watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
package/src/types.ts CHANGED
@@ -283,7 +283,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
283
283
  *
284
284
  * Polled while a tool batch runs (in "wait" mode, only when the batch holds an
285
285
  * interruptible tool) to decide whether to abort in-flight and skip
286
- * not-yet-started *interruptible* waits;
286
+ * not-yet-started *interruptible* waits, unless they handle queued interrupts;
287
287
  * every other already-emitted call still executes and the message injects
288
288
  * at the batch boundary. The queue keeps
289
289
  * owning its messages until the loop reaches the next injection boundary and
@@ -1194,6 +1194,13 @@ export interface AgentTool<
1194
1194
  * batch boundary. Honored in both `interruptMode`s.
1195
1195
  */
1196
1196
  interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
1197
+ /**
1198
+ * An interruptible tool can execute with an already-aborted
1199
+ * `TOOL_INTERRUPT_ABORT_REASON` signal to drain caller-owned wake events.
1200
+ * It must not block when that signal is aborted. External/user cancellation
1201
+ * still prevents execution. Omitted tools keep the usual pre-start skip.
1202
+ */
1203
+ handlesToolInterrupt?: boolean;
1197
1204
  /**
1198
1205
  * Controls how the INTENT_FIELD (`i`) is handled for this tool.
1199
1206
  * - `"require"` (default): `i` is injected and required in the parameter schema.