@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
package/src/provider.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { SimpleCursorClient } from "./client/simple.js";
|
|
2
|
+
import { createProxyServer } from "./proxy/server.js";
|
|
3
|
+
import { parseOpenAIRequest } from "./proxy/handler.js";
|
|
4
|
+
import { createChatCompletionResponse, createChatCompletionChunk } from "./proxy/formatter.js";
|
|
5
|
+
import { StreamToAiSdkParts } from "./streaming/ai-sdk-parts.js";
|
|
6
|
+
import { ToolMapper, type ToolUpdate } from "./acp/tools.js";
|
|
7
|
+
|
|
8
|
+
export interface ProviderOptions {
|
|
9
|
+
baseURL?: string;
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
mode?: 'direct' | 'proxy';
|
|
12
|
+
proxyConfig?: { port?: number; host?: string };
|
|
13
|
+
toolUpdateCallback?: (updates: ToolUpdate[]) => void;
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a Cursor ACP provider compatible with OpenCode
|
|
19
|
+
* Exports a factory function for @ai-sdk/provider compatibility
|
|
20
|
+
*/
|
|
21
|
+
export function createCursorProvider(options: ProviderOptions = {}) {
|
|
22
|
+
const providerOptions = options;
|
|
23
|
+
const mode = options.mode || 'direct';
|
|
24
|
+
|
|
25
|
+
if (mode === 'proxy') {
|
|
26
|
+
// Start proxy server
|
|
27
|
+
const proxy = createProxyServer(options.proxyConfig || {});
|
|
28
|
+
let baseURL: string = options.baseURL ?? proxy.getBaseURL();
|
|
29
|
+
|
|
30
|
+
// Create the provider object
|
|
31
|
+
const provider = {
|
|
32
|
+
id: "cursor-acp",
|
|
33
|
+
name: "Cursor ACP Provider (Proxy Mode)",
|
|
34
|
+
proxy,
|
|
35
|
+
baseURL: '',
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Initialize the provider (starts the proxy server)
|
|
39
|
+
*/
|
|
40
|
+
async init(): Promise<any> {
|
|
41
|
+
baseURL = await proxy.start();
|
|
42
|
+
this.baseURL = baseURL;
|
|
43
|
+
return this;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns a language model for the given model ID
|
|
48
|
+
*/
|
|
49
|
+
languageModel(modelId: string = "cursor-acp/auto") {
|
|
50
|
+
const model = modelId.replace("cursor-acp/", "") || "auto";
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
modelId,
|
|
54
|
+
provider: "cursor-acp",
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Generate text (non-streaming)
|
|
58
|
+
*/
|
|
59
|
+
async doGenerate({ prompt, messages }: any) {
|
|
60
|
+
// Use HTTP API
|
|
61
|
+
const response = await fetch(`${baseURL}/chat/completions`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({
|
|
65
|
+
model: modelId,
|
|
66
|
+
messages: messages || [{ role: "user", content: prompt }],
|
|
67
|
+
stream: false
|
|
68
|
+
})
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const result: any = await response.json();
|
|
72
|
+
return {
|
|
73
|
+
text: result.choices?.[0]?.message?.content || "",
|
|
74
|
+
finishReason: "stop",
|
|
75
|
+
usage: result.usage
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Stream text
|
|
81
|
+
*/
|
|
82
|
+
async doStream({ prompt, messages }: any) {
|
|
83
|
+
const response = await fetch(`${baseURL}/chat/completions`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify({
|
|
87
|
+
model: modelId,
|
|
88
|
+
messages: messages || [{ role: "user", content: prompt }],
|
|
89
|
+
stream: true
|
|
90
|
+
})
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
stream: response.body,
|
|
95
|
+
rawResponse: { headers: Object.fromEntries(response.headers) }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return provider;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Direct mode - existing implementation
|
|
106
|
+
const client = new SimpleCursorClient({
|
|
107
|
+
timeout: 30000,
|
|
108
|
+
maxRetries: 3
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
id: "cursor-acp",
|
|
113
|
+
name: "Cursor ACP Provider",
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns a language model for the given model ID
|
|
117
|
+
*/
|
|
118
|
+
languageModel(modelId: string = "cursor-acp/auto") {
|
|
119
|
+
const model = modelId.replace("cursor-acp/", "") || "auto";
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
modelId,
|
|
123
|
+
provider: "cursor-acp",
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Generate text (non-streaming)
|
|
127
|
+
*/
|
|
128
|
+
async doGenerate(options: any = {}) {
|
|
129
|
+
// Handle both direct prompt and OpenAI-style messages format
|
|
130
|
+
let prompt = "";
|
|
131
|
+
|
|
132
|
+
// Try to extract prompt from various sources
|
|
133
|
+
if (options.prompt) {
|
|
134
|
+
// OpenCode passes prompt as array of messages
|
|
135
|
+
if (Array.isArray(options.prompt)) {
|
|
136
|
+
const lines = [];
|
|
137
|
+
for (const msg of options.prompt) {
|
|
138
|
+
if (msg && typeof msg.content === 'string') {
|
|
139
|
+
lines.push(`${msg.role || 'user'}: ${msg.content}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
prompt = lines.join('\n\n');
|
|
143
|
+
} else if (typeof options.prompt === 'string') {
|
|
144
|
+
prompt = options.prompt;
|
|
145
|
+
}
|
|
146
|
+
} else if (options.inputFormat === "messages" && options.messages) {
|
|
147
|
+
// OpenAI-style messages format
|
|
148
|
+
const messages = Array.isArray(options.messages) ? options.messages : [];
|
|
149
|
+
const lines = [];
|
|
150
|
+
for (const msg of messages) {
|
|
151
|
+
if (msg && typeof msg.content === 'string') {
|
|
152
|
+
lines.push(`${msg.role || 'user'}: ${msg.content}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
prompt = lines.join('\n\n');
|
|
156
|
+
} else if (options.messages) {
|
|
157
|
+
// Alternative format
|
|
158
|
+
const messages = Array.isArray(options.messages) ? options.messages : [];
|
|
159
|
+
prompt = messages.map((m: any) => m?.content || '').filter(Boolean).join('\n\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Fallback for empty prompt
|
|
163
|
+
if (!prompt) {
|
|
164
|
+
prompt = "Hello";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const result = await client.executePrompt(prompt, { model });
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
text: result.content || result.error || "No response",
|
|
171
|
+
finishReason: result.done ? "stop" : "other",
|
|
172
|
+
usage: {
|
|
173
|
+
promptTokens: 0,
|
|
174
|
+
completionTokens: 0
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Stream text - returns a proper ReadableStream for pipeThrough support
|
|
181
|
+
*/
|
|
182
|
+
async doStream(options: any = {}) {
|
|
183
|
+
// Handle both direct prompt and OpenAI-style messages format
|
|
184
|
+
let prompt = "";
|
|
185
|
+
|
|
186
|
+
// Try to extract prompt from various sources
|
|
187
|
+
if (options.prompt) {
|
|
188
|
+
// OpenCode passes prompt as array of messages
|
|
189
|
+
if (Array.isArray(options.prompt)) {
|
|
190
|
+
const lines = [];
|
|
191
|
+
for (const msg of options.prompt) {
|
|
192
|
+
if (msg && typeof msg.content === 'string') {
|
|
193
|
+
lines.push(`${msg.role || 'user'}: ${msg.content}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
prompt = lines.join('\n\n');
|
|
197
|
+
} else if (typeof options.prompt === 'string') {
|
|
198
|
+
prompt = options.prompt;
|
|
199
|
+
}
|
|
200
|
+
} else if (options.inputFormat === "messages" && options.messages) {
|
|
201
|
+
// OpenAI-style messages format
|
|
202
|
+
const messages = Array.isArray(options.messages) ? options.messages : [];
|
|
203
|
+
const lines = [];
|
|
204
|
+
for (const msg of messages) {
|
|
205
|
+
if (msg && typeof msg.content === 'string') {
|
|
206
|
+
lines.push(`${msg.role || 'user'}: ${msg.content}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
prompt = lines.join('\n\n');
|
|
210
|
+
} else if (options.messages) {
|
|
211
|
+
// Alternative format
|
|
212
|
+
const messages = Array.isArray(options.messages) ? options.messages : [];
|
|
213
|
+
prompt = messages.map((m: any) => m?.content || '').filter(Boolean).join('\n\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Fallback for empty prompt
|
|
217
|
+
if (!prompt) {
|
|
218
|
+
prompt = "Hello";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const stream = client.executePromptStream(prompt, { model });
|
|
222
|
+
const converter = new StreamToAiSdkParts();
|
|
223
|
+
const toolMapper = providerOptions.toolUpdateCallback ? new ToolMapper() : null;
|
|
224
|
+
const toolSessionId = providerOptions.sessionId
|
|
225
|
+
?? `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
226
|
+
|
|
227
|
+
// Create a proper ReadableStream that OpenCode can use with pipeThrough
|
|
228
|
+
const readableStream = new ReadableStream({
|
|
229
|
+
async start(controller) {
|
|
230
|
+
try {
|
|
231
|
+
for await (const event of stream) {
|
|
232
|
+
if (toolMapper && event.type === "tool_call") {
|
|
233
|
+
const updates = await toolMapper.mapCursorEventToAcp(
|
|
234
|
+
event,
|
|
235
|
+
event.session_id ?? toolSessionId,
|
|
236
|
+
);
|
|
237
|
+
if (updates.length > 0) {
|
|
238
|
+
providerOptions.toolUpdateCallback?.(updates);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const parts = converter.handleEvent(event);
|
|
242
|
+
for (const part of parts) {
|
|
243
|
+
controller.enqueue(part);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
controller.enqueue({ type: "text-delta", textDelta: "" });
|
|
247
|
+
controller.close();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
controller.error(error);
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
stream: readableStream,
|
|
256
|
+
rawResponse: { headers: {} }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Factory function export for OpenCode compatibility
|
|
265
|
+
export const cursor = createCursorProvider;
|
|
266
|
+
|
|
267
|
+
// Default export
|
|
268
|
+
export default createCursorProvider;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { OpenAiUsage } from "../usage.js";
|
|
2
|
+
|
|
3
|
+
export function createChatCompletionResponse(
|
|
4
|
+
model: string,
|
|
5
|
+
content: string,
|
|
6
|
+
usage?: OpenAiUsage,
|
|
7
|
+
) {
|
|
8
|
+
const response: {
|
|
9
|
+
id: string;
|
|
10
|
+
object: string;
|
|
11
|
+
created: number;
|
|
12
|
+
model: string;
|
|
13
|
+
choices: Array<{
|
|
14
|
+
index: number;
|
|
15
|
+
message: { role: string; content: string };
|
|
16
|
+
finish_reason: string;
|
|
17
|
+
}>;
|
|
18
|
+
usage?: OpenAiUsage;
|
|
19
|
+
} = {
|
|
20
|
+
id: `cursor-acp-${Date.now()}`,
|
|
21
|
+
object: "chat.completion",
|
|
22
|
+
created: Math.floor(Date.now() / 1000),
|
|
23
|
+
model: `cursor-acp/${model}`,
|
|
24
|
+
choices: [
|
|
25
|
+
{
|
|
26
|
+
index: 0,
|
|
27
|
+
message: { role: "assistant", content },
|
|
28
|
+
finish_reason: "stop",
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (usage) {
|
|
34
|
+
response.usage = usage;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return response;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createChatCompletionChunk(
|
|
41
|
+
id: string,
|
|
42
|
+
created: number,
|
|
43
|
+
model: string,
|
|
44
|
+
deltaContent: string,
|
|
45
|
+
done = false,
|
|
46
|
+
) {
|
|
47
|
+
return {
|
|
48
|
+
id,
|
|
49
|
+
object: "chat.completion.chunk",
|
|
50
|
+
created,
|
|
51
|
+
model: `cursor-acp/${model}`,
|
|
52
|
+
choices: [
|
|
53
|
+
{
|
|
54
|
+
index: 0,
|
|
55
|
+
delta: deltaContent ? { content: deltaContent } : {},
|
|
56
|
+
finish_reason: done ? "stop" : null,
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface ParsedRequest {
|
|
2
|
+
model: string;
|
|
3
|
+
prompt: string;
|
|
4
|
+
stream: boolean;
|
|
5
|
+
tools?: any[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseOpenAIRequest(body: any): ParsedRequest {
|
|
9
|
+
const model = body.model?.replace("cursor-acp/", "") || "auto";
|
|
10
|
+
const stream = body.stream === true;
|
|
11
|
+
|
|
12
|
+
// Convert messages array to prompt string
|
|
13
|
+
let prompt = "";
|
|
14
|
+
if (Array.isArray(body.messages)) {
|
|
15
|
+
const lines = body.messages.map((msg: any) => {
|
|
16
|
+
const role = msg.role?.toUpperCase() || "USER";
|
|
17
|
+
const content = typeof msg.content === "string" ? msg.content : "";
|
|
18
|
+
return `${role}: ${content}`;
|
|
19
|
+
});
|
|
20
|
+
prompt = lines.join("\n\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
model,
|
|
25
|
+
prompt,
|
|
26
|
+
stream,
|
|
27
|
+
tools: body.tools
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a delta prompt for cursor-agent --resume sessions.
|
|
3
|
+
* When resuming, cursor-agent already holds conversation state — only send
|
|
4
|
+
* the new turn content instead of replaying the full flattened history.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type TextContentPart = { type: "text"; text: string };
|
|
8
|
+
type ImageContentPart = { type: "image_url"; image_url: { url: string } };
|
|
9
|
+
export type ContentPart = TextContentPart | ImageContentPart | Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export type ProxyMessage = {
|
|
12
|
+
role: string;
|
|
13
|
+
content?: string | ContentPart[] | unknown;
|
|
14
|
+
tool_call_id?: string;
|
|
15
|
+
tool_calls?: Array<{
|
|
16
|
+
id?: string;
|
|
17
|
+
function?: { name?: string; arguments?: string };
|
|
18
|
+
}>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extract text from a message content value that may be a plain string or an
|
|
23
|
+
* array of content parts. Non-text parts (images, audio, etc.) are ignored.
|
|
24
|
+
*/
|
|
25
|
+
export function extractTextContent(content: unknown): string {
|
|
26
|
+
if (typeof content === "string") return content;
|
|
27
|
+
if (Array.isArray(content)) {
|
|
28
|
+
return content
|
|
29
|
+
.map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : ""))
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns prompt text for a resumed session. Falls back to null when delta
|
|
38
|
+
* mode cannot be determined safely (caller should use full prompt builder).
|
|
39
|
+
*/
|
|
40
|
+
export function buildIncrementalPrompt(messages: Array<ProxyMessage>): string | null {
|
|
41
|
+
if (messages.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
const last = messages[messages.length - 1];
|
|
44
|
+
|
|
45
|
+
// Tool-loop continuation: last messages are tool results
|
|
46
|
+
if (last?.role === "tool") {
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
49
|
+
const m = messages[i];
|
|
50
|
+
if (m?.role !== "tool") break;
|
|
51
|
+
const callId = m.tool_call_id || "unknown";
|
|
52
|
+
const body = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
|
|
53
|
+
lines.unshift(`TOOL_RESULT (call_id: ${callId}): ${body}`);
|
|
54
|
+
}
|
|
55
|
+
// Defensive: loop always unshifts at least once, so this is unreachable today.
|
|
56
|
+
if (lines.length === 0) return null;
|
|
57
|
+
lines.push("The above tool calls have been executed. Continue your response based on these results.");
|
|
58
|
+
return lines.join("\n\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Normal follow-up: latest user message only
|
|
62
|
+
if (last?.role === "user") {
|
|
63
|
+
const text = extractTextContent(last.content);
|
|
64
|
+
if (!text.trim()) return null;
|
|
65
|
+
// Mixed multimodal follow-ups must fall back to the full prompt so image/audio
|
|
66
|
+
// parts are not silently dropped.
|
|
67
|
+
if (Array.isArray(last.content) && last.content.some((part) => part?.type && part.type !== "text")) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return text.trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createLogger } from "../utils/logger.js";
|
|
3
|
+
|
|
4
|
+
const log = createLogger("proxy:prompt-builder");
|
|
5
|
+
|
|
6
|
+
// Cache the tool schema block — tools don't change between requests in a session.
|
|
7
|
+
let _cachedToolFingerprint = "";
|
|
8
|
+
let _cachedToolBlock = "";
|
|
9
|
+
|
|
10
|
+
/** Clear cached tool schema block (for testing only). */
|
|
11
|
+
export function _resetToolSchemaCache(): void {
|
|
12
|
+
_cachedToolFingerprint = "";
|
|
13
|
+
_cachedToolBlock = "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Short, collision-resistant digest used in the tool schema fingerprint. */
|
|
17
|
+
function shortHash(value: string): string {
|
|
18
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build a compact fingerprint of the tool schema for cache validation. */
|
|
22
|
+
export function buildToolFingerprint(tools: Array<any>): string {
|
|
23
|
+
if (tools.length === 0) return "";
|
|
24
|
+
// Include names + descriptions + parameter names + required fields to detect
|
|
25
|
+
// schema changes without the cost of full JSON.stringify on every request.
|
|
26
|
+
// The description is hashed (not length-only) so edits that preserve length
|
|
27
|
+
// still invalidate the cache; required is copied before sorting so the
|
|
28
|
+
// caller's schema array is not mutated in place.
|
|
29
|
+
const parts = tools.map((t: any) => {
|
|
30
|
+
const fn = t.function || t;
|
|
31
|
+
const name = fn.name || "?";
|
|
32
|
+
const desc = fn.description || "";
|
|
33
|
+
const paramProps = fn.parameters?.properties || {};
|
|
34
|
+
const paramKeys = Object.keys(paramProps).sort().join(",");
|
|
35
|
+
const required = [...(fn.parameters?.required || [])].sort().join(",");
|
|
36
|
+
return `${name}:${shortHash(desc)}:${paramKeys}:${required}`;
|
|
37
|
+
});
|
|
38
|
+
parts.sort();
|
|
39
|
+
return `${parts.length}:${parts.join("|")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildToolSchemaBlock(tools: Array<any>): string {
|
|
43
|
+
const fingerprint = buildToolFingerprint(tools);
|
|
44
|
+
if (fingerprint && fingerprint === _cachedToolFingerprint) {
|
|
45
|
+
return _cachedToolBlock;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const toolDescs = tools
|
|
49
|
+
.map((t: any) => {
|
|
50
|
+
const fn = t.function || t;
|
|
51
|
+
const name = fn.name || "unknown";
|
|
52
|
+
const desc = fn.description || "";
|
|
53
|
+
const params = fn.parameters;
|
|
54
|
+
const paramStr = params ? JSON.stringify(params) : "{}";
|
|
55
|
+
return `- ${name}: ${desc}\n Parameters: ${paramStr}`;
|
|
56
|
+
})
|
|
57
|
+
.join("\n");
|
|
58
|
+
|
|
59
|
+
const block =
|
|
60
|
+
`SYSTEM: You have access to the following tools. When you need to use one, respond with a tool_call in the standard OpenAI format.\n` +
|
|
61
|
+
`Tool guidance: prefer write/edit for file changes; use bash mainly to run commands/tests.\n\nAvailable tools:\n${toolDescs}`;
|
|
62
|
+
|
|
63
|
+
if (fingerprint) {
|
|
64
|
+
_cachedToolFingerprint = fingerprint;
|
|
65
|
+
_cachedToolBlock = block;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return block;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a text prompt from OpenAI chat messages + tool definitions.
|
|
73
|
+
* Handles role:"tool" result messages and assistant tool_calls that
|
|
74
|
+
* plain text flattening would silently drop.
|
|
75
|
+
*/
|
|
76
|
+
export function buildPromptFromMessages(messages: Array<any>, tools: Array<any>, subagentNames: string[] = []): string {
|
|
77
|
+
if (log.isDebugEnabled()) {
|
|
78
|
+
const messageSummary = messages.map((m: any, i: number) => {
|
|
79
|
+
const role = m?.role ?? "?";
|
|
80
|
+
const hasToolCalls = Array.isArray(m?.tool_calls) ? m.tool_calls.length : 0;
|
|
81
|
+
const tcNames = hasToolCalls > 0 ? m.tool_calls.map((tc: any) => tc?.function?.name).join(",") : "";
|
|
82
|
+
const contentType = typeof m?.content;
|
|
83
|
+
const contentLen = typeof m?.content === "string" ? m.content.length : Array.isArray(m?.content) ? `arr:${m.content.length}` : "null";
|
|
84
|
+
const toolCallId = m?.tool_call_id ?? null;
|
|
85
|
+
return { i, role, hasToolCalls, tcNames, contentType, contentLen, toolCallId };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const assistantWithToolCalls = messages.filter((m: any) => m?.role === "assistant" && Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
|
|
89
|
+
const assistantEmpty = messages.filter((m: any) => m?.role === "assistant" && (!m?.tool_calls || m.tool_calls.length === 0) && (!m?.content || m.content === "" || m.content === null));
|
|
90
|
+
const toolResults = messages.filter((m: any) => m?.role === "tool");
|
|
91
|
+
|
|
92
|
+
log.debug("buildPromptFromMessages", {
|
|
93
|
+
totalMessages: messages.length,
|
|
94
|
+
totalTools: tools.length,
|
|
95
|
+
messageSummary,
|
|
96
|
+
stats: {
|
|
97
|
+
assistantWithToolCalls: assistantWithToolCalls.length,
|
|
98
|
+
assistantEmpty: assistantEmpty.length,
|
|
99
|
+
toolResults: toolResults.length,
|
|
100
|
+
},
|
|
101
|
+
assistantDetails: assistantWithToolCalls.length > 0 ? assistantWithToolCalls.map((m: any, i: number) => ({
|
|
102
|
+
index: i,
|
|
103
|
+
toolCallCount: Array.isArray(m?.tool_calls) ? m.tool_calls.length : 0,
|
|
104
|
+
toolCallIds: Array.isArray(m?.tool_calls) ? m.tool_calls.map((tc: any) => tc?.id).join(",") : "",
|
|
105
|
+
toolCallNames: Array.isArray(m?.tool_calls) ? m.tool_calls.map((tc: any) => tc?.function?.name).join(",") : "",
|
|
106
|
+
contentType: typeof m?.content,
|
|
107
|
+
contentPreview: typeof m?.content === "string" ? m.content.slice(0, 50) : typeof m?.content,
|
|
108
|
+
})) : [],
|
|
109
|
+
emptyAssistantDetails: assistantEmpty.length > 0 ? assistantEmpty.map((m: any, i: number) => ({
|
|
110
|
+
index: i,
|
|
111
|
+
contentType: typeof m?.content,
|
|
112
|
+
contentPreview: typeof m?.content === "string" ? m.content.slice(0, 50) : typeof m?.content,
|
|
113
|
+
})) : [],
|
|
114
|
+
toolResultDetails: toolResults.length > 0 ? toolResults.map((m: any, i: number) => ({
|
|
115
|
+
index: i,
|
|
116
|
+
toolCallId: m?.tool_call_id,
|
|
117
|
+
contentPreview: typeof m?.content === "string" ? m.content.slice(0, 100) : typeof m?.content,
|
|
118
|
+
})) : [],
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lines: string[] = [];
|
|
123
|
+
|
|
124
|
+
if (tools.length > 0) {
|
|
125
|
+
lines.push(buildToolSchemaBlock(tools));
|
|
126
|
+
const hasTaskTool = tools.some((t: any) => {
|
|
127
|
+
const name = (t?.function?.name ?? t?.name ?? "").toLowerCase();
|
|
128
|
+
return name === "task";
|
|
129
|
+
});
|
|
130
|
+
if (hasTaskTool && subagentNames.length > 0) {
|
|
131
|
+
lines.push(
|
|
132
|
+
`When calling the task tool, set subagent_type to one of: ${subagentNames.join(", ")}. Do not omit this parameter.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const message of messages) {
|
|
138
|
+
const role = typeof message.role === "string" ? message.role : "user";
|
|
139
|
+
|
|
140
|
+
// tool result messages (from multi-turn tool execution loop)
|
|
141
|
+
if (role === "tool") {
|
|
142
|
+
const callId = message.tool_call_id || "unknown";
|
|
143
|
+
const body =
|
|
144
|
+
typeof message.content === "string"
|
|
145
|
+
? message.content
|
|
146
|
+
: JSON.stringify(message.content ?? "");
|
|
147
|
+
lines.push(`TOOL_RESULT (call_id: ${callId}): ${body}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// assistant messages that contain tool_calls (previous turn's tool invocations)
|
|
152
|
+
if (
|
|
153
|
+
role === "assistant" &&
|
|
154
|
+
Array.isArray(message.tool_calls) &&
|
|
155
|
+
message.tool_calls.length > 0
|
|
156
|
+
) {
|
|
157
|
+
const tcTexts = message.tool_calls.map((tc: any) => {
|
|
158
|
+
const fn = tc.function || {};
|
|
159
|
+
return `tool_call(id: ${tc.id || "?"}, name: ${fn.name || "?"}, args: ${fn.arguments || "{}"})`;
|
|
160
|
+
});
|
|
161
|
+
const text = typeof message.content === "string" ? message.content : "";
|
|
162
|
+
lines.push(`ASSISTANT: ${text ? text + "\n" : ""}${tcTexts.join("\n")}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// standard text messages
|
|
167
|
+
const content = message.content;
|
|
168
|
+
if (typeof content === "string") {
|
|
169
|
+
lines.push(`${role.toUpperCase()}: ${content}`);
|
|
170
|
+
} else if (Array.isArray(content)) {
|
|
171
|
+
const textParts = content
|
|
172
|
+
.map((part: any) => {
|
|
173
|
+
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") {
|
|
174
|
+
return part.text;
|
|
175
|
+
}
|
|
176
|
+
return "";
|
|
177
|
+
})
|
|
178
|
+
.filter(Boolean);
|
|
179
|
+
if (textParts.length) {
|
|
180
|
+
lines.push(`${role.toUpperCase()}: ${textParts.join("\n")}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Add continuation suffix after tool results to anchor model on completed state
|
|
186
|
+
const hasToolResults = messages.some((m: any) => m?.role === "tool");
|
|
187
|
+
if (hasToolResults) {
|
|
188
|
+
lines.push(
|
|
189
|
+
"The above tool calls have been executed. Continue your response based on these results."
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const finalPrompt = lines.join("\n\n");
|
|
194
|
+
log.debug("buildPromptFromMessages: final prompt", {
|
|
195
|
+
lineCount: lines.length,
|
|
196
|
+
promptLength: finalPrompt.length,
|
|
197
|
+
promptPreview: finalPrompt.slice(0, 500),
|
|
198
|
+
hasToolResultFormat: finalPrompt.includes("TOOL_RESULT"),
|
|
199
|
+
hasAssistantToolCallFormat: finalPrompt.includes("tool_call(id:"),
|
|
200
|
+
hasCompletionSignal: finalPrompt.includes("The above tool calls have been executed"),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
return finalPrompt;
|
|
204
|
+
}
|