@oh-my-pi/pi-agent-core 18.1.5 → 18.1.8

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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.6] - 2026-09-03
6
+
7
+ ### Added
8
+
9
+ - Added support for streaming tool argument updates, providing more responsive tool-call progress.
10
+
11
+ ### Changed
12
+
13
+ - Improved steering behavior so queued steering commands preserve already-emitted non-interruptible tool calls while aborting or skipping only interruptible waits.
14
+
5
15
  ## [18.1.2] - 2026-09-01
6
16
 
7
17
  ### Fixed
@@ -195,8 +195,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
195
195
  /**
196
196
  * Peeks whether steering messages are queued, without consuming them.
197
197
  *
198
- * Called after each tool execution (unless interruptMode is "wait") to decide
199
- * whether to skip the remaining tool calls in the batch. The queue keeps
198
+ * Polled while a tool batch runs (unless interruptMode is "wait") to decide
199
+ * whether to abort in-flight and skip not-yet-started *interruptible* waits;
200
+ * every other already-emitted call still executes and the message injects
201
+ * at the batch boundary. The queue keeps
200
202
  * owning its messages until the loop reaches the next injection boundary and
201
203
  * dequeues via {@link getSteeringMessages} — so callers can still cancel or
202
204
  * restore queued messages while in-flight tools settle, and an external
@@ -653,8 +655,29 @@ export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprov
653
655
  export interface AgentToolContext {
654
656
  }
655
657
  export type AgentToolExecFn<TParameters extends TSchema = TSchema, TDetails = any, TTheme = unknown> = (this: AgentTool<TParameters, TDetails, TTheme>, toolCallId: string, params: Static<TParameters>, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>, context?: AgentToolContext) => Promise<AgentToolResult<TDetails, TParameters>>;
658
+ /** Live receiver for a tool call's streamed arguments (see AgentTool.openArgStream). */
659
+ export interface AgentToolArgStream {
660
+ /** Raw wire fragment of the arguments (JSON text, or verbatim payload for custom-format tools). */
661
+ push(delta: string): void;
662
+ /** Arguments are complete; `args` is the final parsed object the loop will pass to `execute`. */
663
+ end(args: unknown): void;
664
+ /** The call will never execute (stream error, abort, blocked). Release resources. */
665
+ cancel(): void;
666
+ }
667
+ export interface AgentToolArgStreamInit {
668
+ toolCallId: string;
669
+ toolName: string;
670
+ /** Wire-level name for custom-format tools; undefined for JSON function tools. */
671
+ customWireName?: string;
672
+ /** Push a serializable projection of the in-flight call (e.g. diff previews); surfaces as `tool_stream_update`. */
673
+ emit(update: unknown): void;
674
+ }
656
675
  export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any, TTheme = unknown> extends Tool<TParameters> {
657
676
  label: string;
677
+ /**
678
+ * Called at `toolcall_start`, before any argument delta. Return `undefined` to opt out.
679
+ */
680
+ openArgStream?: (init: AgentToolArgStreamInit) => AgentToolArgStream | undefined;
658
681
  /** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */
659
682
  hidden?: boolean;
660
683
  /** If true, tool can stage a pending action that requires explicit resolution via the resolve tool. */
@@ -673,14 +696,15 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
673
696
  /** If true, argument validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. */
674
697
  lenientArgValidation?: boolean;
675
698
  /**
676
- * Whether the agent loop may abort this tool mid-execution to deliver a
677
- * queued steering message. A function resolves this per call from the raw,
678
- * pre-validation arguments.
699
+ * Whether the agent loop may abort this tool mid-execution or skip it
700
+ * before it starts to deliver a queued steering message. A function
701
+ * resolves this per call from the raw, pre-validation arguments.
679
702
  *
680
703
  * Enable only for calls that purely *wait* and observe their abort signal
681
704
  * cleanly (e.g. `job` poll), so the abort surfaces the tool's current
682
- * snapshot rather than corrupting a side effect. Honored only when
683
- * `interruptMode` is "immediate".
705
+ * snapshot rather than corrupting a side effect. Every other call runs to
706
+ * completion even when steering is queued; the message lands at the next
707
+ * batch boundary. Honored only when `interruptMode` is "immediate".
684
708
  */
685
709
  interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
686
710
  /**
@@ -777,6 +801,11 @@ export type AgentEvent = {
777
801
  toolName: string;
778
802
  args: any;
779
803
  partialResult: any;
804
+ } | {
805
+ type: "tool_stream_update";
806
+ toolCallId: string;
807
+ toolName: string;
808
+ update: unknown;
780
809
  } | {
781
810
  type: "tool_execution_end";
782
811
  toolCallId: string;
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.1.5",
4
+ "version": "18.1.8",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -35,16 +35,16 @@
35
35
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "18.1.5",
39
- "@oh-my-pi/pi-catalog": "18.1.5",
40
- "@oh-my-pi/pi-natives": "18.1.5",
41
- "@oh-my-pi/pi-utils": "18.1.5",
42
- "@oh-my-pi/pi-wire": "18.1.5",
43
- "@oh-my-pi/snapcompact": "18.1.5",
38
+ "@oh-my-pi/pi-ai": "18.1.8",
39
+ "@oh-my-pi/pi-catalog": "18.1.8",
40
+ "@oh-my-pi/pi-natives": "18.1.8",
41
+ "@oh-my-pi/pi-utils": "18.1.8",
42
+ "@oh-my-pi/pi-wire": "18.1.8",
43
+ "@oh-my-pi/snapcompact": "18.1.8",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@oh-my-pi/omptype": "18.1.5",
47
+ "@oh-my-pi/omptype": "18.1.8",
48
48
  "@opentelemetry/context-async-hooks": "^2.9.0",
49
49
  "@opentelemetry/sdk-trace-base": "^2.9.0",
50
50
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -73,6 +73,7 @@ import type {
73
73
  AgentMessage,
74
74
  AgentPreModelCallResult,
75
75
  AgentTool,
76
+ AgentToolArgStream,
76
77
  AgentToolCall,
77
78
  AgentToolResult,
78
79
  AgentTurnEndContext,
@@ -924,7 +925,10 @@ function extractIntent(args: Record<string, unknown>): { intent?: string; stripp
924
925
  if (typeof intent !== "string") {
925
926
  return { strippedArgs };
926
927
  }
927
- const trimmed = intent.trim();
928
+ const trimmed = intent
929
+ .trim()
930
+ .replace(/\s*\.+$/, "")
931
+ .trim();
928
932
  return { intent: trimmed.length > 0 ? trimmed : undefined, strippedArgs };
929
933
  }
930
934
 
@@ -1775,6 +1779,17 @@ async function streamAssistantResponse(
1775
1779
  let partialMessage: AssistantMessage | null = null;
1776
1780
  let addedPartial = false;
1777
1781
  const completedToolCallIds = new Set<string>();
1782
+ const argStreams = new Map<number, { id: string; stream: AgentToolArgStream }>();
1783
+ const cancelArgStreams = (): void => {
1784
+ for (const { id, stream: argStream } of argStreams.values()) {
1785
+ try {
1786
+ argStream.cancel();
1787
+ } catch (error) {
1788
+ logger.debug("Tool argument stream cancel failed", { toolCallId: id, error });
1789
+ }
1790
+ }
1791
+ argStreams.clear();
1792
+ };
1778
1793
 
1779
1794
  const responseIterator = response[Symbol.asyncIterator]();
1780
1795
  const finishAbortedStream = async (): Promise<AssistantMessage> => {
@@ -1888,6 +1903,52 @@ async function streamAssistantResponse(
1888
1903
  // when the LLM is streaming chunks faster than the loop can rest.
1889
1904
  await yieldIfDue();
1890
1905
 
1906
+ if (event.type === "toolcall_start") {
1907
+ const block = event.partial.content[event.contentIndex];
1908
+ if (block?.type === "toolCall") {
1909
+ const tool = resolveToolForCall(context.tools, block, config.resolveFallbackTool);
1910
+ if (tool?.openArgStream) {
1911
+ try {
1912
+ const argStream = tool.openArgStream({
1913
+ toolCallId: block.id,
1914
+ toolName: block.name,
1915
+ customWireName: block.customWireName,
1916
+ emit: update =>
1917
+ stream.push({
1918
+ type: "tool_stream_update",
1919
+ toolCallId: block.id,
1920
+ toolName: block.name,
1921
+ update,
1922
+ }),
1923
+ });
1924
+ if (argStream) argStreams.set(event.contentIndex, { id: block.id, stream: argStream });
1925
+ } catch (error) {
1926
+ logger.debug("Tool argument stream open failed", { toolCallId: block.id, error });
1927
+ }
1928
+ }
1929
+ }
1930
+ } else if (event.type === "toolcall_delta") {
1931
+ const entry = argStreams.get(event.contentIndex);
1932
+ if (entry) {
1933
+ try {
1934
+ entry.stream.push(event.delta);
1935
+ } catch (error) {
1936
+ logger.debug("Tool argument stream push failed", { toolCallId: entry.id, error });
1937
+ }
1938
+ }
1939
+ } else if (event.type === "toolcall_end") {
1940
+ const entry = argStreams.get(event.contentIndex);
1941
+ if (entry) {
1942
+ try {
1943
+ entry.stream.end(event.toolCall.arguments);
1944
+ } catch (error) {
1945
+ logger.debug("Tool argument stream end failed", { toolCallId: entry.id, error });
1946
+ } finally {
1947
+ argStreams.delete(event.contentIndex);
1948
+ }
1949
+ }
1950
+ }
1951
+
1891
1952
  switch (event.type) {
1892
1953
  case "start":
1893
1954
  partialMessage = event.partial;
@@ -1944,6 +2005,7 @@ async function streamAssistantResponse(
1944
2005
  }
1945
2006
  } finally {
1946
2007
  detachAbortListener?.();
2008
+ cancelArgStreams();
1947
2009
  }
1948
2010
 
1949
2011
  let trailing = await response.result();
@@ -2440,8 +2502,9 @@ async function executeToolCalls(
2440
2502
  // Queued steering hard-aborts only interruptible waits and raises the
2441
2503
  // cooperative soft signal for everything else: the boundary dequeue
2442
2504
  // below injects the message as soon as running tools finish (or
2443
- // background themselves), and not-yet-started tools are skipped.
2444
- // Idempotent — a second steer poll after the abort is a no-op.
2505
+ // background themselves), and not-yet-started interruptible waits
2506
+ // are skipped. Idempotent — a second steer poll after the abort is
2507
+ // a no-op.
2445
2508
  if (!steeringAbortController.signal.aborted) {
2446
2509
  interruptState.triggered = true;
2447
2510
  interruptState.source = steeringSource ?? "unknown";
@@ -2495,16 +2558,18 @@ async function executeToolCalls(
2495
2558
  };
2496
2559
 
2497
2560
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
2498
- // A pending interrupt preempts not-yet-started tools so the message
2499
- // injects promptly. A peer-IRC interrupt is the exception: it aborts
2500
- // interruptible waits only and leaves non-interruptible foreground work
2501
- // untouched (see the emit branch below and the `does not abort a
2502
- // non-interruptible foreground tool` case). That guarantee must hold for
2503
- // work still queued behind the aborted wait too otherwise a batched
2504
- // `todo`/`write` gets dropped as "Skipped due to pending peer interrupt"
2505
- // purely for being ordered after the wait (#7493). User/system steering
2506
- // still preempts everything queued.
2507
- if (interruptState.triggered && (record.interruptible || interruptState.source !== "irc")) {
2561
+ // A pending interrupt preempts not-yet-started *interruptible* waits so
2562
+ // the message injects promptly instead of sitting out a `hub wait`.
2563
+ // Non-interruptible work is never skipped, whatever the source: the
2564
+ // expensive part generating the call is already paid, the tool
2565
+ // itself is cheap, and a skip only makes the model re-emit the same
2566
+ // call after the steer lands (#10439). The same guarantee is what keeps
2567
+ // a batched `todo`/`write` queued behind an aborted wait alive (#7493)
2568
+ // and lets a subagent's already-emitted terminal `yield` commit when
2569
+ // the parent steers mid-stream (#10645). The steer still injects at the
2570
+ // batch boundary; the cooperative soft signal lets long-running tools
2571
+ // step aside on their own.
2572
+ if (interruptState.triggered && record.interruptible) {
2508
2573
  // Skip both span emission and the collector orphan record here. The
2509
2574
  // tail sweep below (after `Promise.allSettled`) is the single path
2510
2575
  // that handles "no result message was produced" — it calls
@@ -2719,8 +2784,8 @@ async function executeToolCalls(
2719
2784
 
2720
2785
  // While tool calls are in flight, queued steering or interrupting IRC would
2721
2786
  // otherwise wait out the tools' own window. Poll only non-consuming queues:
2722
- // detection hard-aborts interruptible waits, soft-signals cooperative tools
2723
- // (auto-background bash), and skips not-yet-started tools, so the boundary
2787
+ // detection hard-aborts interruptible waits (running or not yet started)
2788
+ // and soft-signals cooperative tools (auto-background bash), so the boundary
2724
2789
  // dequeue below injects the message promptly. Gated on immediate-interrupt
2725
2790
  // mode; checkSteering is idempotent (no-op once triggered).
2726
2791
  const watchSteeringWhileRunning =
@@ -27,6 +27,7 @@ import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
27
27
  import {
28
28
  applyCodexResidencyHeader,
29
29
  CODEX_BASE_URL,
30
+ codexRoutingHint,
30
31
  getCodexAccountId,
31
32
  OPENAI_HEADER_VALUES,
32
33
  OPENAI_HEADERS,
@@ -407,7 +408,7 @@ function buildCompactionV2Headers(
407
408
  "content-type": "application/json",
408
409
  ...resolveOpenAIRequestSetup(
409
410
  { provider: model.provider, id: model.id, baseUrl: model.baseUrl, headers: model.headers },
410
- { apiKey, messages: [], openAISessionId: routingSessionId, promptCacheSessionId },
411
+ { apiKey, messages: [], sessionId: request.sessionId ?? routingSessionId, promptCacheSessionId },
411
412
  ).headers,
412
413
  };
413
414
  if (api === "openai-codex-responses" || model.provider === "openai-codex") {
@@ -424,6 +425,7 @@ function buildCompactionV2Headers(
424
425
  headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
425
426
  headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
426
427
  headers[OPENAI_HEADERS.CODEX_BETA_FEATURES] = OPENAI_HEADER_VALUES.REMOTE_COMPACTION_V2;
428
+ headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.model, undefined);
427
429
  if (model.useResponsesLite) {
428
430
  headers[OPENAI_HEADERS.RESPONSES_LITE] = "true";
429
431
  }
@@ -48,6 +48,7 @@ import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
48
48
  import {
49
49
  applyCodexResidencyHeader,
50
50
  CODEX_BASE_URL,
51
+ codexRoutingHint,
51
52
  getCodexAccountId,
52
53
  OPENAI_HEADER_VALUES,
53
54
  OPENAI_HEADERS,
@@ -824,6 +825,8 @@ export async function requestOpenAiRemoteCompaction(
824
825
  }
825
826
  headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
826
827
  headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
828
+ // This compaction request sends no `service_tier`, so the hint is model-only.
829
+ headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.model, undefined);
827
830
  Object.assign(
828
831
  headers,
829
832
  createOpenAICodexCompatibilityMetadata({
package/src/types.ts CHANGED
@@ -245,8 +245,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
245
245
  /**
246
246
  * Peeks whether steering messages are queued, without consuming them.
247
247
  *
248
- * Called after each tool execution (unless interruptMode is "wait") to decide
249
- * whether to skip the remaining tool calls in the batch. The queue keeps
248
+ * Polled while a tool batch runs (unless interruptMode is "wait") to decide
249
+ * whether to abort in-flight and skip not-yet-started *interruptible* waits;
250
+ * every other already-emitted call still executes and the message injects
251
+ * at the batch boundary. The queue keeps
250
252
  * owning its messages until the loop reaches the next injection boundary and
251
253
  * dequeues via {@link getSteeringMessages} — so callers can still cancel or
252
254
  * restore queued messages while in-flight tools settle, and an external
@@ -758,6 +760,25 @@ export type AgentToolExecFn<TParameters extends TSchema = TSchema, TDetails = an
758
760
  context?: AgentToolContext,
759
761
  ) => Promise<AgentToolResult<TDetails, TParameters>>;
760
762
 
763
+ /** Live receiver for a tool call's streamed arguments (see AgentTool.openArgStream). */
764
+ export interface AgentToolArgStream {
765
+ /** Raw wire fragment of the arguments (JSON text, or verbatim payload for custom-format tools). */
766
+ push(delta: string): void;
767
+ /** Arguments are complete; `args` is the final parsed object the loop will pass to `execute`. */
768
+ end(args: unknown): void;
769
+ /** The call will never execute (stream error, abort, blocked). Release resources. */
770
+ cancel(): void;
771
+ }
772
+
773
+ export interface AgentToolArgStreamInit {
774
+ toolCallId: string;
775
+ toolName: string;
776
+ /** Wire-level name for custom-format tools; undefined for JSON function tools. */
777
+ customWireName?: string;
778
+ /** Push a serializable projection of the in-flight call (e.g. diff previews); surfaces as `tool_stream_update`. */
779
+ emit(update: unknown): void;
780
+ }
781
+
761
782
  // AgentTool extends Tool but adds the execute function
762
783
  export interface AgentTool<
763
784
  TParameters extends TSchema = TSchema,
@@ -766,6 +787,10 @@ export interface AgentTool<
766
787
  > extends Tool<TParameters> {
767
788
  // A human-readable label for the tool to be displayed in UI
768
789
  label: string;
790
+ /**
791
+ * Called at `toolcall_start`, before any argument delta. Return `undefined` to opt out.
792
+ */
793
+ openArgStream?: (init: AgentToolArgStreamInit) => AgentToolArgStream | undefined;
769
794
  /** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */
770
795
  hidden?: boolean;
771
796
  /** If true, tool can stage a pending action that requires explicit resolution via the resolve tool. */
@@ -784,14 +809,15 @@ export interface AgentTool<
784
809
  /** If true, argument validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. */
785
810
  lenientArgValidation?: boolean;
786
811
  /**
787
- * Whether the agent loop may abort this tool mid-execution to deliver a
788
- * queued steering message. A function resolves this per call from the raw,
789
- * pre-validation arguments.
812
+ * Whether the agent loop may abort this tool mid-execution or skip it
813
+ * before it starts to deliver a queued steering message. A function
814
+ * resolves this per call from the raw, pre-validation arguments.
790
815
  *
791
816
  * Enable only for calls that purely *wait* and observe their abort signal
792
817
  * cleanly (e.g. `job` poll), so the abort surfaces the tool's current
793
- * snapshot rather than corrupting a side effect. Honored only when
794
- * `interruptMode` is "immediate".
818
+ * snapshot rather than corrupting a side effect. Every other call runs to
819
+ * completion even when steering is queued; the message lands at the next
820
+ * batch boundary. Honored only when `interruptMode` is "immediate".
795
821
  */
796
822
  interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
797
823
  /**
@@ -885,4 +911,5 @@ export type AgentEvent =
885
911
  // Tool execution lifecycle
886
912
  | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any; intent?: string }
887
913
  | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
914
+ | { type: "tool_stream_update"; toolCallId: string; toolName: string; update: unknown }
888
915
  | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError?: boolean };