@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.
Files changed (80) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +270 -0
  3. package/dist/cli/discover.js +527 -0
  4. package/dist/cli/mcptool.js +10339 -0
  5. package/dist/cli/opencode-cursor.js +2989 -0
  6. package/dist/index.js +20588 -0
  7. package/dist/plugin-entry.js +19848 -0
  8. package/package.json +82 -0
  9. package/scripts/cursor-agent-runner.mjs +272 -0
  10. package/scripts/sdk-runner.mjs +412 -0
  11. package/src/acp/metrics.ts +83 -0
  12. package/src/acp/sessions.ts +107 -0
  13. package/src/acp/tools.ts +209 -0
  14. package/src/auth.ts +175 -0
  15. package/src/cli/discover.ts +53 -0
  16. package/src/cli/mcptool.ts +133 -0
  17. package/src/cli/model-discovery.ts +71 -0
  18. package/src/cli/opencode-cursor.ts +1195 -0
  19. package/src/client/cursor-agent-child.ts +459 -0
  20. package/src/client/sdk-child.ts +550 -0
  21. package/src/client/simple.ts +293 -0
  22. package/src/commands/status.ts +39 -0
  23. package/src/index.ts +39 -0
  24. package/src/mcp/client-manager.ts +166 -0
  25. package/src/mcp/config.ts +169 -0
  26. package/src/mcp/tool-bridge.ts +133 -0
  27. package/src/models/config.ts +64 -0
  28. package/src/models/discovery.ts +105 -0
  29. package/src/models/index.ts +3 -0
  30. package/src/models/pricing.ts +196 -0
  31. package/src/models/sync.ts +247 -0
  32. package/src/models/types.ts +11 -0
  33. package/src/models/variants.ts +446 -0
  34. package/src/plugin-entry.ts +28 -0
  35. package/src/plugin-toggle.ts +81 -0
  36. package/src/plugin.ts +2802 -0
  37. package/src/provider/backend.ts +71 -0
  38. package/src/provider/boundary.ts +168 -0
  39. package/src/provider/passthrough-tracker.ts +38 -0
  40. package/src/provider/runtime-interception.ts +818 -0
  41. package/src/provider/tool-loop-guard.ts +644 -0
  42. package/src/provider/tool-schema-compat.ts +800 -0
  43. package/src/provider.ts +268 -0
  44. package/src/proxy/formatter.ts +60 -0
  45. package/src/proxy/handler.ts +29 -0
  46. package/src/proxy/incremental-prompt.ts +74 -0
  47. package/src/proxy/prompt-builder.ts +204 -0
  48. package/src/proxy/server.ts +207 -0
  49. package/src/proxy/session-resume.ts +312 -0
  50. package/src/proxy/tool-loop.ts +359 -0
  51. package/src/proxy/types.ts +13 -0
  52. package/src/services/toast-service.ts +81 -0
  53. package/src/streaming/ai-sdk-parts.ts +109 -0
  54. package/src/streaming/delta-tracker.ts +89 -0
  55. package/src/streaming/line-buffer.ts +44 -0
  56. package/src/streaming/openai-sse.ts +118 -0
  57. package/src/streaming/parser.ts +22 -0
  58. package/src/streaming/types.ts +158 -0
  59. package/src/tools/core/executor.ts +25 -0
  60. package/src/tools/core/registry.ts +27 -0
  61. package/src/tools/core/types.ts +31 -0
  62. package/src/tools/defaults.ts +954 -0
  63. package/src/tools/discovery.ts +140 -0
  64. package/src/tools/executors/cli.ts +59 -0
  65. package/src/tools/executors/local.ts +25 -0
  66. package/src/tools/executors/mcp.ts +39 -0
  67. package/src/tools/executors/sdk.ts +39 -0
  68. package/src/tools/index.ts +8 -0
  69. package/src/tools/registry.ts +34 -0
  70. package/src/tools/router.ts +123 -0
  71. package/src/tools/schema.ts +58 -0
  72. package/src/tools/skills/loader.ts +61 -0
  73. package/src/tools/skills/resolver.ts +21 -0
  74. package/src/tools/types.ts +29 -0
  75. package/src/types.ts +8 -0
  76. package/src/usage.ts +112 -0
  77. package/src/utils/binary.ts +71 -0
  78. package/src/utils/errors.ts +224 -0
  79. package/src/utils/logger.ts +191 -0
  80. package/src/utils/perf.ts +76 -0
@@ -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
+ }