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