@oh-my-pi/pi-agent-core 15.12.4 → 15.13.1

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,12 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.13.1] - 2026-06-15
6
+
7
+ ### Added
8
+
9
+ - Added repetition-loop detection to the streaming agent loop for Gemini-family providers. A runaway run of a repeated text or thinking unit is detected mid-stream from a bounded rolling tail (O(1) per delta), the provider request is aborted, the repeated tail is collapsed to a single representative copy, and the turn ends gracefully with an `error` stop reason. Legitimate all-numeric/whitespace/punctuation runs (hexdumps, zero-fills, numeric tables) are not misclassified as loops ([#2549](https://github.com/can1357/oh-my-pi/pull/2549) by [@usr-bin-roygbiv](https://github.com/usr-bin-roygbiv)).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed repetition loop handling to collapse repeated `thinking` blocks to a single representative copy when a loop is detected
14
+ - Fixed repetition-loop detection to ignore repeats that contain only digits, whitespace, or punctuation so legitimate numeric outputs no longer stop with a repetition-loop error
15
+ - Fixed false-positive repetition-loop checks across `text` and `thinking` stream boundaries by tracking loop detection per block type
16
+
17
+ ## [15.12.6] - 2026-06-14
18
+
19
+ ### Fixed
20
+
21
+ - Fixed dynamic forced tool choices from queue hooks being filtered against the active per-turn tool set before provider dispatch. ([#1701](https://github.com/can1357/oh-my-pi/issues/1701))
22
+
5
23
  ## [15.12.4] - 2026-06-13
24
+
6
25
  ### Fixed
7
26
 
8
27
  - Fixed remote compaction input trimming to use unlimited context when `model.contextWindow` is unset
9
28
 
10
29
  ## [15.12.1] - 2026-06-12
30
+
11
31
  ### Breaking Changes
12
32
 
13
33
  - Changed `pruneSupersededToolResults` to allow `supersedeKey` to be omitted so useless-result pruning can run without read-style supersede grouping
@@ -23,6 +43,7 @@
23
43
  - Changed `pruneSupersededToolResults` to allow omitted `supersedeKey` when `pruneUseless` is enabled, so useless-result pruning can run without read-style supersede grouping
24
44
 
25
45
  ## [15.11.4] - 2026-06-12
46
+
26
47
  ### Added
27
48
 
28
49
  - Added `hasSteeringMessages` to `AgentLoopConfig` (wired by `Agent` to its steering queue): a peek used by the immediate-interrupt poll during tool execution, so the loop can detect queued steering without dequeuing and the queue keeps owning its messages until the injection boundary
@@ -48,7 +69,9 @@
48
69
  ### Fixed
49
70
 
50
71
  - Fixed whitespace-only error tool results so Anthropic requests no longer 400 with `tool_result: content cannot be empty if is_error is true` and wedge the session on every subsequent turn
72
+
51
73
  ## [15.11.0] - 2026-06-10
74
+
52
75
  ### Breaking Changes
53
76
 
54
77
  - Removed `compaction/index.ts` re-export of snapcompact helpers, so snapcompact utilities are no longer available from the agent compaction barrel and should be imported from `@oh-my-pi/snapcompact`
@@ -229,10 +252,6 @@
229
252
 
230
253
  - Fixed compaction summarizer throws losing the provider's HTTP status. `generateSummary`, `generateHandoff`, `generateShortSummary`, and `generateTurnPrefixSummary` now route their `stopReason === "error"` throws through a `createSummarizationError` helper that copies `AssistantMessage.errorStatus` onto the thrown `Error` as `.status`, letting downstream consumers (e.g. `AgentSession.#isCompactionAuthFailure` in `@oh-my-pi/pi-coding-agent`) branch on real provider 401/403s without regex-scraping the message body.
231
254
 
232
- ### Changed
233
-
234
- - Changed `Agent.appendMessage`, `popMessage`, `clearMessages`, and `reset` to mutate `state.messages` and `state.pendingToolCalls` in place instead of allocating a fresh array/Set on every transition. Subscribers that capture `state.messages` by reference now observe updates without needing to re-read `state` after each event. The public type signature is unchanged (always `AgentMessage[]` / `Set<string>`).
235
-
236
255
  ## [15.5.0] - 2026-05-26
237
256
 
238
257
  ### Added
@@ -646,7 +665,7 @@
646
665
 
647
666
  ### Changed
648
667
 
649
- - Switched from local `@oh-my-pi/pi-ai` to upstream `@oh-my-pi/pi-ai` package
668
+ - Switched from local `@oh-my-pi/pi-ai` to upstream `@mariozechner/pi-ai` package
650
669
 
651
670
  ### Added
652
671
 
@@ -699,39 +718,65 @@
699
718
 
700
719
  Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mono](https://github.com/badlogic/pi-mono).
701
720
 
721
+ ## [0.38.0] - 2026-01-08
722
+
723
+ ### Added
724
+
725
+ - `thinkingBudgets` option on `Agent` and `AgentOptions` to customize token budgets per thinking level ([#529](https://github.com/badlogic/pi-mono/pull/529) by [@melihmucuk](https://github.com/melihmucuk))
726
+
727
+ ## [0.37.3] - 2026-01-06
728
+
729
+ ### Added
730
+
731
+ - `sessionId` option on `Agent` to forward session identifiers to LLM providers for session-based caching.
732
+
733
+ ## [0.37.0] - 2026-01-05
734
+
735
+ ### Fixed
736
+
737
+ - `minimal` thinking level now maps to `minimal` reasoning effort instead of being treated as `low`.
738
+
739
+ ## [0.32.0] - 2026-01-03
740
+
741
+ ### Breaking Changes
742
+
743
+ - **Queue API replaced with steer/followUp**: The `queueMessage()` method has been split into two methods with different delivery semantics ([#403](https://github.com/badlogic/pi-mono/issues/403)):
744
+ - `steer(msg)`: Interrupts the agent mid-run. Delivered after current tool execution, skips remaining tools.
745
+ - `followUp(msg)`: Waits until the agent finishes. Delivered only when there are no more tool calls or steering messages.
746
+ - **Queue mode renamed**: `queueMode` option renamed to `steeringMode`. Added new `followUpMode` option. Both control whether messages are delivered one-at-a-time or all at once.
747
+ - **AgentLoopConfig callbacks renamed**: `getQueuedMessages` split into `getSteeringMessages` and `getFollowUpMessages`.
748
+ - **Agent methods renamed**:
749
+ - `queueMessage()` → `steer()` and `followUp()`
750
+ - `clearMessageQueue()` → `clearSteeringQueue()`, `clearFollowUpQueue()`, `clearAllQueues()`
751
+ - `setQueueMode()`/`getQueueMode()` → `setSteeringMode()`/`getSteeringMode()` and `setFollowUpMode()`/`getFollowUpMode()`
752
+
753
+ ### Fixed
754
+
755
+ - `prompt()` and `continue()` now throw if called while the agent is already streaming, preventing race conditions and corrupted state. Use `steer()` or `followUp()` to queue messages during streaming, or `await` the previous call.
756
+
702
757
  ## [0.31.0] - 2026-01-02
703
758
 
704
759
  ### Breaking Changes
705
760
 
706
761
  - **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, and `AgentTransport` interface have been removed. Use the `streamFn` option directly for custom streaming implementations.
707
-
708
762
  - **Agent options renamed**:
709
763
  - `transport` → removed (use `streamFn` instead)
710
764
  - `messageTransformer` → `convertToLlm`
711
765
  - `preprocessor` → `transformContext`
712
-
713
766
  - **`AppMessage` renamed to `AgentMessage`**: All references to `AppMessage` have been renamed to `AgentMessage` for consistency.
714
-
715
767
  - **`CustomMessages` renamed to `CustomAgentMessages`**: The declaration merging interface has been renamed.
716
-
717
768
  - **`UserMessageWithAttachments` and `Attachment` types removed**: Attachment handling is now the responsibility of the `convertToLlm` function.
718
-
719
769
  - **Agent loop moved from `@oh-my-pi/pi-ai`**: The `agentLoop`, `agentLoopContinue`, and related types have moved to this package. Import from `@oh-my-pi/pi-agent` instead.
720
770
 
721
771
  ### Added
722
772
 
723
773
  - `streamFn` option on `Agent` for custom stream implementations. Default uses `streamSimple` from pi-ai.
724
-
725
774
  - `streamProxy()` utility function for browser apps that need to proxy LLM calls through a backend server. Replaces the removed `AppTransport`.
726
-
727
775
  - `getApiKey` option for dynamic API key resolution (useful for expiring OAuth tokens like GitHub Copilot).
728
-
729
776
  - `agentLoop()` and `agentLoopContinue()` low-level functions for running the agent loop without the `Agent` class wrapper.
730
-
731
777
  - New exported types: `AgentLoopConfig`, `AgentContext`, `AgentTool`, `AgentToolResult`, `AgentToolUpdateCallback`, `StreamFn`.
732
778
 
733
779
  ### Changed
734
780
 
735
781
  - `Agent` constructor now has all options optional (empty options use defaults).
736
-
737
- - `queueMessage()` is now synchronous (no longer returns a Promise).
782
+ - `queueMessage()` is now synchronous (no longer returns a Promise).
@@ -296,7 +296,7 @@ export declare class Agent {
296
296
  */
297
297
  setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
298
298
  emitExternalEvent(event: AgentEvent): void;
299
- setSystemPrompt(v: string[]): void;
299
+ setSystemPrompt(v: string[] | string): void;
300
300
  setModel(m: Model): void;
301
301
  setThinkingLevel(l: Effort | undefined): void;
302
302
  setDisableReasoning(disabled: boolean): void;
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": "15.12.4",
4
+ "version": "15.13.1",
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,11 +35,11 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.12.4",
39
- "@oh-my-pi/pi-catalog": "15.12.4",
40
- "@oh-my-pi/pi-natives": "15.12.4",
41
- "@oh-my-pi/pi-utils": "15.12.4",
42
- "@oh-my-pi/snapcompact": "15.12.4",
38
+ "@oh-my-pi/pi-ai": "15.13.1",
39
+ "@oh-my-pi/pi-catalog": "15.13.1",
40
+ "@oh-my-pi/pi-natives": "15.13.1",
41
+ "@oh-my-pi/pi-utils": "15.13.1",
42
+ "@oh-my-pi/snapcompact": "15.13.1",
43
43
  "@opentelemetry/api": "^1.9.1"
44
44
  },
45
45
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  validateToolArguments,
16
16
  zodToWireSchema,
17
17
  } from "@oh-my-pi/pi-ai";
18
- import { sanitizeText } from "@oh-my-pi/pi-utils";
18
+ import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
19
19
  import {
20
20
  createHarmonyAuditEvent,
21
21
  detectHarmonyLeakInAssistantMessage,
@@ -483,6 +483,7 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
483
483
  properties: {
484
484
  [INTENT_FIELD]: {
485
485
  type: "string",
486
+ description: "Concise intent in present participle form (2-6 words) strictly on a single line, no newlines",
486
487
  },
487
488
  ...properties,
488
489
  },
@@ -708,6 +709,7 @@ async function runLoopBody(
708
709
  });
709
710
  }
710
711
  stream.push({ type: "turn_end", message, toolResults });
712
+
711
713
  stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
712
714
  stream.end(newMessages);
713
715
  return;
@@ -917,6 +919,10 @@ async function streamAssistantResponse(
917
919
  ? AbortSignal.any([signal, harmonyAbortController.signal])
918
920
  : harmonyAbortController.signal
919
921
  : signal;
922
+ const repetitionAbortController = new AbortController();
923
+ const finalRequestSignal = requestSignal
924
+ ? AbortSignal.any([requestSignal, repetitionAbortController.signal])
925
+ : repetitionAbortController.signal;
920
926
  const effectiveTemperature =
921
927
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
922
928
  const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice;
@@ -984,7 +990,7 @@ async function streamAssistantResponse(
984
990
  reasoning: effectiveReasoning,
985
991
  disableReasoning: effectiveDisableReasoning,
986
992
  temperature: effectiveTemperature,
987
- signal: requestSignal,
993
+ signal: finalRequestSignal,
988
994
  onResponse: captureOnResponse,
989
995
  });
990
996
 
@@ -1013,6 +1019,56 @@ async function streamAssistantResponse(
1013
1019
  return aborted;
1014
1020
  };
1015
1021
 
1022
+ const finishRepetitionStream = async (
1023
+ kind: "text" | "thinking",
1024
+ pattern: string,
1025
+ count: number,
1026
+ ): Promise<AssistantMessage> => {
1027
+ repetitionAbortController.abort();
1028
+ try {
1029
+ const cleanup = responseIterator.return?.();
1030
+ if (cleanup) void cleanup.catch(() => {});
1031
+ } catch {
1032
+ // ignore
1033
+ }
1034
+ if (partialMessage) {
1035
+ truncateRepetition(partialMessage, kind, pattern);
1036
+ partialMessage.stopReason = "error";
1037
+ partialMessage.errorMessage = `Repetition loop detected: assistant repeated "${pattern.trim()}" ${count} times consecutively.`;
1038
+ }
1039
+ const finalMsg = snapshotAssistantMessage(
1040
+ partialMessage ?? {
1041
+ role: "assistant",
1042
+ content: [],
1043
+ api: config.model.api,
1044
+ provider: config.model.provider,
1045
+ model: config.model.id,
1046
+ usage: {
1047
+ input: 0,
1048
+ output: 0,
1049
+ cacheRead: 0,
1050
+ cacheWrite: 0,
1051
+ totalTokens: 0,
1052
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1053
+ },
1054
+ stopReason: "error",
1055
+ errorMessage: `Repetition loop detected.`,
1056
+ timestamp: Date.now(),
1057
+ },
1058
+ );
1059
+ if (addedPartial) {
1060
+ context.messages[context.messages.length - 1] = finalMsg;
1061
+ } else {
1062
+ context.messages.push(finalMsg);
1063
+ }
1064
+ if (!addedPartial) {
1065
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMsg) });
1066
+ }
1067
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMsg) });
1068
+ await finishChat(finalMsg);
1069
+ return finalMsg;
1070
+ };
1071
+
1016
1072
  // Set up a single abort race: register the abort listener once for the whole
1017
1073
  // stream and reuse the same race promise for every iterator.next() instead of
1018
1074
  // allocating Promise.withResolvers and add/removeEventListener per event.
@@ -1029,6 +1085,14 @@ async function streamAssistantResponse(
1029
1085
  detachAbortListener = () => requestSignal.removeEventListener("abort", onAbort);
1030
1086
  }
1031
1087
 
1088
+ // Rolling tail of streamed text/thinking used for repetition-loop detection.
1089
+ // Bounded to REPETITION_WINDOW chars and reset when the active block kind
1090
+ // switches (text <-> thinking) so detection stays O(1) per delta and never
1091
+ // miscounts a repeated unit across a thinking/answer boundary.
1092
+ let repetitionTail = "";
1093
+ let repetitionKind: "text" | "thinking" | undefined;
1094
+ const isGeminiModel = config.model.provider.includes("google") || config.model.provider.includes("gemini");
1095
+
1032
1096
  try {
1033
1097
  while (true) {
1034
1098
  let next: IteratorResult<AssistantMessageEvent>;
@@ -1113,6 +1177,27 @@ async function streamAssistantResponse(
1113
1177
  assistantMessageEvent: snapshotAssistantMessageEvent(event),
1114
1178
  message: snapshotAssistantMessage(partialMessage),
1115
1179
  });
1180
+
1181
+ if (isGeminiModel && (event.type === "text_delta" || event.type === "thinking_delta")) {
1182
+ const kind = event.type === "text_delta" ? "text" : "thinking";
1183
+ if (repetitionKind !== kind) {
1184
+ repetitionKind = kind;
1185
+ repetitionTail = "";
1186
+ }
1187
+ repetitionTail += event.delta;
1188
+ if (repetitionTail.length > REPETITION_WINDOW) {
1189
+ repetitionTail = repetitionTail.slice(-REPETITION_WINDOW);
1190
+ }
1191
+ const repetition = detectRepetition(repetitionTail);
1192
+ if (repetition) {
1193
+ const [pattern, count] = repetition;
1194
+ logger.warn("Repetition loop detected during assistant stream, aborting.", {
1195
+ pattern,
1196
+ count,
1197
+ });
1198
+ return await finishRepetitionStream(kind, pattern, count);
1199
+ }
1200
+ }
1116
1201
  }
1117
1202
  break;
1118
1203
  }
@@ -1719,3 +1804,97 @@ function createSkippedToolResult(): AgentToolResult<any> {
1719
1804
  details: {},
1720
1805
  };
1721
1806
  }
1807
+
1808
+ const REPETITION_WINDOW = 250;
1809
+ const REPETITION_MIN_REPEATED_CHARS = 180;
1810
+
1811
+ function detectRepetition(text: string): [pattern: string, count: number] | null {
1812
+ if (text.length < REPETITION_MIN_REPEATED_CHARS) return null;
1813
+
1814
+ const windowSize = Math.min(text.length, REPETITION_WINDOW);
1815
+ const searchSpace = text.slice(-windowSize);
1816
+
1817
+ for (let len = 2; len <= 60; len++) {
1818
+ if (searchSpace.length < len * 4) continue;
1819
+
1820
+ const pattern = searchSpace.slice(-len);
1821
+ // Only treat a repeated unit as a pathological loop when it carries real
1822
+ // linguistic content (a letter or a pictographic emoji). Runs made purely of
1823
+ // digits, whitespace or punctuation are legitimate in tabular / hex / numeric
1824
+ // output (e.g. "00 00 00", "0, 0, 0", "| -- | -- |") and must not trip.
1825
+ if (!/[\p{L}\p{Extended_Pictographic}]/u.test(pattern)) continue;
1826
+
1827
+ let count = 0;
1828
+ let pos = searchSpace.length;
1829
+ while (pos >= len) {
1830
+ const chunk = searchSpace.slice(pos - len, pos);
1831
+ if (chunk === pattern) {
1832
+ count++;
1833
+ pos -= len;
1834
+ } else {
1835
+ break;
1836
+ }
1837
+ }
1838
+
1839
+ if (count >= 4 && len * count >= REPETITION_MIN_REPEATED_CHARS) {
1840
+ return [pattern, count];
1841
+ }
1842
+ }
1843
+ return null;
1844
+ }
1845
+
1846
+ function truncateRepetition(message: AssistantMessage, kind: "text" | "thinking", pattern: string): void {
1847
+ // A repetition loop streams into a single growing block (real providers) or a run
1848
+ // of same-kind blocks (some transports), always at the tail of the message. Gather
1849
+ // that trailing contiguous run and collapse its repeated copies down to one, so the
1850
+ // committed transcript keeps a representative sample instead of the full runaway.
1851
+ const matches = (block: AssistantContentBlock): boolean =>
1852
+ kind === "text" ? block.type === "text" : block.type === "thinking";
1853
+ const readBlock = (block: AssistantContentBlock): string =>
1854
+ block.type === "text" ? block.text : block.type === "thinking" ? block.thinking : "";
1855
+ const clearThinkingReplayAnchors = (block: AssistantContentBlock): void => {
1856
+ if (block.type !== "thinking") return;
1857
+ block.thinkingSignature = undefined;
1858
+ block.itemId = undefined;
1859
+ };
1860
+ const writeBlock = (block: AssistantContentBlock, value: string): void => {
1861
+ if (block.type === "text") {
1862
+ block.text = value;
1863
+ } else if (block.type === "thinking") {
1864
+ block.thinking = value;
1865
+ clearThinkingReplayAnchors(block);
1866
+ }
1867
+ };
1868
+
1869
+ const trailing: AssistantContentBlock[] = [];
1870
+ for (let i = message.content.length - 1; i >= 0; i--) {
1871
+ const block = message.content[i];
1872
+ if (!matches(block)) break;
1873
+ trailing.unshift(block);
1874
+ }
1875
+ if (trailing.length === 0) return;
1876
+ if (kind === "thinking") {
1877
+ for (const block of trailing) clearThinkingReplayAnchors(block);
1878
+ }
1879
+
1880
+ let joined = "";
1881
+ for (const block of trailing) joined += readBlock(block);
1882
+
1883
+ let kept = joined;
1884
+ while (kept.length >= pattern.length * 2 && kept.slice(kept.length - pattern.length * 2) === pattern + pattern) {
1885
+ kept = kept.slice(0, kept.length - pattern.length);
1886
+ }
1887
+
1888
+ let remainingToRemove = joined.length - kept.length;
1889
+ for (let i = trailing.length - 1; i >= 0 && remainingToRemove > 0; i--) {
1890
+ const block = trailing[i];
1891
+ const value = readBlock(block);
1892
+ if (value.length <= remainingToRemove) {
1893
+ remainingToRemove -= value.length;
1894
+ writeBlock(block, "");
1895
+ } else {
1896
+ writeBlock(block, value.slice(0, value.length - remainingToRemove));
1897
+ remainingToRemove = 0;
1898
+ }
1899
+ }
1900
+ }
package/src/agent.ts CHANGED
@@ -657,8 +657,8 @@ export class Agent {
657
657
  }
658
658
 
659
659
  // State mutators
660
- setSystemPrompt(v: string[]) {
661
- this.#state.systemPrompt = v;
660
+ setSystemPrompt(v: string[] | string) {
661
+ this.#state.systemPrompt = typeof v === "string" ? [v] : v;
662
662
  }
663
663
 
664
664
  setModel(m: Model) {
@@ -974,8 +974,13 @@ export class Agent {
974
974
  }
975
975
  : undefined;
976
976
 
977
- const getToolChoice = () =>
978
- this.#getToolChoice?.() ?? refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools);
977
+ const getToolChoice = () => {
978
+ const queuedToolChoice = this.#getToolChoice?.();
979
+ if (queuedToolChoice !== undefined) {
980
+ return refreshToolChoiceForActiveTools(queuedToolChoice, this.#state.tools);
981
+ }
982
+ return refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools);
983
+ };
979
984
 
980
985
  const config: AgentLoopConfig = {
981
986
  model,