@rama_nigg/open-cursor 2.2.0 → 2.3.0
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/dist/cli/opencode-cursor.js +27 -8
- package/dist/index.js +44 -15
- package/dist/plugin-entry.js +44 -15
- package/package.json +9 -3
- package/src/acp/metrics.ts +83 -0
- package/src/acp/sessions.ts +107 -0
- package/src/acp/tools.ts +209 -0
- package/src/auth.ts +269 -0
- package/src/cli/discover.ts +53 -0
- package/src/cli/model-discovery.ts +50 -0
- package/src/cli/opencode-cursor.ts +620 -0
- package/src/client/simple.ts +277 -0
- package/src/commands/status.ts +39 -0
- package/src/index.ts +40 -0
- package/src/models/config.ts +64 -0
- package/src/models/discovery.ts +132 -0
- package/src/models/index.ts +3 -0
- package/src/models/types.ts +11 -0
- package/src/plugin-entry.ts +28 -0
- package/src/plugin-toggle.ts +67 -0
- package/src/plugin.ts +1918 -0
- package/src/provider/boundary.ts +161 -0
- package/src/provider/runtime-interception.ts +721 -0
- package/src/provider/tool-loop-guard.ts +644 -0
- package/src/provider/tool-schema-compat.ts +516 -0
- package/src/provider.ts +268 -0
- package/src/proxy/formatter.ts +42 -0
- package/src/proxy/handler.ts +29 -0
- package/src/proxy/prompt-builder.ts +171 -0
- package/src/proxy/server.ts +207 -0
- package/src/proxy/tool-loop.ts +317 -0
- package/src/proxy/types.ts +13 -0
- package/src/streaming/ai-sdk-parts.ts +105 -0
- package/src/streaming/delta-tracker.ts +33 -0
- package/src/streaming/line-buffer.ts +44 -0
- package/src/streaming/openai-sse.ts +114 -0
- package/src/streaming/parser.ts +22 -0
- package/src/streaming/types.ts +152 -0
- package/src/tools/core/executor.ts +25 -0
- package/src/tools/core/registry.ts +27 -0
- package/src/tools/core/types.ts +31 -0
- package/src/tools/defaults.ts +673 -0
- package/src/tools/discovery.ts +140 -0
- package/src/tools/executors/cli.ts +58 -0
- package/src/tools/executors/local.ts +25 -0
- package/src/tools/executors/mcp.ts +39 -0
- package/src/tools/executors/sdk.ts +39 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/router.ts +123 -0
- package/src/tools/schema.ts +58 -0
- package/src/tools/skills/loader.ts +61 -0
- package/src/tools/skills/resolver.ts +21 -0
- package/src/tools/types.ts +29 -0
- package/src/types.ts +8 -0
- package/src/utils/errors.ts +131 -0
- package/src/utils/logger.ts +146 -0
- package/src/utils/perf.ts +44 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import type { ToolUpdate, ToolMapper } from "../acp/tools.js";
|
|
2
|
+
import { extractOpenAiToolCall, type OpenAiToolCall } from "../proxy/tool-loop.js";
|
|
3
|
+
import type { StreamJsonToolCallEvent } from "../streaming/types.js";
|
|
4
|
+
import type { ToolRouter } from "../tools/router.js";
|
|
5
|
+
import { createLogger } from "../utils/logger.js";
|
|
6
|
+
import { applyToolSchemaCompat, type ToolSchemaValidationResult } from "./tool-schema-compat.js";
|
|
7
|
+
import type { ToolLoopGuard } from "./tool-loop-guard.js";
|
|
8
|
+
import type { ProviderBoundaryMode, ToolLoopMode } from "./boundary.js";
|
|
9
|
+
import type { ProviderBoundary } from "./boundary.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("provider:runtime-interception");
|
|
12
|
+
|
|
13
|
+
interface HandleToolLoopEventBaseOptions {
|
|
14
|
+
event: StreamJsonToolCallEvent;
|
|
15
|
+
toolLoopMode: ToolLoopMode;
|
|
16
|
+
allowedToolNames: Set<string>;
|
|
17
|
+
toolSchemaMap: Map<string, unknown>;
|
|
18
|
+
toolLoopGuard: ToolLoopGuard;
|
|
19
|
+
toolMapper: ToolMapper;
|
|
20
|
+
toolSessionId: string;
|
|
21
|
+
shouldEmitToolUpdates: boolean;
|
|
22
|
+
proxyExecuteToolCalls: boolean;
|
|
23
|
+
suppressConverterToolEvents: boolean;
|
|
24
|
+
toolRouter?: ToolRouter;
|
|
25
|
+
responseMeta: { id: string; created: number; model: string };
|
|
26
|
+
onToolUpdate: (update: ToolUpdate) => Promise<void> | void;
|
|
27
|
+
onToolResult: (toolResult: any) => Promise<void> | void;
|
|
28
|
+
onInterceptedToolCall: (toolCall: OpenAiToolCall) => Promise<void> | void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HandleToolLoopEventLegacyOptions extends HandleToolLoopEventBaseOptions {}
|
|
32
|
+
|
|
33
|
+
export interface HandleToolLoopEventV1Options extends HandleToolLoopEventBaseOptions {
|
|
34
|
+
boundary: ProviderBoundary;
|
|
35
|
+
schemaValidationFailureMode?: "pass_through" | "terminate";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HandleToolLoopEventWithFallbackOptions
|
|
39
|
+
extends HandleToolLoopEventV1Options {
|
|
40
|
+
boundaryMode: ProviderBoundaryMode;
|
|
41
|
+
autoFallbackToLegacy: boolean;
|
|
42
|
+
onFallbackToLegacy?: (error: unknown) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface HandleToolLoopEventResult {
|
|
46
|
+
intercepted: boolean;
|
|
47
|
+
skipConverter: boolean;
|
|
48
|
+
terminate?: ToolLoopTermination;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ToolLoopGuardTermination {
|
|
52
|
+
reason: "loop_guard";
|
|
53
|
+
message: string;
|
|
54
|
+
tool: string;
|
|
55
|
+
fingerprint: string;
|
|
56
|
+
repeatCount: number;
|
|
57
|
+
maxRepeat: number;
|
|
58
|
+
errorClass: string;
|
|
59
|
+
silent?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ToolSchemaValidationTermination {
|
|
63
|
+
reason: "schema_validation";
|
|
64
|
+
message: string;
|
|
65
|
+
tool: string;
|
|
66
|
+
errorClass: "validation";
|
|
67
|
+
repairHint?: string;
|
|
68
|
+
missing: string[];
|
|
69
|
+
unexpected: string[];
|
|
70
|
+
typeErrors: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type ToolLoopTermination = ToolLoopGuardTermination | ToolSchemaValidationTermination;
|
|
74
|
+
|
|
75
|
+
interface NonFatalSchemaValidationResultChunk {
|
|
76
|
+
id: string;
|
|
77
|
+
object: "chat.completion.chunk";
|
|
78
|
+
created: number;
|
|
79
|
+
model: string;
|
|
80
|
+
choices: Array<{
|
|
81
|
+
index: number;
|
|
82
|
+
delta: {
|
|
83
|
+
role: "assistant";
|
|
84
|
+
content: string;
|
|
85
|
+
};
|
|
86
|
+
finish_reason: null;
|
|
87
|
+
}>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class ToolBoundaryExtractionError extends Error {
|
|
91
|
+
cause?: unknown;
|
|
92
|
+
constructor(message: string, cause?: unknown) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "ToolBoundaryExtractionError";
|
|
95
|
+
this.cause = cause;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function handleToolLoopEventLegacy(
|
|
100
|
+
options: HandleToolLoopEventLegacyOptions,
|
|
101
|
+
): Promise<HandleToolLoopEventResult> {
|
|
102
|
+
const {
|
|
103
|
+
event,
|
|
104
|
+
toolLoopMode,
|
|
105
|
+
allowedToolNames,
|
|
106
|
+
toolSchemaMap,
|
|
107
|
+
toolLoopGuard,
|
|
108
|
+
toolMapper,
|
|
109
|
+
toolSessionId,
|
|
110
|
+
shouldEmitToolUpdates,
|
|
111
|
+
proxyExecuteToolCalls,
|
|
112
|
+
suppressConverterToolEvents,
|
|
113
|
+
toolRouter,
|
|
114
|
+
responseMeta,
|
|
115
|
+
onToolUpdate,
|
|
116
|
+
onToolResult,
|
|
117
|
+
onInterceptedToolCall,
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
const interceptedToolCall =
|
|
121
|
+
toolLoopMode === "opencode"
|
|
122
|
+
? extractOpenAiToolCall(event as any, allowedToolNames)
|
|
123
|
+
: null;
|
|
124
|
+
if (interceptedToolCall) {
|
|
125
|
+
const compat = applyToolSchemaCompat(interceptedToolCall, toolSchemaMap);
|
|
126
|
+
let normalizedToolCall = compat.toolCall;
|
|
127
|
+
log.debug("Applied tool schema compatibility (legacy)", {
|
|
128
|
+
tool: normalizedToolCall.function.name,
|
|
129
|
+
originalArgKeys: compat.originalArgKeys,
|
|
130
|
+
normalizedArgKeys: compat.normalizedArgKeys,
|
|
131
|
+
collisionKeys: compat.collisionKeys,
|
|
132
|
+
validationOk: compat.validation.ok,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (compat.validation.hasSchema && !compat.validation.ok) {
|
|
136
|
+
const validationTermination = evaluateSchemaValidationLoopGuard(
|
|
137
|
+
toolLoopGuard,
|
|
138
|
+
normalizedToolCall,
|
|
139
|
+
compat.validation,
|
|
140
|
+
);
|
|
141
|
+
if (validationTermination) {
|
|
142
|
+
return { intercepted: false, skipConverter: true, terminate: validationTermination };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const reroutedWrite = tryRerouteEditToWrite(
|
|
146
|
+
normalizedToolCall,
|
|
147
|
+
compat.normalizedArgs,
|
|
148
|
+
allowedToolNames,
|
|
149
|
+
toolSchemaMap,
|
|
150
|
+
);
|
|
151
|
+
if (reroutedWrite) {
|
|
152
|
+
log.debug("Rerouting malformed edit call to write (legacy)", {
|
|
153
|
+
path: reroutedWrite.path,
|
|
154
|
+
missing: compat.validation.missing,
|
|
155
|
+
typeErrors: compat.validation.typeErrors,
|
|
156
|
+
});
|
|
157
|
+
normalizedToolCall = reroutedWrite.toolCall;
|
|
158
|
+
} else if (shouldEmitNonFatalSchemaValidationHint(normalizedToolCall, compat.validation)) {
|
|
159
|
+
const hintChunk = createNonFatalSchemaValidationHintChunk(
|
|
160
|
+
responseMeta,
|
|
161
|
+
normalizedToolCall,
|
|
162
|
+
compat.validation,
|
|
163
|
+
);
|
|
164
|
+
log.debug("Emitting non-fatal schema validation hint in legacy and skipping malformed tool execution", {
|
|
165
|
+
tool: normalizedToolCall.function.name,
|
|
166
|
+
missing: compat.validation.missing,
|
|
167
|
+
typeErrors: compat.validation.typeErrors,
|
|
168
|
+
});
|
|
169
|
+
await onToolResult(hintChunk);
|
|
170
|
+
return { intercepted: false, skipConverter: true };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const termination = evaluateToolLoopGuard(toolLoopGuard, normalizedToolCall);
|
|
175
|
+
if (termination) {
|
|
176
|
+
return { intercepted: false, skipConverter: true, terminate: termination };
|
|
177
|
+
}
|
|
178
|
+
await onInterceptedToolCall(normalizedToolCall);
|
|
179
|
+
return { intercepted: true, skipConverter: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const updates = await toolMapper.mapCursorEventToAcp(
|
|
183
|
+
event,
|
|
184
|
+
event.session_id ?? toolSessionId,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (shouldEmitToolUpdates) {
|
|
188
|
+
for (const update of updates) {
|
|
189
|
+
await onToolUpdate(update);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (toolRouter && proxyExecuteToolCalls) {
|
|
194
|
+
const toolResult = await toolRouter.handleToolCall(event as any, responseMeta);
|
|
195
|
+
if (toolResult) {
|
|
196
|
+
await onToolResult(toolResult);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
intercepted: false,
|
|
202
|
+
skipConverter: suppressConverterToolEvents,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function handleToolLoopEventV1(
|
|
207
|
+
options: HandleToolLoopEventV1Options,
|
|
208
|
+
): Promise<HandleToolLoopEventResult> {
|
|
209
|
+
const {
|
|
210
|
+
event,
|
|
211
|
+
boundary,
|
|
212
|
+
schemaValidationFailureMode = "pass_through",
|
|
213
|
+
toolLoopMode,
|
|
214
|
+
allowedToolNames,
|
|
215
|
+
toolSchemaMap,
|
|
216
|
+
toolLoopGuard,
|
|
217
|
+
toolMapper,
|
|
218
|
+
toolSessionId,
|
|
219
|
+
shouldEmitToolUpdates,
|
|
220
|
+
proxyExecuteToolCalls,
|
|
221
|
+
suppressConverterToolEvents,
|
|
222
|
+
toolRouter,
|
|
223
|
+
responseMeta,
|
|
224
|
+
onToolUpdate,
|
|
225
|
+
onToolResult,
|
|
226
|
+
onInterceptedToolCall,
|
|
227
|
+
} = options;
|
|
228
|
+
|
|
229
|
+
let interceptedToolCall: OpenAiToolCall | null;
|
|
230
|
+
try {
|
|
231
|
+
interceptedToolCall = boundary.maybeExtractToolCall(
|
|
232
|
+
event,
|
|
233
|
+
allowedToolNames,
|
|
234
|
+
toolLoopMode,
|
|
235
|
+
);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new ToolBoundaryExtractionError("Boundary tool extraction failed", error);
|
|
238
|
+
}
|
|
239
|
+
if (interceptedToolCall) {
|
|
240
|
+
const compat = applyToolSchemaCompat(interceptedToolCall, toolSchemaMap);
|
|
241
|
+
interceptedToolCall = compat.toolCall;
|
|
242
|
+
const editDiag =
|
|
243
|
+
interceptedToolCall.function.name.toLowerCase() === "edit"
|
|
244
|
+
? {
|
|
245
|
+
rawArgs: safeArgTypeSummary(event),
|
|
246
|
+
normalizedArgs: compat.normalizedArgs,
|
|
247
|
+
}
|
|
248
|
+
: undefined;
|
|
249
|
+
log.debug("Applied tool schema compatibility", {
|
|
250
|
+
tool: interceptedToolCall.function.name,
|
|
251
|
+
originalArgKeys: compat.originalArgKeys,
|
|
252
|
+
normalizedArgKeys: compat.normalizedArgKeys,
|
|
253
|
+
collisionKeys: compat.collisionKeys,
|
|
254
|
+
validationOk: compat.validation.ok,
|
|
255
|
+
...(editDiag ? { editDiag } : {}),
|
|
256
|
+
});
|
|
257
|
+
if (compat.validation.hasSchema && !compat.validation.ok) {
|
|
258
|
+
log.debug("Tool schema compatibility validation failed", {
|
|
259
|
+
tool: interceptedToolCall.function.name,
|
|
260
|
+
missing: compat.validation.missing,
|
|
261
|
+
unexpected: compat.validation.unexpected,
|
|
262
|
+
typeErrors: compat.validation.typeErrors,
|
|
263
|
+
repairHint: compat.validation.repairHint,
|
|
264
|
+
});
|
|
265
|
+
const validationTermination = evaluateSchemaValidationLoopGuard(
|
|
266
|
+
toolLoopGuard,
|
|
267
|
+
interceptedToolCall,
|
|
268
|
+
compat.validation,
|
|
269
|
+
);
|
|
270
|
+
if (validationTermination) {
|
|
271
|
+
return { intercepted: false, skipConverter: true, terminate: validationTermination };
|
|
272
|
+
}
|
|
273
|
+
const termination = evaluateToolLoopGuard(toolLoopGuard, interceptedToolCall);
|
|
274
|
+
if (termination) {
|
|
275
|
+
return { intercepted: false, skipConverter: true, terminate: termination };
|
|
276
|
+
}
|
|
277
|
+
const reroutedWrite = tryRerouteEditToWrite(
|
|
278
|
+
interceptedToolCall,
|
|
279
|
+
compat.normalizedArgs,
|
|
280
|
+
allowedToolNames,
|
|
281
|
+
toolSchemaMap,
|
|
282
|
+
);
|
|
283
|
+
if (reroutedWrite) {
|
|
284
|
+
log.debug("Rerouting malformed edit call to write", {
|
|
285
|
+
path: reroutedWrite.path,
|
|
286
|
+
missing: compat.validation.missing,
|
|
287
|
+
typeErrors: compat.validation.typeErrors,
|
|
288
|
+
});
|
|
289
|
+
await onInterceptedToolCall(reroutedWrite.toolCall);
|
|
290
|
+
return {
|
|
291
|
+
intercepted: true,
|
|
292
|
+
skipConverter: true,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (
|
|
296
|
+
schemaValidationFailureMode === "pass_through"
|
|
297
|
+
&& shouldTerminateOnSchemaValidation(interceptedToolCall, compat.validation)
|
|
298
|
+
) {
|
|
299
|
+
return {
|
|
300
|
+
intercepted: false,
|
|
301
|
+
skipConverter: true,
|
|
302
|
+
terminate: createSchemaValidationTermination(interceptedToolCall, compat.validation),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (
|
|
306
|
+
schemaValidationFailureMode === "pass_through"
|
|
307
|
+
&& shouldEmitNonFatalSchemaValidationHint(interceptedToolCall, compat.validation)
|
|
308
|
+
) {
|
|
309
|
+
const hintChunk = createNonFatalSchemaValidationHintChunk(
|
|
310
|
+
responseMeta,
|
|
311
|
+
interceptedToolCall,
|
|
312
|
+
compat.validation,
|
|
313
|
+
);
|
|
314
|
+
log.debug("Emitting non-fatal schema validation hint and skipping malformed tool execution", {
|
|
315
|
+
tool: interceptedToolCall.function.name,
|
|
316
|
+
missing: compat.validation.missing,
|
|
317
|
+
typeErrors: compat.validation.typeErrors,
|
|
318
|
+
});
|
|
319
|
+
await onToolResult(hintChunk);
|
|
320
|
+
return {
|
|
321
|
+
intercepted: false,
|
|
322
|
+
skipConverter: true,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (schemaValidationFailureMode === "terminate") {
|
|
326
|
+
return {
|
|
327
|
+
intercepted: false,
|
|
328
|
+
skipConverter: true,
|
|
329
|
+
terminate: createSchemaValidationTermination(interceptedToolCall, compat.validation),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
log.debug("Forwarding schema-invalid tool call to OpenCode loop", {
|
|
333
|
+
tool: interceptedToolCall.function.name,
|
|
334
|
+
repairHint: compat.validation.repairHint,
|
|
335
|
+
});
|
|
336
|
+
await onInterceptedToolCall(interceptedToolCall);
|
|
337
|
+
return {
|
|
338
|
+
intercepted: true,
|
|
339
|
+
skipConverter: true,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const termination = evaluateToolLoopGuard(toolLoopGuard, interceptedToolCall);
|
|
344
|
+
if (termination) {
|
|
345
|
+
return { intercepted: false, skipConverter: true, terminate: termination };
|
|
346
|
+
}
|
|
347
|
+
await onInterceptedToolCall(interceptedToolCall);
|
|
348
|
+
return { intercepted: true, skipConverter: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const updates = await toolMapper.mapCursorEventToAcp(
|
|
352
|
+
event,
|
|
353
|
+
event.session_id ?? toolSessionId,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
if (shouldEmitToolUpdates) {
|
|
357
|
+
for (const update of updates) {
|
|
358
|
+
await onToolUpdate(update);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (toolRouter && proxyExecuteToolCalls) {
|
|
363
|
+
const toolResult = await toolRouter.handleToolCall(event as any, responseMeta);
|
|
364
|
+
if (toolResult) {
|
|
365
|
+
await onToolResult(toolResult);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
intercepted: false,
|
|
371
|
+
skipConverter: suppressConverterToolEvents,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function handleToolLoopEventWithFallback(
|
|
376
|
+
options: HandleToolLoopEventWithFallbackOptions,
|
|
377
|
+
): Promise<HandleToolLoopEventResult> {
|
|
378
|
+
const {
|
|
379
|
+
boundaryMode,
|
|
380
|
+
autoFallbackToLegacy,
|
|
381
|
+
onFallbackToLegacy,
|
|
382
|
+
...shared
|
|
383
|
+
} = options;
|
|
384
|
+
|
|
385
|
+
if (boundaryMode === "legacy") {
|
|
386
|
+
return handleToolLoopEventLegacy(shared);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const schemaValidationFailureMode: "pass_through" | "terminate" =
|
|
391
|
+
autoFallbackToLegacy
|
|
392
|
+
&& boundaryMode === "v1"
|
|
393
|
+
&& !shouldUsePassThroughForEditSchema(shared.event)
|
|
394
|
+
? "terminate"
|
|
395
|
+
: "pass_through";
|
|
396
|
+
const result = await handleToolLoopEventV1({
|
|
397
|
+
...shared,
|
|
398
|
+
schemaValidationFailureMode,
|
|
399
|
+
});
|
|
400
|
+
if (
|
|
401
|
+
result.terminate
|
|
402
|
+
&& autoFallbackToLegacy
|
|
403
|
+
&& boundaryMode === "v1"
|
|
404
|
+
&& (result.terminate.reason === "loop_guard" || result.terminate.reason === "schema_validation")
|
|
405
|
+
) {
|
|
406
|
+
if (result.terminate.reason === "loop_guard") {
|
|
407
|
+
if (result.terminate.errorClass === "validation" || result.terminate.errorClass === "success") {
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
shared.toolLoopGuard.resetFingerprint(result.terminate.fingerprint);
|
|
411
|
+
onFallbackToLegacy?.(new Error(`loop guard: ${result.terminate.fingerprint}`));
|
|
412
|
+
} else {
|
|
413
|
+
onFallbackToLegacy?.(new Error(`schema validation: ${result.terminate.tool}`));
|
|
414
|
+
}
|
|
415
|
+
return handleToolLoopEventLegacy(shared);
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (
|
|
420
|
+
!autoFallbackToLegacy
|
|
421
|
+
|| boundaryMode !== "v1"
|
|
422
|
+
|| !(error instanceof ToolBoundaryExtractionError)
|
|
423
|
+
) {
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
onFallbackToLegacy?.(error.cause ?? error);
|
|
427
|
+
return handleToolLoopEventLegacy(shared);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function evaluateToolLoopGuard(
|
|
432
|
+
toolLoopGuard: ToolLoopGuard,
|
|
433
|
+
toolCall: OpenAiToolCall,
|
|
434
|
+
): ToolLoopTermination | null {
|
|
435
|
+
const decision = toolLoopGuard.evaluate(toolCall);
|
|
436
|
+
if (!decision.tracked) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
if (!decision.triggered) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
log.debug("Tool loop guard triggered", {
|
|
444
|
+
tool: toolCall.function.name,
|
|
445
|
+
fingerprint: decision.fingerprint,
|
|
446
|
+
repeatCount: decision.repeatCount,
|
|
447
|
+
maxRepeat: decision.maxRepeat,
|
|
448
|
+
errorClass: decision.errorClass,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// For success loops, terminate silently without emitting an error message to the user.
|
|
452
|
+
// The tool has already succeeded; we just need to stop the loop.
|
|
453
|
+
if (decision.errorClass === "success") {
|
|
454
|
+
return {
|
|
455
|
+
reason: "loop_guard",
|
|
456
|
+
message: "",
|
|
457
|
+
tool: toolCall.function.name,
|
|
458
|
+
fingerprint: decision.fingerprint,
|
|
459
|
+
repeatCount: decision.repeatCount,
|
|
460
|
+
maxRepeat: decision.maxRepeat,
|
|
461
|
+
errorClass: decision.errorClass,
|
|
462
|
+
silent: true,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
reason: "loop_guard",
|
|
468
|
+
message: `Tool loop guard stopped repeated failing calls to "${toolCall.function.name}" `
|
|
469
|
+
+ `after ${decision.repeatCount} attempts (limit ${decision.maxRepeat}). `
|
|
470
|
+
+ "Adjust tool arguments and retry.",
|
|
471
|
+
tool: toolCall.function.name,
|
|
472
|
+
fingerprint: decision.fingerprint,
|
|
473
|
+
repeatCount: decision.repeatCount,
|
|
474
|
+
maxRepeat: decision.maxRepeat,
|
|
475
|
+
errorClass: decision.errorClass,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function createSchemaValidationTermination(
|
|
480
|
+
toolCall: OpenAiToolCall,
|
|
481
|
+
validation: ToolSchemaValidationResult,
|
|
482
|
+
): ToolSchemaValidationTermination {
|
|
483
|
+
const reasonParts: string[] = [];
|
|
484
|
+
if (validation.missing.length > 0) {
|
|
485
|
+
reasonParts.push(`missing required: ${validation.missing.join(", ")}`);
|
|
486
|
+
}
|
|
487
|
+
if (validation.unexpected.length > 0) {
|
|
488
|
+
reasonParts.push(`unsupported fields: ${validation.unexpected.join(", ")}`);
|
|
489
|
+
}
|
|
490
|
+
if (validation.typeErrors.length > 0) {
|
|
491
|
+
reasonParts.push(`type errors: ${validation.typeErrors.join("; ")}`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const reasonText = reasonParts.length > 0 ? reasonParts.join(" | ") : "arguments did not match schema";
|
|
495
|
+
const repairHint = validation.repairHint
|
|
496
|
+
? ` ${validation.repairHint}`
|
|
497
|
+
: "";
|
|
498
|
+
return {
|
|
499
|
+
reason: "schema_validation",
|
|
500
|
+
message: `Invalid arguments for tool "${toolCall.function.name}": ${reasonText}.${repairHint}`.trim(),
|
|
501
|
+
tool: toolCall.function.name,
|
|
502
|
+
errorClass: "validation",
|
|
503
|
+
repairHint: validation.repairHint,
|
|
504
|
+
missing: validation.missing,
|
|
505
|
+
unexpected: validation.unexpected,
|
|
506
|
+
typeErrors: validation.typeErrors,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function evaluateSchemaValidationLoopGuard(
|
|
511
|
+
toolLoopGuard: ToolLoopGuard,
|
|
512
|
+
toolCall: OpenAiToolCall,
|
|
513
|
+
validation: ToolSchemaValidationResult,
|
|
514
|
+
): ToolLoopTermination | null {
|
|
515
|
+
const validationSignature = buildValidationSignature(validation);
|
|
516
|
+
const decision = toolLoopGuard.evaluateValidation(toolCall, validationSignature);
|
|
517
|
+
if (!decision.tracked || !decision.triggered) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
log.warn("Tool loop guard triggered on schema validation", {
|
|
522
|
+
tool: toolCall.function.name,
|
|
523
|
+
fingerprint: decision.fingerprint,
|
|
524
|
+
repeatCount: decision.repeatCount,
|
|
525
|
+
maxRepeat: decision.maxRepeat,
|
|
526
|
+
validationSignature,
|
|
527
|
+
});
|
|
528
|
+
return {
|
|
529
|
+
reason: "loop_guard",
|
|
530
|
+
message:
|
|
531
|
+
`Tool loop guard stopped repeated schema-invalid calls to "${toolCall.function.name}" `
|
|
532
|
+
+ `after ${decision.repeatCount} attempts (limit ${decision.maxRepeat}). `
|
|
533
|
+
+ "Adjust tool arguments and retry.",
|
|
534
|
+
tool: toolCall.function.name,
|
|
535
|
+
fingerprint: decision.fingerprint,
|
|
536
|
+
repeatCount: decision.repeatCount,
|
|
537
|
+
maxRepeat: decision.maxRepeat,
|
|
538
|
+
errorClass: decision.errorClass,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function buildValidationSignature(validation: ToolSchemaValidationResult): string {
|
|
543
|
+
const parts: string[] = [];
|
|
544
|
+
if (validation.missing.length > 0) {
|
|
545
|
+
const sortedMissing = [...validation.missing].sort();
|
|
546
|
+
parts.push(`missing:${sortedMissing.join(",")}`);
|
|
547
|
+
}
|
|
548
|
+
if (validation.typeErrors.length > 0) {
|
|
549
|
+
const sortedTypeErrors = [...validation.typeErrors].sort();
|
|
550
|
+
parts.push(`type:${sortedTypeErrors.join(",")}`);
|
|
551
|
+
}
|
|
552
|
+
if (parts.length === 0) {
|
|
553
|
+
return "invalid";
|
|
554
|
+
}
|
|
555
|
+
return parts.join("|");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function shouldEmitNonFatalSchemaValidationHint(
|
|
559
|
+
toolCall: OpenAiToolCall,
|
|
560
|
+
validation: ToolSchemaValidationResult,
|
|
561
|
+
): boolean {
|
|
562
|
+
if (toolCall.function.name.toLowerCase() !== "edit") {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
if (validation.typeErrors.length > 0) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
const missing = new Set(validation.missing);
|
|
569
|
+
return missing.has("old_string") || missing.has("new_string") || missing.has("path");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function shouldTerminateOnSchemaValidation(
|
|
573
|
+
toolCall: OpenAiToolCall,
|
|
574
|
+
validation: ToolSchemaValidationResult,
|
|
575
|
+
): boolean {
|
|
576
|
+
if (toolCall.function.name.toLowerCase() !== "edit") {
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
if (validation.typeErrors.length > 0) {
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function createNonFatalSchemaValidationHintChunk(
|
|
586
|
+
meta: { id: string; created: number; model: string },
|
|
587
|
+
toolCall: OpenAiToolCall,
|
|
588
|
+
validation: ToolSchemaValidationResult,
|
|
589
|
+
): NonFatalSchemaValidationResultChunk {
|
|
590
|
+
const termination = createSchemaValidationTermination(toolCall, validation);
|
|
591
|
+
const hint =
|
|
592
|
+
termination.repairHint
|
|
593
|
+
|| "Use write for full-file replacement, or provide path, old_string, and new_string for edit.";
|
|
594
|
+
const content =
|
|
595
|
+
`Skipped malformed tool call "${toolCall.function.name}": ${termination.message} ${hint}`.trim();
|
|
596
|
+
return {
|
|
597
|
+
id: meta.id,
|
|
598
|
+
object: "chat.completion.chunk",
|
|
599
|
+
created: meta.created,
|
|
600
|
+
model: meta.model,
|
|
601
|
+
choices: [
|
|
602
|
+
{
|
|
603
|
+
index: 0,
|
|
604
|
+
delta: {
|
|
605
|
+
role: "assistant",
|
|
606
|
+
content,
|
|
607
|
+
},
|
|
608
|
+
finish_reason: null,
|
|
609
|
+
},
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function safeArgTypeSummary(event: StreamJsonToolCallEvent): Record<string, string> {
|
|
615
|
+
try {
|
|
616
|
+
let raw: unknown;
|
|
617
|
+
const toolCallPayload = (event as any)?.tool_call;
|
|
618
|
+
if (isRecord(toolCallPayload)) {
|
|
619
|
+
const entries = Object.entries(toolCallPayload);
|
|
620
|
+
if (entries.length > 0) {
|
|
621
|
+
const [, payload] = entries[0];
|
|
622
|
+
if (isRecord(payload)) {
|
|
623
|
+
raw = payload.args;
|
|
624
|
+
if (raw === undefined) {
|
|
625
|
+
const { result: _result, ...rest } = payload;
|
|
626
|
+
if (Object.keys(rest).length > 0) {
|
|
627
|
+
raw = rest;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (raw === undefined) {
|
|
634
|
+
raw = (event as any)?.function?.arguments ?? (event as any)?.arguments;
|
|
635
|
+
}
|
|
636
|
+
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
637
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
638
|
+
return { _raw: typeof parsed };
|
|
639
|
+
}
|
|
640
|
+
const summary: Record<string, string> = {};
|
|
641
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
642
|
+
if (v === null) {
|
|
643
|
+
summary[k] = "null";
|
|
644
|
+
} else if (Array.isArray(v)) {
|
|
645
|
+
summary[k] = `array[${v.length}]`;
|
|
646
|
+
} else {
|
|
647
|
+
summary[k] = typeof v;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return summary;
|
|
651
|
+
} catch {
|
|
652
|
+
return { _error: "parse_failed" };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function shouldUsePassThroughForEditSchema(event: StreamJsonToolCallEvent): boolean {
|
|
657
|
+
const toolCallPayload = (event as any)?.tool_call;
|
|
658
|
+
if (!isRecord(toolCallPayload)) {
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
const keys = Object.keys(toolCallPayload);
|
|
662
|
+
if (keys.length === 0) {
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
const rawName = keys[0];
|
|
666
|
+
const normalizedName = rawName.endsWith("ToolCall")
|
|
667
|
+
? rawName.slice(0, -"ToolCall".length)
|
|
668
|
+
: rawName;
|
|
669
|
+
return normalizedName.toLowerCase() === "edit";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function tryRerouteEditToWrite(
|
|
673
|
+
toolCall: OpenAiToolCall,
|
|
674
|
+
normalizedArgs: Record<string, unknown>,
|
|
675
|
+
allowedToolNames: Set<string>,
|
|
676
|
+
toolSchemaMap: Map<string, unknown>,
|
|
677
|
+
): { path: string; toolCall: OpenAiToolCall } | null {
|
|
678
|
+
if (toolCall.function.name.toLowerCase() !== "edit") {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
if (!allowedToolNames.has("write") || !toolSchemaMap.has("write")) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const path = typeof normalizedArgs.path === "string" && normalizedArgs.path.length > 0
|
|
686
|
+
? normalizedArgs.path
|
|
687
|
+
: null;
|
|
688
|
+
if (!path) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const content =
|
|
693
|
+
typeof normalizedArgs.new_string === "string"
|
|
694
|
+
? normalizedArgs.new_string
|
|
695
|
+
: typeof normalizedArgs.content === "string"
|
|
696
|
+
? normalizedArgs.content
|
|
697
|
+
: null;
|
|
698
|
+
if (content === null) {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const oldString = normalizedArgs.old_string;
|
|
703
|
+
if (typeof oldString === "string" && oldString.length > 0) {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
path,
|
|
709
|
+
toolCall: {
|
|
710
|
+
...toolCall,
|
|
711
|
+
function: {
|
|
712
|
+
name: "write",
|
|
713
|
+
arguments: JSON.stringify({ path, content }),
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
720
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
721
|
+
}
|