@oh-my-pi/pi-agent-core 16.3.11 → 16.3.12

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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.12] - 2026-07-08
6
+
7
+ ### Added
8
+
9
+ - Added per-tool abort metadata so stream-wide aborts can label matching tool-call placeholders separately from unaffected sibling calls ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed handoff generation retrying with `toolChoice: "auto"` when custom OpenAI-compatible providers reject `toolChoice: "none"` with an auto-only 400. ([#4715](https://github.com/can1357/oh-my-pi/issues/4715))
14
+ - Fixed generic remote compaction against OpenAI-compatible `/chat/completions` endpoints (for example llama.cpp `openai-completions`) by sending chat messages instead of the custom `{ systemPrompt, prompt }` summarizer payload. ([#4630](https://github.com/can1357/oh-my-pi/issues/4630))
15
+
5
16
  ## [16.3.7] - 2026-07-05
6
17
 
7
18
  ### Fixed
@@ -8,6 +8,24 @@ import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector.js"
8
8
  import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
9
9
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
10
10
  export declare const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
11
+ /**
12
+ * Cadence (ms) for polling queued steering while an `interruptible` tool is in
13
+ * flight, so a steer cuts the wait short instead of sitting idle until the
14
+ * tool's own window elapses. A cheap synchronous queue check; latency-bounded
15
+ * at one tick.
16
+ */
17
+ /**
18
+ * Abort reason for a turn-wide interruption where only some tool calls caused
19
+ * the abort and sibling placeholders need neutral messages.
20
+ */
21
+ export interface ToolScopedAbortReason {
22
+ readonly kind: "tool-scoped-abort";
23
+ readonly message: string;
24
+ readonly toolCallMessages: Record<string, string>;
25
+ readonly defaultToolCallMessage: string;
26
+ }
27
+ /** Creates an abort reason that labels matching tool calls separately from siblings. */
28
+ export declare function createToolScopedAbortReason(message: string, toolCallMessages: Record<string, string>, defaultToolCallMessage: string): ToolScopedAbortReason;
11
29
  export declare function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined;
12
30
  /**
13
31
  * Start an agent loop with a new prompt message.
@@ -249,8 +249,9 @@ export interface HandoffFromContextOptions {
249
249
  * `streamOptions` that mirror the live turn's cache routing. That keeps the
250
250
  * cache-preserving context construction in the host (which owns the transform
251
251
  * pipeline) while this function centralizes the handoff request contract:
252
- * `toolChoice: "none"`, clamped reasoning effort, oneshot telemetry, text-only
253
- * extraction, and provider-error mapping.
252
+ * cache-first `toolChoice: "none"`, clamped reasoning effort, one retry for
253
+ * auto-only `tool_choice` providers, oneshot telemetry, text-only extraction,
254
+ * and provider-error mapping.
254
255
  */
255
256
  export declare function generateHandoffFromContext(context: Context, model: Model, options: HandoffFromContextOptions): Promise<string>;
256
257
  export declare function generateHandoff(messages: AgentMessage[], model: Model, apiKey: ApiKey, options: HandoffOptions, signal?: AbortSignal): Promise<string>;
@@ -71,7 +71,24 @@ export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: stri
71
71
  fetch?: FetchImpl;
72
72
  timeoutMs?: number;
73
73
  }): Promise<OpenAiRemoteCompactionResponse>;
74
+ /**
75
+ * Generic remote-compaction POST. Two wire shapes are auto-selected by
76
+ * endpoint suffix so a single `compaction.remoteEndpoint` setting can point at
77
+ * either a purpose-built omp summarizer (`{systemPrompt, prompt}` → `{summary}`)
78
+ * or any OpenAI-compatible chat-completions server (`/chat/completions`,
79
+ * `/v1/chat/completions`, …) as reported for llama.cpp / vLLM / etc. in
80
+ * issue #4630: without this, the omp payload was rejected with
81
+ * HTTP 400 `"'messages' is required"`, compaction silently fell back to
82
+ * local summarization, and context grew unbounded.
83
+ *
84
+ * When `context.model` is provided the chat-completions body is tagged with
85
+ * that model's wire id (llama.cpp requires the field) and `context.apiKey` is
86
+ * forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
87
+ * 401s force-refresh through the standard credential rotation policy.
88
+ */
74
89
  export declare function requestRemoteCompaction(endpoint: string, request: RemoteCompactionRequest, signal?: AbortSignal, opts?: {
75
90
  fetch?: FetchImpl;
76
91
  timeoutMs?: number;
92
+ model?: Model;
93
+ apiKey?: string;
77
94
  }): Promise<RemoteCompactionResponse>;
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": "16.3.11",
4
+ "version": "16.3.12",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.3.11",
39
- "@oh-my-pi/pi-catalog": "16.3.11",
40
- "@oh-my-pi/pi-natives": "16.3.11",
41
- "@oh-my-pi/pi-utils": "16.3.11",
42
- "@oh-my-pi/pi-wire": "16.3.11",
43
- "@oh-my-pi/snapcompact": "16.3.11",
38
+ "@oh-my-pi/pi-ai": "16.3.12",
39
+ "@oh-my-pi/pi-catalog": "16.3.12",
40
+ "@oh-my-pi/pi-natives": "16.3.12",
41
+ "@oh-my-pi/pi-utils": "16.3.12",
42
+ "@oh-my-pi/pi-wire": "16.3.12",
43
+ "@oh-my-pi/snapcompact": "16.3.12",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -112,6 +112,26 @@ function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: stri
112
112
  * tool's own window elapses. A cheap synchronous queue check; latency-bounded
113
113
  * at one tick.
114
114
  */
115
+ /**
116
+ * Abort reason for a turn-wide interruption where only some tool calls caused
117
+ * the abort and sibling placeholders need neutral messages.
118
+ */
119
+ export interface ToolScopedAbortReason {
120
+ readonly kind: "tool-scoped-abort";
121
+ readonly message: string;
122
+ readonly toolCallMessages: Record<string, string>;
123
+ readonly defaultToolCallMessage: string;
124
+ }
125
+
126
+ /** Creates an abort reason that labels matching tool calls separately from siblings. */
127
+ export function createToolScopedAbortReason(
128
+ message: string,
129
+ toolCallMessages: Record<string, string>,
130
+ defaultToolCallMessage: string,
131
+ ): ToolScopedAbortReason {
132
+ return { kind: "tool-scoped-abort", message, toolCallMessages, defaultToolCallMessage };
133
+ }
134
+
115
135
  const STEERING_INTERRUPT_POLL_MS = 250;
116
136
 
117
137
  class HarmonyLeakInterruption extends Error {
@@ -173,6 +193,7 @@ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
173
193
  cost: { ...message.usage.cost },
174
194
  },
175
195
  disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined,
196
+ toolCallAbortMessages: message.toolCallAbortMessages ? { ...message.toolCallAbortMessages } : undefined,
176
197
  };
177
198
  }
178
199
 
@@ -918,9 +939,17 @@ async function runLoopBody(
918
939
  (c): c is ToolCallContent =>
919
940
  c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
920
941
  );
942
+ // Provider-built aborted messages (stream error events) carry no
943
+ // per-tool labels; derive them from a tool-scoped abort signal so
944
+ // only the matching call is blamed and siblings stay neutral.
945
+ const scopedAbort = toolScopedAbortReason(signal);
946
+ const toolCallAbortMessages =
947
+ message.toolCallAbortMessages ??
948
+ (scopedAbort ? buildToolCallAbortMessages(message, scopedAbort) : undefined);
921
949
  const toolResults: ToolResultMessage[] = [];
922
950
  for (const toolCall of toolCalls) {
923
- const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
951
+ const errorMessage = toolCallAbortMessages?.[toolCall.id] ?? message.errorMessage;
952
+ const result = createAbortedToolResult(toolCall, stream, message.stopReason, errorMessage);
924
953
  currentContext.messages.push(result);
925
954
  newMessages.push(result);
926
955
  toolResults.push(result);
@@ -1610,6 +1639,34 @@ function emitDiscardedHarmonyPartial(
1610
1639
  });
1611
1640
  }
1612
1641
 
1642
+ function isStringRecord(value: unknown): value is Record<string, string> {
1643
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1644
+ return Object.values(value).every(child => typeof child === "string");
1645
+ }
1646
+
1647
+ function toolScopedAbortReason(signal: AbortSignal | undefined): ToolScopedAbortReason | undefined {
1648
+ const reason = signal?.reason;
1649
+ if (!reason || typeof reason !== "object") return undefined;
1650
+ if (Reflect.get(reason, "kind") !== "tool-scoped-abort") return undefined;
1651
+ if (typeof Reflect.get(reason, "message") !== "string") return undefined;
1652
+ if (typeof Reflect.get(reason, "defaultToolCallMessage") !== "string") return undefined;
1653
+ return isStringRecord(Reflect.get(reason, "toolCallMessages")) ? reason : undefined;
1654
+ }
1655
+
1656
+ function buildToolCallAbortMessages(
1657
+ message: AssistantMessage,
1658
+ reason: ToolScopedAbortReason,
1659
+ ): Record<string, string> | undefined {
1660
+ let hasToolCall = false;
1661
+ const messages: Record<string, string> = {};
1662
+ for (const block of message.content) {
1663
+ if (block.type !== "toolCall") continue;
1664
+ hasToolCall = true;
1665
+ messages[block.id] = reason.toolCallMessages[block.id] ?? reason.defaultToolCallMessage;
1666
+ }
1667
+ return hasToolCall ? messages : undefined;
1668
+ }
1669
+
1613
1670
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
1614
1671
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
1615
1672
  * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
@@ -1617,6 +1674,8 @@ function emitDiscardedHarmonyPartial(
1617
1674
  * `signal.reason` is the default `AbortError` `DOMException`) falls back to the
1618
1675
  * generic sentinel that downstream renderers treat as "no specific reason". */
1619
1676
  export function abortReasonText(signal: AbortSignal | undefined): string {
1677
+ const scopedReason = toolScopedAbortReason(signal);
1678
+ if (scopedReason) return scopedReason.message;
1620
1679
  const reason = signal?.reason;
1621
1680
  if (typeof reason === "string" && reason.trim().length > 0) return reason;
1622
1681
  if (reason instanceof Error && reason.name !== "AbortError" && reason.message.trim().length > 0) {
@@ -1665,6 +1724,11 @@ function emitAbortedAssistantMessage(
1665
1724
  // labeled user interrupt still surfaces through `errorMessage`, but partial
1666
1725
  // tool arguments are unsafe to keep and can carry incomplete provider IDs.
1667
1726
  const retained = retainCompletedToolCalls(base, completedToolCallIds);
1727
+ const scopedAbort = toolScopedAbortReason(requestSignal);
1728
+ const toolCallAbortMessages = scopedAbort ? buildToolCallAbortMessages(retained, scopedAbort) : undefined;
1729
+ if (toolCallAbortMessages) {
1730
+ retained.toolCallAbortMessages = toolCallAbortMessages;
1731
+ }
1668
1732
  const abortedMessage = snapshotAssistantMessage(retained);
1669
1733
  if (addedPartial) {
1670
1734
  context.messages[context.messages.length - 1] = abortedMessage;
@@ -698,6 +698,12 @@ function createSummarizationError(prefix: string, response: AssistantMessage): E
698
698
  return response.errorStatus === undefined ? new Error(text) : new ProviderHttpError(text, response.errorStatus);
699
699
  }
700
700
 
701
+ function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boolean {
702
+ if (response.errorStatus !== 400) return false;
703
+ const message = response.errorMessage ?? "";
704
+ return /\btool_choice\b/i.test(message) && /\bauto\b/i.test(message) && /\bsupported\b/i.test(message);
705
+ }
706
+
701
707
  /**
702
708
  * Generate a summary of the conversation using the LLM.
703
709
  * If previousSummary is provided, uses the update prompt to merge.
@@ -813,14 +819,17 @@ export async function generateSummary(
813
819
  ];
814
820
 
815
821
  if (options?.remoteEndpoint) {
816
- const remote = await requestRemoteCompaction(
817
- options.remoteEndpoint,
818
- {
819
- systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
820
- prompt: promptText,
821
- },
822
- signal,
823
- { fetch: options.fetch },
822
+ const endpoint = options.remoteEndpoint;
823
+ const remote = await withAuth(
824
+ apiKey,
825
+ key =>
826
+ requestRemoteCompaction(
827
+ endpoint,
828
+ { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText },
829
+ signal,
830
+ { fetch: options.fetch, model, apiKey: key },
831
+ ),
832
+ { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
824
833
  );
825
834
  return remote.summary;
826
835
  }
@@ -917,24 +926,33 @@ export interface HandoffFromContextOptions {
917
926
  * `streamOptions` that mirror the live turn's cache routing. That keeps the
918
927
  * cache-preserving context construction in the host (which owns the transform
919
928
  * pipeline) while this function centralizes the handoff request contract:
920
- * `toolChoice: "none"`, clamped reasoning effort, oneshot telemetry, text-only
921
- * extraction, and provider-error mapping.
929
+ * cache-first `toolChoice: "none"`, clamped reasoning effort, one retry for
930
+ * auto-only `tool_choice` providers, oneshot telemetry, text-only extraction,
931
+ * and provider-error mapping.
922
932
  */
923
933
  export async function generateHandoffFromContext(
924
934
  context: Context,
925
935
  model: Model,
926
936
  options: HandoffFromContextOptions,
927
937
  ): Promise<string> {
928
- const response = await instrumentedCompleteSimple(
929
- model,
930
- context,
931
- {
932
- ...options.streamOptions,
933
- reasoning: resolveCompactionEffort(model, options.thinkingLevel),
934
- toolChoice: "none",
935
- },
936
- { telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl },
937
- );
938
+ const requestOptions = {
939
+ ...options.streamOptions,
940
+ reasoning: resolveCompactionEffort(model, options.thinkingLevel),
941
+ toolChoice: "none" as const,
942
+ };
943
+ let response = await instrumentedCompleteSimple(model, context, requestOptions, {
944
+ telemetry: options.telemetry,
945
+ oneshotKind: "handoff",
946
+ completeImpl: options.completeImpl,
947
+ });
948
+ if (response.stopReason === "error" && shouldRetryHandoffWithAutoToolChoice(response)) {
949
+ response = await instrumentedCompleteSimple(
950
+ model,
951
+ context,
952
+ { ...requestOptions, toolChoice: "auto" },
953
+ { telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl },
954
+ );
955
+ }
938
956
 
939
957
  if (response.stopReason === "error") {
940
958
  throw createSummarizationError("Handoff generation failed", response);
@@ -1001,14 +1019,17 @@ async function generateShortSummary(
1001
1019
  promptText += SHORT_SUMMARY_PROMPT;
1002
1020
 
1003
1021
  if (options?.remoteEndpoint) {
1004
- const remote = await requestRemoteCompaction(
1005
- options.remoteEndpoint,
1006
- {
1007
- systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
1008
- prompt: promptText,
1009
- },
1010
- signal,
1011
- { fetch: options?.fetch },
1022
+ const endpoint = options.remoteEndpoint;
1023
+ const remote = await withAuth(
1024
+ apiKey,
1025
+ key =>
1026
+ requestRemoteCompaction(
1027
+ endpoint,
1028
+ { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText },
1029
+ signal,
1030
+ { fetch: options?.fetch, model, apiKey: key },
1031
+ ),
1032
+ { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
1012
1033
  );
1013
1034
  return remote.summary;
1014
1035
  }
@@ -547,16 +547,55 @@ export async function requestOpenAiRemoteCompaction(
547
547
  return { provider: model.provider, replacementHistory, compactionItem };
548
548
  }
549
549
 
550
+ /**
551
+ * Generic remote-compaction POST. Two wire shapes are auto-selected by
552
+ * endpoint suffix so a single `compaction.remoteEndpoint` setting can point at
553
+ * either a purpose-built omp summarizer (`{systemPrompt, prompt}` → `{summary}`)
554
+ * or any OpenAI-compatible chat-completions server (`/chat/completions`,
555
+ * `/v1/chat/completions`, …) as reported for llama.cpp / vLLM / etc. in
556
+ * issue #4630: without this, the omp payload was rejected with
557
+ * HTTP 400 `"'messages' is required"`, compaction silently fell back to
558
+ * local summarization, and context grew unbounded.
559
+ *
560
+ * When `context.model` is provided the chat-completions body is tagged with
561
+ * that model's wire id (llama.cpp requires the field) and `context.apiKey` is
562
+ * forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
563
+ * 401s force-refresh through the standard credential rotation policy.
564
+ */
550
565
  export async function requestRemoteCompaction(
551
566
  endpoint: string,
552
567
  request: RemoteCompactionRequest,
553
568
  signal?: AbortSignal,
554
- opts?: { fetch?: FetchImpl; timeoutMs?: number },
569
+ opts?: { fetch?: FetchImpl; timeoutMs?: number; model?: Model; apiKey?: string },
555
570
  ): Promise<RemoteCompactionResponse> {
571
+ let endpointPath = endpoint;
572
+ try {
573
+ endpointPath = new URL(endpoint).pathname;
574
+ } catch {
575
+ // Keep the raw endpoint for relative/custom fetch implementations.
576
+ }
577
+ const isChatCompletions = /\/chat\/completions\/?$/.test(endpointPath);
578
+ const headers: Record<string, string> = { "content-type": "application/json" };
579
+ if (isChatCompletions) {
580
+ if (opts?.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
581
+ if (opts?.model?.headers) Object.assign(headers, opts.model.headers);
582
+ }
583
+
584
+ const body: Record<string, unknown> = isChatCompletions
585
+ ? {
586
+ model: opts?.model ? resolveOpenAiCompactModel(opts.model) : undefined,
587
+ messages: [
588
+ { role: "system", content: request.systemPrompt },
589
+ { role: "user", content: request.prompt },
590
+ ],
591
+ stream: false,
592
+ }
593
+ : { systemPrompt: request.systemPrompt, prompt: request.prompt };
594
+
556
595
  const response = await (opts?.fetch ?? fetch)(endpoint, {
557
596
  method: "POST",
558
- headers: { "content-type": "application/json" },
559
- body: JSON.stringify(request),
597
+ headers,
598
+ body: JSON.stringify(body),
560
599
  signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
561
600
  });
562
601
 
@@ -577,6 +616,31 @@ export async function requestRemoteCompaction(
577
616
  );
578
617
  }
579
618
 
619
+ if (isChatCompletions) {
620
+ type ChatCompletionsResponse = {
621
+ choices?: Array<{
622
+ message?: {
623
+ content?: string | Array<{ type?: string; text?: string }> | null;
624
+ };
625
+ }>;
626
+ };
627
+ const data = (await response.json()) as ChatCompletionsResponse | undefined;
628
+ const choice = data?.choices?.[0]?.message?.content;
629
+ let summary: string | undefined;
630
+ if (typeof choice === "string") {
631
+ summary = choice;
632
+ } else if (Array.isArray(choice)) {
633
+ summary = choice
634
+ .filter((part): part is { type?: string; text: string } => typeof part?.text === "string")
635
+ .map(part => part.text)
636
+ .join("");
637
+ }
638
+ if (typeof summary !== "string" || summary.length === 0) {
639
+ throw new Error("Remote compaction response missing choices[0].message.content");
640
+ }
641
+ return { summary };
642
+ }
643
+
580
644
  const data = (await response.json()) as RemoteCompactionResponse | undefined;
581
645
  if (!data || typeof data.summary !== "string") {
582
646
  throw new Error("Remote compaction response missing summary");