cursor-opencode-provider 0.1.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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/auth.d.ts +48 -0
- package/dist/auth.js +200 -0
- package/dist/context/agents.d.ts +8 -0
- package/dist/context/agents.js +76 -0
- package/dist/context/build.d.ts +12 -0
- package/dist/context/build.js +83 -0
- package/dist/context/env.d.ts +1 -0
- package/dist/context/env.js +29 -0
- package/dist/context/git.d.ts +20 -0
- package/dist/context/git.js +59 -0
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/layout.d.ts +17 -0
- package/dist/context/layout.js +58 -0
- package/dist/context/paths.d.ts +3 -0
- package/dist/context/paths.js +11 -0
- package/dist/context/plugins.d.ts +8 -0
- package/dist/context/plugins.js +50 -0
- package/dist/context/rules.d.ts +19 -0
- package/dist/context/rules.js +198 -0
- package/dist/context/skills.d.ts +11 -0
- package/dist/context/skills.js +104 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +18 -0
- package/dist/language-model.d.ts +45 -0
- package/dist/language-model.js +834 -0
- package/dist/models.d.ts +49 -0
- package/dist/models.js +136 -0
- package/dist/plugin-v2.d.ts +2 -0
- package/dist/plugin-v2.js +48 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +201 -0
- package/dist/protocol/blob-store.d.ts +15 -0
- package/dist/protocol/blob-store.js +52 -0
- package/dist/protocol/checkpoint.d.ts +17 -0
- package/dist/protocol/checkpoint.js +29 -0
- package/dist/protocol/checksum.d.ts +2 -0
- package/dist/protocol/checksum.js +23 -0
- package/dist/protocol/client-version.d.ts +5 -0
- package/dist/protocol/client-version.js +150 -0
- package/dist/protocol/device-id.d.ts +8 -0
- package/dist/protocol/device-id.js +121 -0
- package/dist/protocol/framing.d.ts +10 -0
- package/dist/protocol/framing.js +90 -0
- package/dist/protocol/kv.d.ts +24 -0
- package/dist/protocol/kv.js +81 -0
- package/dist/protocol/messages.d.ts +11 -0
- package/dist/protocol/messages.js +676 -0
- package/dist/protocol/request.d.ts +36 -0
- package/dist/protocol/request.js +90 -0
- package/dist/protocol/stream.d.ts +38 -0
- package/dist/protocol/stream.js +64 -0
- package/dist/protocol/struct.d.ts +19 -0
- package/dist/protocol/struct.js +186 -0
- package/dist/protocol/thinking.d.ts +15 -0
- package/dist/protocol/thinking.js +17 -0
- package/dist/protocol/tools.d.ts +94 -0
- package/dist/protocol/tools.js +631 -0
- package/dist/session.d.ts +81 -0
- package/dist/session.js +96 -0
- package/dist/shared.d.ts +15 -0
- package/dist/shared.js +13 -0
- package/dist/transport/connect.d.ts +23 -0
- package/dist/transport/connect.js +275 -0
- package/package.json +65 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { bidiRunStream, trace } from "./transport/connect.js";
|
|
5
|
+
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
6
|
+
import { decodeFramePayload } from "./protocol/framing.js";
|
|
7
|
+
import { decodeMessage } from "./protocol/messages.js";
|
|
8
|
+
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, toolsToDescriptors, detectExecVariantField, buildRawEmptyExecReply, buildRequestContextResult, REQUEST_CONTEXT_RESULT_FIELD, } from "./protocol/tools.js";
|
|
9
|
+
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
10
|
+
import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
11
|
+
import { conversationBlobCount } from "./protocol/blob-store.js";
|
|
12
|
+
import { sessionManager } from "./session.js";
|
|
13
|
+
import { readCache, cacheFilePath, resolveVariantParameters } from "./models.js";
|
|
14
|
+
import { buildRequestContext } from "./context/build.js";
|
|
15
|
+
let _availableModels;
|
|
16
|
+
// mtime of the cache file the last time we loaded it. Compared on each call
|
|
17
|
+
// so discoverModels' background refresh is picked up without a process restart.
|
|
18
|
+
let _availableModelsMtimeMs = 0;
|
|
19
|
+
export function createCursorLanguageModel(modelId, providerId, options) {
|
|
20
|
+
return {
|
|
21
|
+
specificationVersion: "v3",
|
|
22
|
+
provider: providerId,
|
|
23
|
+
modelId,
|
|
24
|
+
supportedUrls: {},
|
|
25
|
+
async doStream(callOptions) {
|
|
26
|
+
return doStreamImpl(modelId, options, callOptions);
|
|
27
|
+
},
|
|
28
|
+
async doGenerate(callOptions) {
|
|
29
|
+
const result = await doStreamImpl(modelId, options, callOptions);
|
|
30
|
+
const parts = [];
|
|
31
|
+
const reader = result.stream.getReader();
|
|
32
|
+
while (true) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done)
|
|
35
|
+
break;
|
|
36
|
+
parts.push(value);
|
|
37
|
+
}
|
|
38
|
+
return foldStreamParts(parts);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function doStreamImpl(modelId, options, callOptions) {
|
|
43
|
+
// A raw `sk-...` API key must be exchanged for a JWT before it can be used
|
|
44
|
+
// as a Bearer token (the plugin path does this in auth.ts). The accessToken
|
|
45
|
+
// path is already a JWT from OAuth/key-exchange, so we use it as-is.
|
|
46
|
+
// resolveBearerToken caches apiKey exchanges so we don't hit /auth/exchange
|
|
47
|
+
// on every turn.
|
|
48
|
+
const { resolveBearerToken } = await import("./auth.js");
|
|
49
|
+
const token = await resolveBearerToken({
|
|
50
|
+
accessToken: options.accessToken,
|
|
51
|
+
apiKey: options.apiKey,
|
|
52
|
+
baseUrl: options.baseURL,
|
|
53
|
+
});
|
|
54
|
+
const prompt = callOptions.prompt;
|
|
55
|
+
// ── Continuation vs fresh turn ──
|
|
56
|
+
// OpenCode embeds *all* historical tool results in every prompt. Only the
|
|
57
|
+
// trailing tool-message suffix (after the last assistant/user message) is a
|
|
58
|
+
// live continuation. Treating mid-prompt history as continuation caused
|
|
59
|
+
// false "orphaned tool results" errors after Cursor turn_ended and OpenCode
|
|
60
|
+
// started the next step with old tools still in the prompt body.
|
|
61
|
+
const trailingToolResults = extractTrailingToolResults(prompt);
|
|
62
|
+
let session = findContinuationSession(trailingToolResults);
|
|
63
|
+
if (session) {
|
|
64
|
+
// Deliver only results that this live session is still awaiting.
|
|
65
|
+
const pendingResults = trailingToolResults.filter((r) => r.sessionId === session.sessionId && session.pending.has(r.execId));
|
|
66
|
+
trace(`continuation: ${trailingToolResults.length} trailing tool result(s), ` +
|
|
67
|
+
`${pendingResults.length} pending for sessionId=${session.sessionId} ` +
|
|
68
|
+
`pending={${[...session.pending.keys()].join(",")}}`);
|
|
69
|
+
for (const r of pendingResults) {
|
|
70
|
+
const pending = session.pending.get(r.execId);
|
|
71
|
+
if (!pending)
|
|
72
|
+
continue;
|
|
73
|
+
try {
|
|
74
|
+
for (const frame of buildExecClientMessages({
|
|
75
|
+
execId: r.execId,
|
|
76
|
+
resultField: pending.resultField,
|
|
77
|
+
output: r.output,
|
|
78
|
+
error: r.error,
|
|
79
|
+
})) {
|
|
80
|
+
session.stream.write(frame);
|
|
81
|
+
}
|
|
82
|
+
trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} outLen=${r.output.length}`);
|
|
83
|
+
// Only resolve after the write succeeded — if it threw, the server never
|
|
84
|
+
// got the result and would block on heartbeats forever otherwise.
|
|
85
|
+
sessionManager.resolve(session.sessionId, r.execId);
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
trace(`continuation: write FAILED execId=${r.execId} err=${e.message} — leaving pending`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
sessionManager.touch(session);
|
|
92
|
+
}
|
|
93
|
+
else if (trailingToolResults.length > 0) {
|
|
94
|
+
// True continuation (prompt ends with tool results) but the Cursor Run is
|
|
95
|
+
// gone. Soft-stop instead of re-prompting (doom loop) or hard-erroring.
|
|
96
|
+
const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
|
|
97
|
+
trace(`continuation: ${trailingToolResults.length} orphaned trailing tool result(s) [${ids}] — soft-stop`);
|
|
98
|
+
return orphanedToolResultsStream(ids);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
// Fresh turn (prompt ends with user/assistant text). Historical tool
|
|
102
|
+
// results may exist mid-prompt; they are not live exec replies.
|
|
103
|
+
const historical = extractToolResults(prompt).length;
|
|
104
|
+
if (historical > 0) {
|
|
105
|
+
trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
|
|
106
|
+
}
|
|
107
|
+
session = await startSession(modelId, token, callOptions, options);
|
|
108
|
+
}
|
|
109
|
+
const boundSession = session;
|
|
110
|
+
const textId = crypto.randomUUID();
|
|
111
|
+
const reasoningId = crypto.randomUUID();
|
|
112
|
+
return {
|
|
113
|
+
stream: new ReadableStream({
|
|
114
|
+
async pull(controller) {
|
|
115
|
+
// The outer try is a safety net for any throw that escapes pump() —
|
|
116
|
+
// e.g. an unhandled decode/gunzip error or a frames-iterator throw on
|
|
117
|
+
// a non-200 HTTP/2 response. Without it the pull promise rejects, the
|
|
118
|
+
// ReadableStream errors, and the session is never cleaned up.
|
|
119
|
+
try {
|
|
120
|
+
try {
|
|
121
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
// Controller already cancelled by the consumer — stop pumping.
|
|
125
|
+
trace(`pull: stream-start enqueue failed (cancelled) err=${e.message}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
boundSession.pumpActive = true;
|
|
129
|
+
try {
|
|
130
|
+
await pump(boundSession, controller, { textId, reasoningId }, callOptions.abortSignal);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
boundSession.pumpActive = false;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
controller.close();
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
trace(`pull: close failed (already closed/cancelled) err=${e.message}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
boundSession.pumpActive = false;
|
|
144
|
+
trace(`pull: pump threw (cleaning up): ${e.message}`);
|
|
145
|
+
sessionManager.closeUnlessPending(boundSession);
|
|
146
|
+
try {
|
|
147
|
+
controller.error(e instanceof Error ? e : new Error(String(e)));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* controller already errored/closed */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
cancel() {
|
|
155
|
+
// OpenCode cancels the ReadableStream after "tool-calls"; keep the
|
|
156
|
+
// Cursor Run stream alive so the next doStream can write results.
|
|
157
|
+
trace("ReadableStream cancel() → closeUnlessPending");
|
|
158
|
+
sessionManager.closeUnlessPending(boundSession);
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async function startSession(modelId, token, callOptions, options) {
|
|
164
|
+
const prompt = callOptions.prompt;
|
|
165
|
+
// Cursor stores server-side history keyed by conversation_id. Minting a new
|
|
166
|
+
// UUID every turn made each OpenCode user message look like a brand-new chat.
|
|
167
|
+
// OpenCode sends X-Session-Id / x-session-affinity on every call — derive a
|
|
168
|
+
// stable UUID from that so follow-ups hit the same server conversation.
|
|
169
|
+
const conversationId = resolveConversationId(callOptions);
|
|
170
|
+
const userText = extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".";
|
|
171
|
+
const systemPrompt = extractSystemPrompt(prompt);
|
|
172
|
+
const tools = extractTools(callOptions);
|
|
173
|
+
await loadAvailableModels();
|
|
174
|
+
const providerOptions = callOptions.providerOptions?.cursor;
|
|
175
|
+
const reasoningEffort = providerOptions?.reasoningEffort;
|
|
176
|
+
const maxMode = !!(providerOptions?.maxMode ?? false);
|
|
177
|
+
const modelInfo = _availableModels?.find((m) => m.id === modelId);
|
|
178
|
+
const parameterValues = resolveVariantParameters(modelInfo, { reasoningEffort, maxMode });
|
|
179
|
+
// Do NOT pass callOptions.abortSignal into the h2 Run stream. OpenCode aborts
|
|
180
|
+
// that signal when a turn ends with tool-calls; the Cursor stream must stay
|
|
181
|
+
// open until we write the exec results on the next doStream.
|
|
182
|
+
const stream = await bidiRunStream(token, {
|
|
183
|
+
baseURL: options.baseURL,
|
|
184
|
+
headers: options.headers,
|
|
185
|
+
});
|
|
186
|
+
// Build the tool descriptors once — advertised in AgentRunRequest #4 mcp_tools
|
|
187
|
+
// AND echoed into the request_context reply (server turn-setup probe).
|
|
188
|
+
const toolDescriptors = tools.length > 0 ? toolsToDescriptors(tools) : [];
|
|
189
|
+
// Compaction/summary turns pass tools:{} (and may set toolChoice "none").
|
|
190
|
+
// Cursor still has native Grep/etc.; allowTools gates emitting tool-call parts.
|
|
191
|
+
const allowTools = computeAllowTools(toolDescriptors.length, callOptions.toolChoice);
|
|
192
|
+
const workspaceRoot = options.workspaceRoot || process.cwd();
|
|
193
|
+
const requestContext = await buildRequestContext({ workspaceRoot, tools });
|
|
194
|
+
// CLI parity: echo the last conversation_checkpoint_update as conversation_state.
|
|
195
|
+
// Turn 1 (no checkpoint) seeds system prompt only; live user text is current-only.
|
|
196
|
+
const conversationState = getCheckpoint(conversationId);
|
|
197
|
+
const reqBytes = buildRunRequest({
|
|
198
|
+
text: userText,
|
|
199
|
+
modelId,
|
|
200
|
+
conversationId,
|
|
201
|
+
systemPrompt: conversationState ? undefined : systemPrompt,
|
|
202
|
+
conversationState,
|
|
203
|
+
parameterValues,
|
|
204
|
+
maxMode,
|
|
205
|
+
availableModels: _availableModels,
|
|
206
|
+
tools,
|
|
207
|
+
requestContext,
|
|
208
|
+
});
|
|
209
|
+
// Content hashes — Cursor content-addresses large payloads; logging these lets
|
|
210
|
+
// us match a server get_blob_args.blob_id to what it wants served.
|
|
211
|
+
const sha = (b) => createHash("sha256").update(b).digest("hex");
|
|
212
|
+
const skillsCount = Array.isArray(requestContext.agent_skills)
|
|
213
|
+
? requestContext.agent_skills.length
|
|
214
|
+
: 0;
|
|
215
|
+
const hooksCtx = typeof requestContext.hooks_additional_context === "string"
|
|
216
|
+
? requestContext.hooks_additional_context
|
|
217
|
+
: "";
|
|
218
|
+
trace(`outbound Run: model=${modelId} conversationId=${conversationId} ` +
|
|
219
|
+
`params=${JSON.stringify(parameterValues ?? [])} ` +
|
|
220
|
+
`maxMode=${maxMode} systemPromptLen=${systemPrompt?.length ?? 0} tools=${tools.length} ` +
|
|
221
|
+
`skills=${skillsCount} hooks=${hooksCtx ? hooksCtx.split("\n").length : 0} ` +
|
|
222
|
+
`availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
|
|
223
|
+
`checkpointLen=${conversationState?.length ?? 0} runRequestBytes=${reqBytes.length}`);
|
|
224
|
+
if (hooksCtx)
|
|
225
|
+
trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
|
|
226
|
+
trace(`hash run_request sha256=${sha(reqBytes)}`);
|
|
227
|
+
if (systemPrompt)
|
|
228
|
+
trace(`hash systemPrompt sha256=${sha(systemPrompt)}`);
|
|
229
|
+
if (conversationState)
|
|
230
|
+
trace(`hash checkpoint sha256=${sha(conversationState)}`);
|
|
231
|
+
stream.write(reqBytes);
|
|
232
|
+
const session = {
|
|
233
|
+
sessionId: crypto.randomUUID(),
|
|
234
|
+
conversationId,
|
|
235
|
+
stream,
|
|
236
|
+
frames: stream.frames()[Symbol.asyncIterator](),
|
|
237
|
+
pending: new Map(),
|
|
238
|
+
blobs: new Map(),
|
|
239
|
+
toolDescriptors,
|
|
240
|
+
requestContext,
|
|
241
|
+
allowTools,
|
|
242
|
+
pumpActive: false,
|
|
243
|
+
heartbeat: null,
|
|
244
|
+
expiresAt: Date.now() + 300_000,
|
|
245
|
+
};
|
|
246
|
+
session.heartbeat = setInterval(() => {
|
|
247
|
+
try {
|
|
248
|
+
stream.write(buildHeartbeat());
|
|
249
|
+
}
|
|
250
|
+
catch { /* closed */ }
|
|
251
|
+
}, 5000);
|
|
252
|
+
callOptions.abortSignal?.addEventListener("abort", () => {
|
|
253
|
+
// Abort after tool-calls is normal — preserve pending sessions.
|
|
254
|
+
trace("abortSignal aborted → closeUnlessPending");
|
|
255
|
+
sessionManager.closeUnlessPending(session);
|
|
256
|
+
}, { once: true });
|
|
257
|
+
return session;
|
|
258
|
+
}
|
|
259
|
+
/** Emit a soft-stop stream when trailing tool results have no live Cursor Run. */
|
|
260
|
+
function orphanedToolResultsStream(ids) {
|
|
261
|
+
const textId = crypto.randomUUID();
|
|
262
|
+
const message = `The Cursor agent stream ended before tool results could be delivered ` +
|
|
263
|
+
`(${ids.split(",").length} result(s)). Please retry your request.`;
|
|
264
|
+
trace(`orphanedToolResultsStream: soft-stop for [${ids}]`);
|
|
265
|
+
return {
|
|
266
|
+
stream: new ReadableStream({
|
|
267
|
+
pull(controller) {
|
|
268
|
+
try {
|
|
269
|
+
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
270
|
+
controller.enqueue({ type: "text-start", id: textId });
|
|
271
|
+
controller.enqueue({ type: "text-delta", id: textId, delta: message });
|
|
272
|
+
controller.enqueue({ type: "text-end", id: textId });
|
|
273
|
+
controller.enqueue({
|
|
274
|
+
type: "finish",
|
|
275
|
+
finishReason: { unified: "stop", raw: undefined },
|
|
276
|
+
usage: {
|
|
277
|
+
inputTokens: { total: 0, noCache: undefined, cacheRead: 0, cacheWrite: 0 },
|
|
278
|
+
outputTokens: { total: 0, text: undefined, reasoning: undefined },
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
controller.close();
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
trace(`orphanedToolResultsStream: enqueue failed err=${e.message}`);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* OpenCode re-sends the full tool-result history on every continuation. Prefer
|
|
292
|
+
* the newest result that still has a live pending exec on its tagged session.
|
|
293
|
+
*/
|
|
294
|
+
export function findContinuationSession(toolResults) {
|
|
295
|
+
for (let i = toolResults.length - 1; i >= 0; i--) {
|
|
296
|
+
const r = toolResults[i];
|
|
297
|
+
const s = sessionManager.findByExecIds(r.sessionId, [r.execId]);
|
|
298
|
+
if (s)
|
|
299
|
+
return s;
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
async function loadAvailableModels() {
|
|
304
|
+
const configDir = resolveModelCacheDir();
|
|
305
|
+
if (!configDir)
|
|
306
|
+
return;
|
|
307
|
+
try {
|
|
308
|
+
const filePath = cacheFilePath(configDir);
|
|
309
|
+
let mtime = 0;
|
|
310
|
+
try {
|
|
311
|
+
const stat = await fs.promises.stat(filePath);
|
|
312
|
+
mtime = stat.mtimeMs;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// file missing — fall through with mtime=0
|
|
316
|
+
}
|
|
317
|
+
// Re-read when the file changed (discoverModels background refresh).
|
|
318
|
+
if (mtime !== _availableModelsMtimeMs) {
|
|
319
|
+
const cached = await readCache(configDir);
|
|
320
|
+
_availableModels = cached?.models;
|
|
321
|
+
_availableModelsMtimeMs = mtime;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch { /* ignore */ }
|
|
325
|
+
}
|
|
326
|
+
/** Same directory the plugin writes to (CURSOR_CONFIG_DIR, else OpenCode config). */
|
|
327
|
+
function resolveModelCacheDir() {
|
|
328
|
+
if (process.env.CURSOR_CONFIG_DIR)
|
|
329
|
+
return process.env.CURSOR_CONFIG_DIR;
|
|
330
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
331
|
+
if (!home)
|
|
332
|
+
return undefined;
|
|
333
|
+
return process.env.XDG_CONFIG_HOME
|
|
334
|
+
? path.join(process.env.XDG_CONFIG_HOME, "opencode")
|
|
335
|
+
: path.join(home, ".config", "opencode");
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Read the held-open stream, emitting stream parts, until the turn boundary:
|
|
339
|
+
* - a tool call (exec_server_message) → emit tool-call, finish "tool-calls",
|
|
340
|
+
* and KEEP the session open for the result on the next doStream call;
|
|
341
|
+
* - turn_ended / stream end → finish "stop" and close the session.
|
|
342
|
+
*/
|
|
343
|
+
async function pump(session, controller, ids, abortSignal) {
|
|
344
|
+
const { textId, reasoningId } = ids;
|
|
345
|
+
let textStarted = false;
|
|
346
|
+
let reasoningStarted = false;
|
|
347
|
+
// OpenCode cancels the ReadableStream between turns (see the cancel handler
|
|
348
|
+
// in doStreamImpl). The frames iterator can still yield a final `done` after
|
|
349
|
+
// the cancel lands — controller.enqueue on a cancelled controller throws.
|
|
350
|
+
// safeEnqueue swallows that throw and tracks the close so we stop pumping.
|
|
351
|
+
let streamClosed = false;
|
|
352
|
+
const safeEnqueue = (part) => {
|
|
353
|
+
if (streamClosed)
|
|
354
|
+
return false;
|
|
355
|
+
try {
|
|
356
|
+
controller.enqueue(part);
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
streamClosed = true;
|
|
361
|
+
trace(`pump: enqueue on closed controller (suppressing) err=${e.message}`);
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const safeError = (err) => {
|
|
366
|
+
if (streamClosed)
|
|
367
|
+
return;
|
|
368
|
+
try {
|
|
369
|
+
controller.error(err);
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
trace(`pump: controller.error failed (suppressing) err=${e.message}`);
|
|
373
|
+
}
|
|
374
|
+
streamClosed = true;
|
|
375
|
+
};
|
|
376
|
+
/** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
|
|
377
|
+
const closeOpenSpans = () => {
|
|
378
|
+
for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
|
|
379
|
+
safeEnqueue(part);
|
|
380
|
+
}
|
|
381
|
+
reasoningStarted = false;
|
|
382
|
+
textStarted = false;
|
|
383
|
+
};
|
|
384
|
+
const emitText = (text) => {
|
|
385
|
+
if (!text)
|
|
386
|
+
return;
|
|
387
|
+
// Close reasoning before text (hosts expect reasoning-end before text-start).
|
|
388
|
+
if (reasoningStarted && !textStarted) {
|
|
389
|
+
safeEnqueue({ type: "reasoning-end", id: reasoningId });
|
|
390
|
+
reasoningStarted = false;
|
|
391
|
+
}
|
|
392
|
+
if (!textStarted) {
|
|
393
|
+
safeEnqueue({ type: "text-start", id: textId });
|
|
394
|
+
textStarted = true;
|
|
395
|
+
}
|
|
396
|
+
safeEnqueue({ type: "text-delta", id: textId, delta: text });
|
|
397
|
+
};
|
|
398
|
+
const emitReasoning = (text) => {
|
|
399
|
+
if (!text)
|
|
400
|
+
return;
|
|
401
|
+
if (!reasoningStarted) {
|
|
402
|
+
safeEnqueue({ type: "reasoning-start", id: reasoningId });
|
|
403
|
+
reasoningStarted = true;
|
|
404
|
+
}
|
|
405
|
+
safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text });
|
|
406
|
+
};
|
|
407
|
+
const emitFinish = (te, reason) => {
|
|
408
|
+
closeOpenSpans();
|
|
409
|
+
const usage = {
|
|
410
|
+
inputTokens: {
|
|
411
|
+
total: te?.input_tokens ?? 0,
|
|
412
|
+
noCache: undefined,
|
|
413
|
+
cacheRead: te?.cache_read ?? 0,
|
|
414
|
+
cacheWrite: te?.cache_write ?? 0,
|
|
415
|
+
},
|
|
416
|
+
outputTokens: {
|
|
417
|
+
total: te?.output_tokens ?? 0,
|
|
418
|
+
text: undefined,
|
|
419
|
+
reasoning: undefined,
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
safeEnqueue({ type: "finish", usage, finishReason: reason });
|
|
423
|
+
};
|
|
424
|
+
while (true) {
|
|
425
|
+
// Consumer cancelled / closed the ReadableStream. Stop reading Cursor
|
|
426
|
+
// frames so a continuation doStream can resume the same iterator —
|
|
427
|
+
// keeping the loop alive would discard frames the next pump needs.
|
|
428
|
+
if (streamClosed) {
|
|
429
|
+
trace(`pump: stream closed (consumer cancelled) pending=${session.pending.size}`);
|
|
430
|
+
sessionManager.closeUnlessPending(session);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (abortSignal?.aborted) {
|
|
434
|
+
// Stop feeding this ReadableStream, but keep the Run session if we still
|
|
435
|
+
// owe Cursor an exec result (OpenCode aborts between tool-call turns).
|
|
436
|
+
trace(`pump: abortSignal aborted pending=${session.pending.size}`);
|
|
437
|
+
sessionManager.closeUnlessPending(session);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const next = await session.frames.next();
|
|
441
|
+
if (next.done) {
|
|
442
|
+
trace("pump: frames iterator ended with NO frames received (silent end)");
|
|
443
|
+
emitFinish(undefined, { unified: "stop", raw: undefined });
|
|
444
|
+
sessionManager.close(session);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const frame = next.value;
|
|
448
|
+
if (frame.flags & 0x02) {
|
|
449
|
+
// End-stream: the real server half-closes without a trailer, but surface
|
|
450
|
+
// any Connect error payload if present.
|
|
451
|
+
if (frame.payload.length > 0) {
|
|
452
|
+
try {
|
|
453
|
+
const text = new TextDecoder().decode(decodeFramePayload(frame));
|
|
454
|
+
if (text.includes('"error"')) {
|
|
455
|
+
safeError(new Error(`Cursor API error: ${text.slice(0, 500)}`));
|
|
456
|
+
sessionManager.close(session);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
catch { /* not decodable */ }
|
|
461
|
+
}
|
|
462
|
+
emitFinish(undefined, { unified: "stop", raw: undefined });
|
|
463
|
+
sessionManager.close(session);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
// decodeFramePayload can throw on a corrupt gzip payload (gunzipSync).
|
|
467
|
+
// Skip the frame rather than abort the whole turn.
|
|
468
|
+
let payload;
|
|
469
|
+
try {
|
|
470
|
+
payload = decodeFramePayload(frame);
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
trace(`gunzip FAILED (skipping frame): flags=0x${frame.flags.toString(16)} len=${frame.payload.length} err=${e.message}`);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
let asm;
|
|
477
|
+
try {
|
|
478
|
+
asm = decodeMessage("AgentServerMessage", payload);
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
// A single malformed/truncated frame must not abort the whole turn
|
|
482
|
+
// (protobufjs throws "index out of range: …" on length overruns). Log it
|
|
483
|
+
// and keep pumping.
|
|
484
|
+
const preview = Array.from(payload.subarray(0, 32))
|
|
485
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
486
|
+
.join("");
|
|
487
|
+
trace(`decode FAILED (skipping): flags=0x${frame.flags.toString(16)} len=${payload.length} ` +
|
|
488
|
+
`topField=${payload.length ? payload[0] >> 3 : "-"} err=${e.message} hex=${preview}`);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
const iu = asm.interaction_update;
|
|
492
|
+
const esm = asm.exec_server_message;
|
|
493
|
+
const kv = asm.kv_server_message;
|
|
494
|
+
const checkpointRaw = asm.conversation_checkpoint_update;
|
|
495
|
+
const topField = payload.length > 0 ? payload[0] >> 3 : 0;
|
|
496
|
+
{
|
|
497
|
+
const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
|
|
498
|
+
trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
|
|
499
|
+
`exec=${esm ? "yes" : "no"} kv=${kv ? "yes" : "no"} ` +
|
|
500
|
+
`checkpoint=${checkpointRaw ? "yes" : "no"}`);
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
// CLI: conversationCheckpointUpdate → replace agentStore conversation state.
|
|
504
|
+
// Store opaque bytes keyed by conversation_id; next Run echoes them.
|
|
505
|
+
if (checkpointRaw != null) {
|
|
506
|
+
const bytes = normalizeCheckpointBytes(checkpointRaw);
|
|
507
|
+
if (bytes && bytes.length > 0) {
|
|
508
|
+
setCheckpoint(session.conversationId, bytes);
|
|
509
|
+
trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (iu?.text_delta) {
|
|
513
|
+
emitText(iu.text_delta.text ?? "");
|
|
514
|
+
}
|
|
515
|
+
else if (iu?.thinking_delta) {
|
|
516
|
+
emitReasoning(iu.thinking_delta.text ?? "");
|
|
517
|
+
}
|
|
518
|
+
else if (iu?.turn_ended) {
|
|
519
|
+
emitFinish(iu.turn_ended, { unified: "stop", raw: undefined });
|
|
520
|
+
sessionManager.close(session);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
else if (esm) {
|
|
524
|
+
const esmId = esm.id ?? 0;
|
|
525
|
+
if (esm.request_context_args) {
|
|
526
|
+
// Server turn-setup probe (#10). Reply with full OpenCode-sourced context.
|
|
527
|
+
{
|
|
528
|
+
const rc = session.requestContext;
|
|
529
|
+
const skills = Array.isArray(rc.agent_skills) ? rc.agent_skills.length : 0;
|
|
530
|
+
const hooks = typeof rc.hooks_additional_context === "string" ? rc.hooks_additional_context : "";
|
|
531
|
+
trace(`exec request_context: id=${esmId} — replying context ` +
|
|
532
|
+
`tools=${session.toolDescriptors.length} skills=${skills} ` +
|
|
533
|
+
`hooks=${hooks ? hooks.split("\n").length : 0}`);
|
|
534
|
+
if (hooks)
|
|
535
|
+
trace(`exec request_context hooks_additional_context: ${hooks}`);
|
|
536
|
+
}
|
|
537
|
+
try {
|
|
538
|
+
session.stream.write(buildRequestContextResult(esmId, session.requestContext));
|
|
539
|
+
trace(`exec request_context: replied`);
|
|
540
|
+
}
|
|
541
|
+
catch (e) {
|
|
542
|
+
trace(`exec request_context: write FAILED ${e.message}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
const parsed = parseExecServerMessage(esm);
|
|
547
|
+
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
548
|
+
if (parsed) {
|
|
549
|
+
// OpenCode throws "Tool call not allowed while generating summary"
|
|
550
|
+
// when assistantMessage.summary is set. Compaction/summary turns
|
|
551
|
+
// advertise no tools — refuse on the Cursor channel and keep
|
|
552
|
+
// pumping for text / turn_ended instead of emitting tool-call.
|
|
553
|
+
if (!session.allowTools) {
|
|
554
|
+
const reason = "Tool calls are not available during this turn (summary/compaction).";
|
|
555
|
+
trace(`exec: REFUSED (allowTools=false) toolName=${parsed.toolName} — auto-replying`);
|
|
556
|
+
try {
|
|
557
|
+
for (const frame of buildExecClientMessages({
|
|
558
|
+
execId: parsed.id,
|
|
559
|
+
resultField: parsed.resultField,
|
|
560
|
+
output: "",
|
|
561
|
+
error: reason,
|
|
562
|
+
})) {
|
|
563
|
+
session.stream.write(frame);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch (e) {
|
|
567
|
+
trace(`exec: REFUSED write FAILED ${e.message}`);
|
|
568
|
+
}
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const tc = buildToolCallPart(parsed, session.sessionId);
|
|
572
|
+
// Keep the stream open; the result arrives on the next doStream call.
|
|
573
|
+
sessionManager.registerPending(parsed.id, session, parsed.resultField);
|
|
574
|
+
// tc.input is already a JSON string (LanguageModelV3ToolCall.input).
|
|
575
|
+
trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
|
|
576
|
+
// Close open text/reasoning spans before tool-call (required by AI SDK V3).
|
|
577
|
+
closeOpenSpans();
|
|
578
|
+
safeEnqueue({
|
|
579
|
+
type: "tool-call",
|
|
580
|
+
toolCallId: tc.toolCallId,
|
|
581
|
+
toolName: tc.toolName,
|
|
582
|
+
input: tc.input,
|
|
583
|
+
});
|
|
584
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
// Unmapped exec variant (diagnostics/smart-mode-classifier/etc.). We
|
|
588
|
+
// MUST still reply or the server blocks. Emit an empty result at the
|
|
589
|
+
// variant's field number (request/result share the number) and keep
|
|
590
|
+
// pumping. The hex dump records exactly which variant it was.
|
|
591
|
+
const variantField = detectExecVariantField(payload);
|
|
592
|
+
const hex = Array.from(payload.subarray(0, 48))
|
|
593
|
+
.map((x) => x.toString(16).padStart(2, "0"))
|
|
594
|
+
.join("");
|
|
595
|
+
trace(`exec UNMAPPED: id=${esmId} variantField=${variantField} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
|
|
596
|
+
if (variantField && variantField !== REQUEST_CONTEXT_RESULT_FIELD) {
|
|
597
|
+
try {
|
|
598
|
+
session.stream.write(buildRawEmptyExecReply(esmId, variantField));
|
|
599
|
+
trace(`exec UNMAPPED: replied empty result field=${variantField}`);
|
|
600
|
+
}
|
|
601
|
+
catch (e) {
|
|
602
|
+
trace(`exec UNMAPPED: write FAILED field=${variantField} err=${e.message}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
else if (kv) {
|
|
608
|
+
// KV blob channel: ack set_blob / answer get_blob, then keep pumping.
|
|
609
|
+
// Not replying hangs the turn — see protocol/kv.ts.
|
|
610
|
+
trace(`kv frame raw: gunzippedLen=${payload.length} id=${kv.id ?? "?"} ` +
|
|
611
|
+
`get=${!!kv.get_blob_args} set=${!!kv.set_blob_args} ` +
|
|
612
|
+
`getBlobIdLen=${kv.get_blob_args?.blob_id?.length ?? "-"} ` +
|
|
613
|
+
`setBlobIdLen=${kv.set_blob_args?.blob_id?.length ?? "-"} ` +
|
|
614
|
+
`setDataLen=${kv.set_blob_args?.blob_data?.length ?? "-"}`);
|
|
615
|
+
const handled = handleKvServerMessage(kv, session);
|
|
616
|
+
if (handled) {
|
|
617
|
+
try {
|
|
618
|
+
session.stream.write(handled.reply);
|
|
619
|
+
trace(`kv replied: kind=${handled.kind} id=${handled.id} blobId=${handled.blobIdHex.slice(0, 16)}… ` +
|
|
620
|
+
`found=${handled.found} echoed=${!!handled.echoed} ` +
|
|
621
|
+
`sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
|
|
622
|
+
}
|
|
623
|
+
catch { /* stream closed; pump will surface end */ }
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
// Any per-frame dispatch throw (e.g. protobufjs length overrun in
|
|
629
|
+
// exec/args decode) must not abort the whole turn — log and skip.
|
|
630
|
+
trace(`frame dispatch FAILED (skipping): topField=${topField} err=${e.message}`);
|
|
631
|
+
}
|
|
632
|
+
// heartbeat / step / partial_tool_call / interaction_query →
|
|
633
|
+
// ignore (partial args are display-only; the exec channel is authoritative.
|
|
634
|
+
// Checkpoints are handled above.)
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Normalize protobufjs bytes / Buffer / number[] into a Uint8Array. */
|
|
638
|
+
function normalizeCheckpointBytes(raw) {
|
|
639
|
+
if (raw instanceof Uint8Array)
|
|
640
|
+
return raw;
|
|
641
|
+
if (Buffer.isBuffer(raw))
|
|
642
|
+
return new Uint8Array(raw);
|
|
643
|
+
if (Array.isArray(raw))
|
|
644
|
+
return Uint8Array.from(raw);
|
|
645
|
+
if (raw && typeof raw === "object" && "type" in raw && "data" in raw) {
|
|
646
|
+
// protobufjs sometimes yields { type: "Buffer", data: number[] }
|
|
647
|
+
const data = raw.data;
|
|
648
|
+
if (Array.isArray(data))
|
|
649
|
+
return Uint8Array.from(data);
|
|
650
|
+
}
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
function extractToolResults(prompt) {
|
|
654
|
+
const out = [];
|
|
655
|
+
for (const msg of prompt) {
|
|
656
|
+
if (msg.role !== "tool" || !Array.isArray(msg.content))
|
|
657
|
+
continue;
|
|
658
|
+
for (const part of msg.content) {
|
|
659
|
+
const p = part;
|
|
660
|
+
if (p.type !== "tool-result")
|
|
661
|
+
continue;
|
|
662
|
+
const parsed = parseExecIdFromToolCallId(p.toolCallId ?? "");
|
|
663
|
+
if (!parsed)
|
|
664
|
+
continue;
|
|
665
|
+
const { text, isError } = toolResultOutputToText(p.output);
|
|
666
|
+
out.push({
|
|
667
|
+
sessionId: parsed.sessionId,
|
|
668
|
+
execId: parsed.execId,
|
|
669
|
+
toolName: p.toolName ?? "mcp",
|
|
670
|
+
output: text,
|
|
671
|
+
error: isError ? text : undefined,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Tool results that form a live continuation: only the trailing run of `tool`
|
|
679
|
+
* messages after the last non-tool message. Mid-prompt historical tool results
|
|
680
|
+
* are ignored — they are conversation history, not replies for a held-open Run.
|
|
681
|
+
*/
|
|
682
|
+
export function extractTrailingToolResults(prompt) {
|
|
683
|
+
if (prompt.length === 0)
|
|
684
|
+
return [];
|
|
685
|
+
let i = prompt.length - 1;
|
|
686
|
+
while (i >= 0 && prompt[i].role === "tool")
|
|
687
|
+
i--;
|
|
688
|
+
// Continuations end with tool messages. Anything else (user/assistant/system)
|
|
689
|
+
// means this is a fresh model call that merely carries tools in history.
|
|
690
|
+
if (i === prompt.length - 1)
|
|
691
|
+
return [];
|
|
692
|
+
return extractToolResults(prompt.slice(i + 1));
|
|
693
|
+
}
|
|
694
|
+
function toolResultOutputToText(output) {
|
|
695
|
+
if (output == null)
|
|
696
|
+
return { text: "", isError: false };
|
|
697
|
+
if (typeof output === "string")
|
|
698
|
+
return { text: output, isError: false };
|
|
699
|
+
const o = output;
|
|
700
|
+
// LanguageModelV3 tool-result output: { type: "text"|"json"|"error-text"|..., value }
|
|
701
|
+
const isError = typeof o.type === "string" && o.type.startsWith("error");
|
|
702
|
+
if (o.type === "text" || o.type === "error-text") {
|
|
703
|
+
return { text: String(o.value ?? ""), isError };
|
|
704
|
+
}
|
|
705
|
+
if (o.type === "json" || o.type === "error-json") {
|
|
706
|
+
return { text: JSON.stringify(o.value ?? null), isError };
|
|
707
|
+
}
|
|
708
|
+
if (o.type === "content" && Array.isArray(o.value)) {
|
|
709
|
+
const text = o.value
|
|
710
|
+
.map((c) => {
|
|
711
|
+
const cp = c;
|
|
712
|
+
return cp.type === "text" ? String(cp.text ?? "") : "";
|
|
713
|
+
})
|
|
714
|
+
.join("");
|
|
715
|
+
return { text, isError };
|
|
716
|
+
}
|
|
717
|
+
return { text: JSON.stringify(output), isError };
|
|
718
|
+
}
|
|
719
|
+
function extractSystemPrompt(prompt) {
|
|
720
|
+
const parts = [];
|
|
721
|
+
for (const m of prompt) {
|
|
722
|
+
if (m.role === "system" && typeof m.content === "string")
|
|
723
|
+
parts.push(m.content);
|
|
724
|
+
}
|
|
725
|
+
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Map OpenCode's session id header to a stable Cursor conversation_id (UUID).
|
|
729
|
+
* Falls back to a random UUID when headers are absent (direct SDK use).
|
|
730
|
+
*/
|
|
731
|
+
export function resolveConversationId(callOptions) {
|
|
732
|
+
const h = callOptions.headers ?? {};
|
|
733
|
+
const raw = h["x-session-id"] ??
|
|
734
|
+
h["X-Session-Id"] ??
|
|
735
|
+
h["x-session-affinity"] ??
|
|
736
|
+
h["x-opencode-session"];
|
|
737
|
+
if (typeof raw === "string" && raw.trim().length > 0) {
|
|
738
|
+
return sessionIdToUuid(raw.trim());
|
|
739
|
+
}
|
|
740
|
+
return crypto.randomUUID();
|
|
741
|
+
}
|
|
742
|
+
/** Deterministic UUID (version-4 shape) from an arbitrary session key. */
|
|
743
|
+
export function sessionIdToUuid(sessionId) {
|
|
744
|
+
const hash = createHash("sha256").update(`cursor-opencode-provider:conv:${sessionId}`).digest();
|
|
745
|
+
const bytes = Buffer.from(hash.subarray(0, 16));
|
|
746
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
747
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
748
|
+
const hex = bytes.toString("hex");
|
|
749
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
750
|
+
}
|
|
751
|
+
function extractTools(callOptions) {
|
|
752
|
+
const tools = callOptions.tools;
|
|
753
|
+
if (!tools || tools.length === 0) {
|
|
754
|
+
trace("extractTools: callOptions.tools empty/missing");
|
|
755
|
+
return [];
|
|
756
|
+
}
|
|
757
|
+
const out = [];
|
|
758
|
+
for (const t of tools) {
|
|
759
|
+
// LanguageModelV3FunctionTool always has type:"function". Be defensive in
|
|
760
|
+
// case a middleware strips it — still accept anything with a name + schema.
|
|
761
|
+
const any = t;
|
|
762
|
+
if (any.type === "function" || (any.name && any.inputSchema !== undefined)) {
|
|
763
|
+
if (!any.name)
|
|
764
|
+
continue;
|
|
765
|
+
out.push({ name: any.name, description: any.description, inputSchema: any.inputSchema });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
trace(`extractTools: ${tools.length} incoming → ${out.length} advertised [${out.map((t) => t.name).join(",")}]`);
|
|
769
|
+
return out;
|
|
770
|
+
}
|
|
771
|
+
/** Exported for tests — AI SDK V3 span ends that must precede finish / tool-call. */
|
|
772
|
+
export function spanEndParts(opts) {
|
|
773
|
+
const out = [];
|
|
774
|
+
if (opts.reasoningStarted)
|
|
775
|
+
out.push({ type: "reasoning-end", id: opts.reasoningId });
|
|
776
|
+
if (opts.textStarted)
|
|
777
|
+
out.push({ type: "text-end", id: opts.textId });
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
/** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
|
|
781
|
+
export function computeAllowTools(toolCount, toolChoice) {
|
|
782
|
+
return toolCount > 0 && toolChoice?.type !== "none";
|
|
783
|
+
}
|
|
784
|
+
function extractUserText(lastUser) {
|
|
785
|
+
if (!lastUser)
|
|
786
|
+
return ".";
|
|
787
|
+
const content = lastUser.content;
|
|
788
|
+
if (typeof content === "string")
|
|
789
|
+
return content;
|
|
790
|
+
if (Array.isArray(content)) {
|
|
791
|
+
const texts = [];
|
|
792
|
+
for (const part of content) {
|
|
793
|
+
const p = part;
|
|
794
|
+
if (p.type === "text" && typeof p.text === "string")
|
|
795
|
+
texts.push(p.text);
|
|
796
|
+
}
|
|
797
|
+
if (texts.length > 0)
|
|
798
|
+
return texts.join("\n");
|
|
799
|
+
}
|
|
800
|
+
return ".";
|
|
801
|
+
}
|
|
802
|
+
function foldStreamParts(parts) {
|
|
803
|
+
let text = "";
|
|
804
|
+
let reasoning = "";
|
|
805
|
+
const content = [];
|
|
806
|
+
let finishReason = { unified: "stop", raw: undefined };
|
|
807
|
+
let usage = {
|
|
808
|
+
inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
|
809
|
+
outputTokens: { total: undefined, text: undefined, reasoning: undefined },
|
|
810
|
+
};
|
|
811
|
+
for (const part of parts) {
|
|
812
|
+
if (part.type === "text-delta")
|
|
813
|
+
text += part.delta;
|
|
814
|
+
else if (part.type === "reasoning-delta")
|
|
815
|
+
reasoning += part.delta;
|
|
816
|
+
else if (part.type === "tool-call") {
|
|
817
|
+
content.push({
|
|
818
|
+
type: "tool-call",
|
|
819
|
+
toolCallId: part.toolCallId,
|
|
820
|
+
toolName: part.toolName,
|
|
821
|
+
input: part.input,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
else if (part.type === "finish") {
|
|
825
|
+
finishReason = part.finishReason;
|
|
826
|
+
usage = part.usage;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (reasoning)
|
|
830
|
+
content.unshift({ type: "reasoning", text: reasoning });
|
|
831
|
+
if (text)
|
|
832
|
+
content.unshift({ type: "text", text });
|
|
833
|
+
return { content, finishReason, usage, warnings: [] };
|
|
834
|
+
}
|