@tduyng/pi-opencode-z 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +178 -0
- package/README.md +69 -2
- package/package.json +42 -4
- package/src/client.ts +280 -0
- package/src/config.ts +96 -0
- package/src/index.ts +195 -0
- package/src/mcp.ts +315 -0
- package/src/models.ts +86 -0
- package/src/session.ts +191 -0
- package/src/stream.ts +1013 -0
package/src/stream.ts
ADDED
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Validator } from 'ata-validator'
|
|
5
|
+
import { t } from 'ata-validator/t'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
calculateCost,
|
|
9
|
+
createAssistantMessageEventStream,
|
|
10
|
+
type Api,
|
|
11
|
+
type AssistantMessage,
|
|
12
|
+
type AssistantMessageEventStream,
|
|
13
|
+
type ImageContent,
|
|
14
|
+
type Message,
|
|
15
|
+
type Model,
|
|
16
|
+
type SimpleStreamOptions,
|
|
17
|
+
type TextContent,
|
|
18
|
+
type Tool,
|
|
19
|
+
type ToolCall
|
|
20
|
+
} from '@earendil-works/pi-ai'
|
|
21
|
+
import type { ToolAnnotations } from '@earendil-works/pi-coding-agent'
|
|
22
|
+
|
|
23
|
+
import { api, type OpenCodeEvent } from './client.js'
|
|
24
|
+
import type {
|
|
25
|
+
JsonObject,
|
|
26
|
+
JsonValue,
|
|
27
|
+
McpContent,
|
|
28
|
+
McpResult,
|
|
29
|
+
McpServerHandle,
|
|
30
|
+
McpToolAnnotations,
|
|
31
|
+
McpToolDef
|
|
32
|
+
} from './mcp.js'
|
|
33
|
+
import { variantFor } from './models.js'
|
|
34
|
+
import {
|
|
35
|
+
BRIDGE_AGENT,
|
|
36
|
+
bridgeSession,
|
|
37
|
+
createSessionStore,
|
|
38
|
+
ensureProjectDir,
|
|
39
|
+
findByOpencodeId,
|
|
40
|
+
messageFingerprint,
|
|
41
|
+
releaseParked,
|
|
42
|
+
resetOpencodeSession,
|
|
43
|
+
TOOL_PREFIX,
|
|
44
|
+
toolsKey,
|
|
45
|
+
type BridgeSession,
|
|
46
|
+
type BridgeSessionStore,
|
|
47
|
+
type TokenTotals
|
|
48
|
+
} from './session.js'
|
|
49
|
+
const isString = new Validator(t.string()).isValidObject
|
|
50
|
+
const isNumber = new Validator(t.number()).isValidObject
|
|
51
|
+
|
|
52
|
+
const DEFAULT_TURN_TIMEOUT_MS = 300_000
|
|
53
|
+
const PARK_GRACE_MS = 15_000
|
|
54
|
+
const TOOLS_RELOAD_MS = 250
|
|
55
|
+
const MAX_NUDGES = 2
|
|
56
|
+
|
|
57
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
58
|
+
|
|
59
|
+
export type BridgeContext = { messages: Message[] }
|
|
60
|
+
|
|
61
|
+
export type TranscriptState = {
|
|
62
|
+
systemPrompt: string
|
|
63
|
+
tools: Tool[]
|
|
64
|
+
conversation: Message[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function replayTranscript(messages: Message[]): TranscriptState {
|
|
68
|
+
const sections = new Map<string, string>()
|
|
69
|
+
const tools = new Map<string, Tool>()
|
|
70
|
+
const conversation: Message[] = []
|
|
71
|
+
const additions: string[] = []
|
|
72
|
+
let base = ''
|
|
73
|
+
let leading = true
|
|
74
|
+
|
|
75
|
+
for (const message of messages) {
|
|
76
|
+
if (message.role !== 'system') {
|
|
77
|
+
conversation.push(message)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
const text = contentText(message.content)
|
|
81
|
+
if (leading) {
|
|
82
|
+
base = text
|
|
83
|
+
leading = false
|
|
84
|
+
} else if (text) {
|
|
85
|
+
additions.push(text)
|
|
86
|
+
}
|
|
87
|
+
for (const [name, value] of Object.entries(message.sections ?? {})) {
|
|
88
|
+
if (value === null) sections.delete(name)
|
|
89
|
+
else sections.set(name, value)
|
|
90
|
+
}
|
|
91
|
+
for (const tool of message.toolsAdded ?? []) tools.set(tool.name, tool)
|
|
92
|
+
for (const reference of message.toolsRemoved ?? []) tools.delete(reference.name)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
systemPrompt: [base, ...sections.values(), ...additions].filter(Boolean).join('\n\n'),
|
|
97
|
+
tools: [...tools.values()],
|
|
98
|
+
conversation
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const PROVIDER_ID = 'opencode'
|
|
102
|
+
|
|
103
|
+
type SessionListener = (event: OpenCodeEvent) => void
|
|
104
|
+
type RecoveryReason = 'refused' | 'empty'
|
|
105
|
+
|
|
106
|
+
export type BridgeRuntime = {
|
|
107
|
+
sessions: BridgeSessionStore
|
|
108
|
+
listeners: Map<string, SessionListener>
|
|
109
|
+
mcp?: McpServerHandle
|
|
110
|
+
toolHints?: ToolHints
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type ToolHints = (name: string) => McpToolAnnotations | undefined
|
|
114
|
+
|
|
115
|
+
export const NO_TOOL_HINTS: ToolHints = () => undefined
|
|
116
|
+
|
|
117
|
+
export function createBridgeRuntime(): BridgeRuntime {
|
|
118
|
+
return { sessions: createSessionStore(), listeners: new Map() }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function toolHintsFrom(getAllTools: () => { name: string; annotations?: ToolAnnotations }[]): ToolHints {
|
|
122
|
+
return (name) => getAllTools().find((tool) => tool.name === name)?.annotations
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function attachMcpServer(runtime: BridgeRuntime, handle: McpServerHandle): void {
|
|
126
|
+
runtime.mcp = handle
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type BridgeEvent =
|
|
130
|
+
| { type: 'session.step.started'; sessionID: string }
|
|
131
|
+
| { type: 'session.step.streamed'; sessionID: string }
|
|
132
|
+
| { type: 'session.execution.succeeded'; sessionID: string }
|
|
133
|
+
| { type: 'session.reasoning.delta' | 'session.text.delta'; sessionID: string; delta: string }
|
|
134
|
+
| { type: 'session.reasoning.ended' | 'session.text.ended'; sessionID: string; text: string }
|
|
135
|
+
| { type: 'session.tool.input.started'; sessionID: string; name: string; id?: string }
|
|
136
|
+
| { type: 'session.tool.success' | 'session.tool.failed'; sessionID: string; id: string }
|
|
137
|
+
| {
|
|
138
|
+
type: 'session.step.failed' | 'session.execution.interrupted' | 'session.execution.failed'
|
|
139
|
+
sessionID: string
|
|
140
|
+
failure?: string
|
|
141
|
+
}
|
|
142
|
+
| { type: 'session.step.ended'; sessionID: string; tokens?: TokenTotals }
|
|
143
|
+
| { type: 'permission.asked'; sessionID: string; id: string; action: string }
|
|
144
|
+
| { type: 'bridge.tool.parked'; sessionID: string; callId: string }
|
|
145
|
+
| { type: 'other'; sessionID: string; failure?: string }
|
|
146
|
+
|
|
147
|
+
function decodeEvent(event: OpenCodeEvent): BridgeEvent {
|
|
148
|
+
const cached = decodedEvents.get(event)
|
|
149
|
+
if (cached !== undefined) return cached
|
|
150
|
+
const decoded = decodeUncached(event)
|
|
151
|
+
decodedEvents.set(event, decoded)
|
|
152
|
+
return decoded
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const decodedEvents = new WeakMap<OpenCodeEvent, BridgeEvent>()
|
|
156
|
+
|
|
157
|
+
function decodeUncached(event: OpenCodeEvent): BridgeEvent {
|
|
158
|
+
const data = event.data ?? {}
|
|
159
|
+
const sessionID = isString(data['sessionID']) ? data['sessionID'] : ''
|
|
160
|
+
switch (event.type) {
|
|
161
|
+
case 'session.step.started':
|
|
162
|
+
case 'session.step.streamed':
|
|
163
|
+
case 'session.execution.succeeded':
|
|
164
|
+
return { type: event.type, sessionID }
|
|
165
|
+
case 'session.reasoning.delta':
|
|
166
|
+
case 'session.text.delta': {
|
|
167
|
+
const delta = data['delta']
|
|
168
|
+
if (isString(delta)) return { type: event.type, sessionID, delta }
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
case 'session.reasoning.ended':
|
|
172
|
+
case 'session.text.ended': {
|
|
173
|
+
const text = data['text']
|
|
174
|
+
return { type: event.type, sessionID, text: isString(text) ? text : '' }
|
|
175
|
+
}
|
|
176
|
+
case 'session.tool.input.started': {
|
|
177
|
+
const name = data['name']
|
|
178
|
+
if (!isString(name)) break
|
|
179
|
+
const id = data['id']
|
|
180
|
+
return { type: event.type, sessionID, name, id: isString(id) ? id : undefined }
|
|
181
|
+
}
|
|
182
|
+
case 'session.tool.success':
|
|
183
|
+
case 'session.tool.failed': {
|
|
184
|
+
const id = data['id']
|
|
185
|
+
if (isString(id)) return { type: event.type, sessionID, id }
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
case 'session.step.failed':
|
|
189
|
+
case 'session.execution.interrupted':
|
|
190
|
+
case 'session.execution.failed':
|
|
191
|
+
return { type: event.type, sessionID, failure: failureOf(event) }
|
|
192
|
+
case 'session.step.ended':
|
|
193
|
+
return { type: event.type, sessionID, tokens: decodeStepTokens(event) }
|
|
194
|
+
case 'permission.asked': {
|
|
195
|
+
const id = data['id']
|
|
196
|
+
if (!isString(id)) break
|
|
197
|
+
const action = data['action']
|
|
198
|
+
return { type: event.type, sessionID, id, action: isString(action) ? action : 'unknown' }
|
|
199
|
+
}
|
|
200
|
+
case 'bridge.tool.parked': {
|
|
201
|
+
const callId = data['callId']
|
|
202
|
+
if (isString(callId)) return { type: event.type, sessionID, callId }
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { type: 'other', sessionID, failure: failureOf(event) }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function failureOf(event: OpenCodeEvent): string | undefined {
|
|
210
|
+
const data = event.data
|
|
211
|
+
const errorMessage = data?.['errorMessage']
|
|
212
|
+
const message = data?.['message']
|
|
213
|
+
const error = data?.['error']
|
|
214
|
+
const direct =
|
|
215
|
+
(isString(errorMessage) && errorMessage.trim()) ||
|
|
216
|
+
(isString(message) && message.trim()) ||
|
|
217
|
+
(isString(error) && error.trim())
|
|
218
|
+
if (direct) return direct
|
|
219
|
+
if (!isErrorObjectSchema(error)) return undefined
|
|
220
|
+
const parsed = error
|
|
221
|
+
return parsed.message?.trim() || parsed.errorMessage?.trim() || parsed.reason?.trim() || undefined
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isObject(value: unknown): value is JsonObject {
|
|
225
|
+
return isJsonObjectSchema(value)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function decodeStepTokens(event: OpenCodeEvent): TokenTotals | undefined {
|
|
229
|
+
const tokens = event.data?.['tokens']
|
|
230
|
+
if (!tokens) return undefined
|
|
231
|
+
if (Array.isArray(tokens)) return emptyTokenTotals()
|
|
232
|
+
if (!isObject(tokens)) return undefined
|
|
233
|
+
const cache = isObject(tokens['cache']) ? tokens['cache'] : undefined
|
|
234
|
+
return {
|
|
235
|
+
input: tokenCount(tokens['input']),
|
|
236
|
+
output: tokenCount(tokens['output']) + tokenCount(tokens['reasoning']),
|
|
237
|
+
cacheRead: tokenCount(cache?.['read']),
|
|
238
|
+
cacheWrite: tokenCount(cache?.['write'])
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function tokenCount(value: JsonValue | undefined): number {
|
|
243
|
+
return isNumber(value) && Number.isFinite(value) && value > 0 ? value : 0
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function emptyTokenTotals(): TokenTotals {
|
|
247
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function routeEvent(runtime: BridgeRuntime, event: OpenCodeEvent): void {
|
|
251
|
+
const decoded = decodeEvent(event)
|
|
252
|
+
if (!decoded.sessionID) return
|
|
253
|
+
|
|
254
|
+
if (decoded.type === 'session.step.ended') {
|
|
255
|
+
const session = findByOpencodeId(runtime.sessions, decoded.sessionID)
|
|
256
|
+
if (session) parkLateUsage(session, decoded.tokens)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (decoded.type === 'permission.asked') {
|
|
261
|
+
const { sessionID, id, action } = decoded
|
|
262
|
+
void answerPermission(runtime.sessions, sessionID, id, action).catch(() => undefined)
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
runtime.listeners.get(decoded.sessionID)?.(event)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function answerPermission(
|
|
269
|
+
sessions: BridgeSessionStore,
|
|
270
|
+
sessionId: string,
|
|
271
|
+
requestId: string,
|
|
272
|
+
action: string
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
const session = findByOpencodeId(sessions, sessionId)
|
|
275
|
+
if (!session) return
|
|
276
|
+
if (session.answeredPermissions.has(requestId)) return
|
|
277
|
+
|
|
278
|
+
const reply: { decision: 'once' | 'reject'; message?: string } = action.startsWith(TOOL_PREFIX)
|
|
279
|
+
? { decision: 'once' }
|
|
280
|
+
: {
|
|
281
|
+
decision: 'reject',
|
|
282
|
+
message: `Your built-in ${action} tool is disabled here. Call the matching pi_ tool instead.`
|
|
283
|
+
}
|
|
284
|
+
session.answeredPermissions.add(requestId)
|
|
285
|
+
if (reply.decision === 'reject') session.refusedTools.push(action)
|
|
286
|
+
const send = () => api('POST', `/api/session/${sessionId}/permission/${requestId}/reply`, reply)
|
|
287
|
+
try {
|
|
288
|
+
await send()
|
|
289
|
+
} catch {
|
|
290
|
+
try {
|
|
291
|
+
await send()
|
|
292
|
+
} catch {
|
|
293
|
+
session.answeredPermissions.delete(requestId)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function parkLateUsage(session: BridgeSession, step: TokenTotals | undefined): void {
|
|
299
|
+
if (!step) return
|
|
300
|
+
session.lateUsage.input += step.input
|
|
301
|
+
session.lateUsage.output += step.output
|
|
302
|
+
session.lateUsage.cacheRead += step.cacheRead
|
|
303
|
+
session.lateUsage.cacheWrite += step.cacheWrite
|
|
304
|
+
session.latestStep = step
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function takeLateUsage(session: BridgeSession | undefined, usage: AssistantMessage['usage']): void {
|
|
308
|
+
if (!session) return
|
|
309
|
+
usage.input += session.lateUsage.input
|
|
310
|
+
usage.output += session.lateUsage.output
|
|
311
|
+
usage.cacheRead += session.lateUsage.cacheRead
|
|
312
|
+
usage.cacheWrite += session.lateUsage.cacheWrite
|
|
313
|
+
const step = session.latestStep
|
|
314
|
+
usage.totalTokens = step.input + step.output + step.cacheRead + step.cacheWrite
|
|
315
|
+
session.lateUsage = emptyTokenTotals()
|
|
316
|
+
session.latestStep = emptyTokenTotals()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function emptyUsage(): AssistantMessage['usage'] {
|
|
320
|
+
return {
|
|
321
|
+
input: 0,
|
|
322
|
+
output: 0,
|
|
323
|
+
cacheRead: 0,
|
|
324
|
+
cacheWrite: 0,
|
|
325
|
+
totalTokens: 0,
|
|
326
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function contentText(content: string | (TextContent | ImageContent)[]): string {
|
|
331
|
+
if (Array.isArray(content)) {
|
|
332
|
+
return content
|
|
333
|
+
.map((part) => (part.type === 'text' ? part.text : `[image: ${part.mimeType}]`))
|
|
334
|
+
.join('\n')
|
|
335
|
+
.trim()
|
|
336
|
+
}
|
|
337
|
+
return content
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function imagesIn(messages: Message[]): ImageContent[] {
|
|
341
|
+
const images: ImageContent[] = []
|
|
342
|
+
for (const message of messages) {
|
|
343
|
+
if (message.role === 'assistant' || !Array.isArray(message.content)) continue
|
|
344
|
+
for (const part of message.content) if (part.type === 'image') images.push(part)
|
|
345
|
+
}
|
|
346
|
+
return images
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function serializeHistory(messages: Message[]): string {
|
|
350
|
+
const parts: string[] = []
|
|
351
|
+
for (const message of messages) {
|
|
352
|
+
if (message.role === 'user') {
|
|
353
|
+
parts.push(`USER:\n${contentText(message.content)}`)
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
if (message.role === 'toolResult') {
|
|
357
|
+
parts.push(
|
|
358
|
+
`RESULT of ${TOOL_PREFIX}${message.toolName}${message.isError ? ' (failed)' : ''}:\n${contentText(message.content)}`
|
|
359
|
+
)
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
const rendered = Array.isArray(message.content)
|
|
363
|
+
? message.content
|
|
364
|
+
.map((part) => {
|
|
365
|
+
if (part.type === 'text') return part.text
|
|
366
|
+
if (part.type === 'thinking') return ''
|
|
367
|
+
return `CALLED ${TOOL_PREFIX}${part.name}(${JSON.stringify(part.arguments)})`
|
|
368
|
+
})
|
|
369
|
+
.filter(Boolean)
|
|
370
|
+
.join('\n')
|
|
371
|
+
: message.content
|
|
372
|
+
if (rendered) parts.push(`ASSISTANT:\n${rendered}`)
|
|
373
|
+
}
|
|
374
|
+
return parts.join('\n\n')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function preamble(systemPrompt: string): string {
|
|
378
|
+
return systemPrompt.trim() ? `<system>\n${systemPrompt.trim()}\n</system>` : ''
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const LEAKED_CALL_MARKER = /<\/?(?:invoke|parameter|tool_call)\b/
|
|
382
|
+
|
|
383
|
+
function leakedToolCall(text: string): boolean {
|
|
384
|
+
return LEAKED_CALL_MARKER.test(text.replaceAll('\u200b', '')) && text.includes(TOOL_PREFIX)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function refusalNudge(refused: string[]): string {
|
|
388
|
+
const names = [...new Set(refused)].join(', ')
|
|
389
|
+
return [
|
|
390
|
+
`Your built-in ${names} call was refused and opencode stopped the step. Built-in tools are disabled here.`,
|
|
391
|
+
'Redo that work with the matching pi_ tool, such as pi_read, pi_bash, pi_edit or pi_write.'
|
|
392
|
+
].join('\n')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const EMPTY_TURN_NUDGE = [
|
|
396
|
+
'Your turn ended after reasoning without a tool call or final answer.',
|
|
397
|
+
'Call one of the declared pi_ tools now. A functions. namespace label is not part of the tool name.'
|
|
398
|
+
].join('\n')
|
|
399
|
+
|
|
400
|
+
export function mcpToolDefs(tools: Tool[], hints: ToolHints): McpToolDef[] {
|
|
401
|
+
return tools.map((tool) => {
|
|
402
|
+
const annotations = hints(tool.name)
|
|
403
|
+
const def: McpToolDef = {
|
|
404
|
+
name: tool.name,
|
|
405
|
+
description: tool.description,
|
|
406
|
+
inputSchema: mcpInputSchema(tool.parameters as JsonObject)
|
|
407
|
+
}
|
|
408
|
+
if (annotations) def.annotations = annotations
|
|
409
|
+
return def
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const JsonObjectSchema = t.object({}, { additionalProperties: true })
|
|
414
|
+
const isJsonObjectSchema = new Validator(JsonObjectSchema).isValidObject
|
|
415
|
+
const RequiredArraySchema = t.array(t.string())
|
|
416
|
+
const isRequiredArraySchema = new Validator(RequiredArraySchema).isValidObject
|
|
417
|
+
const RequiredObjectSchema = t.object({}, { additionalProperties: true })
|
|
418
|
+
const isRequiredObjectSchema = new Validator(RequiredObjectSchema).isValidObject
|
|
419
|
+
|
|
420
|
+
export function mcpInputSchema(parameters: JsonObject | undefined): JsonObject {
|
|
421
|
+
if (!isJsonObjectSchema(parameters)) return { type: 'object' }
|
|
422
|
+
const parsed = parameters
|
|
423
|
+
const required = parsed['required']
|
|
424
|
+
if (isRequiredArraySchema(required)) {
|
|
425
|
+
return parsed as JsonObject
|
|
426
|
+
}
|
|
427
|
+
if (isRequiredObjectSchema(required)) {
|
|
428
|
+
return { ...parsed, required: Object.keys(required) } as JsonObject
|
|
429
|
+
}
|
|
430
|
+
return { ...parsed, required: [] } as JsonObject
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function piToolResult(message: { content: (TextContent | ImageContent)[]; isError: boolean }): McpResult {
|
|
434
|
+
const content: McpContent[] = message.content.map((part) =>
|
|
435
|
+
part.type === 'text'
|
|
436
|
+
? { type: 'text', text: part.text }
|
|
437
|
+
: { type: 'image', data: part.data, mimeType: part.mimeType }
|
|
438
|
+
)
|
|
439
|
+
return {
|
|
440
|
+
content: content.length > 0 ? content : [{ type: 'text', text: '(no output)' }],
|
|
441
|
+
isError: message.isError
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function turnTimeoutMs(value: number | undefined): number {
|
|
446
|
+
return value !== undefined && Number.isFinite(value) && value > 0 ? value : DEFAULT_TURN_TIMEOUT_MS
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function transcriptDrifted(session: BridgeSession, messages: Message[]): boolean {
|
|
450
|
+
if (messages.length < session.fingerprints.length) return true
|
|
451
|
+
for (let index = 0; index < session.fingerprints.length; index++) {
|
|
452
|
+
const message = messages[index]
|
|
453
|
+
if (!message || session.fingerprints[index] !== messageFingerprint(message)) return true
|
|
454
|
+
}
|
|
455
|
+
return false
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function streamOpenCodeBridge(
|
|
459
|
+
runtime: BridgeRuntime,
|
|
460
|
+
model: Model<Api>,
|
|
461
|
+
context: BridgeContext,
|
|
462
|
+
options?: SimpleStreamOptions
|
|
463
|
+
): AssistantMessageEventStream {
|
|
464
|
+
const stream = createAssistantMessageEventStream()
|
|
465
|
+
void runTurn(runtime, stream, model, context, options)
|
|
466
|
+
return stream
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function runTurn(
|
|
470
|
+
runtime: BridgeRuntime,
|
|
471
|
+
stream: AssistantMessageEventStream,
|
|
472
|
+
model: Model<Api>,
|
|
473
|
+
context: BridgeContext,
|
|
474
|
+
options?: SimpleStreamOptions
|
|
475
|
+
): Promise<void> {
|
|
476
|
+
const output: AssistantMessage = {
|
|
477
|
+
role: 'assistant',
|
|
478
|
+
content: [],
|
|
479
|
+
api: model.api,
|
|
480
|
+
provider: model.provider,
|
|
481
|
+
model: model.id,
|
|
482
|
+
usage: emptyUsage(),
|
|
483
|
+
stopReason: 'stop',
|
|
484
|
+
timestamp: Date.now()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const piSessionId = options?.sessionId || `ephemeral-${Math.random().toString(36).slice(2)}`
|
|
488
|
+
const session = bridgeSession(runtime.sessions, piSessionId)
|
|
489
|
+
let listenerKey: string | undefined
|
|
490
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
491
|
+
let onAbort: (() => void) | undefined
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
if (options?.signal?.aborted) {
|
|
495
|
+
output.stopReason = 'aborted'
|
|
496
|
+
output.errorMessage = 'opencode request aborted before dispatch'
|
|
497
|
+
stream.push({ type: 'error', reason: 'aborted', error: output })
|
|
498
|
+
stream.end()
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
if (!runtime.mcp) throw new Error('the opencode bridge MCP server is not running')
|
|
502
|
+
stream.push({ type: 'start', partial: output })
|
|
503
|
+
|
|
504
|
+
const transcript = replayTranscript(context.messages)
|
|
505
|
+
session.tools = transcript.tools
|
|
506
|
+
runtime.mcp.register(session.token, {
|
|
507
|
+
listTools: () => mcpToolDefs(session.tools, runtime.toolHints ?? NO_TOOL_HINTS),
|
|
508
|
+
callTool: (name, args) => parkToolCall(runtime, session, name, args)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
const variant = variantFor(model.thinkingLevelMap, options?.reasoning)
|
|
512
|
+
const messages = transcript.conversation
|
|
513
|
+
|
|
514
|
+
if (session.opencodeSessionId && (session.modelId !== model.id || transcriptDrifted(session, messages))) {
|
|
515
|
+
await resetOpencodeSession(session)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (session.opencodeSessionId && session.toolsKey !== toolsKey(transcript.tools)) {
|
|
519
|
+
const refreshed = await runtime.mcp.notifyToolsChanged(session.token)
|
|
520
|
+
if (refreshed) await sleep(TOOLS_RELOAD_MS)
|
|
521
|
+
else await resetOpencodeSession(session)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const tail = messages.slice(session.syncedCount)
|
|
525
|
+
const resuming = canResume(session, tail)
|
|
526
|
+
|
|
527
|
+
if (!resuming && session.opencodeSessionId && tail.some((message) => message.role !== 'user')) {
|
|
528
|
+
releaseParked(session, 'Pi did not complete the previous call. Rebuilding the opencode session.')
|
|
529
|
+
await api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
|
|
530
|
+
await resetOpencodeSession(session)
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!resuming && session.parked.size > 0) {
|
|
534
|
+
releaseParked(session, 'Pi did not run this call. Ask again if you still need it.')
|
|
535
|
+
await api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
|
|
536
|
+
await resetOpencodeSession(session)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const opencodeSessionId = resuming
|
|
540
|
+
? session.opencodeSessionId
|
|
541
|
+
: await ensureOpencodeSession(runtime, session, model, variant)
|
|
542
|
+
if (!opencodeSessionId) throw new Error('could not open an opencode session')
|
|
543
|
+
|
|
544
|
+
listenerKey = opencodeSessionId
|
|
545
|
+
let nudges = 0
|
|
546
|
+
const turn = createTurn(
|
|
547
|
+
runtime.sessions,
|
|
548
|
+
stream,
|
|
549
|
+
output,
|
|
550
|
+
model,
|
|
551
|
+
(reason) => {
|
|
552
|
+
if (nudges >= MAX_NUDGES || options?.signal?.aborted) return false
|
|
553
|
+
const refused = session.refusedTools
|
|
554
|
+
if (reason === 'refused' && refused.length === 0) return false
|
|
555
|
+
nudges++
|
|
556
|
+
session.refusedTools = []
|
|
557
|
+
if (reason === 'refused') {
|
|
558
|
+
releaseParked(session, 'opencode stopped this step; the call was dropped.')
|
|
559
|
+
}
|
|
560
|
+
const text = reason === 'empty' ? EMPTY_TURN_NUDGE : refusalNudge(refused)
|
|
561
|
+
void (async () => {
|
|
562
|
+
try {
|
|
563
|
+
await api('POST', `/api/session/${opencodeSessionId}/prompt`, { text })
|
|
564
|
+
} catch (nudgeError) {
|
|
565
|
+
turn.fail(nudgeError instanceof Error ? nudgeError.message : String(nudgeError))
|
|
566
|
+
}
|
|
567
|
+
})()
|
|
568
|
+
return true
|
|
569
|
+
},
|
|
570
|
+
options?.signal
|
|
571
|
+
)
|
|
572
|
+
runtime.listeners.set(listenerKey, turn.handle)
|
|
573
|
+
|
|
574
|
+
const onAbortTurn = () => {
|
|
575
|
+
const sessionId = session.opencodeSessionId
|
|
576
|
+
if (sessionId) {
|
|
577
|
+
void api('POST', `/api/session/${sessionId}/interrupt`).catch(() => undefined)
|
|
578
|
+
}
|
|
579
|
+
turn.fail('opencode request aborted')
|
|
580
|
+
}
|
|
581
|
+
onAbort = onAbortTurn
|
|
582
|
+
if (options?.signal?.aborted) {
|
|
583
|
+
onAbortTurn()
|
|
584
|
+
} else {
|
|
585
|
+
options?.signal?.addEventListener('abort', onAbortTurn, { once: true })
|
|
586
|
+
}
|
|
587
|
+
const limit = turnTimeoutMs(options?.timeoutMs)
|
|
588
|
+
timer = setTimeout(() => {
|
|
589
|
+
if (session.opencodeSessionId) {
|
|
590
|
+
void api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
|
|
591
|
+
}
|
|
592
|
+
turn.fail(
|
|
593
|
+
`opencode did not answer within ${Math.round(limit / 1000)}s (model ${model.id}). ` +
|
|
594
|
+
'The bridge dropped the stuck session, so just retry the prompt.'
|
|
595
|
+
)
|
|
596
|
+
}, limit)
|
|
597
|
+
|
|
598
|
+
if (resuming) {
|
|
599
|
+
for (const [callId, parked] of session.parked) {
|
|
600
|
+
const result = tail.find((message) => message.role === 'toolResult' && message.toolCallId === callId)
|
|
601
|
+
parked.resolve(
|
|
602
|
+
result && result.role === 'toolResult'
|
|
603
|
+
? piToolResult(result)
|
|
604
|
+
: { content: [{ type: 'text', text: '(no result)' }], isError: true }
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
session.parked.clear()
|
|
608
|
+
} else {
|
|
609
|
+
if (variant && session.variant !== variant) {
|
|
610
|
+
await api('POST', `/api/session/${opencodeSessionId}/model`, {
|
|
611
|
+
model: { providerID: PROVIDER_ID, id: model.id, variant }
|
|
612
|
+
}).catch(() => undefined)
|
|
613
|
+
session.variant = variant
|
|
614
|
+
}
|
|
615
|
+
await sendPrompt(session, transcript, tail, opencodeSessionId)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
await turn.done
|
|
619
|
+
|
|
620
|
+
if (output.stopReason === 'error' || output.stopReason === 'aborted') {
|
|
621
|
+
releaseParked(session, 'The bridge stopped before this call completed.')
|
|
622
|
+
if (session.opencodeSessionId) {
|
|
623
|
+
await api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
|
|
624
|
+
}
|
|
625
|
+
} else {
|
|
626
|
+
session.syncedCount = syncedCountAfter(messages, output)
|
|
627
|
+
session.fingerprints = messages.map(messageFingerprint)
|
|
628
|
+
session.modelId = model.id
|
|
629
|
+
session.toolsKey = toolsKey(transcript.tools)
|
|
630
|
+
}
|
|
631
|
+
} catch (error) {
|
|
632
|
+
output.stopReason = options?.signal?.aborted ? 'aborted' : 'error'
|
|
633
|
+
output.errorMessage = error instanceof Error ? error.message : String(error)
|
|
634
|
+
if (output.content.length === 0) output.content.push({ type: 'text', text: output.errorMessage })
|
|
635
|
+
releaseParked(session, 'The bridge turn failed.')
|
|
636
|
+
if (session.opencodeSessionId) {
|
|
637
|
+
await api('POST', `/api/session/${session.opencodeSessionId}/interrupt`).catch(() => undefined)
|
|
638
|
+
}
|
|
639
|
+
stream.push({ type: 'error', reason: output.stopReason, error: output })
|
|
640
|
+
stream.end()
|
|
641
|
+
} finally {
|
|
642
|
+
if (timer) clearTimeout(timer)
|
|
643
|
+
if (listenerKey) runtime.listeners.delete(listenerKey)
|
|
644
|
+
if (onAbort) options?.signal?.removeEventListener('abort', onAbort)
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function syncedCountAfter(messages: Message[], output: AssistantMessage): number {
|
|
649
|
+
return output.content.length > 0 ? messages.length + 1 : messages.length
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function canResume(session: BridgeSession, tail: Message[]): boolean {
|
|
653
|
+
if (!session.opencodeSessionId || session.parked.size === 0) return false
|
|
654
|
+
if (tail.some((message) => message.role === 'user')) return false
|
|
655
|
+
const answered = new Set(
|
|
656
|
+
tail.filter((message) => message.role === 'toolResult').map((message) => message.toolCallId)
|
|
657
|
+
)
|
|
658
|
+
for (const callId of session.parked.keys()) if (!answered.has(callId)) return false
|
|
659
|
+
return true
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function ensureOpencodeSession(
|
|
663
|
+
runtime: BridgeRuntime,
|
|
664
|
+
session: BridgeSession,
|
|
665
|
+
model: Model<Api>,
|
|
666
|
+
variant: string | undefined
|
|
667
|
+
): Promise<string> {
|
|
668
|
+
if (session.opencodeSessionId) return session.opencodeSessionId
|
|
669
|
+
if (!runtime.mcp) throw new Error('the opencode bridge MCP server is not running')
|
|
670
|
+
|
|
671
|
+
const dir = await ensureProjectDir(session, runtime.mcp.urlFor(session.token))
|
|
672
|
+
const created = await api<{ data?: { id?: string } }>('POST', '/api/session', {
|
|
673
|
+
title: 'pi bridge',
|
|
674
|
+
agent: BRIDGE_AGENT,
|
|
675
|
+
model: { providerID: PROVIDER_ID, id: model.id, variant },
|
|
676
|
+
location: { directory: dir }
|
|
677
|
+
})
|
|
678
|
+
const id = created?.data?.id
|
|
679
|
+
if (!id) throw new Error('opencode did not return a session id')
|
|
680
|
+
session.opencodeSessionId = id
|
|
681
|
+
session.variant = variant
|
|
682
|
+
return id
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function sendPrompt(
|
|
686
|
+
session: BridgeSession,
|
|
687
|
+
transcript: TranscriptState,
|
|
688
|
+
tail: Message[],
|
|
689
|
+
opencodeSessionId: string
|
|
690
|
+
): Promise<void> {
|
|
691
|
+
const firstPrompt = session.syncedCount === 0
|
|
692
|
+
const blocks: string[] = []
|
|
693
|
+
|
|
694
|
+
if (firstPrompt) {
|
|
695
|
+
blocks.push(preamble(transcript.systemPrompt))
|
|
696
|
+
const history = serializeHistory(transcript.conversation)
|
|
697
|
+
if (history) blocks.push(history)
|
|
698
|
+
} else {
|
|
699
|
+
if (transcript.systemPrompt !== (session.systemPrompt ?? '')) {
|
|
700
|
+
blocks.push(`<system-update>\n${transcript.systemPrompt.trim()}\n</system-update>`)
|
|
701
|
+
}
|
|
702
|
+
const fresh = tail
|
|
703
|
+
.filter((message) => message.role === 'user')
|
|
704
|
+
.map((message) => (message.role === 'user' ? contentText(message.content) : ''))
|
|
705
|
+
blocks.push(...fresh.filter(Boolean))
|
|
706
|
+
}
|
|
707
|
+
session.refusedTools = []
|
|
708
|
+
|
|
709
|
+
const files = await writeImages(session, imagesIn(tail))
|
|
710
|
+
const text = blocks.filter(Boolean).join('\n\n') || '(continue)'
|
|
711
|
+
const payload = { text, files: files.length > 0 ? files : undefined }
|
|
712
|
+
await api('POST', `/api/session/${opencodeSessionId}/prompt`, payload)
|
|
713
|
+
session.systemPrompt = transcript.systemPrompt
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function writeImages(session: BridgeSession, images: ImageContent[]): Promise<{ uri: string }[]> {
|
|
717
|
+
if (images.length === 0 || !session.dir) return []
|
|
718
|
+
const written: { uri: string }[] = []
|
|
719
|
+
for (const [index, image] of images.entries()) {
|
|
720
|
+
const extension = image.mimeType.split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'bin'
|
|
721
|
+
const path = join(session.dir, `image-${Date.now()}-${index}.${extension}`)
|
|
722
|
+
await writeFile(path, Buffer.from(image.data, 'base64'))
|
|
723
|
+
written.push({ uri: `file://${path}` })
|
|
724
|
+
}
|
|
725
|
+
return written
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function parkToolCall(
|
|
729
|
+
runtime: BridgeRuntime,
|
|
730
|
+
session: BridgeSession,
|
|
731
|
+
prefixed: string,
|
|
732
|
+
args: JsonObject
|
|
733
|
+
): Promise<McpResult> {
|
|
734
|
+
const name = prefixed.startsWith(TOOL_PREFIX) ? prefixed.slice(TOOL_PREFIX.length) : prefixed
|
|
735
|
+
if (!session.tools.some((tool) => tool.name === name)) {
|
|
736
|
+
return Promise.resolve({
|
|
737
|
+
content: [{ type: 'text', text: `${name} is not available in this session anymore.` }],
|
|
738
|
+
isError: true
|
|
739
|
+
})
|
|
740
|
+
}
|
|
741
|
+
const callId = `oc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|
742
|
+
return new Promise<McpResult>((resolve) => {
|
|
743
|
+
session.parked.set(callId, { name, args, resolve })
|
|
744
|
+
const owner = session.opencodeSessionId ?? ''
|
|
745
|
+
const notify = runtime.listeners.get(owner)
|
|
746
|
+
if (!notify) {
|
|
747
|
+
session.parked.delete(callId)
|
|
748
|
+
resolve({
|
|
749
|
+
content: [{ type: 'text', text: 'The bridge turn ended before this call arrived.' }],
|
|
750
|
+
isError: true
|
|
751
|
+
})
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
notify({ type: 'bridge.tool.parked', data: { callId, sessionID: owner } })
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
type Turn = {
|
|
759
|
+
handle: SessionListener
|
|
760
|
+
done: Promise<void>
|
|
761
|
+
fail(message: string): void
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export function createTurn(
|
|
765
|
+
sessions: BridgeSessionStore,
|
|
766
|
+
stream: AssistantMessageEventStream,
|
|
767
|
+
output: AssistantMessage,
|
|
768
|
+
model: Model<Api>,
|
|
769
|
+
recover: (reason: RecoveryReason) => boolean = () => false,
|
|
770
|
+
signal?: AbortSignal
|
|
771
|
+
): Turn {
|
|
772
|
+
let settle: () => void = () => undefined
|
|
773
|
+
const done = new Promise<void>((resolve) => {
|
|
774
|
+
settle = resolve
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
let textIndex: number | undefined
|
|
778
|
+
let thinkingIndex: number | undefined
|
|
779
|
+
let finished = false
|
|
780
|
+
let stepStreamed = false
|
|
781
|
+
let owner: BridgeSession | undefined
|
|
782
|
+
const piCalls = new Map<string, string>()
|
|
783
|
+
const parkedCallIds: string[] = []
|
|
784
|
+
const ready = () => stepStreamed && piCalls.size > 0 && parkedCallIds.length >= piCalls.size
|
|
785
|
+
let failure: string | undefined
|
|
786
|
+
let parkTimer: ReturnType<typeof setTimeout> | undefined
|
|
787
|
+
const clearPark = () => {
|
|
788
|
+
if (!parkTimer) return
|
|
789
|
+
clearTimeout(parkTimer)
|
|
790
|
+
parkTimer = undefined
|
|
791
|
+
}
|
|
792
|
+
const resetExecution = () => {
|
|
793
|
+
stepStreamed = false
|
|
794
|
+
piCalls.clear()
|
|
795
|
+
parkedCallIds.length = 0
|
|
796
|
+
failure = undefined
|
|
797
|
+
clearPark()
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const openText = () => {
|
|
801
|
+
if (textIndex !== undefined) return textIndex
|
|
802
|
+
textIndex = output.content.length
|
|
803
|
+
output.content.push({ type: 'text', text: '' })
|
|
804
|
+
stream.push({ type: 'text_start', contentIndex: textIndex, partial: output })
|
|
805
|
+
return textIndex
|
|
806
|
+
}
|
|
807
|
+
const closeText = (text: string) => {
|
|
808
|
+
if (textIndex === undefined) return
|
|
809
|
+
const block = output.content[textIndex]
|
|
810
|
+
if (block?.type === 'text') block.text = text
|
|
811
|
+
stream.push({ type: 'text_end', contentIndex: textIndex, content: text, partial: output })
|
|
812
|
+
textIndex = undefined
|
|
813
|
+
}
|
|
814
|
+
const openThinking = () => {
|
|
815
|
+
if (thinkingIndex !== undefined) return thinkingIndex
|
|
816
|
+
thinkingIndex = output.content.length
|
|
817
|
+
output.content.push({ type: 'thinking', thinking: '' })
|
|
818
|
+
stream.push({ type: 'thinking_start', contentIndex: thinkingIndex, partial: output })
|
|
819
|
+
return thinkingIndex
|
|
820
|
+
}
|
|
821
|
+
const closeThinking = (text: string) => {
|
|
822
|
+
if (thinkingIndex === undefined) return
|
|
823
|
+
const block = output.content[thinkingIndex]
|
|
824
|
+
if (block?.type === 'thinking') block.thinking = text
|
|
825
|
+
stream.push({ type: 'thinking_end', contentIndex: thinkingIndex, content: text, partial: output })
|
|
826
|
+
thinkingIndex = undefined
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const finish = (reason: 'stop' | 'toolUse') => {
|
|
830
|
+
if (finished) return
|
|
831
|
+
finished = true
|
|
832
|
+
clearPark()
|
|
833
|
+
takeLateUsage(owner, output.usage)
|
|
834
|
+
closeText(textIndex !== undefined ? textOf(output, textIndex) : '')
|
|
835
|
+
closeThinking(thinkingIndex !== undefined ? thinkingOf(output, thinkingIndex) : '')
|
|
836
|
+
calculateCost(model, output.usage)
|
|
837
|
+
output.stopReason = reason
|
|
838
|
+
stream.push({ type: 'done', reason, message: output })
|
|
839
|
+
stream.end()
|
|
840
|
+
settle()
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const fail = (message: string) => {
|
|
844
|
+
if (finished) return
|
|
845
|
+
finished = true
|
|
846
|
+
clearPark()
|
|
847
|
+
takeLateUsage(owner, output.usage)
|
|
848
|
+
const reason = signal?.aborted ? 'aborted' : 'error'
|
|
849
|
+
output.stopReason = reason
|
|
850
|
+
output.errorMessage = message
|
|
851
|
+
if (output.content.length === 0) output.content.push({ type: 'text', text: message })
|
|
852
|
+
stream.push({ type: 'error', reason, error: output })
|
|
853
|
+
stream.end()
|
|
854
|
+
settle()
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const emitToolCalls = (session: BridgeSession) => {
|
|
858
|
+
clearPark()
|
|
859
|
+
for (const callId of parkedCallIds) {
|
|
860
|
+
const parked = session.parked.get(callId)
|
|
861
|
+
if (!parked) continue
|
|
862
|
+
const toolCall: ToolCall = {
|
|
863
|
+
type: 'toolCall',
|
|
864
|
+
id: callId,
|
|
865
|
+
name: parked.name,
|
|
866
|
+
arguments: parked.args
|
|
867
|
+
}
|
|
868
|
+
const contentIndex = output.content.length
|
|
869
|
+
output.content.push(toolCall)
|
|
870
|
+
stream.push({ type: 'toolcall_start', contentIndex, partial: output })
|
|
871
|
+
stream.push({
|
|
872
|
+
type: 'toolcall_delta',
|
|
873
|
+
contentIndex,
|
|
874
|
+
delta: JSON.stringify(toolCall.arguments),
|
|
875
|
+
partial: output
|
|
876
|
+
})
|
|
877
|
+
stream.push({ type: 'toolcall_end', contentIndex, toolCall, partial: output })
|
|
878
|
+
}
|
|
879
|
+
finish('toolUse')
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const handle: SessionListener = (event) => {
|
|
883
|
+
if (finished) return
|
|
884
|
+
const decoded = decodeEvent(event)
|
|
885
|
+
const session = findByOpencodeId(sessions, decoded.sessionID)
|
|
886
|
+
if (session) owner = session
|
|
887
|
+
|
|
888
|
+
switch (decoded.type) {
|
|
889
|
+
case 'session.step.started':
|
|
890
|
+
stepStreamed = false
|
|
891
|
+
piCalls.clear()
|
|
892
|
+
clearPark()
|
|
893
|
+
return
|
|
894
|
+
case 'session.reasoning.delta': {
|
|
895
|
+
const index = openThinking()
|
|
896
|
+
const block = output.content[index]
|
|
897
|
+
if (block?.type === 'thinking') block.thinking += decoded.delta
|
|
898
|
+
stream.push({ type: 'thinking_delta', contentIndex: index, delta: decoded.delta, partial: output })
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
case 'session.reasoning.ended':
|
|
902
|
+
closeThinking(decoded.text)
|
|
903
|
+
return
|
|
904
|
+
case 'session.text.delta': {
|
|
905
|
+
const index = openText()
|
|
906
|
+
const block = output.content[index]
|
|
907
|
+
if (block?.type === 'text') block.text += decoded.delta
|
|
908
|
+
stream.push({ type: 'text_delta', contentIndex: index, delta: decoded.delta, partial: output })
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
case 'session.text.ended':
|
|
912
|
+
closeText(decoded.text)
|
|
913
|
+
return
|
|
914
|
+
case 'session.tool.input.started': {
|
|
915
|
+
if (decoded.name.startsWith(TOOL_PREFIX)) {
|
|
916
|
+
piCalls.set(decoded.id ?? `${decoded.name}#${piCalls.size}`, decoded.name)
|
|
917
|
+
}
|
|
918
|
+
return
|
|
919
|
+
}
|
|
920
|
+
case 'session.tool.success':
|
|
921
|
+
case 'session.tool.failed': {
|
|
922
|
+
if (!piCalls.delete(decoded.id)) return
|
|
923
|
+
if (parkedCallIds.length >= piCalls.size) clearPark()
|
|
924
|
+
if (session && ready()) emitToolCalls(session)
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
case 'session.step.failed': {
|
|
928
|
+
if (decoded.failure) failure = decoded.failure
|
|
929
|
+
piCalls.clear()
|
|
930
|
+
clearPark()
|
|
931
|
+
return
|
|
932
|
+
}
|
|
933
|
+
case 'session.step.streamed':
|
|
934
|
+
stepStreamed = true
|
|
935
|
+
if (session && ready()) {
|
|
936
|
+
emitToolCalls(session)
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
if (piCalls.size > parkedCallIds.length && !parkTimer) {
|
|
940
|
+
const missing = piCalls.size - parkedCallIds.length
|
|
941
|
+
const total = piCalls.size
|
|
942
|
+
const names = [...piCalls.values()].join(', ')
|
|
943
|
+
parkTimer = setTimeout(
|
|
944
|
+
() =>
|
|
945
|
+
fail(
|
|
946
|
+
`opencode started ${names || 'a pi_ tool'} but ${missing} of ${total} call(s) ` +
|
|
947
|
+
`never reached the bridge within ${PARK_GRACE_MS / 1000}s`
|
|
948
|
+
),
|
|
949
|
+
PARK_GRACE_MS
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
return
|
|
953
|
+
case 'bridge.tool.parked':
|
|
954
|
+
parkedCallIds.push(decoded.callId)
|
|
955
|
+
if (session && ready()) emitToolCalls(session)
|
|
956
|
+
return
|
|
957
|
+
case 'session.execution.succeeded': {
|
|
958
|
+
const hasResult = output.content.some(
|
|
959
|
+
(block) =>
|
|
960
|
+
block.type === 'toolCall' ||
|
|
961
|
+
(block.type === 'text' && block.text.trim().length > 0 && !leakedToolCall(block.text))
|
|
962
|
+
)
|
|
963
|
+
if (!hasResult && recover('empty')) {
|
|
964
|
+
const leaked = textIndex === undefined ? undefined : output.content[textIndex]
|
|
965
|
+
closeText(leaked?.type === 'text' ? leaked.text : '')
|
|
966
|
+
resetExecution()
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
finish('stop')
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
case 'session.execution.interrupted':
|
|
973
|
+
case 'session.execution.failed': {
|
|
974
|
+
if (decoded.failure) failure = decoded.failure
|
|
975
|
+
if (recover('refused')) {
|
|
976
|
+
resetExecution()
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
fail(
|
|
980
|
+
failure ??
|
|
981
|
+
(decoded.type === 'session.execution.failed'
|
|
982
|
+
? 'opencode could not finish the request'
|
|
983
|
+
: 'opencode interrupted the request')
|
|
984
|
+
)
|
|
985
|
+
return
|
|
986
|
+
}
|
|
987
|
+
case 'session.step.ended':
|
|
988
|
+
case 'permission.asked':
|
|
989
|
+
return
|
|
990
|
+
case 'other':
|
|
991
|
+
if (decoded.failure) failure = decoded.failure
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return { handle, done, fail }
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const ErrorObjectSchema = t.object({
|
|
999
|
+
message: t.optional(t.string()),
|
|
1000
|
+
errorMessage: t.optional(t.string()),
|
|
1001
|
+
reason: t.optional(t.string())
|
|
1002
|
+
})
|
|
1003
|
+
const isErrorObjectSchema = new Validator(ErrorObjectSchema).isValidObject
|
|
1004
|
+
|
|
1005
|
+
function textOf(output: AssistantMessage, index: number): string {
|
|
1006
|
+
const block = output.content[index]
|
|
1007
|
+
return block?.type === 'text' ? block.text : ''
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function thinkingOf(output: AssistantMessage, index: number): string {
|
|
1011
|
+
const block = output.content[index]
|
|
1012
|
+
return block?.type === 'thinking' ? block.thinking : ''
|
|
1013
|
+
}
|