@oh-my-pi/pi-agent-core 18.13.0 → 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,18 @@
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
+
11
+ ## [18.13.1] - 2026-10-27
12
+
13
+ ### Added
14
+
15
+ - Hosts can distinguish safely paused agents from turns still draining with `Agent.isPaused` ([#17073](https://github.com/can1357/oh-my-pi/pull/17073) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)).
16
+
5
17
  ## [18.13.0] - 2026-10-26
6
18
 
7
19
  ### Added
@@ -240,6 +240,8 @@ export interface AgentPromptOptions {
240
240
  }
241
241
  export declare class Agent {
242
242
  #private;
243
+ /** True while this unfinished turn is safely parked by the process pause gate. */
244
+ get isPaused(): boolean;
243
245
  streamFn: StreamFn;
244
246
  getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
245
247
  /** Prepare actual queue deliveries after dequeue gates; commit runs only while ownership remains valid. */
@@ -23,6 +23,9 @@ export declare class SpeculativeOperationCoordinator {
23
23
  #private;
24
24
  readonly config: SpeculativeToolExecutionConfig;
25
25
  readonly environment?: CoordinatorEnvironment | undefined;
26
+ /** Observer of existing speculative execution draining; no separate work ledger. */
27
+ onIdle?: () => void;
28
+ get isRunning(): boolean;
26
29
  constructor(config: SpeculativeToolExecutionConfig, environment?: CoordinatorEnvironment | undefined);
27
30
  get maxInFlight(): number;
28
31
  get size(): number;
@@ -126,6 +126,8 @@ export interface SteeringQueueState {
126
126
  */
127
127
  export interface AgentLoopConfig extends SimpleStreamOptions {
128
128
  model: Model;
129
+ /** Reports whether this loop has reached a safe process-pause boundary. */
130
+ onPauseChange?: (paused: boolean) => void;
129
131
  /**
130
132
  * When to interrupt tool execution for steering messages.
131
133
  * - "immediate" = cut interruptible waits short and raise the cooperative
@@ -221,7 +223,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
221
223
  *
222
224
  * Polled while a tool batch runs (in "wait" mode, only when the batch holds an
223
225
  * interruptible tool) to decide whether to abort in-flight and skip
224
- * not-yet-started *interruptible* waits;
226
+ * not-yet-started *interruptible* waits, unless they handle queued interrupts;
225
227
  * every other already-emitted call still executes and the message injects
226
228
  * at the batch boundary. The queue keeps
227
229
  * owning its messages until the loop reaches the next injection boundary and
@@ -1054,6 +1056,13 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
1054
1056
  * batch boundary. Honored in both `interruptMode`s.
1055
1057
  */
1056
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;
1057
1066
  /**
1058
1067
  * Controls how the INTENT_FIELD (`i`) is handled for this tool.
1059
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.0",
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.0",
42
- "@oh-my-pi/pi-catalog": "18.13.0",
43
- "@oh-my-pi/pi-natives": "18.13.0",
44
- "@oh-my-pi/pi-utils": "18.13.0",
45
- "@oh-my-pi/pi-wire": "18.13.0",
46
- "@oh-my-pi/snapcompact": "18.13.0",
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.0",
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
@@ -110,6 +110,17 @@ import {
110
110
  SPECULATIVE_STREAM_SESSION,
111
111
  } from "./types";
112
112
  import { yieldIfDue } from "./utils/yield";
113
+
114
+ async function waitForProcessPause(config: AgentLoopConfig, signal?: AbortSignal): Promise<void> {
115
+ try {
116
+ while (agentPauseGate.paused && !signal?.aborted) {
117
+ config.onPauseChange?.(true);
118
+ await agentPauseGate.waitUntilResumed(signal);
119
+ }
120
+ } finally {
121
+ config.onPauseChange?.(false);
122
+ }
123
+ }
113
124
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
114
125
  export const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
115
126
 
@@ -1331,7 +1342,7 @@ async function runLoopBody(
1331
1342
  // Park at the turn boundary while the process-wide pause gate is
1332
1343
  // engaged (host /pause). An external abort releases the park so a
1333
1344
  // cancelled run still unwinds while everything else stays frozen.
1334
- if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(signal);
1345
+ if (agentPauseGate.paused) await waitForProcessPause(config, signal);
1335
1346
 
1336
1347
  // Build the provider-bound context before opening the turn. Queue
1337
1348
  // messages are added now but their events remain deferred until
@@ -1443,6 +1454,7 @@ async function runLoopBody(
1443
1454
  let recovered: HarmonyRecoveredToolCall | undefined;
1444
1455
  let message: AssistantMessage;
1445
1456
  try {
1457
+ if (agentPauseGate.paused) await waitForProcessPause(config, signal);
1446
1458
  message = await streamAssistantResponse(
1447
1459
  currentContext,
1448
1460
  config,
@@ -3349,6 +3361,7 @@ async function executeToolCalls(
3349
3361
  tool,
3350
3362
  args,
3351
3363
  interruptible,
3364
+ handlesToolInterrupt: interruptible && tool?.handlesToolInterrupt === true,
3352
3365
  signal: interruptible ? interruptibleSignal : nonInterruptibleSignal,
3353
3366
  started: false,
3354
3367
  result: undefined as AgentToolResult<any> | undefined,
@@ -3367,6 +3380,22 @@ async function executeToolCalls(
3367
3380
  };
3368
3381
  });
3369
3382
 
3383
+ // Derive batch readiness from existing dispatch records, not extra participants.
3384
+ const refreshPauseState = (): void => {
3385
+ config.onPauseChange?.(
3386
+ agentPauseGate.paused &&
3387
+ !signal?.aborted &&
3388
+ !speculationCoordinator?.isRunning &&
3389
+ records.some(record => !record.started && !record.skipped && !record.toolResultMessage) &&
3390
+ !records.some(record => record.started && !record.toolResultMessage),
3391
+ );
3392
+ };
3393
+ if (speculationCoordinator) speculationCoordinator.onIdle = refreshPauseState;
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
+
3370
3399
  const checkAsideInterrupts = async (): Promise<void> => {
3371
3400
  // Asides only fire once: an interrupt already recorded on interruptState
3372
3401
  // must not re-abort, and (unlike steering) never re-consumes a queue.
@@ -3429,9 +3458,9 @@ async function executeToolCalls(
3429
3458
  // interruptMode is "wait") raises the cooperative soft signal for
3430
3459
  // everything else: the boundary dequeue
3431
3460
  // below injects the message as soon as running tools finish (or
3432
- // background themselves), and not-yet-started interruptible waits
3433
- // are skipped. Idempotent — a second steer poll after the abort is
3434
- // 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.
3435
3464
  if (!steeringAbortController.signal.aborted) {
3436
3465
  interruptState.triggered = true;
3437
3466
  interruptState.source = steeringSource ?? "unknown";
@@ -3494,8 +3523,8 @@ async function executeToolCalls(
3494
3523
  };
3495
3524
 
3496
3525
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
3497
- // A pending interrupt preempts not-yet-started *interruptible* waits so
3498
- // 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.
3499
3528
  // Non-interruptible work is never skipped, whatever the source: the
3500
3529
  // expensive part — generating the call — is already paid, the tool
3501
3530
  // itself is cheap, and a skip only makes the model re-emit the same
@@ -3505,7 +3534,7 @@ async function executeToolCalls(
3505
3534
  // the parent steers mid-stream (#10645). The steer still injects at the
3506
3535
  // batch boundary; the cooperative soft signal lets long-running tools
3507
3536
  // step aside on their own.
3508
- if (interruptState.triggered && record.interruptible) {
3537
+ if (interruptState.triggered && record.interruptible && !record.handlesToolInterrupt) {
3509
3538
  // Skip both span emission and the collector orphan record here. The
3510
3539
  // tail sweep below (after `Promise.allSettled`) is the single path
3511
3540
  // that handles "no result message was produced" — it calls
@@ -3517,7 +3546,11 @@ async function executeToolCalls(
3517
3546
  // Park before starting this tool while the process-wide pause gate is
3518
3547
  // engaged. Tools already executing are unaffected (pausing never aborts);
3519
3548
  // a batch interrupted mid-pause unwinds via the signal checks below.
3520
- if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(record.signal);
3549
+ while (agentPauseGate.paused && !record.signal.aborted) {
3550
+ refreshPauseState();
3551
+ await agentPauseGate.waitUntilResumed(record.signal);
3552
+ }
3553
+ config.onPauseChange?.(false);
3521
3554
 
3522
3555
  const { toolCall, tool } = record;
3523
3556
  // Validation (and the beforeToolCall hook) ran in the prepare phase; a
@@ -3535,14 +3568,14 @@ async function executeToolCalls(
3535
3568
  return;
3536
3569
  }
3537
3570
  const effectiveArgs = record.args;
3538
- if (record.signal.aborted) {
3571
+ if (isToolAborted(record)) {
3539
3572
  record.skipped = true;
3540
3573
  recordSkippedTool(telemetry, {
3541
3574
  toolCallId: toolCall.id,
3542
3575
  toolName: toolCall.name,
3543
3576
  status: "aborted",
3544
3577
  });
3545
- emitToolResult(record, createToolSignalAbortedResult(record.signal), true);
3578
+ emitToolResult(record, createToolSignalAbortedResult(signal?.aborted ? signal : record.signal), true);
3546
3579
  return;
3547
3580
  }
3548
3581
  record.started = true;
@@ -3570,17 +3603,19 @@ async function executeToolCalls(
3570
3603
  let caughtError: unknown;
3571
3604
  let completedToolExecution = false;
3572
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
+ };
3573
3612
 
3574
3613
  await runInActiveSpan(toolSpan, async () => {
3575
3614
  try {
3576
3615
  if (!tool) {
3577
3616
  throw new Error(formatToolNotFoundMessage(toolCall.name, tools, suggestFallbackToolNames?.()));
3578
3617
  }
3579
- if (record.signal.aborted) {
3580
- result = createToolSignalAbortedResult(record.signal);
3581
- isError = true;
3582
- return;
3583
- }
3618
+ if (abortBeforeToolExecution()) return;
3584
3619
 
3585
3620
  if (record.prepareError !== undefined) throw record.prepareError;
3586
3621
  if (record.blocked) {
@@ -3591,6 +3626,9 @@ async function executeToolCalls(
3591
3626
  record.executionArgs ??
3592
3627
  (transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs);
3593
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;
3594
3632
 
3595
3633
  const speculativeOutcome = speculationCoordinator
3596
3634
  ? await speculationCoordinator.claim(tool, toolCall, executionArgs)
@@ -3607,6 +3645,7 @@ async function executeToolCalls(
3607
3645
  }
3608
3646
 
3609
3647
  if (!completedToolExecution) {
3648
+ if (abortBeforeToolExecution()) return;
3610
3649
  // The cooperative steering signal and the passive-context sink
3611
3650
  // ride the loop-owned ToolCallContext (surfacing as
3612
3651
  // `ctx.toolCall.*`); the host surfaces the sink on the context it
@@ -3631,9 +3670,12 @@ async function executeToolCalls(
3631
3670
  } else if (streamSession && !streamSession.contextIndependent) {
3632
3671
  await streamSession.discard("outer tool context cannot carry stream speculation");
3633
3672
  }
3634
- executionStarted = true;
3635
3673
  let rawResult: unknown;
3636
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;
3637
3679
  rawResult = await tool.execute(
3638
3680
  toolCall.id,
3639
3681
  executionArgs,
@@ -3713,10 +3755,10 @@ async function executeToolCalls(
3713
3755
  const interrupted = interruptState.triggered;
3714
3756
  const perToolAborted = record.signal.aborted;
3715
3757
  const abortedDuringExecution = perToolAborted && isError && !completedToolExecution;
3716
- if (record.interruptible && interrupted && abortedDuringExecution) {
3758
+ if (record.interruptible && interrupted && abortedDuringExecution && !signal?.aborted) {
3717
3759
  // Queued steering/asides abort only interruptible tools. An external
3718
- // user abort can also fail a non-interruptible tool (e.g. cancelled
3719
- // 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.
3720
3762
  record.skipped = true;
3721
3763
  emitToolResult(record, createSkippedToolResult(interruptState.source, executionStarted), true);
3722
3764
  } else {
@@ -3773,7 +3815,7 @@ async function executeToolCalls(
3773
3815
  (softInterrupts || records.some(record => record.interruptible)) &&
3774
3816
  (hasSteeringMessages !== undefined || hasAsidePeek);
3775
3817
  // Live-taken steering is already pending: interrupt before any call starts
3776
- // so not-yet-started interruptible waits are skipped outright.
3818
+ // so interruptible tools skip or drain the queued wake without blocking.
3777
3819
  if (liveSteering.length > 0) await checkSteering();
3778
3820
  const eventDrivenSteeringWatch =
3779
3821
  watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
@@ -3851,7 +3893,13 @@ async function executeToolCalls(
3851
3893
  concurrency = concurrencyMode ?? "shared";
3852
3894
  }
3853
3895
  const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
3854
- const task = start.then(() => runTool(record, index));
3896
+ const task = start.then(async () => {
3897
+ try {
3898
+ await runTool(record, index);
3899
+ } finally {
3900
+ refreshPauseState();
3901
+ }
3902
+ });
3855
3903
  tasks.push(task);
3856
3904
  if (concurrency === "exclusive") {
3857
3905
  lastExclusive = task;
@@ -3863,6 +3911,8 @@ async function executeToolCalls(
3863
3911
  try {
3864
3912
  await Promise.allSettled(tasks);
3865
3913
  } finally {
3914
+ if (speculationCoordinator) speculationCoordinator.onIdle = undefined;
3915
+ config.onPauseChange?.(false);
3866
3916
  steeringWatchAbortController.abort();
3867
3917
  await steeringWatchPromise?.catch(() => undefined);
3868
3918
  clearInterval(steeringWatchTimer);
package/src/agent.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  unpairedToolCallTail,
40
40
  } from "./agent-loop";
41
41
  import type { AppendOnlyContextManager } from "./append-only-context";
42
+ import { agentPauseGate } from "./pause";
42
43
  import { isProviderRefusalMessage } from "./replay-policy";
43
44
  import { SentToolDefinitions } from "./sent-tool-definitions";
44
45
  import { Tokenizer } from "./tokenizer";
@@ -474,6 +475,12 @@ export class Agent {
474
475
  #cwdResolver?: () => string | undefined;
475
476
 
476
477
  #runningPrompt?: Promise<void>;
478
+ #isPaused = false;
479
+
480
+ /** True while this unfinished turn is safely parked by the process pause gate. */
481
+ get isPaused(): boolean {
482
+ return this.#isPaused && agentPauseGate.paused && !this.#abortController?.signal.aborted;
483
+ }
477
484
  #resolveRunningPrompt?: () => void;
478
485
  #kimiApiFormat?: "openai" | "anthropic";
479
486
  #preferWebsockets?: boolean;
@@ -1783,6 +1790,7 @@ export class Agent {
1783
1790
  this.#state.isStreaming = true;
1784
1791
  this.#state.streamMessage = null;
1785
1792
  this.#state.error = undefined;
1793
+ this.#isPaused = false;
1786
1794
 
1787
1795
  // Clear Cursor tool result buffer at start of each run
1788
1796
  this.#cursorToolResultBuffer = [];
@@ -1955,6 +1963,9 @@ export class Agent {
1955
1963
  onAssistantMessageEvent: this.#onAssistantMessageEvent,
1956
1964
  onHarmonyLeak: this.#onHarmonyLeak,
1957
1965
  onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
1966
+ onPauseChange: paused => {
1967
+ this.#isPaused = paused;
1968
+ },
1958
1969
  getToolChoice,
1959
1970
  softToolRequirementState: this.#softToolRequirementState,
1960
1971
  onToolChoiceRejected: () => {
@@ -2205,6 +2216,7 @@ export class Agent {
2205
2216
  this.#emit({ type: "agent_end", messages: [errorMsg] });
2206
2217
  }
2207
2218
  } finally {
2219
+ this.#isPaused = false;
2208
2220
  this.#restoreUndeliveredQueuedMessages(loopAbortController);
2209
2221
  resolveRun?.();
2210
2222
  if (this.#abortController === loopAbortController) {
@@ -239,6 +239,12 @@ export class SpeculativeOperationCoordinator {
239
239
  #closed = false;
240
240
  #admissionsFinalized = false;
241
241
  #running = 0;
242
+ /** Observer of existing speculative execution draining; no separate work ledger. */
243
+ onIdle?: () => void;
244
+
245
+ get isRunning(): boolean {
246
+ return this.#running > 0;
247
+ }
242
248
 
243
249
  constructor(
244
250
  readonly config: SpeculativeToolExecutionConfig,
@@ -955,6 +961,7 @@ export class SpeculativeOperationCoordinator {
955
961
  } finally {
956
962
  this.#running--;
957
963
  this.#drain();
964
+ if (this.#running === 0) this.onIdle?.();
958
965
  }
959
966
  })();
960
967
  }
package/src/types.ts CHANGED
@@ -175,6 +175,9 @@ export interface SteeringQueueState {
175
175
  export interface AgentLoopConfig extends SimpleStreamOptions {
176
176
  model: Model;
177
177
 
178
+ /** Reports whether this loop has reached a safe process-pause boundary. */
179
+ onPauseChange?: (paused: boolean) => void;
180
+
178
181
  /**
179
182
  * When to interrupt tool execution for steering messages.
180
183
  * - "immediate" = cut interruptible waits short and raise the cooperative
@@ -280,7 +283,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
280
283
  *
281
284
  * Polled while a tool batch runs (in "wait" mode, only when the batch holds an
282
285
  * interruptible tool) to decide whether to abort in-flight and skip
283
- * not-yet-started *interruptible* waits;
286
+ * not-yet-started *interruptible* waits, unless they handle queued interrupts;
284
287
  * every other already-emitted call still executes and the message injects
285
288
  * at the batch boundary. The queue keeps
286
289
  * owning its messages until the loop reaches the next injection boundary and
@@ -1191,6 +1194,13 @@ export interface AgentTool<
1191
1194
  * batch boundary. Honored in both `interruptMode`s.
1192
1195
  */
1193
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;
1194
1204
  /**
1195
1205
  * Controls how the INTENT_FIELD (`i`) is handled for this tool.
1196
1206
  * - `"require"` (default): `i` is injected and required in the parameter schema.