@linxiraos/pi-agent-core 1.1.8 → 1.1.10

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,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.10] - 2026-09-07
6
+
7
+ - OMP v18.1.11 sync baseline (`e3106be68f`); no package-specific user-visible changes.
8
+
9
+ ## [1.1.9] - 2026-09-05
10
+
11
+ - v18.1.10 sync baseline (compaction and tool-arg stream updates).
12
+
5
13
  ## [1.1.8] - 2026-09-04
6
14
 
7
15
  - OMP sync v18.1.2–v18.1.5: Agent Hub activity stream groundwork, declarative provider auth registry, and tool-roster notice plumbing shared with the session layer.
@@ -20,12 +28,6 @@
20
28
 
21
29
  - 同步上游 OMP v18.0.5 / v18.0.6(内部运行时与构建改进)。
22
30
 
23
- ## [1.1.1] - 2026-08-25
24
-
25
- ### Fixed
26
-
27
- - Published tarballs now carry real dependency versions instead of Bun's `catalog:` protocol (1.1.0 installs failed with "Unsupported URL Type catalog:").
28
-
29
31
  ## [1.1.0] - 2026-08-25
30
32
 
31
33
  ### Changed
@@ -7,6 +7,7 @@
7
7
  * compaction item as replacement history.
8
8
  */
9
9
  import type { CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@linxiraos/pi-ai";
10
+ import { type OpenAICodexCompactionBody } from "@linxiraos/pi-ai/providers/openai-codex-responses";
10
11
  /** Retained-message budget Codex uses after streamed V2 compaction. */
11
12
  export declare const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64000;
12
13
  /** Max retries for V2 streaming compaction on transient stream errors. */
@@ -21,18 +22,11 @@ export interface CompactionV2Usage {
21
22
  cachedInputTokens?: number;
22
23
  reasoningOutputTokens?: number;
23
24
  }
24
- /** Request body fields needed for Responses-stream V2 compaction. */
25
+ /** Provider-ready Responses body and local state needed for V2 compaction. */
25
26
  export interface CompactionV2Request {
26
- model: string;
27
+ body: OpenAICodexCompactionBody;
27
28
  input: unknown[];
28
- instructions: string;
29
29
  retainedMessageBudget: number;
30
- tools?: unknown[];
31
- /** Responses reasoning param (effort + summary), matching a normal turn; omitted for non-reasoning models. */
32
- reasoning?: {
33
- effort: string;
34
- summary: string;
35
- };
36
30
  sessionId?: string;
37
31
  promptCacheKey?: string;
38
32
  }
@@ -61,6 +55,12 @@ export declare function buildCompactionV2Request(model: Model, input: unknown[],
61
55
  promptCacheKey?: string;
62
56
  retainedMessageBudget?: number;
63
57
  }): CompactionV2Request;
58
+ /** Wrap a body built by the normal Codex serializer for V2 compaction transport. */
59
+ export declare function buildCompactionV2RequestFromBody(model: Model, body: OpenAICodexCompactionBody, options?: {
60
+ sessionId?: string;
61
+ promptCacheKey?: string;
62
+ retainedMessageBudget?: number;
63
+ }): CompactionV2Request;
64
64
  /** Request V2 compaction over the normal OpenAI Responses streaming endpoint. */
65
65
  export declare function requestCompactionV2Streaming(model: Model, apiKey: string, request: CompactionV2Request, signal?: AbortSignal, options?: {
66
66
  fetch?: FetchImpl;
@@ -160,7 +160,8 @@ export interface SummaryOptions {
160
160
  promptOverride?: string;
161
161
  extraContext?: string[];
162
162
  remoteEndpoint?: string;
163
- remoteInstructions?: string;
163
+ /** Stable system-prompt segments from the live turn, preserved for provider cache reuse. */
164
+ remoteSystemPrompt?: string[];
164
165
  initiatorOverride?: MessageAttribution;
165
166
  metadata?: Record<string, unknown>;
166
167
  convertToLlm?: ConvertToLlm;
@@ -456,7 +456,7 @@ export declare function recordManualChatTelemetry(telemetry: AgentTelemetry | un
456
456
  /**
457
457
  * Options accepted by {@link instrumentedCompleteSimple}. Mirrors the
458
458
  * `streamAssistantResponse` chat-span lifecycle for oneshot LLM calls
459
- * (compaction summaries, handoff document, branch summary, inspect_image).
459
+ * (compaction summaries, handoff document, branch summary, image_question).
460
460
  */
461
461
  export interface InstrumentedChatSpanOptions {
462
462
  readonly telemetry: AgentTelemetry | undefined;
@@ -467,7 +467,7 @@ export interface InstrumentedChatSpanOptions {
467
467
  /**
468
468
  * Tag stamped onto `pi.gen_ai.oneshot.kind`. Values used by the agent:
469
469
  * `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
470
- * `handoff`, `branch_summary`, `inspect_image`. Free-form to allow callers
470
+ * `handoff`, `branch_summary`, `image_question`. Free-form to allow callers
471
471
  * outside this package to add new kinds without bumping the helper.
472
472
  */
473
473
  readonly oneshotKind?: string;
@@ -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,22 +1,55 @@
1
1
  {
2
- "type": "module",
3
- "name": "@linxiraos/pi-agent-core",
4
- "version": "1.1.8",
5
- "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
- "homepage": "https://linxira-os.github.io/zeta/",
7
- "author": "Can Boluk",
8
- "contributors": [
9
- "Mario Zechner"
2
+ "exports": {
3
+ "./*": {
4
+ "import": "./src/*.ts",
5
+ "types": "./dist/types/*.d.ts"
6
+ },
7
+ "./compaction/*": {
8
+ "import": "./src/compaction/*.ts",
9
+ "types": "./dist/types/compaction/*.d.ts"
10
+ },
11
+ "./compaction": {
12
+ "import": "./src/compaction.ts",
13
+ "types": "./dist/types/compaction.d.ts"
14
+ },
15
+ ".": {
16
+ "import": "./src/index.ts",
17
+ "types": "./dist/types/index.d.ts"
18
+ }
19
+ },
20
+ "files": [
21
+ "src",
22
+ "README.md",
23
+ "CHANGELOG.md",
24
+ "LICENSE",
25
+ "THIRD-PARTY-NOTICES.txt",
26
+ "dist/types"
10
27
  ],
11
- "license": "MIT",
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
- "directory": "packages/agent"
28
+ "engines": {
29
+ "bun": ">=1.3.14"
16
30
  },
17
- "bugs": {
18
- "url": "https://github.com/Linxira-OS/linxira-zeta/issues"
31
+ "devDependencies": {
32
+ "@linxiraos/pi-omptype": "1.1.10",
33
+ "@types/bun": "^1.3.14",
34
+ "@opentelemetry/sdk-trace-base": "^2.9.0",
35
+ "@opentelemetry/context-async-hooks": "^2.9.0"
36
+ },
37
+ "dependencies": {
38
+ "@linxiraos/pi-snapcompact": "1.1.10",
39
+ "@linxiraos/pi-wire": "1.1.10",
40
+ "@linxiraos/pi-utils": "1.1.10",
41
+ "@linxiraos/pi-natives": "1.1.10",
42
+ "@linxiraos/pi-catalog": "1.1.10",
43
+ "@linxiraos/pi-ai": "1.1.10",
44
+ "@opentelemetry/api": "^1.9.1"
45
+ },
46
+ "scripts": {
47
+ "test": "bun test --parallel",
48
+ "check:types": "tsgo -p tsconfig.json --noEmit",
49
+ "check": "tsgo -p tsconfig.json --noEmit"
19
50
  },
51
+ "types": "./dist/types/index.d.ts",
52
+ "main": "./src/index.ts",
20
53
  "keywords": [
21
54
  "ai",
22
55
  "agent",
@@ -24,58 +57,22 @@
24
57
  "transport",
25
58
  "state-management"
26
59
  ],
27
- "main": "./src/index.ts",
28
- "types": "./dist/types/index.d.ts",
29
- "scripts": {
30
- "check": "biome check . && bun run check:types",
31
- "check:types": "tsgo -p tsconfig.json --noEmit",
32
- "lint": "biome lint .",
33
- "test": "bun test --parallel",
34
- "fix": "biome check --write --unsafe .",
35
- "fmt": "biome format --write ."
36
- },
37
- "dependencies": {
38
- "@linxiraos/pi-ai": "1.1.8",
39
- "@linxiraos/pi-catalog": "1.1.8",
40
- "@linxiraos/pi-natives": "1.1.8",
41
- "@linxiraos/pi-utils": "1.1.8",
42
- "@linxiraos/pi-wire": "1.1.8",
43
- "@linxiraos/pi-snapcompact": "1.1.8",
44
- "@opentelemetry/api": "^1.9.1"
45
- },
46
- "devDependencies": {
47
- "@linxiraos/pi-omptype": "1.1.8",
48
- "@opentelemetry/context-async-hooks": "^2.9.0",
49
- "@opentelemetry/sdk-trace-base": "^2.9.0",
50
- "@types/bun": "^1.3.14"
60
+ "bugs": {
61
+ "url": "https://github.com/Linxira-OS/linxira-zeta/issues"
51
62
  },
52
- "engines": {
53
- "bun": ">=1.3.14"
63
+ "repository": {
64
+ "directory": "packages/agent",
65
+ "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
66
+ "type": "git"
54
67
  },
55
- "files": [
56
- "src",
57
- "README.md",
58
- "CHANGELOG.md",
59
- "LICENSE",
60
- "THIRD-PARTY-NOTICES.txt",
61
- "dist/types"
68
+ "license": "MIT",
69
+ "contributors": [
70
+ "Mario Zechner"
62
71
  ],
63
- "exports": {
64
- ".": {
65
- "types": "./dist/types/index.d.ts",
66
- "import": "./src/index.ts"
67
- },
68
- "./compaction": {
69
- "types": "./dist/types/compaction.d.ts",
70
- "import": "./src/compaction.ts"
71
- },
72
- "./compaction/*": {
73
- "types": "./dist/types/compaction/*.d.ts",
74
- "import": "./src/compaction/*.ts"
75
- },
76
- "./*": {
77
- "types": "./dist/types/*.d.ts",
78
- "import": "./src/*.ts"
79
- }
80
- }
72
+ "author": "Can Boluk",
73
+ "homepage": "https://linxira-os.github.io/zeta/",
74
+ "description": "General-purpose agent with transport abstraction, state management, and attachment support",
75
+ "version": "1.1.10",
76
+ "name": "@linxiraos/pi-agent-core",
77
+ "type": "module"
81
78
  }
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 "@linxiraos/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,
@@ -70,15 +71,11 @@ export interface CompactionV2Usage {
70
71
  reasoningOutputTokens?: number;
71
72
  }
72
73
 
73
- /** Request body fields needed for Responses-stream V2 compaction. */
74
+ /** Provider-ready Responses body and local state needed for V2 compaction. */
74
75
  export interface CompactionV2Request {
75
- model: string;
76
+ body: OpenAICodexCompactionBody;
76
77
  input: unknown[];
77
- instructions: string;
78
78
  retainedMessageBudget: number;
79
- tools?: unknown[];
80
- /** Responses reasoning param (effort + summary), matching a normal turn; omitted for non-reasoning models. */
81
- reasoning?: { effort: string; summary: string };
82
79
  sessionId?: string;
83
80
  promptCacheKey?: string;
84
81
  }
@@ -204,13 +201,44 @@ export function buildCompactionV2Request(
204
201
  retainedMessageBudget?: number;
205
202
  },
206
203
  ): CompactionV2Request {
207
- return {
204
+ const cacheOptions = { sessionId: options?.sessionId, promptCacheKey: options?.promptCacheKey };
205
+ const promptCacheKey = getOpenAIPromptCacheKey(cacheOptions);
206
+ const body: OpenAICodexCompactionBody = {
208
207
  model: resolveCompactionV2Model(model),
209
208
  input,
210
209
  instructions,
210
+ stream: true,
211
+ store: false,
212
+ ...(options?.reasoning || model.useResponsesLite
213
+ ? {
214
+ reasoning: model.useResponsesLite ? { ...options?.reasoning, context: "all_turns" } : options?.reasoning,
215
+ include: ["reasoning.encrypted_content"],
216
+ }
217
+ : {}),
218
+ ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
219
+ ...(options?.tools && options.tools.length > 0 ? { tools: options.tools, tool_choice: "auto" } : {}),
220
+ };
221
+ if (model.useResponsesLite) {
222
+ applyCodexResponsesLiteShape(body);
223
+ }
224
+ return buildCompactionV2RequestFromBody(model, body, options);
225
+ }
226
+
227
+ /** Wrap a body built by the normal Codex serializer for V2 compaction transport. */
228
+ export function buildCompactionV2RequestFromBody(
229
+ model: Model,
230
+ body: OpenAICodexCompactionBody,
231
+ options?: {
232
+ sessionId?: string;
233
+ promptCacheKey?: string;
234
+ retainedMessageBudget?: number;
235
+ },
236
+ ): CompactionV2Request {
237
+ const input = Array.isArray(body.input) ? body.input : [];
238
+ return {
239
+ body: { ...body, model: resolveCompactionV2Model(model), input },
240
+ input,
211
241
  retainedMessageBudget: resolveCompactionV2RetainedMessageBudget(options?.retainedMessageBudget),
212
- reasoning: options?.reasoning,
213
- tools: options?.tools,
214
242
  sessionId: options?.sessionId,
215
243
  promptCacheKey: options?.promptCacheKey,
216
244
  };
@@ -311,35 +339,17 @@ async function attemptCompactionV2Streaming(
311
339
  },
312
340
  ): Promise<CompactionV2Response> {
313
341
  // Faithful to Codex: append the compaction trigger as the final input item
314
- // of an otherwise-normal Responses request, then stream the result. `store`
315
- // stays false — compaction must never persist a server-side response object.
316
- const cacheOptions = { sessionId: request.sessionId, promptCacheKey: request.promptCacheKey };
317
- const promptCacheKey = getOpenAIPromptCacheKey(cacheOptions);
342
+ // of an otherwise-normal Responses request. `store` remains false —
343
+ // compaction must never persist a server-side response object.
318
344
  const body: OpenAICodexCompactionBody = {
319
- model: request.model,
345
+ ...request.body,
320
346
  input: [...request.input, COMPACTION_TRIGGER_ITEM],
321
- instructions: request.instructions,
322
- stream: true,
323
347
  store: false,
324
- ...(request.reasoning || model.useResponsesLite
325
- ? {
326
- // Lite implies gpt-5.4+, where codex-rs sends `all_turns` replay.
327
- reasoning: model.useResponsesLite ? { ...request.reasoning, context: "all_turns" } : request.reasoning,
328
- include: ["reasoning.encrypted_content"],
329
- }
330
- : {}),
331
- ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
332
- ...(request.tools && request.tools.length > 0 ? { tools: request.tools, tool_choice: "auto" } : {}),
348
+ stream: true,
333
349
  };
334
350
  if (options.codexMetadata) {
335
351
  body.client_metadata = options.codexMetadata.clientMetadata;
336
352
  }
337
- // Responses Lite models take the same rewrite on the compaction stream:
338
- // instructions/tools ride as input items (codex-rs `compact_remote_v2`
339
- // builds through `build_responses_request`).
340
- if (model.useResponsesLite) {
341
- applyCodexResponsesLiteShape(body);
342
- }
343
353
 
344
354
  if (shouldUseCodexProviderTransport(model)) {
345
355
  const eventStream = await openCodexCompactionEventStream(model, body, {
@@ -407,7 +417,7 @@ function buildCompactionV2Headers(
407
417
  "content-type": "application/json",
408
418
  ...resolveOpenAIRequestSetup(
409
419
  { provider: model.provider, id: model.id, baseUrl: model.baseUrl, headers: model.headers },
410
- { apiKey, messages: [], openAISessionId: routingSessionId, promptCacheSessionId },
420
+ { apiKey, messages: [], sessionId: request.sessionId ?? routingSessionId, promptCacheSessionId },
411
421
  ).headers,
412
422
  };
413
423
  if (api === "openai-codex-responses" || model.provider === "openai-codex") {
@@ -424,6 +434,7 @@ function buildCompactionV2Headers(
424
434
  headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
425
435
  headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
426
436
  headers[OPENAI_HEADERS.CODEX_BETA_FEATURES] = OPENAI_HEADER_VALUES.REMOTE_COMPACTION_V2;
437
+ headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.body.model, undefined);
427
438
  if (model.useResponsesLite) {
428
439
  headers[OPENAI_HEADERS.RESPONSES_LITE] = "true";
429
440
  }
@@ -25,7 +25,12 @@ import {
25
25
  } from "@linxiraos/pi-ai";
26
26
  import type { Dialect } from "@linxiraos/pi-ai/dialect";
27
27
  import * as AIError from "@linxiraos/pi-ai/error";
28
- import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-responses";
28
+ import type { InputItem as CodexInputItem } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
29
+ import {
30
+ buildTransformedCodexRequestBody,
31
+ createOpenAICodexCompactionRequestContext,
32
+ type OpenAICodexCompactionBody,
33
+ } from "@linxiraos/pi-ai/providers/openai-codex-responses";
29
34
  import { convertTools } from "@linxiraos/pi-ai/providers/openai-responses";
30
35
  import { buildResponsesInput, resolveOpenAICompatPolicy } from "@linxiraos/pi-ai/providers/openai-shared";
31
36
  import { stripOpenAIResponsesOutputOnlyStatusesForReplay } from "@linxiraos/pi-ai/utils";
@@ -39,6 +44,7 @@ import { Tokenizer } from "../tokenizer";
39
44
  import type { AgentMessage } from "../types";
40
45
  import {
41
46
  buildCompactionV2Request,
47
+ buildCompactionV2RequestFromBody,
42
48
  getCompactionV2PreserveData,
43
49
  requestCompactionV2Streaming,
44
50
  shouldUseCompactionV2Streaming,
@@ -660,7 +666,8 @@ export interface SummaryOptions {
660
666
  promptOverride?: string;
661
667
  extraContext?: string[];
662
668
  remoteEndpoint?: string;
663
- remoteInstructions?: string;
669
+ /** Stable system-prompt segments from the live turn, preserved for provider cache reuse. */
670
+ remoteSystemPrompt?: string[];
664
671
  initiatorOverride?: MessageAttribution;
665
672
  metadata?: Record<string, unknown>;
666
673
  convertToLlm?: ConvertToLlm;
@@ -1433,6 +1440,19 @@ export function prepareCompaction(
1433
1440
  // ============================================================================
1434
1441
 
1435
1442
  const TURN_PREFIX_SUMMARIZATION_PROMPT = prompt.render(compactionTurnPrefixPrompt);
1443
+ function isCodexResponsesModel(model: Model): model is Model<"openai-codex-responses"> {
1444
+ return model.api === "openai-codex-responses";
1445
+ }
1446
+
1447
+ function isCodexInputItem(item: Record<string, unknown>): item is CodexInputItem & Record<string, unknown> {
1448
+ return (
1449
+ (item.id === undefined || item.id === null || typeof item.id === "string") &&
1450
+ (item.type === undefined || item.type === null || typeof item.type === "string") &&
1451
+ (item.role === undefined || typeof item.role === "string") &&
1452
+ (item.call_id === undefined || item.call_id === null || typeof item.call_id === "string") &&
1453
+ (item.name === undefined || typeof item.name === "string")
1454
+ );
1455
+ }
1436
1456
 
1437
1457
  function openAiCompatSupportsImageDetailOriginal(model: Model): boolean {
1438
1458
  const compat = model.compat;
@@ -1546,7 +1566,7 @@ export async function compact(
1546
1566
  promptOverride: options?.promptOverride,
1547
1567
  extraContext: options?.extraContext,
1548
1568
  remoteEndpoint: settings.remoteEnabled === false ? undefined : settings.remoteEndpoint,
1549
- remoteInstructions: options?.remoteInstructions,
1569
+ remoteSystemPrompt: options?.remoteSystemPrompt,
1550
1570
  initiatorOverride: options?.initiatorOverride,
1551
1571
  metadata: options?.metadata,
1552
1572
  convertToLlm: options?.convertToLlm,
@@ -1598,17 +1618,63 @@ export async function compact(
1598
1618
  previousRemoteCompaction?.provider === model.provider
1599
1619
  ? previousRemoteCompaction.replacementHistory
1600
1620
  : undefined;
1601
- const remoteHistory = buildOpenAiResponsesCompactionInput(
1602
- (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1603
- model,
1604
- previousReplacementHistory,
1605
- );
1621
+ const messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages);
1622
+ const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
1623
+ let codexBody: OpenAICodexCompactionBody | undefined;
1624
+ let remoteHistory: Array<Record<string, unknown>>;
1625
+ if (isCodexResponsesModel(model)) {
1626
+ const previousCodexInput: CodexInputItem[] = [];
1627
+ for (const item of previousReplacementHistory ?? []) {
1628
+ if (!isCodexInputItem(item)) {
1629
+ throw new Error("Stored Codex V2 compaction history contains an invalid input item");
1630
+ }
1631
+ previousCodexInput.push(item);
1632
+ }
1633
+ codexBody = await buildTransformedCodexRequestBody(
1634
+ model,
1635
+ { systemPrompt: remoteSystemPrompt, messages, tools: summaryOptions.tools },
1636
+ {
1637
+ reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
1638
+ forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
1639
+ responsesLite: model.useResponsesLite,
1640
+ sessionId: summaryOptions.sessionId,
1641
+ promptCacheKey: summaryOptions.promptCacheKey,
1642
+ providerSessionState: summaryOptions.providerSessionState,
1643
+ codexCompaction: createOpenAICodexCompactionRequestContext({
1644
+ context: summaryOptions.codexCompaction,
1645
+ implementation: "responses_compaction_v2",
1646
+ }),
1647
+ },
1648
+ undefined,
1649
+ previousCodexInput,
1650
+ );
1651
+ const input = Array.isArray(codexBody.input) ? codexBody.input : [];
1652
+ const nativeInput: Array<Record<string, unknown>> = [];
1653
+ for (const item of input) {
1654
+ if (!isRecord(item)) {
1655
+ throw new Error("Codex V2 compaction input contains a non-object item");
1656
+ }
1657
+ nativeInput.push(item);
1658
+ }
1659
+ remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
1660
+ codexBody.input = remoteHistory;
1661
+ } else {
1662
+ remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
1663
+ }
1606
1664
  if (remoteHistory.length > 0) {
1607
1665
  try {
1608
- const instructions = summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT;
1609
- const tools = summaryOptions.tools
1610
- ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1611
- : undefined;
1666
+ const instructions = codexBody
1667
+ ? typeof codexBody.instructions === "string"
1668
+ ? codexBody.instructions
1669
+ : ""
1670
+ : remoteSystemPrompt.join("\n\n");
1671
+ const tools = codexBody
1672
+ ? Array.isArray(codexBody.tools)
1673
+ ? codexBody.tools
1674
+ : undefined
1675
+ : summaryOptions.tools
1676
+ ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1677
+ : undefined;
1612
1678
  const trimmed = trimRemoteCompactionInputToContextWindow(
1613
1679
  remoteHistory,
1614
1680
  new Tokenizer(model),
@@ -1626,13 +1692,18 @@ export async function compact(
1626
1692
  contextWindow: model.contextWindow,
1627
1693
  });
1628
1694
  }
1629
- const request = buildCompactionV2Request(model, trimmed.input, instructions, {
1630
- tools,
1631
- reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1695
+ const requestOptions = {
1632
1696
  sessionId: summaryOptions.sessionId,
1633
1697
  promptCacheKey: summaryOptions.promptCacheKey,
1634
1698
  retainedMessageBudget: settings.v2RetainedMessageBudget,
1635
- });
1699
+ };
1700
+ const request = codexBody
1701
+ ? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
1702
+ : buildCompactionV2Request(model, trimmed.input, instructions, {
1703
+ ...requestOptions,
1704
+ tools,
1705
+ reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1706
+ });
1636
1707
  const remote = await withAuth(
1637
1708
  apiKey,
1638
1709
  key =>
@@ -1685,7 +1756,7 @@ export async function compact(
1685
1756
  model,
1686
1757
  key,
1687
1758
  remoteHistory,
1688
- summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT,
1759
+ summaryOptions.remoteSystemPrompt?.join("\n\n") ?? SUMMARIZATION_SYSTEM_PROMPT,
1689
1760
  signal,
1690
1761
  {
1691
1762
  fetch: summaryOptions.fetch,
@@ -48,6 +48,7 @@ import { captureOpenAIHttpError } from "@linxiraos/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/telemetry.ts CHANGED
@@ -1638,7 +1638,7 @@ export async function recordManualChatTelemetry(
1638
1638
  /**
1639
1639
  * Options accepted by {@link instrumentedCompleteSimple}. Mirrors the
1640
1640
  * `streamAssistantResponse` chat-span lifecycle for oneshot LLM calls
1641
- * (compaction summaries, handoff document, branch summary, inspect_image).
1641
+ * (compaction summaries, handoff document, branch summary, image_question).
1642
1642
  */
1643
1643
  export interface InstrumentedChatSpanOptions {
1644
1644
  readonly telemetry: AgentTelemetry | undefined;
@@ -1649,7 +1649,7 @@ export interface InstrumentedChatSpanOptions {
1649
1649
  /**
1650
1650
  * Tag stamped onto `pi.gen_ai.oneshot.kind`. Values used by the agent:
1651
1651
  * `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
1652
- * `handoff`, `branch_summary`, `inspect_image`. Free-form to allow callers
1652
+ * `handoff`, `branch_summary`, `image_question`. Free-form to allow callers
1653
1653
  * outside this package to add new kinds without bumping the helper.
1654
1654
  */
1655
1655
  readonly oneshotKind?: string;
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,11 +760,34 @@ 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<TParameters extends TSchema = TSchema, TDetails = any, TTheme = unknown>
763
784
  extends Tool<TParameters> {
764
785
  // A human-readable label for the tool to be displayed in UI
765
786
  label: string;
787
+ /**
788
+ * Called at `toolcall_start`, before any argument delta. Return `undefined` to opt out.
789
+ */
790
+ openArgStream?: (init: AgentToolArgStreamInit) => AgentToolArgStream | undefined;
766
791
  /** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */
767
792
  hidden?: boolean;
768
793
  /** If true, tool can stage a pending action that requires explicit resolution via the resolve tool. */
@@ -781,14 +806,15 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
781
806
  /** If true, argument validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. */
782
807
  lenientArgValidation?: boolean;
783
808
  /**
784
- * Whether the agent loop may abort this tool mid-execution to deliver a
785
- * queued steering message. A function resolves this per call from the raw,
786
- * pre-validation arguments.
809
+ * Whether the agent loop may abort this tool mid-execution — or skip it
810
+ * before it starts — to deliver a queued steering message. A function
811
+ * resolves this per call from the raw, pre-validation arguments.
787
812
  *
788
813
  * Enable only for calls that purely *wait* and observe their abort signal
789
814
  * cleanly (e.g. `job` poll), so the abort surfaces the tool's current
790
- * snapshot rather than corrupting a side effect. Honored only when
791
- * `interruptMode` is "immediate".
815
+ * snapshot rather than corrupting a side effect. Every other call runs to
816
+ * completion even when steering is queued; the message lands at the next
817
+ * batch boundary. Honored only when `interruptMode` is "immediate".
792
818
  */
793
819
  interruptible?: boolean | ((args: Partial<Static<TParameters>>) => boolean);
794
820
  /**
@@ -882,4 +908,5 @@ export type AgentEvent =
882
908
  // Tool execution lifecycle
883
909
  | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any; intent?: string }
884
910
  | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
911
+ | { type: "tool_stream_update"; toolCallId: string; toolName: string; update: unknown }
885
912
  | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError?: boolean };