@oh-my-pi/pi-ai 16.2.9 → 16.2.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 +35 -0
- package/dist/types/dialect/fenced-thinking.d.ts +53 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/providers/cursor.d.ts +2 -1
- package/dist/types/utils/thinking-loop.d.ts +10 -5
- package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
- package/package.json +4 -4
- package/src/dialect/anthropic.ts +19 -7
- package/src/dialect/deepseek.ts +19 -5
- package/src/dialect/fenced-thinking.ts +184 -0
- package/src/dialect/gemini.ts +20 -19
- package/src/dialect/owned-stream.ts +64 -17
- package/src/dialect/thinking.ts +22 -3
- package/src/index.ts +1 -0
- package/src/providers/cursor.ts +25 -3
- package/src/providers/devin.ts +11 -2
- package/src/providers/pi-native-client.ts +40 -12
- package/src/providers/transform-messages.ts +25 -14
- package/src/utils/leaked-thinking-stream.ts +47 -13
- package/src/utils/thinking-loop.ts +10 -5
- package/src/utils/tool-call-loop-guard.ts +107 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
2
|
+
import type { AssistantMessage, ToolCall, ToolResultMessage } from "../types";
|
|
3
|
+
|
|
4
|
+
const LEGACY_INTENT_FIELD = "__intent";
|
|
5
|
+
const RESULT_SUMMARY_LIMIT = 200;
|
|
6
|
+
const ARGUMENT_SUMMARY_LIMIT = 400;
|
|
7
|
+
|
|
8
|
+
/** Runtime settings for cross-turn tool-call repetition detection. */
|
|
9
|
+
export interface ToolCallLoopGuardOptions {
|
|
10
|
+
readonly threshold: number;
|
|
11
|
+
readonly exemptTools: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A completed assistant turn plus the tool results it produced. */
|
|
15
|
+
export interface ToolCallLoopTurn {
|
|
16
|
+
readonly message: AssistantMessage;
|
|
17
|
+
readonly toolResults: readonly ToolResultMessage[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Details needed to steer the model away from a repeated tool call. */
|
|
21
|
+
export interface RepeatedToolCallDetection {
|
|
22
|
+
readonly kind: "repeated_tool_call";
|
|
23
|
+
readonly toolName: string;
|
|
24
|
+
readonly count: number;
|
|
25
|
+
readonly resultSummary: string;
|
|
26
|
+
readonly argumentsSummary: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canonicalizeToolCallValue(value: unknown): unknown {
|
|
30
|
+
if (Array.isArray(value)) {
|
|
31
|
+
return value.map(item => canonicalizeToolCallValue(item));
|
|
32
|
+
}
|
|
33
|
+
if (!value || typeof value !== "object") {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const input = value as Record<string, unknown>;
|
|
38
|
+
const output: Record<string, unknown> = {};
|
|
39
|
+
for (const key of Object.keys(input).sort()) {
|
|
40
|
+
if (key === INTENT_FIELD || key === LEGACY_INTENT_FIELD) continue;
|
|
41
|
+
output[key] = canonicalizeToolCallValue(input[key]);
|
|
42
|
+
}
|
|
43
|
+
return output;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summarizeText(text: string, limit: number): string {
|
|
47
|
+
let summary = text.replace(/\s+/g, " ").trim();
|
|
48
|
+
if (summary.length > limit) {
|
|
49
|
+
summary = `${summary.slice(0, limit)}…`;
|
|
50
|
+
}
|
|
51
|
+
return summary;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function summarizeToolResult(toolResults: readonly ToolResultMessage[], toolCallId: string): string {
|
|
55
|
+
const result = toolResults.find(candidate => candidate.toolCallId === toolCallId);
|
|
56
|
+
if (!result) return "";
|
|
57
|
+
|
|
58
|
+
const textParts: string[] = [];
|
|
59
|
+
for (const block of result.content) {
|
|
60
|
+
if (block.type === "text") {
|
|
61
|
+
textParts.push(block.text);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return summarizeText(textParts.join("\n"), RESULT_SUMMARY_LIMIT);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Detects consecutive identical assistant tool calls across model turns. */
|
|
68
|
+
export class ToolCallLoopGuard {
|
|
69
|
+
#threshold: number;
|
|
70
|
+
#exemptTools: ReadonlySet<string>;
|
|
71
|
+
#lastHash: string | undefined;
|
|
72
|
+
#count = 0;
|
|
73
|
+
|
|
74
|
+
constructor(options: ToolCallLoopGuardOptions) {
|
|
75
|
+
this.#threshold = Math.max(1, Math.trunc(options.threshold));
|
|
76
|
+
this.#exemptTools = new Set(options.exemptTools);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Records one completed turn and returns the threshold hit, if any. */
|
|
80
|
+
recordTurn(turn: ToolCallLoopTurn): RepeatedToolCallDetection | null {
|
|
81
|
+
const toolCalls = turn.message.content.filter((part): part is ToolCall => part.type === "toolCall");
|
|
82
|
+
if (toolCalls.length !== 1 || this.#exemptTools.has(toolCalls[0]!.name)) {
|
|
83
|
+
this.#lastHash = undefined;
|
|
84
|
+
this.#count = 0;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const toolCall = toolCalls[0]!;
|
|
89
|
+
const canonicalArgs = JSON.stringify(canonicalizeToolCallValue(toolCall.arguments));
|
|
90
|
+
const hash = `${toolCall.name}:${canonicalArgs}`;
|
|
91
|
+
if (hash === this.#lastHash) {
|
|
92
|
+
this.#count++;
|
|
93
|
+
} else {
|
|
94
|
+
this.#lastHash = hash;
|
|
95
|
+
this.#count = 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
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
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|