pi-opencode-cli-bridge 0.2.14 → 0.2.16
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 +2 -2
- package/src/index.ts +1 -1
- package/src/stream.ts +439 -308
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ The original MIT copyright notice is preserved in [LICENSE](LICENSE). The new pa
|
|
|
33
33
|
|
|
34
34
|
- Node.js **22.19.0 or newer**.
|
|
35
35
|
- Pi Coding Agent **1.2.0 or newer**; the development SDK is pinned to 1.2.0.
|
|
36
|
-
- OpenCode installed on the same machine and available on `PATH`, or selected with `OPENCODE_PI_BIN`. Supports OpenCode up to **2.2.
|
|
36
|
+
- OpenCode installed on the same machine and available on `PATH`, or selected with `OPENCODE_PI_BIN`. Supports OpenCode up to **2.2.5**. Real-CLI integration tests cover **1.19.0** and **2.2.5**; intermediate releases are not individually verified.
|
|
37
37
|
|
|
38
38
|
Check OpenCode before starting Pi:
|
|
39
39
|
|
|
@@ -140,11 +140,11 @@ Reasoning variants, image support and token limits follow CLI discovery. Token u
|
|
|
140
140
|
|
|
141
141
|
- Prompts, system instructions, tool declarations, conversation text and attached images pass to OpenCode and its model provider. Do not send confidential data unless that provider's policies are suitable.
|
|
142
142
|
- The extension does not directly extract credentials or call model HTTP endpoints. The OpenCode subprocess still runs as your user and can use its own configuration, credentials and inherited environment.
|
|
143
|
-
- The native-tool guard is a guard rail, **not a sandbox**. Integration tests run the real OpenCode 1.19.0 and 2.2.
|
|
143
|
+
- The native-tool guard is a guard rail, **not a sandbox**. Integration tests run the real OpenCode 1.19.0 and 2.2.5 CLIs against a local model fixture that requests native `write` and `bash`/`shell` calls, and check that neither runs with the legacy plugin intact, missing, or failing at startup. Other OpenCode versions, tools contributed by your own global OpenCode configuration (for example MCP servers), and a hostile local process are not covered.
|
|
144
144
|
- Pi and OpenCode can both retain local conversation data. Best-effort cleanup is not a guarantee of secure deletion.
|
|
145
145
|
- Free-tier model availability and accepted request shapes can change. This fork makes no guarantee of continued access or compliance with a hosted provider's terms; review the applicable terms yourself.
|
|
146
|
-
- Live tests on OpenCode 2.2.
|
|
147
|
-
- At startup Pi warns when your OpenCode version exceeds the supported maximum, **2.2.
|
|
146
|
+
- Live tests on OpenCode 2.2.5 with DeepSeek v4.1 Flash Free verified text replies, a Pi `read` call and tool-result continuation with thinking off. High thinking was not reverified on 2.2.5; an earlier test timed out. No default thinking setting is changed by this package.
|
|
147
|
+
- At startup Pi warns when your OpenCode version exceeds the supported maximum, **2.2.5**, or its version cannot be determined.
|
|
148
148
|
- Timeouts and cancellation escalate from `SIGTERM` to `SIGKILL` on the CLI's process group after 250 ms; on Windows the whole process tree is ended with `taskkill /T /F` (not tested on Windows). If OpenCode quits before reading the prompt, the turn shows OpenCode's own error rather than a broken-pipe error. Malformed or unexpected CLI events end the turn with an error instead of crashing Pi. Shutdown waits for session cleanup.
|
|
149
149
|
|
|
150
150
|
See [SECURITY.md](SECURITY.md) for the trust boundaries and [docs/code-review.md](https://github.com/Gybra/pi-opencode-cli-bridge/blob/main/docs/code-review.md) for what was fixed and what remains. This is an experimental, unaudited fork.
|
|
@@ -159,7 +159,7 @@ OPENCODE_PI_INTEGRATION_BIN="$(which opencode)" npm run test:integration
|
|
|
159
159
|
npm pack --dry-run
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
-
`npm test` uses a fake OpenCode executable. `npm run test:integration` needs the real OpenCode CLI at version 1.19.0 or 2.2.
|
|
162
|
+
`npm test` uses a fake OpenCode executable. `npm run test:integration` needs the real OpenCode CLI at version 1.19.0 or 2.2.5 (on `PATH` or in `OPENCODE_PI_INTEGRATION_BIN`), runs it with isolated home and data directories against a local model fixture, and makes no network request. Neither suite needs API credentials. Development SDKs are pinned; Pi supplies those packages at runtime. The npm archive includes the extension source, README, security policy and license, not tests or installed dependencies.
|
|
163
163
|
|
|
164
164
|
## License
|
|
165
165
|
|
package/SECURITY.md
CHANGED
|
@@ -10,7 +10,7 @@ Never include prompts, conversation history, images, API keys, auth files or ses
|
|
|
10
10
|
- Requests are delegated to the real OpenCode CLI. The extension does not directly read credentials, but the subprocess inherits the environment and uses OpenCode's own configuration and authentication.
|
|
11
11
|
- System instructions, tool declarations, transcript text and image attachments can reach the selected model provider. Provider retention and training policies apply.
|
|
12
12
|
- The current Pi tool list is checked before marker responses become Pi tool calls. Pi remains responsible for argument validation and tool permission checks.
|
|
13
|
-
- Native OpenCode tools are guarded by an agent permission of `ask` for everything, which non-interactive `opencode run` rejects. On 1.19.0 a generated `tool.execute.before` plugin provides a second guard; on 2.2.
|
|
13
|
+
- Native OpenCode tools are guarded by an agent permission of `ask` for everything, which non-interactive `opencode run` rejects. On 1.19.0 a generated `tool.execute.before` plugin provides a second guard; on 2.2.5 the bridge relies on permissions, not that legacy plugin. Native declarations remain visible to the model. Real-CLI integration tests on both releases request `write` and `bash`/`shell` side effects and verify that neither executes with the legacy plugin intact, missing, or failing. These guards are not a security boundary against a hostile local process, your own OpenCode configuration (it is loaded and may add tools or agent overrides), or other OpenCode versions.
|
|
14
14
|
- Temporary project directories and OpenCode session records are removed best-effort. Pi's own conversation history is not deleted by the extension.
|
|
15
15
|
|
|
16
16
|
## Known gaps
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-opencode-cli-bridge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "Bridge the OpenCode CLI into Pi Coding Agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
17
17
|
"test": "npm run build && node --test --test-timeout=90000 dist/index.test.js dist/process.test.js",
|
|
18
|
-
"test:integration": "npm run build && node --test
|
|
18
|
+
"test:integration": "npm run build && node --test dist/native-tools.integration.test.js",
|
|
19
19
|
"prepublishOnly": "npm run typecheck && npm test && npm run test:integration"
|
|
20
20
|
},
|
|
21
21
|
"pi": {
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { OpenCodeModelInfo } from "./types/models.js";
|
|
|
9
9
|
|
|
10
10
|
const PROVIDER_ID = "opencode-cli";
|
|
11
11
|
const API_ID = "opencode-cli-runner";
|
|
12
|
-
const MAX_SUPPORTED_OPENCODE_VERSION = "2.2.
|
|
12
|
+
const MAX_SUPPORTED_OPENCODE_VERSION = "2.2.5";
|
|
13
13
|
let registeredModels: OpenCodeModelInfo[] = [];
|
|
14
14
|
let lastDiscoveryTime: number | undefined;
|
|
15
15
|
let lastDiscoveryError: string | undefined;
|
package/src/stream.ts
CHANGED
|
@@ -33,6 +33,38 @@ const STDERR_LIMIT = 20_000;
|
|
|
33
33
|
*/
|
|
34
34
|
const DEFAULT_TURN_TIMEOUT_MS = 180_000;
|
|
35
35
|
|
|
36
|
+
type ToolCallFailure = Extract<ToolCallParseResult, { ok: false }>;
|
|
37
|
+
|
|
38
|
+
/** Accumulated state of one OpenCode turn, mutated as events arrive. */
|
|
39
|
+
type TurnLog = {
|
|
40
|
+
text: string;
|
|
41
|
+
thinking: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
sessionId: string | undefined;
|
|
44
|
+
opencodeToolUse: string | undefined;
|
|
45
|
+
nativeToolRequests: { name: string; arguments: unknown }[];
|
|
46
|
+
nativeTextMark: number;
|
|
47
|
+
stepNativeCalls: (ParsedToolCall | undefined)[];
|
|
48
|
+
nativeCalls: ParsedToolCall[] | undefined;
|
|
49
|
+
opencodeError: string | undefined;
|
|
50
|
+
finishReason: "stop" | "length";
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Fixed inputs the line handler needs besides the mutable log. */
|
|
54
|
+
type LineDeps = {
|
|
55
|
+
model: Model<Api>;
|
|
56
|
+
output: AssistantMessage;
|
|
57
|
+
options: SimpleStreamOptions | undefined;
|
|
58
|
+
allowedToolNames: Set<string>;
|
|
59
|
+
projectDirs: string[];
|
|
60
|
+
nativeStepEnd: Error;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type TurnDecision =
|
|
64
|
+
| { kind: "success"; toolCalls: ParsedToolCall[]; displayText: string }
|
|
65
|
+
| { kind: "error"; error: Error; toolCallFailure?: ToolCallFailure }
|
|
66
|
+
| { kind: "recoverable"; error: Error; toolCallFailure?: ToolCallFailure };
|
|
67
|
+
|
|
36
68
|
function emptyUsage(): AssistantMessage["usage"] {
|
|
37
69
|
return {
|
|
38
70
|
input: 0,
|
|
@@ -74,6 +106,353 @@ export function resolveTurnTimeoutMs(timeoutMs: unknown): number {
|
|
|
74
106
|
: DEFAULT_TURN_TIMEOUT_MS;
|
|
75
107
|
}
|
|
76
108
|
|
|
109
|
+
/** Build the `opencode run` argument array for one attempt. */
|
|
110
|
+
function buildOpenCodeArgs(input: {
|
|
111
|
+
model: Model<Api>;
|
|
112
|
+
v2: boolean;
|
|
113
|
+
reasoning: ModelThinkingLevel | undefined;
|
|
114
|
+
sessionDir: string;
|
|
115
|
+
continuationSessionId: string | undefined;
|
|
116
|
+
imagePaths: string[];
|
|
117
|
+
}): string[] {
|
|
118
|
+
const variant = input.reasoning && input.reasoning !== "off"
|
|
119
|
+
? input.model.thinkingLevelMap?.[input.reasoning]
|
|
120
|
+
: undefined;
|
|
121
|
+
const args = [
|
|
122
|
+
"run",
|
|
123
|
+
"-m",
|
|
124
|
+
input.v2 && typeof variant === "string" && variant.trim()
|
|
125
|
+
? `${input.model.id}#${variant}`
|
|
126
|
+
: input.model.id,
|
|
127
|
+
"--agent",
|
|
128
|
+
AGENT_ID,
|
|
129
|
+
"--format",
|
|
130
|
+
"json",
|
|
131
|
+
...(input.v2 ? ["--standalone"] : ["--dir", input.sessionDir]),
|
|
132
|
+
];
|
|
133
|
+
if (input.continuationSessionId) {
|
|
134
|
+
args.push("--session", input.continuationSessionId);
|
|
135
|
+
}
|
|
136
|
+
args.push(
|
|
137
|
+
...reasoningCliArgs(
|
|
138
|
+
input.reasoning,
|
|
139
|
+
input.v2 ? undefined : input.model.thinkingLevelMap,
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
for (const path of input.imagePaths) args.push("--file", path);
|
|
143
|
+
return args;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Parse and dispatch one line of OpenCode JSON output. */
|
|
147
|
+
async function handleOpenCodeLine(
|
|
148
|
+
line: string,
|
|
149
|
+
log: TurnLog,
|
|
150
|
+
deps: LineDeps,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
// Output after a translated native step belongs to a run Pi already ended.
|
|
153
|
+
if (!line.trim() || log.nativeCalls) return;
|
|
154
|
+
let parsed: unknown;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(line);
|
|
157
|
+
} catch {
|
|
158
|
+
// Plugins share OpenCode's stdout; keep stray log lines as diagnostics.
|
|
159
|
+
log.stderr = (log.stderr + `\n${line}`).slice(-STDERR_LIMIT);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const event = objectRecord(parsed);
|
|
163
|
+
const part = objectRecord(event?.part);
|
|
164
|
+
if (!event || typeof event.type !== "string" ||
|
|
165
|
+
(event.part !== undefined && !part)) {
|
|
166
|
+
throw new Error("Invalid OpenCode event envelope");
|
|
167
|
+
}
|
|
168
|
+
await deps.options?.onProviderStreamEvent?.(event, deps.model);
|
|
169
|
+
|
|
170
|
+
// Every JSON event carries the OpenCode session ID (#71).
|
|
171
|
+
if (typeof event.sessionID === "string" && event.sessionID) {
|
|
172
|
+
log.sessionId = event.sessionID;
|
|
173
|
+
} else if (typeof part?.sessionID === "string" && part.sessionID) {
|
|
174
|
+
log.sessionId = part.sessionID;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (event.type === "text" || event.type === "reasoning") {
|
|
178
|
+
if (typeof part?.text !== "string") {
|
|
179
|
+
throw new Error("Invalid OpenCode event text");
|
|
180
|
+
}
|
|
181
|
+
if (event.type === "text") log.text += part.text;
|
|
182
|
+
else log.thinking += part.text;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (event.type === "step_finish") {
|
|
187
|
+
if (/length|max.?tokens/i.test(String(part?.reason ?? ""))) {
|
|
188
|
+
log.finishReason = "length";
|
|
189
|
+
}
|
|
190
|
+
const tokens = objectRecord(part?.tokens);
|
|
191
|
+
if (tokens) {
|
|
192
|
+
const cache = objectRecord(tokens.cache);
|
|
193
|
+
deps.output.usage.input = Number(tokens.input ?? 0);
|
|
194
|
+
deps.output.usage.output =
|
|
195
|
+
Number(tokens.output ?? 0) + Number(tokens.reasoning ?? 0);
|
|
196
|
+
deps.output.usage.cacheRead = Number(cache?.read ?? 0);
|
|
197
|
+
deps.output.usage.cacheWrite = Number(cache?.write ?? 0);
|
|
198
|
+
deps.output.usage.totalTokens = Number(
|
|
199
|
+
tokens.total ??
|
|
200
|
+
deps.output.usage.input +
|
|
201
|
+
deps.output.usage.output +
|
|
202
|
+
deps.output.usage.cacheRead +
|
|
203
|
+
deps.output.usage.cacheWrite,
|
|
204
|
+
);
|
|
205
|
+
calculateCost(deps.model, deps.output.usage);
|
|
206
|
+
}
|
|
207
|
+
const calls = log.stepNativeCalls;
|
|
208
|
+
log.stepNativeCalls = [];
|
|
209
|
+
// A fully translatable step ends the turn like a normal tool-call response.
|
|
210
|
+
// Otherwise OpenCode continues and the model sees the blocker's correction.
|
|
211
|
+
if (calls.length > 0 && calls.every(Boolean)) {
|
|
212
|
+
log.nativeCalls = calls as ParsedToolCall[];
|
|
213
|
+
throw deps.nativeStepEnd;
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (event.type === "tool_use") {
|
|
219
|
+
log.opencodeToolUse = part?.tool ? String(part.tool) : "unknown";
|
|
220
|
+
log.nativeTextMark = log.text.length;
|
|
221
|
+
const input = objectRecord(part?.state)?.input;
|
|
222
|
+
log.nativeToolRequests.push({ name: log.opencodeToolUse, arguments: input });
|
|
223
|
+
log.stepNativeCalls.push(translateNativeToolCall(
|
|
224
|
+
log.opencodeToolUse,
|
|
225
|
+
input,
|
|
226
|
+
deps.allowedToolNames,
|
|
227
|
+
deps.projectDirs,
|
|
228
|
+
));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (event.type === "error") {
|
|
233
|
+
const error = objectRecord(event.error);
|
|
234
|
+
const message = objectRecord(error?.data)?.message ?? error?.message ?? part?.message;
|
|
235
|
+
log.opencodeError =
|
|
236
|
+
typeof message === "string" && message.trim()
|
|
237
|
+
? message.trim()
|
|
238
|
+
: safeJson(event);
|
|
239
|
+
log.stderr = (log.stderr + `\n${log.opencodeError}`).slice(-STDERR_LIMIT);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Decide what the accumulated turn output means, or which error ends it. */
|
|
244
|
+
function decideTurn(input: {
|
|
245
|
+
log: TurnLog;
|
|
246
|
+
allowedToolNames: Set<string>;
|
|
247
|
+
allowRecovery: boolean;
|
|
248
|
+
}): TurnDecision {
|
|
249
|
+
const { log, allowedToolNames, allowRecovery } = input;
|
|
250
|
+
const toolCallResult = parseToolCallResponse(log.text, allowedToolNames);
|
|
251
|
+
if (!toolCallResult.ok) {
|
|
252
|
+
return {
|
|
253
|
+
kind: "error",
|
|
254
|
+
error: new Error(toolCallRejectionMessage(toolCallResult, allowedToolNames)),
|
|
255
|
+
toolCallFailure: toolCallResult,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
// Unrecoverable marker text is stripped so it never leaks into the
|
|
259
|
+
// visible chat response; remaining prose still displays normally.
|
|
260
|
+
const displayText = toolCallResult.cleanedText ?? log.text;
|
|
261
|
+
let textCalls = toolCallResult.calls;
|
|
262
|
+
// DeepSeek sometimes writes its only calls in the reasoning and replies
|
|
263
|
+
// with nothing, which would end the turn silently.
|
|
264
|
+
if (textCalls.length === 0 && !log.nativeCalls && !displayText.trim()) {
|
|
265
|
+
let reasoningResult = parseToolCallResponse(log.thinking, allowedToolNames);
|
|
266
|
+
// Markers stripped with no call surviving were meant as calls too.
|
|
267
|
+
if (reasoningResult.ok && reasoningResult.calls.length === 0 && reasoningResult.cleanedText !== undefined) {
|
|
268
|
+
reasoningResult = { ok: false, rejection: { reason: "malformed_markers" } };
|
|
269
|
+
}
|
|
270
|
+
if (!reasoningResult.ok) {
|
|
271
|
+
return {
|
|
272
|
+
kind: "error",
|
|
273
|
+
error: new Error(toolCallRejectionMessage(reasoningResult, allowedToolNames)),
|
|
274
|
+
toolCallFailure: reasoningResult,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
textCalls = reasoningResult.calls;
|
|
278
|
+
}
|
|
279
|
+
// A blocked native call with no usable Pi call recovers: nothing after it
|
|
280
|
+
// to answer with, or prose while Pi exposes tools to try (e.g. "please run
|
|
281
|
+
// a curl"). A valid call in text or reasoning is used instead of retrying;
|
|
282
|
+
// with no Pi tools the prose is the model's explanation and stays final.
|
|
283
|
+
if (
|
|
284
|
+
log.opencodeToolUse && !log.nativeCalls && textCalls.length === 0 &&
|
|
285
|
+
(!log.text.slice(log.nativeTextMark).trim() || allowedToolNames.size > 0)
|
|
286
|
+
) {
|
|
287
|
+
return {
|
|
288
|
+
kind: "recoverable",
|
|
289
|
+
error: new Error(
|
|
290
|
+
`OpenCode could not use the available Pi tools${allowRecovery ? "" : " after one recovery attempt"} (blocked native tool: ${JSON.stringify(log.opencodeToolUse)}). Retry the request or select another OpenCode model.`,
|
|
291
|
+
),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
const toolCalls = [...textCalls, ...(log.nativeCalls ?? [])];
|
|
295
|
+
if (toolCalls.length === 0 && displayText.trim()) {
|
|
296
|
+
// Check if the model tried to use XML-style tool-use instead of
|
|
297
|
+
// <pi_tool_call> markers — a common mistake with Claude-based models.
|
|
298
|
+
const xmlTool = detectXmlToolUse(displayText);
|
|
299
|
+
if (xmlTool) {
|
|
300
|
+
return {
|
|
301
|
+
kind: "error",
|
|
302
|
+
error: new Error(
|
|
303
|
+
`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.`,
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (toolCalls.length === 0 && !displayText.trim() && log.thinking.trim()) {
|
|
309
|
+
return {
|
|
310
|
+
kind: "recoverable",
|
|
311
|
+
error: new Error(
|
|
312
|
+
`OpenCode returned only reasoning without an answer or Pi tool call${allowRecovery ? "" : " after one recovery attempt"}. Retry the request or select another OpenCode model.`,
|
|
313
|
+
),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (toolCalls.length === 0 && !displayText.trim() && !log.thinking.trim()) {
|
|
317
|
+
const stderrMsg = log.stderr.trim();
|
|
318
|
+
if (stderrMsg) {
|
|
319
|
+
return { kind: "error", error: new Error(`OpenCode returned stderr: ${stderrMsg}`) };
|
|
320
|
+
}
|
|
321
|
+
// The model may have emitted text events with empty strings, or
|
|
322
|
+
// may have produced reasoning-only output that was not captured
|
|
323
|
+
// as assistant text. Provide a clearer diagnostic.
|
|
324
|
+
const rawTextPreview = log.text
|
|
325
|
+
? `raw text length=${log.text.length} (trimmed to "${log.text.slice(0, 80).replace(/\n/g, " ")}")`
|
|
326
|
+
: "no text events received";
|
|
327
|
+
const rawThinkingPreview = log.thinking
|
|
328
|
+
? `thinking length=${log.thinking.length}`
|
|
329
|
+
: "no thinking output";
|
|
330
|
+
return {
|
|
331
|
+
kind: "error",
|
|
332
|
+
error: new Error(
|
|
333
|
+
`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.`,
|
|
334
|
+
),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return { kind: "success", toolCalls, displayText };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Push thinking, tool-call or text events for a successful turn. */
|
|
341
|
+
function emitTurn(
|
|
342
|
+
stream: AssistantMessageEventStream,
|
|
343
|
+
output: AssistantMessage,
|
|
344
|
+
turn: {
|
|
345
|
+
thinking: string;
|
|
346
|
+
reasoning: ModelThinkingLevel | undefined;
|
|
347
|
+
toolCalls: ParsedToolCall[];
|
|
348
|
+
displayText: string;
|
|
349
|
+
finishReason: "stop" | "length";
|
|
350
|
+
},
|
|
351
|
+
): void {
|
|
352
|
+
if (turn.thinking && turn.reasoning && turn.reasoning !== "off") {
|
|
353
|
+
const thinkingIndex = output.content.length;
|
|
354
|
+
output.content.push({ type: "thinking", thinking: turn.thinking });
|
|
355
|
+
stream.push({
|
|
356
|
+
type: "thinking_start",
|
|
357
|
+
contentIndex: thinkingIndex,
|
|
358
|
+
partial: output,
|
|
359
|
+
});
|
|
360
|
+
stream.push({
|
|
361
|
+
type: "thinking_delta",
|
|
362
|
+
contentIndex: thinkingIndex,
|
|
363
|
+
delta: turn.thinking,
|
|
364
|
+
partial: output,
|
|
365
|
+
});
|
|
366
|
+
stream.push({
|
|
367
|
+
type: "thinking_end",
|
|
368
|
+
contentIndex: thinkingIndex,
|
|
369
|
+
content: turn.thinking,
|
|
370
|
+
partial: output,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (turn.toolCalls.length > 0) {
|
|
375
|
+
output.stopReason = "toolUse";
|
|
376
|
+
for (const call of turn.toolCalls) {
|
|
377
|
+
const toolCall: ToolCall = {
|
|
378
|
+
type: "toolCall",
|
|
379
|
+
id: `opencode_pi_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
380
|
+
name: call.name,
|
|
381
|
+
arguments: call.arguments as ToolCall["arguments"],
|
|
382
|
+
};
|
|
383
|
+
const contentIndex = output.content.length;
|
|
384
|
+
output.content.push(toolCall);
|
|
385
|
+
stream.push({
|
|
386
|
+
type: "toolcall_start",
|
|
387
|
+
contentIndex,
|
|
388
|
+
partial: output,
|
|
389
|
+
});
|
|
390
|
+
stream.push({
|
|
391
|
+
type: "toolcall_delta",
|
|
392
|
+
contentIndex,
|
|
393
|
+
delta: safeJson(toolCall.arguments),
|
|
394
|
+
partial: output,
|
|
395
|
+
});
|
|
396
|
+
stream.push({
|
|
397
|
+
type: "toolcall_end",
|
|
398
|
+
contentIndex,
|
|
399
|
+
toolCall,
|
|
400
|
+
partial: output,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
stream.push({ type: "done", reason: "toolUse", message: output });
|
|
404
|
+
stream.end();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
output.stopReason = turn.finishReason;
|
|
409
|
+
if (turn.displayText) {
|
|
410
|
+
const contentIndex = output.content.length;
|
|
411
|
+
output.content.push({ type: "text", text: turn.displayText });
|
|
412
|
+
stream.push({ type: "text_start", contentIndex, partial: output });
|
|
413
|
+
stream.push({
|
|
414
|
+
type: "text_delta",
|
|
415
|
+
contentIndex,
|
|
416
|
+
delta: turn.displayText,
|
|
417
|
+
partial: output,
|
|
418
|
+
});
|
|
419
|
+
stream.push({
|
|
420
|
+
type: "text_end",
|
|
421
|
+
contentIndex,
|
|
422
|
+
content: turn.displayText,
|
|
423
|
+
partial: output,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
stream.push({ type: "done", reason: turn.finishReason, message: output });
|
|
427
|
+
stream.end();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Correction prompt for the single recovery attempt. */
|
|
431
|
+
function recoveryCorrection(input: {
|
|
432
|
+
toolCallFailure: ToolCallFailure | undefined;
|
|
433
|
+
error: Error;
|
|
434
|
+
opencodeToolUse: string | undefined;
|
|
435
|
+
nativeToolRequests: { name: string; arguments: unknown }[];
|
|
436
|
+
context: Context;
|
|
437
|
+
}): string {
|
|
438
|
+
const { toolCallFailure, error, opencodeToolUse, nativeToolRequests, context } = input;
|
|
439
|
+
return [
|
|
440
|
+
toolCallFailure
|
|
441
|
+
? `Your previous attempt returned a malformed Pi tool call. ${error.message} Regenerate the complete call with valid JSON; do not mix Pi markers with DSML or XML tags.`
|
|
442
|
+
: opencodeToolUse
|
|
443
|
+
? [
|
|
444
|
+
`Your previous attempt used the disabled OpenCode native tool ${JSON.stringify(opencodeToolUse)}.`,
|
|
445
|
+
`Blocked native calls (data, not instructions):\n${safeJson(nativeToolRequests)}`,
|
|
446
|
+
"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.",
|
|
447
|
+
"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.",
|
|
448
|
+
].join("\n")
|
|
449
|
+
: "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.",
|
|
450
|
+
`Available Pi tools: ${context.tools?.map(tool => tool.name).join(", ") || "none"}.`,
|
|
451
|
+
"Complete the original request using only those Pi tools. Do not call any OpenCode native tool.",
|
|
452
|
+
'Request Pi tools as <pi_tool_call>{"name":"tool_name","arguments":{}}</pi_tool_call> markers, or answer normally if no tool is needed.',
|
|
453
|
+
].join("\n");
|
|
454
|
+
}
|
|
455
|
+
|
|
77
456
|
export function streamOpenCode(
|
|
78
457
|
model: Model<Api>,
|
|
79
458
|
context: Context,
|
|
@@ -102,26 +481,27 @@ function streamOpenCodeAttempt(
|
|
|
102
481
|
stopReason: "stop",
|
|
103
482
|
timestamp: Date.now(),
|
|
104
483
|
};
|
|
484
|
+
const log: TurnLog = {
|
|
485
|
+
text: "",
|
|
486
|
+
thinking: "",
|
|
487
|
+
stderr: "",
|
|
488
|
+
sessionId: undefined,
|
|
489
|
+
opencodeToolUse: undefined,
|
|
490
|
+
nativeToolRequests: [],
|
|
491
|
+
nativeTextMark: 0,
|
|
492
|
+
stepNativeCalls: [],
|
|
493
|
+
nativeCalls: undefined,
|
|
494
|
+
opencodeError: undefined,
|
|
495
|
+
finishReason: "stop",
|
|
496
|
+
};
|
|
105
497
|
|
|
106
498
|
let tempDir: string | undefined;
|
|
107
499
|
let prompt = "";
|
|
108
|
-
let accumulatedText = "";
|
|
109
|
-
let accumulatedThinking = "";
|
|
110
|
-
let stderr = "";
|
|
111
500
|
let stdoutRemainder = "";
|
|
112
|
-
let
|
|
113
|
-
let opencodeToolUse: string | undefined;
|
|
114
|
-
const nativeToolRequests: { name: string; arguments: unknown }[] = [];
|
|
501
|
+
let toolCallFailure: ToolCallFailure | undefined;
|
|
115
502
|
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
503
|
// Thrown from the event handler so the process runner stops OpenCode.
|
|
122
504
|
const nativeStepEnd = new Error("OpenCode stopped after a translated native step");
|
|
123
|
-
let opencodeError: string | undefined;
|
|
124
|
-
let finishReason: "stop" | "length" = "stop";
|
|
125
505
|
// Per-Pi-session reuse key supplied by Pi core (stable for the life of
|
|
126
506
|
// the Pi session). Without it, every turn runs an isolated fresh
|
|
127
507
|
// session in its own temporary directory.
|
|
@@ -182,116 +562,24 @@ function streamOpenCodeAttempt(
|
|
|
182
562
|
if (options?.signal?.aborted) throw new Error("Request was aborted");
|
|
183
563
|
const v2 = await usesV2Cli();
|
|
184
564
|
const requestedReasoning = options?.reasoning as ModelThinkingLevel | undefined;
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
}
|
|
565
|
+
const args = buildOpenCodeArgs({
|
|
566
|
+
model,
|
|
567
|
+
v2,
|
|
568
|
+
reasoning: requestedReasoning,
|
|
569
|
+
sessionDir,
|
|
570
|
+
continuationSessionId: isContinuation ? piState?.opencodeSessionId : undefined,
|
|
571
|
+
imagePaths,
|
|
572
|
+
});
|
|
284
573
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
stderr = (stderr + `\n${opencodeError}`).slice(-STDERR_LIMIT);
|
|
293
|
-
}
|
|
574
|
+
const deps: LineDeps = {
|
|
575
|
+
model,
|
|
576
|
+
output,
|
|
577
|
+
options,
|
|
578
|
+
allowedToolNames,
|
|
579
|
+
projectDirs,
|
|
580
|
+
nativeStepEnd,
|
|
294
581
|
};
|
|
582
|
+
const handleLine = (line: string) => handleOpenCodeLine(line, log, deps);
|
|
295
583
|
|
|
296
584
|
const result = await runOpenCodeProcess(opencodeBin(), args, {
|
|
297
585
|
cwd: v2 ? sessionDir : undefined,
|
|
@@ -332,196 +620,47 @@ function streamOpenCodeAttempt(
|
|
|
332
620
|
throw error;
|
|
333
621
|
});
|
|
334
622
|
if (result) {
|
|
335
|
-
stderr = (stderr + result.stderr).slice(-STDERR_LIMIT);
|
|
623
|
+
log.stderr = (log.stderr + result.stderr).slice(-STDERR_LIMIT);
|
|
336
624
|
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
|
-
);
|
|
625
|
+
throw new Error(log.stderr.trim() || `opencode exited with code ${result.code}`);
|
|
396
626
|
}
|
|
397
627
|
}
|
|
398
|
-
if (
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
);
|
|
628
|
+
if (log.opencodeError) throw new Error(log.opencodeError);
|
|
629
|
+
|
|
630
|
+
const decision = decideTurn({ log, allowedToolNames, allowRecovery });
|
|
631
|
+
if (decision.kind !== "success") {
|
|
632
|
+
if (decision.toolCallFailure) toolCallFailure = decision.toolCallFailure;
|
|
633
|
+
if (decision.kind === "recoverable") recoverableFailure = decision.error;
|
|
634
|
+
throw decision.error;
|
|
427
635
|
}
|
|
636
|
+
const { toolCalls, displayText } = decision;
|
|
428
637
|
setEstimatedUsage(
|
|
429
638
|
model,
|
|
430
639
|
output,
|
|
431
640
|
prompt,
|
|
432
|
-
|
|
641
|
+
log.text + log.thinking,
|
|
433
642
|
);
|
|
434
643
|
// The turn succeeded: record the session for reuse, or delete the
|
|
435
644
|
// one-shot session record so it cannot accumulate (#71, #75).
|
|
436
645
|
// 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;
|
|
646
|
+
if (piState && !payloadHook && !log.nativeCalls) {
|
|
647
|
+
piState.opencodeSessionId = log.sessionId ?? piState.opencodeSessionId;
|
|
440
648
|
piState.modelId = model.id;
|
|
441
649
|
piState.toolsKey = serializeTools(context.tools);
|
|
442
650
|
piState.systemPrompt = context.systemPrompt ?? "";
|
|
443
651
|
piState.sentParts = transcriptParts;
|
|
444
652
|
} else {
|
|
445
653
|
if (piState) dropOpencodeSession(piState);
|
|
446
|
-
deleteOpenCodeSession(
|
|
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;
|
|
654
|
+
deleteOpenCodeSession(log.sessionId);
|
|
503
655
|
}
|
|
504
656
|
|
|
505
|
-
output
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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();
|
|
657
|
+
emitTurn(stream, output, {
|
|
658
|
+
thinking: log.thinking,
|
|
659
|
+
reasoning: requestedReasoning,
|
|
660
|
+
toolCalls,
|
|
661
|
+
displayText,
|
|
662
|
+
finishReason: log.finishReason,
|
|
663
|
+
});
|
|
525
664
|
} catch (error) {
|
|
526
665
|
if (toolCallFailure && toolCallFailure.rejection.reason !== "unavailable_tool" && error instanceof Error) {
|
|
527
666
|
recoverableFailure = error;
|
|
@@ -530,27 +669,19 @@ function streamOpenCodeAttempt(
|
|
|
530
669
|
// Any error, timeout, or abort drops the session ID so the next turn
|
|
531
670
|
// starts fresh with the full transcript; the failed session record is
|
|
532
671
|
// deleted best-effort and never fails the turn (#71, #75).
|
|
533
|
-
deleteOpenCodeSession(
|
|
672
|
+
deleteOpenCodeSession(log.sessionId);
|
|
534
673
|
if (piState) dropOpencodeSession(piState);
|
|
535
674
|
const remainingMs = deadline - Date.now();
|
|
536
675
|
if (recoverableFailure && error === recoverableFailure && allowRecovery &&
|
|
537
676
|
!options?.signal?.aborted && remainingMs > 0) {
|
|
538
|
-
setEstimatedUsage(model, output, prompt,
|
|
539
|
-
const correction =
|
|
540
|
-
toolCallFailure
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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");
|
|
677
|
+
setEstimatedUsage(model, output, prompt, log.text + log.thinking);
|
|
678
|
+
const correction = recoveryCorrection({
|
|
679
|
+
toolCallFailure,
|
|
680
|
+
error: recoverableFailure,
|
|
681
|
+
opencodeToolUse: log.opencodeToolUse,
|
|
682
|
+
nativeToolRequests: log.nativeToolRequests,
|
|
683
|
+
context,
|
|
684
|
+
});
|
|
554
685
|
// A fresh attempt keeps rejected native calls out of the continued transcript.
|
|
555
686
|
const recovery = streamOpenCodeAttempt(model, {
|
|
556
687
|
...context,
|