pi-opencode-cli-bridge 0.2.12 → 0.2.14
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/README.md +5 -5
- package/SECURITY.md +1 -1
- package/package.json +3 -3
- package/src/cli.ts +87 -0
- package/src/index.ts +9 -1978
- package/src/json.ts +13 -0
- package/src/models.ts +351 -0
- package/src/process.ts +1 -1
- package/src/prompt.ts +181 -0
- package/src/sessions.ts +182 -0
- package/src/stream.ts +595 -0
- package/src/tool-calls.ts +556 -0
- package/src/types/models.ts +22 -0
- package/src/types/tool-calls.ts +15 -0
package/src/stream.ts
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
calculateCost,
|
|
5
|
+
createAssistantMessageEventStream,
|
|
6
|
+
getCurrentSystemPrompt,
|
|
7
|
+
getCurrentTools,
|
|
8
|
+
normalizeContext,
|
|
9
|
+
type Api,
|
|
10
|
+
type AssistantMessage,
|
|
11
|
+
type AssistantMessageEventStream,
|
|
12
|
+
type Context,
|
|
13
|
+
type Model,
|
|
14
|
+
type ModelThinkingLevel,
|
|
15
|
+
type SimpleStreamOptions,
|
|
16
|
+
type ToolCall,
|
|
17
|
+
} from "@earendil-works/pi-ai";
|
|
18
|
+
import { opencodeBin, usesV2Cli } from "./cli.js";
|
|
19
|
+
import { objectRecord, safeJson } from "./json.js";
|
|
20
|
+
import { reasoningCliArgs } from "./models.js";
|
|
21
|
+
import { runOpenCodeProcess } from "./process.js";
|
|
22
|
+
import { buildContinuationPrompt, buildPrompt, imageContentsForModel, serializedTranscriptParts, serializeTools, writeImageFiles } from "./prompt.js";
|
|
23
|
+
import { AGENT_ID, continuationDelta, createTempAgentDir, deleteOpenCodeSession, dropOpencodeSession, ensurePiSessionDir, getPiSessionState } from "./sessions.js";
|
|
24
|
+
import { detectXmlToolUse, parseToolCallResponse, toolCallRejectionMessage, translateNativeToolCall } from "./tool-calls.js";
|
|
25
|
+
import type { ParsedToolCall, ToolCallParseResult } from "./types/tool-calls.js";
|
|
26
|
+
|
|
27
|
+
const STDERR_LIMIT = 20_000;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Upper bound for every model turn, applied even when Pi supplies no
|
|
31
|
+
* `timeoutMs`. A session ID reused from the wrong project directory hangs
|
|
32
|
+
* with empty stdout/stderr, so no turn may wait indefinitely (#73).
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_TURN_TIMEOUT_MS = 180_000;
|
|
35
|
+
|
|
36
|
+
function emptyUsage(): AssistantMessage["usage"] {
|
|
37
|
+
return {
|
|
38
|
+
input: 0,
|
|
39
|
+
output: 0,
|
|
40
|
+
cacheRead: 0,
|
|
41
|
+
cacheWrite: 0,
|
|
42
|
+
totalTokens: 0,
|
|
43
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function estimateTokens(text: string): number {
|
|
48
|
+
return Math.max(1, Math.ceil(text.length / 4));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setEstimatedUsage(
|
|
52
|
+
model: Model<Api>,
|
|
53
|
+
output: AssistantMessage,
|
|
54
|
+
prompt: string,
|
|
55
|
+
text: string,
|
|
56
|
+
) {
|
|
57
|
+
if (output.usage.totalTokens > 0) return;
|
|
58
|
+
output.usage.input = estimateTokens(prompt);
|
|
59
|
+
output.usage.output = estimateTokens(text);
|
|
60
|
+
output.usage.totalTokens = output.usage.input + output.usage.output;
|
|
61
|
+
calculateCost(model, output.usage);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Effective per-turn timeout: honor Pi's `timeoutMs` when it is a positive
|
|
66
|
+
* finite number, otherwise fall back to the unconditional internal bound so
|
|
67
|
+
* no turn can hang indefinitely (#73).
|
|
68
|
+
*/
|
|
69
|
+
export function resolveTurnTimeoutMs(timeoutMs: unknown): number {
|
|
70
|
+
return typeof timeoutMs === "number" &&
|
|
71
|
+
Number.isFinite(timeoutMs) &&
|
|
72
|
+
timeoutMs > 0
|
|
73
|
+
? timeoutMs
|
|
74
|
+
: DEFAULT_TURN_TIMEOUT_MS;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function streamOpenCode(
|
|
78
|
+
model: Model<Api>,
|
|
79
|
+
context: Context,
|
|
80
|
+
options?: SimpleStreamOptions,
|
|
81
|
+
): AssistantMessageEventStream {
|
|
82
|
+
return streamOpenCodeAttempt(model, context, options, true);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function streamOpenCodeAttempt(
|
|
86
|
+
model: Model<Api>,
|
|
87
|
+
context: Context,
|
|
88
|
+
options: SimpleStreamOptions | undefined,
|
|
89
|
+
allowRecovery: boolean,
|
|
90
|
+
): AssistantMessageEventStream {
|
|
91
|
+
const stream = createAssistantMessageEventStream();
|
|
92
|
+
const deadline = Date.now() + resolveTurnTimeoutMs(options?.timeoutMs);
|
|
93
|
+
|
|
94
|
+
(async () => {
|
|
95
|
+
const output: AssistantMessage = {
|
|
96
|
+
role: "assistant",
|
|
97
|
+
content: [],
|
|
98
|
+
api: model.api,
|
|
99
|
+
provider: model.provider,
|
|
100
|
+
model: model.id,
|
|
101
|
+
usage: emptyUsage(),
|
|
102
|
+
stopReason: "stop",
|
|
103
|
+
timestamp: Date.now(),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
let tempDir: string | undefined;
|
|
107
|
+
let prompt = "";
|
|
108
|
+
let accumulatedText = "";
|
|
109
|
+
let accumulatedThinking = "";
|
|
110
|
+
let stderr = "";
|
|
111
|
+
let stdoutRemainder = "";
|
|
112
|
+
let capturedSessionId: string | undefined;
|
|
113
|
+
let opencodeToolUse: string | undefined;
|
|
114
|
+
const nativeToolRequests: { name: string; arguments: unknown }[] = [];
|
|
115
|
+
let recoverableFailure: Error | undefined;
|
|
116
|
+
let toolCallFailure: Extract<ToolCallParseResult, { ok: false }> | undefined;
|
|
117
|
+
// Text after the last blocked native call shows whether the model recovered in-run.
|
|
118
|
+
let nativeTextMark = 0;
|
|
119
|
+
let stepNativeCalls: (ParsedToolCall | undefined)[] = [];
|
|
120
|
+
let nativeCalls: ParsedToolCall[] | undefined;
|
|
121
|
+
// Thrown from the event handler so the process runner stops OpenCode.
|
|
122
|
+
const nativeStepEnd = new Error("OpenCode stopped after a translated native step");
|
|
123
|
+
let opencodeError: string | undefined;
|
|
124
|
+
let finishReason: "stop" | "length" = "stop";
|
|
125
|
+
// Per-Pi-session reuse key supplied by Pi core (stable for the life of
|
|
126
|
+
// the Pi session). Without it, every turn runs an isolated fresh
|
|
127
|
+
// session in its own temporary directory.
|
|
128
|
+
const piSessionId =
|
|
129
|
+
typeof options?.sessionId === "string" && options.sessionId
|
|
130
|
+
? options.sessionId
|
|
131
|
+
: undefined;
|
|
132
|
+
const piState = piSessionId ? getPiSessionState(piSessionId) : undefined;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// pi 1.x carries the current prompt and tools in transcript system messages.
|
|
136
|
+
const { messages } = normalizeContext(context);
|
|
137
|
+
context = {
|
|
138
|
+
messages: messages.filter((message) => message.role !== "system"),
|
|
139
|
+
systemPrompt: getCurrentSystemPrompt(messages),
|
|
140
|
+
tools: getCurrentTools(messages),
|
|
141
|
+
};
|
|
142
|
+
const transcriptParts = serializedTranscriptParts(context.messages);
|
|
143
|
+
const allowedToolNames = new Set(
|
|
144
|
+
context.tools?.map((tool) => tool.name) ?? [],
|
|
145
|
+
);
|
|
146
|
+
stream.push({ type: "start", partial: output });
|
|
147
|
+
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
148
|
+
|
|
149
|
+
// Continuation decision (#75, #76): reuse the recorded OpenCode
|
|
150
|
+
// session only when the model, tools, system prompt, and transcript
|
|
151
|
+
// prefix all still match. Any drift restarts fresh — and the stale
|
|
152
|
+
// session record is deleted so it cannot leak.
|
|
153
|
+
// A payload hook may rewrite the prompt, so its session cannot be continued as a transcript prefix.
|
|
154
|
+
const payloadHook = options?.onPayload;
|
|
155
|
+
let delta = piState && !payloadHook
|
|
156
|
+
? continuationDelta(piState, model.id, context, transcriptParts)
|
|
157
|
+
: undefined;
|
|
158
|
+
if (piState && piState.opencodeSessionId && delta === undefined) {
|
|
159
|
+
dropOpencodeSession(piState);
|
|
160
|
+
}
|
|
161
|
+
const isContinuation = delta !== undefined;
|
|
162
|
+
prompt = isContinuation
|
|
163
|
+
? buildContinuationPrompt(delta)
|
|
164
|
+
: buildPrompt(context);
|
|
165
|
+
const sentMessages = isContinuation
|
|
166
|
+
? context.messages.slice(transcriptParts.length - delta.length)
|
|
167
|
+
: context.messages;
|
|
168
|
+
|
|
169
|
+
const sessionDir = piState
|
|
170
|
+
? await ensurePiSessionDir(piState)
|
|
171
|
+
: await createTempAgentDir().then((dir) => {
|
|
172
|
+
tempDir = dir;
|
|
173
|
+
return dir;
|
|
174
|
+
});
|
|
175
|
+
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
176
|
+
const projectDirs = [sessionDir, await realpath(sessionDir).catch(() => sessionDir)];
|
|
177
|
+
const images = imageContentsForModel(
|
|
178
|
+
sentMessages,
|
|
179
|
+
model.input.includes("image"),
|
|
180
|
+
);
|
|
181
|
+
const imagePaths = await writeImageFiles(images, sessionDir);
|
|
182
|
+
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
183
|
+
const v2 = await usesV2Cli();
|
|
184
|
+
const requestedReasoning = options?.reasoning as ModelThinkingLevel | undefined;
|
|
185
|
+
const variant = requestedReasoning && requestedReasoning !== "off"
|
|
186
|
+
? model.thinkingLevelMap?.[requestedReasoning] : undefined;
|
|
187
|
+
const args = [
|
|
188
|
+
"run",
|
|
189
|
+
"-m",
|
|
190
|
+
v2 && typeof variant === "string" && variant.trim() ? `${model.id}#${variant}` : model.id,
|
|
191
|
+
"--agent",
|
|
192
|
+
AGENT_ID,
|
|
193
|
+
"--format",
|
|
194
|
+
"json",
|
|
195
|
+
...(v2 ? ["--standalone"] : ["--dir", sessionDir]),
|
|
196
|
+
];
|
|
197
|
+
if (isContinuation && piState?.opencodeSessionId) {
|
|
198
|
+
args.push("--session", piState.opencodeSessionId);
|
|
199
|
+
}
|
|
200
|
+
args.push(...reasoningCliArgs(requestedReasoning, v2 ? undefined : model.thinkingLevelMap));
|
|
201
|
+
for (const path of imagePaths) args.push("--file", path);
|
|
202
|
+
|
|
203
|
+
const handleLine = async (line: string) => {
|
|
204
|
+
// Output after a translated native step belongs to a run Pi already ended.
|
|
205
|
+
if (!line.trim() || nativeCalls) return;
|
|
206
|
+
let parsed: unknown;
|
|
207
|
+
try {
|
|
208
|
+
parsed = JSON.parse(line);
|
|
209
|
+
} catch {
|
|
210
|
+
// Plugins share OpenCode's stdout; keep stray log lines as diagnostics.
|
|
211
|
+
stderr = (stderr + `\n${line}`).slice(-STDERR_LIMIT);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const event = objectRecord(parsed);
|
|
215
|
+
const part = objectRecord(event?.part);
|
|
216
|
+
if (!event || typeof event.type !== "string" ||
|
|
217
|
+
(event.part !== undefined && !part)) {
|
|
218
|
+
throw new Error("Invalid OpenCode event envelope");
|
|
219
|
+
}
|
|
220
|
+
await options?.onProviderStreamEvent?.(event, model);
|
|
221
|
+
|
|
222
|
+
// Every JSON event carries the OpenCode session ID (#71).
|
|
223
|
+
if (typeof event.sessionID === "string" && event.sessionID) {
|
|
224
|
+
capturedSessionId = event.sessionID;
|
|
225
|
+
} else if (
|
|
226
|
+
typeof part?.sessionID === "string" &&
|
|
227
|
+
part.sessionID
|
|
228
|
+
) {
|
|
229
|
+
capturedSessionId = part.sessionID;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (event.type === "text" || event.type === "reasoning") {
|
|
233
|
+
if (typeof part?.text !== "string") throw new Error("Invalid OpenCode event text");
|
|
234
|
+
if (event.type === "text") accumulatedText += part.text;
|
|
235
|
+
else accumulatedThinking += part.text;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (event.type === "step_finish") {
|
|
240
|
+
if (/length|max.?tokens/i.test(String(part?.reason ?? ""))) {
|
|
241
|
+
finishReason = "length";
|
|
242
|
+
}
|
|
243
|
+
const tokens = objectRecord(part?.tokens);
|
|
244
|
+
if (tokens) {
|
|
245
|
+
const cache = objectRecord(tokens.cache);
|
|
246
|
+
output.usage.input = Number(tokens.input ?? 0);
|
|
247
|
+
output.usage.output =
|
|
248
|
+
Number(tokens.output ?? 0) + Number(tokens.reasoning ?? 0);
|
|
249
|
+
output.usage.cacheRead = Number(cache?.read ?? 0);
|
|
250
|
+
output.usage.cacheWrite = Number(cache?.write ?? 0);
|
|
251
|
+
output.usage.totalTokens = Number(
|
|
252
|
+
tokens.total ??
|
|
253
|
+
output.usage.input +
|
|
254
|
+
output.usage.output +
|
|
255
|
+
output.usage.cacheRead +
|
|
256
|
+
output.usage.cacheWrite,
|
|
257
|
+
);
|
|
258
|
+
calculateCost(model, output.usage);
|
|
259
|
+
}
|
|
260
|
+
const calls = stepNativeCalls;
|
|
261
|
+
stepNativeCalls = [];
|
|
262
|
+
// A fully translatable step ends the turn like a normal tool-call response.
|
|
263
|
+
// Otherwise OpenCode continues and the model sees the blocker's correction.
|
|
264
|
+
if (calls.length > 0 && calls.every(Boolean)) {
|
|
265
|
+
nativeCalls = calls as ParsedToolCall[];
|
|
266
|
+
throw nativeStepEnd;
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (event.type === "tool_use") {
|
|
272
|
+
opencodeToolUse = part?.tool ? String(part.tool) : "unknown";
|
|
273
|
+
nativeTextMark = accumulatedText.length;
|
|
274
|
+
const input = objectRecord(part?.state)?.input;
|
|
275
|
+
nativeToolRequests.push({ name: opencodeToolUse, arguments: input });
|
|
276
|
+
stepNativeCalls.push(translateNativeToolCall(
|
|
277
|
+
opencodeToolUse,
|
|
278
|
+
input,
|
|
279
|
+
allowedToolNames,
|
|
280
|
+
projectDirs,
|
|
281
|
+
));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (event.type === "error") {
|
|
286
|
+
const error = objectRecord(event.error);
|
|
287
|
+
const message = objectRecord(error?.data)?.message ?? error?.message ?? part?.message;
|
|
288
|
+
opencodeError =
|
|
289
|
+
typeof message === "string" && message.trim()
|
|
290
|
+
? message.trim()
|
|
291
|
+
: safeJson(event);
|
|
292
|
+
stderr = (stderr + `\n${opencodeError}`).slice(-STDERR_LIMIT);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const result = await runOpenCodeProcess(opencodeBin(), args, {
|
|
297
|
+
cwd: v2 ? sessionDir : undefined,
|
|
298
|
+
input: async () => {
|
|
299
|
+
if (!payloadHook) return prompt;
|
|
300
|
+
const replacement = await payloadHook({ prompt }, model);
|
|
301
|
+
if (replacement === undefined) return prompt;
|
|
302
|
+
const payload = objectRecord(replacement);
|
|
303
|
+
if (typeof payload?.prompt !== "string") {
|
|
304
|
+
throw new Error("OpenCode CLI payload must be an object with a string prompt");
|
|
305
|
+
}
|
|
306
|
+
return (prompt = payload.prompt);
|
|
307
|
+
},
|
|
308
|
+
// A CLI subprocess has no HTTP status; 0 marks the non-HTTP transport.
|
|
309
|
+
onStart: () => options?.onResponse?.(
|
|
310
|
+
{ status: 0, headers: { "x-pi-transport": "opencode-cli" } },
|
|
311
|
+
model,
|
|
312
|
+
),
|
|
313
|
+
timeoutMs: resolveTurnTimeoutMs(options?.timeoutMs),
|
|
314
|
+
signal: options?.signal,
|
|
315
|
+
env: {
|
|
316
|
+
...process.env,
|
|
317
|
+
...options?.env,
|
|
318
|
+
...(v2 ? { PWD: sessionDir } : {}),
|
|
319
|
+
OPENCODE_DISABLE_UPDATE_CHECK: "1",
|
|
320
|
+
OPENCODE_CONFIG_DIR: join(sessionDir, ".opencode"),
|
|
321
|
+
},
|
|
322
|
+
onStdout: async (chunk) => {
|
|
323
|
+
stdoutRemainder += chunk;
|
|
324
|
+
const lines = stdoutRemainder.split(/\r?\n/);
|
|
325
|
+
stdoutRemainder = lines.pop() ?? "";
|
|
326
|
+
for (const line of lines) await handleLine(line);
|
|
327
|
+
},
|
|
328
|
+
onEnd: () => handleLine(stdoutRemainder),
|
|
329
|
+
}).catch((error: unknown) => {
|
|
330
|
+
// Stopping OpenCode after a translated native step is a normal end of turn.
|
|
331
|
+
if (error === nativeStepEnd) return undefined;
|
|
332
|
+
throw error;
|
|
333
|
+
});
|
|
334
|
+
if (result) {
|
|
335
|
+
stderr = (stderr + result.stderr).slice(-STDERR_LIMIT);
|
|
336
|
+
if (result.code !== 0) {
|
|
337
|
+
throw new Error(stderr.trim() || `opencode exited with code ${result.code}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (opencodeError) throw new Error(opencodeError);
|
|
341
|
+
const toolCallResult = parseToolCallResponse(
|
|
342
|
+
accumulatedText,
|
|
343
|
+
allowedToolNames,
|
|
344
|
+
);
|
|
345
|
+
if (!toolCallResult.ok) {
|
|
346
|
+
toolCallFailure = toolCallResult;
|
|
347
|
+
throw new Error(
|
|
348
|
+
toolCallRejectionMessage(toolCallResult, allowedToolNames),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
// Unrecoverable marker text is stripped so it never leaks into the
|
|
352
|
+
// visible chat response; remaining prose still displays normally.
|
|
353
|
+
const displayText = toolCallResult.cleanedText ?? accumulatedText;
|
|
354
|
+
let textCalls = toolCallResult.calls;
|
|
355
|
+
// DeepSeek sometimes writes its only calls in the reasoning and replies
|
|
356
|
+
// with nothing, which would end the turn silently.
|
|
357
|
+
if (textCalls.length === 0 && !nativeCalls && !displayText.trim()) {
|
|
358
|
+
let reasoningResult = parseToolCallResponse(
|
|
359
|
+
accumulatedThinking,
|
|
360
|
+
allowedToolNames,
|
|
361
|
+
);
|
|
362
|
+
// Markers stripped with no call surviving were meant as calls too.
|
|
363
|
+
if (reasoningResult.ok && reasoningResult.calls.length === 0 && reasoningResult.cleanedText !== undefined) {
|
|
364
|
+
reasoningResult = { ok: false, rejection: { reason: "malformed_markers" } };
|
|
365
|
+
}
|
|
366
|
+
if (!reasoningResult.ok) {
|
|
367
|
+
toolCallFailure = reasoningResult;
|
|
368
|
+
throw new Error(
|
|
369
|
+
toolCallRejectionMessage(reasoningResult, allowedToolNames),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
textCalls = reasoningResult.calls;
|
|
373
|
+
}
|
|
374
|
+
// A blocked native call with no usable Pi call recovers: nothing after it
|
|
375
|
+
// to answer with, or prose while Pi exposes tools to try (e.g. "please run
|
|
376
|
+
// a curl"). A valid call in text or reasoning is used instead of retrying;
|
|
377
|
+
// with no Pi tools the prose is the model's explanation and stays final.
|
|
378
|
+
if (
|
|
379
|
+
opencodeToolUse && !nativeCalls && textCalls.length === 0 &&
|
|
380
|
+
(!accumulatedText.slice(nativeTextMark).trim() || allowedToolNames.size > 0)
|
|
381
|
+
) {
|
|
382
|
+
recoverableFailure = new Error(
|
|
383
|
+
`OpenCode could not use the available Pi tools${allowRecovery ? "" : " after one recovery attempt"} (blocked native tool: ${JSON.stringify(opencodeToolUse)}). Retry the request or select another OpenCode model.`,
|
|
384
|
+
);
|
|
385
|
+
throw recoverableFailure;
|
|
386
|
+
}
|
|
387
|
+
const toolCalls = [...textCalls, ...(nativeCalls ?? [])];
|
|
388
|
+
if (toolCalls.length === 0 && displayText.trim()) {
|
|
389
|
+
// Check if the model tried to use XML-style tool-use instead of
|
|
390
|
+
// <pi_tool_call> markers — a common mistake with Claude-based models.
|
|
391
|
+
const xmlTool = detectXmlToolUse(displayText);
|
|
392
|
+
if (xmlTool) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`OpenCode attempted to use its disabled native tool (${JSON.stringify(xmlTool)}). This bridge only accepts <pi_tool_call>{"name":"...","arguments":{}}</pi_tool_call> markers. Do not use XML-style tool-use syntax.`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (toolCalls.length === 0 && !displayText.trim() && accumulatedThinking.trim()) {
|
|
399
|
+
recoverableFailure = new Error(
|
|
400
|
+
`OpenCode returned only reasoning without an answer or Pi tool call${allowRecovery ? "" : " after one recovery attempt"}. Retry the request or select another OpenCode model.`,
|
|
401
|
+
);
|
|
402
|
+
throw recoverableFailure;
|
|
403
|
+
}
|
|
404
|
+
if (
|
|
405
|
+
toolCalls.length === 0 &&
|
|
406
|
+
!displayText.trim() &&
|
|
407
|
+
!accumulatedThinking.trim()
|
|
408
|
+
) {
|
|
409
|
+
const stderrMsg = stderr.trim();
|
|
410
|
+
if (stderrMsg) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`OpenCode returned stderr: ${stderrMsg}`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
// The model may have emitted text events with empty strings, or
|
|
416
|
+
// may have produced reasoning-only output that was not captured
|
|
417
|
+
// as assistant text. Provide a clearer diagnostic.
|
|
418
|
+
const rawTextPreview = accumulatedText
|
|
419
|
+
? `raw text length=${accumulatedText.length} (trimmed to "${accumulatedText.slice(0, 80).replace(/\n/g, " ")}")`
|
|
420
|
+
: "no text events received";
|
|
421
|
+
const rawThinkingPreview = accumulatedThinking
|
|
422
|
+
? `thinking length=${accumulatedThinking.length}`
|
|
423
|
+
: "no thinking output";
|
|
424
|
+
throw new Error(
|
|
425
|
+
`OpenCode returned empty assistant response (${rawTextPreview}; ${rawThinkingPreview}). This can happen with some free models when the prompt is too long, the model times out, or the model is overloaded. Retry the request or select another OpenCode model.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
setEstimatedUsage(
|
|
429
|
+
model,
|
|
430
|
+
output,
|
|
431
|
+
prompt,
|
|
432
|
+
accumulatedText + accumulatedThinking,
|
|
433
|
+
);
|
|
434
|
+
// The turn succeeded: record the session for reuse, or delete the
|
|
435
|
+
// one-shot session record so it cannot accumulate (#71, #75).
|
|
436
|
+
// A session holding a blocked native call cannot be continued as a transcript prefix.
|
|
437
|
+
if (piState && !payloadHook && !nativeCalls) {
|
|
438
|
+
piState.opencodeSessionId =
|
|
439
|
+
capturedSessionId ?? piState.opencodeSessionId;
|
|
440
|
+
piState.modelId = model.id;
|
|
441
|
+
piState.toolsKey = serializeTools(context.tools);
|
|
442
|
+
piState.systemPrompt = context.systemPrompt ?? "";
|
|
443
|
+
piState.sentParts = transcriptParts;
|
|
444
|
+
} else {
|
|
445
|
+
if (piState) dropOpencodeSession(piState);
|
|
446
|
+
deleteOpenCodeSession(capturedSessionId);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (accumulatedThinking && requestedReasoning && requestedReasoning !== "off") {
|
|
450
|
+
const thinkingIndex = output.content.length;
|
|
451
|
+
output.content.push({ type: "thinking", thinking: accumulatedThinking });
|
|
452
|
+
stream.push({
|
|
453
|
+
type: "thinking_start",
|
|
454
|
+
contentIndex: thinkingIndex,
|
|
455
|
+
partial: output,
|
|
456
|
+
});
|
|
457
|
+
stream.push({
|
|
458
|
+
type: "thinking_delta",
|
|
459
|
+
contentIndex: thinkingIndex,
|
|
460
|
+
delta: accumulatedThinking,
|
|
461
|
+
partial: output,
|
|
462
|
+
});
|
|
463
|
+
stream.push({
|
|
464
|
+
type: "thinking_end",
|
|
465
|
+
contentIndex: thinkingIndex,
|
|
466
|
+
content: accumulatedThinking,
|
|
467
|
+
partial: output,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (toolCalls.length > 0) {
|
|
472
|
+
output.stopReason = "toolUse";
|
|
473
|
+
for (const call of toolCalls) {
|
|
474
|
+
const toolCall: ToolCall = {
|
|
475
|
+
type: "toolCall",
|
|
476
|
+
id: `opencode_pi_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
477
|
+
name: call.name,
|
|
478
|
+
arguments: call.arguments as ToolCall["arguments"],
|
|
479
|
+
};
|
|
480
|
+
const contentIndex = output.content.length;
|
|
481
|
+
output.content.push(toolCall);
|
|
482
|
+
stream.push({
|
|
483
|
+
type: "toolcall_start",
|
|
484
|
+
contentIndex,
|
|
485
|
+
partial: output,
|
|
486
|
+
});
|
|
487
|
+
stream.push({
|
|
488
|
+
type: "toolcall_delta",
|
|
489
|
+
contentIndex,
|
|
490
|
+
delta: safeJson(toolCall.arguments),
|
|
491
|
+
partial: output,
|
|
492
|
+
});
|
|
493
|
+
stream.push({
|
|
494
|
+
type: "toolcall_end",
|
|
495
|
+
contentIndex,
|
|
496
|
+
toolCall,
|
|
497
|
+
partial: output,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
stream.push({ type: "done", reason: "toolUse", message: output });
|
|
501
|
+
stream.end();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
output.stopReason = finishReason;
|
|
506
|
+
if (displayText) {
|
|
507
|
+
const contentIndex = output.content.length;
|
|
508
|
+
output.content.push({ type: "text", text: displayText });
|
|
509
|
+
stream.push({ type: "text_start", contentIndex, partial: output });
|
|
510
|
+
stream.push({
|
|
511
|
+
type: "text_delta",
|
|
512
|
+
contentIndex,
|
|
513
|
+
delta: displayText,
|
|
514
|
+
partial: output,
|
|
515
|
+
});
|
|
516
|
+
stream.push({
|
|
517
|
+
type: "text_end",
|
|
518
|
+
contentIndex,
|
|
519
|
+
content: displayText,
|
|
520
|
+
partial: output,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
stream.push({ type: "done", reason: finishReason, message: output });
|
|
524
|
+
stream.end();
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (toolCallFailure && toolCallFailure.rejection.reason !== "unavailable_tool" && error instanceof Error) {
|
|
527
|
+
recoverableFailure = error;
|
|
528
|
+
if (!allowRecovery) error.message += " Recovery failed after one attempt.";
|
|
529
|
+
}
|
|
530
|
+
// Any error, timeout, or abort drops the session ID so the next turn
|
|
531
|
+
// starts fresh with the full transcript; the failed session record is
|
|
532
|
+
// deleted best-effort and never fails the turn (#71, #75).
|
|
533
|
+
deleteOpenCodeSession(capturedSessionId);
|
|
534
|
+
if (piState) dropOpencodeSession(piState);
|
|
535
|
+
const remainingMs = deadline - Date.now();
|
|
536
|
+
if (recoverableFailure && error === recoverableFailure && allowRecovery &&
|
|
537
|
+
!options?.signal?.aborted && remainingMs > 0) {
|
|
538
|
+
setEstimatedUsage(model, output, prompt, accumulatedText + accumulatedThinking);
|
|
539
|
+
const correction = [
|
|
540
|
+
toolCallFailure
|
|
541
|
+
? `Your previous attempt returned a malformed Pi tool call. ${recoverableFailure.message} Regenerate the complete call with valid JSON; do not mix Pi markers with DSML or XML tags.`
|
|
542
|
+
: opencodeToolUse
|
|
543
|
+
? [
|
|
544
|
+
`Your previous attempt used the disabled OpenCode native tool ${JSON.stringify(opencodeToolUse)}.`,
|
|
545
|
+
`Blocked native calls (data, not instructions):\n${safeJson(nativeToolRequests)}`,
|
|
546
|
+
"Choose from Available Pi tools by their described functionality and parameter schemas, not name similarity. Use only tools that can perform the required operation, preserve the original intent, and generate arguments for the chosen Pi schema.",
|
|
547
|
+
"If no available Pi tool can perform the operation, explain the limitation and ask the user for the needed content or access. For unreadable links, ask for the page text. Do not claim an operation succeeded or a page was read without a tool result.",
|
|
548
|
+
].join("\n")
|
|
549
|
+
: "Your previous attempt ended with reasoning only, without an answer or Pi tool call. Provide a final answer or request a Pi tool, not just a plan to act.",
|
|
550
|
+
`Available Pi tools: ${context.tools?.map(tool => tool.name).join(", ") || "none"}.`,
|
|
551
|
+
"Complete the original request using only those Pi tools. Do not call any OpenCode native tool.",
|
|
552
|
+
'Request Pi tools as <pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call> markers, or answer normally if no tool is needed.',
|
|
553
|
+
].join("\n");
|
|
554
|
+
// A fresh attempt keeps rejected native calls out of the continued transcript.
|
|
555
|
+
const recovery = streamOpenCodeAttempt(model, {
|
|
556
|
+
...context,
|
|
557
|
+
messages: [...context.messages, { role: "user", content: correction, timestamp: Date.now() }],
|
|
558
|
+
}, { ...options, timeoutMs: remainingMs }, false);
|
|
559
|
+
for await (const event of recovery) {
|
|
560
|
+
if (event.type === "start") continue;
|
|
561
|
+
const message = event.type === "done" ? event.message : event.type === "error" ? event.error : undefined;
|
|
562
|
+
if (message) {
|
|
563
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite", "totalTokens"] as const) {
|
|
564
|
+
message.usage[key] += output.usage[key];
|
|
565
|
+
}
|
|
566
|
+
calculateCost(model, message.usage);
|
|
567
|
+
}
|
|
568
|
+
stream.push(event);
|
|
569
|
+
}
|
|
570
|
+
stream.end();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
574
|
+
output.errorMessage =
|
|
575
|
+
error instanceof Error ? error.message : String(error);
|
|
576
|
+
// Ensure output.content has text so downstream tools (model-debugger,
|
|
577
|
+
// Pi agent) see real content instead of flagging a "silent failure."
|
|
578
|
+
if (output.content.length === 0) {
|
|
579
|
+
output.content.push({ type: "text", text: `Error: ${output.errorMessage}` });
|
|
580
|
+
}
|
|
581
|
+
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
582
|
+
stream.end();
|
|
583
|
+
} finally {
|
|
584
|
+
// Only one-shot turns own their directory. Per-Pi-session directories
|
|
585
|
+
// persist for reuse and are removed at session teardown (#74).
|
|
586
|
+
if (tempDir) {
|
|
587
|
+
await rm(tempDir, { recursive: true, force: true }).catch(
|
|
588
|
+
() => undefined,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
})();
|
|
593
|
+
|
|
594
|
+
return stream;
|
|
595
|
+
}
|