jeopi-ai 16.2.22 → 16.2.24

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.24] - 2026-07-03
6
+
7
+ ### Added
8
+
9
+ - `ToolCallLoopGuard.recordTurn` now also detects an alternating A,B,A,B,… tool-call cycle (exactly two distinct calls filling a 6-turn window), returning a `cyclical_tool_calls` detection alongside the existing `repeated_tool_call` one. `recordTurn`'s return type is now `ToolCallLoopDetection = RepeatedToolCallDetection | CyclicalToolCallDetection`.
10
+
5
11
  ## [16.2.21] - 2026-07-02
6
12
 
7
13
  ### Fixed
@@ -3,6 +3,8 @@ import type { AssistantMessage, ToolResultMessage } from "../types";
3
3
  export interface ToolCallLoopGuardOptions {
4
4
  readonly threshold: number;
5
5
  readonly exemptTools: readonly string[];
6
+ /** Window size for alternating-pattern (A,B,A,B,…) cycle detection; defaults to 6. */
7
+ readonly cycleWindowSize?: number;
6
8
  }
7
9
  /** A completed assistant turn plus the tool results it produced. */
8
10
  export interface ToolCallLoopTurn {
@@ -17,10 +19,18 @@ export interface RepeatedToolCallDetection {
17
19
  readonly resultSummary: string;
18
20
  readonly argumentsSummary: string;
19
21
  }
22
+ /** Details needed to steer the model away from an alternating tool-call cycle (A,B,A,B,…). */
23
+ export interface CyclicalToolCallDetection {
24
+ readonly kind: "cyclical_tool_calls";
25
+ /** Distinct tool names seen in the detection window, in first-seen order. */
26
+ readonly toolNames: readonly string[];
27
+ readonly windowSize: number;
28
+ }
29
+ export type ToolCallLoopDetection = RepeatedToolCallDetection | CyclicalToolCallDetection;
20
30
  /** Detects consecutive identical assistant tool calls across model turns. */
21
31
  export declare class ToolCallLoopGuard {
22
32
  #private;
23
33
  constructor(options: ToolCallLoopGuardOptions);
24
- /** Records one completed turn and returns the threshold hit, if any. */
25
- recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null;
34
+ /** Records one completed turn and returns the threshold/cycle hit, if any. */
35
+ recordTurn(turn: ToolCallLoopTurn): ToolCallLoopDetection | null;
26
36
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-ai",
4
- "version": "16.2.22",
4
+ "version": "16.2.24",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "jeopi-catalog": "16.2.22",
42
- "jeopi-utils": "16.2.22",
43
- "jeopi-wire": "16.2.22",
41
+ "jeopi-catalog": "16.2.24",
42
+ "jeopi-utils": "16.2.24",
43
+ "jeopi-wire": "16.2.24",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -9,6 +9,8 @@ const ARGUMENT_SUMMARY_LIMIT = 400;
9
9
  export interface ToolCallLoopGuardOptions {
10
10
  readonly threshold: number;
11
11
  readonly exemptTools: readonly string[];
12
+ /** Window size for alternating-pattern (A,B,A,B,…) cycle detection; defaults to 6. */
13
+ readonly cycleWindowSize?: number;
12
14
  }
13
15
 
14
16
  /** A completed assistant turn plus the tool results it produced. */
@@ -26,6 +28,16 @@ export interface RepeatedToolCallDetection {
26
28
  readonly argumentsSummary: string;
27
29
  }
28
30
 
31
+ /** Details needed to steer the model away from an alternating tool-call cycle (A,B,A,B,…). */
32
+ export interface CyclicalToolCallDetection {
33
+ readonly kind: "cyclical_tool_calls";
34
+ /** Distinct tool names seen in the detection window, in first-seen order. */
35
+ readonly toolNames: readonly string[];
36
+ readonly windowSize: number;
37
+ }
38
+
39
+ export type ToolCallLoopDetection = RepeatedToolCallDetection | CyclicalToolCallDetection;
40
+
29
41
  function canonicalizeToolCallValue(value: unknown): unknown {
30
42
  if (Array.isArray(value)) {
31
43
  return value.map(item => canonicalizeToolCallValue(item));
@@ -70,18 +82,24 @@ export class ToolCallLoopGuard {
70
82
  #exemptTools: ReadonlySet<string>;
71
83
  #lastHash: string | undefined;
72
84
  #count = 0;
85
+ #cycleWindowSize: number;
86
+ #recentCalls: Array<{ hash: string; toolName: string }> = [];
87
+ #lastCycleSignature: string | undefined;
73
88
 
74
89
  constructor(options: ToolCallLoopGuardOptions) {
75
90
  this.#threshold = Math.max(1, Math.trunc(options.threshold));
76
91
  this.#exemptTools = new Set(options.exemptTools);
92
+ this.#cycleWindowSize = Math.max(3, Math.trunc(options.cycleWindowSize ?? 6));
77
93
  }
78
94
 
79
- /** Records one completed turn and returns the threshold hit, if any. */
80
- recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null {
95
+ /** Records one completed turn and returns the threshold/cycle hit, if any. */
96
+ recordTurn(turn: ToolCallLoopTurn): ToolCallLoopDetection | null {
81
97
  const toolCalls = turn.message.content.filter((part): part is ToolCall => part.type === "toolCall");
82
98
  if (toolCalls.length !== 1 || this.#exemptTools.has(toolCalls[0]!.name)) {
83
99
  this.#lastHash = undefined;
84
100
  this.#count = 0;
101
+ this.#recentCalls = [];
102
+ this.#lastCycleSignature = undefined;
85
103
  return null;
86
104
  }
87
105
 
@@ -95,13 +113,36 @@ export class ToolCallLoopGuard {
95
113
  this.#count = 1;
96
114
  }
97
115
 
98
- if (this.#count !== this.#threshold) return null;
99
- return {
100
- kind: "repeated_tool_call",
101
- toolName: toolCall.name,
102
- count: this.#count,
103
- resultSummary: summarizeToolResult(turn.toolResults, toolCall.id),
104
- argumentsSummary: summarizeText(canonicalArgs, ARGUMENT_SUMMARY_LIMIT),
105
- };
116
+ if (this.#count === this.#threshold) {
117
+ return {
118
+ kind: "repeated_tool_call",
119
+ toolName: toolCall.name,
120
+ count: this.#count,
121
+ resultSummary: summarizeToolResult(turn.toolResults, toolCall.id),
122
+ argumentsSummary: summarizeText(canonicalArgs, ARGUMENT_SUMMARY_LIMIT),
123
+ };
124
+ }
125
+
126
+ // Alternating A,B,A,B,… pattern: exact-repeat detection above never fires
127
+ // (the hash changes every turn), but the model is still spinning between
128
+ // exactly two distinct calls without making progress.
129
+ this.#recentCalls.push({ hash, toolName: toolCall.name });
130
+ if (this.#recentCalls.length > this.#cycleWindowSize) this.#recentCalls.shift();
131
+ if (this.#recentCalls.length < this.#cycleWindowSize) return null;
132
+
133
+ const distinctHashes = new Set(this.#recentCalls.map(call => call.hash));
134
+ if (distinctHashes.size !== 2) {
135
+ this.#lastCycleSignature = undefined;
136
+ return null;
137
+ }
138
+ const signature = [...distinctHashes].sort().join("|");
139
+ if (signature === this.#lastCycleSignature) return null;
140
+ this.#lastCycleSignature = signature;
141
+
142
+ const toolNames: string[] = [];
143
+ for (const call of this.#recentCalls) {
144
+ if (!toolNames.includes(call.toolName)) toolNames.push(call.toolName);
145
+ }
146
+ return { kind: "cyclical_tool_calls", toolNames, windowSize: this.#cycleWindowSize };
106
147
  }
107
148
  }