@llm4ts/runner 2.2.1 → 2.3.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/dist/Doctor.d.ts +10 -1
- package/dist/Doctor.d.ts.map +1 -1
- package/dist/Doctor.js +99 -6
- package/dist/Doctor.js.map +1 -1
- package/dist/FlowRunner.d.ts +2 -0
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +53 -3
- package/dist/FlowRunner.js.map +1 -1
- package/dist/NodeGeminiAcpBridge.d.ts +112 -0
- package/dist/NodeGeminiAcpBridge.d.ts.map +1 -0
- package/dist/NodeGeminiAcpBridge.js +377 -0
- package/dist/NodeGeminiAcpBridge.js.map +1 -0
- package/dist/PiModels.d.ts +65 -0
- package/dist/PiModels.d.ts.map +1 -0
- package/dist/PiModels.js +148 -0
- package/dist/PiModels.js.map +1 -0
- package/package.json +5 -3
- package/src/Doctor.ts +126 -6
- package/src/FlowRunner.ts +85 -3
- package/src/NodeGeminiAcpBridge.ts +576 -0
- package/src/PiModels.ts +222 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
|
2
|
+
import * as Deferred from "effect/Deferred"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Option from "effect/Option"
|
|
5
|
+
import * as Ref from "effect/Ref"
|
|
6
|
+
import type * as Scope from "effect/Scope"
|
|
7
|
+
import { InvalidRequestError, ProviderError, type LlmError } from "@llm4ts/core/Errors"
|
|
8
|
+
import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
|
|
9
|
+
import {
|
|
10
|
+
geminiAcpArgv,
|
|
11
|
+
openGeminiAcpSession,
|
|
12
|
+
type GeminiAcpSessionShape
|
|
13
|
+
} from "@llm4ts/core/providers/GeminiAcpSession"
|
|
14
|
+
import {
|
|
15
|
+
isJsonRecord,
|
|
16
|
+
jsonArray,
|
|
17
|
+
jsonBooleanField,
|
|
18
|
+
jsonField,
|
|
19
|
+
jsonStringField,
|
|
20
|
+
type JsonRecord,
|
|
21
|
+
type JsonValue
|
|
22
|
+
} from "@llm4ts/core/providers/CliSupport"
|
|
23
|
+
import { FlowLlmError } from "@llm4ts/flow/FlowError"
|
|
24
|
+
import { handleMcpRequest, type McpTool } from "@llm4ts/flow/McpServer"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Speaks the Anthropic Messages API on `/v1/messages` (what `pi`'s
|
|
28
|
+
* `~/.pi/agent/models.json` custom-provider entry points at) and MCP over
|
|
29
|
+
* HTTP on `/mcp` (what an ACP `session/new` registers with gemini). See
|
|
30
|
+
* ADR 0016 for why: pi's tool loop — not gemini's — executes every tool
|
|
31
|
+
* call, so a tool call gemini makes through the registered MCP tools pauses
|
|
32
|
+
* here until pi's *next* `/v1/messages` request supplies the result.
|
|
33
|
+
*
|
|
34
|
+
* One active ACP session per bridge instance (ADR 0016's simplifying
|
|
35
|
+
* assumption): correlation needs no session-id routing, just one pending
|
|
36
|
+
* tool call and one "what happens next" slot at a time.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
interface AnthropicToolDef {
|
|
40
|
+
readonly name: string
|
|
41
|
+
readonly description: string
|
|
42
|
+
readonly inputSchema: JsonRecord
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ToolResultPayload {
|
|
46
|
+
readonly content: string
|
|
47
|
+
readonly isError: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PendingToolCall {
|
|
51
|
+
readonly id: string
|
|
52
|
+
readonly name: string
|
|
53
|
+
readonly resultDeferred: Deferred.Deferred<ToolResultPayload, LlmError>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type TurnOutcome =
|
|
57
|
+
| { readonly _tag: "Text"; readonly text: string }
|
|
58
|
+
| {
|
|
59
|
+
readonly _tag: "ToolUse"
|
|
60
|
+
readonly id: string
|
|
61
|
+
readonly name: string
|
|
62
|
+
readonly input: JsonValue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface BridgeState {
|
|
66
|
+
readonly session: GeminiAcpSessionShape
|
|
67
|
+
readonly cwd: string
|
|
68
|
+
readonly mcpServerUrl: string
|
|
69
|
+
readonly sessionId: Ref.Ref<Option.Option<string>>
|
|
70
|
+
readonly tools: Ref.Ref<ReadonlyArray<AnthropicToolDef>>
|
|
71
|
+
readonly pendingToolCall: Ref.Ref<Option.Option<PendingToolCall>>
|
|
72
|
+
readonly turnOutcome: Ref.Ref<Option.Option<Deferred.Deferred<TurnOutcome, LlmError>>>
|
|
73
|
+
readonly nextToolCallId: Ref.Ref<number>
|
|
74
|
+
readonly forkBackground: <A>(effect: Effect.Effect<A, LlmError>) => Effect.Effect<void>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const makeBridgeState = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.makeBridgeState")(
|
|
78
|
+
function* (
|
|
79
|
+
session: GeminiAcpSessionShape,
|
|
80
|
+
cwd: string,
|
|
81
|
+
mcpServerUrl: string,
|
|
82
|
+
forkBackground: <A>(effect: Effect.Effect<A, LlmError>) => Effect.Effect<void>
|
|
83
|
+
): Effect.fn.Return<BridgeState> {
|
|
84
|
+
return {
|
|
85
|
+
session,
|
|
86
|
+
cwd,
|
|
87
|
+
mcpServerUrl,
|
|
88
|
+
sessionId: yield* Ref.make<Option.Option<string>>(Option.none()),
|
|
89
|
+
tools: yield* Ref.make<ReadonlyArray<AnthropicToolDef>>([]),
|
|
90
|
+
pendingToolCall: yield* Ref.make<Option.Option<PendingToolCall>>(Option.none()),
|
|
91
|
+
turnOutcome: yield* Ref.make<Option.Option<Deferred.Deferred<TurnOutcome, LlmError>>>(
|
|
92
|
+
Option.none()
|
|
93
|
+
),
|
|
94
|
+
nextToolCallId: yield* Ref.make(0),
|
|
95
|
+
forkBackground
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const resolveTurnOutcomeSuccess = (state: BridgeState, outcome: TurnOutcome): Effect.Effect<void> =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const current = yield* Ref.get(state.turnOutcome)
|
|
103
|
+
if (Option.isSome(current)) {
|
|
104
|
+
yield* Deferred.succeed(current.value, outcome)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const resolveTurnOutcomeFailure = (state: BridgeState, error: LlmError): Effect.Effect<void> =>
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
const current = yield* Ref.get(state.turnOutcome)
|
|
111
|
+
if (Option.isSome(current)) {
|
|
112
|
+
yield* Deferred.fail(current.value, error)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
export const anthropicToolToMcpTool = (state: BridgeState, def: AnthropicToolDef): McpTool => ({
|
|
117
|
+
name: def.name,
|
|
118
|
+
description: def.description,
|
|
119
|
+
inputSchema: def.inputSchema,
|
|
120
|
+
call: (arguments_) =>
|
|
121
|
+
Effect.gen(function* () {
|
|
122
|
+
const id = yield* Ref.updateAndGet(state.nextToolCallId, (current) => current + 1)
|
|
123
|
+
const toolCallId = `toolu_${id}`
|
|
124
|
+
const resultDeferred = yield* Deferred.make<ToolResultPayload, LlmError>()
|
|
125
|
+
yield* Ref.set(
|
|
126
|
+
state.pendingToolCall,
|
|
127
|
+
Option.some({ id: toolCallId, name: def.name, resultDeferred })
|
|
128
|
+
)
|
|
129
|
+
yield* resolveTurnOutcomeSuccess(state, {
|
|
130
|
+
_tag: "ToolUse",
|
|
131
|
+
id: toolCallId,
|
|
132
|
+
name: def.name,
|
|
133
|
+
input: arguments_
|
|
134
|
+
})
|
|
135
|
+
const outcome = yield* Deferred.await(resultDeferred)
|
|
136
|
+
if (outcome.isError) {
|
|
137
|
+
return yield* ProviderError.make({ message: outcome.content })
|
|
138
|
+
}
|
|
139
|
+
return outcome.content
|
|
140
|
+
}).pipe(Effect.mapError((error) => FlowLlmError.from(error)))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const parseToolDefs = (body: JsonValue): ReadonlyArray<AnthropicToolDef> =>
|
|
144
|
+
jsonArray(jsonField(body, "tools")).flatMap((tool) => {
|
|
145
|
+
const name = jsonStringField(tool, "name")
|
|
146
|
+
if (name === undefined) {
|
|
147
|
+
return []
|
|
148
|
+
}
|
|
149
|
+
const inputSchema = jsonField(tool, "input_schema")
|
|
150
|
+
return [
|
|
151
|
+
{
|
|
152
|
+
name,
|
|
153
|
+
description: jsonStringField(tool, "description") ?? "",
|
|
154
|
+
inputSchema: inputSchema !== undefined && isJsonRecord(inputSchema) ? inputSchema : {}
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const lastMessage = (body: JsonValue): JsonValue | undefined => {
|
|
160
|
+
const messages = jsonArray(jsonField(body, "messages"))
|
|
161
|
+
return messages[messages.length - 1]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const contentBlocksText = (value: JsonValue | undefined): string =>
|
|
165
|
+
typeof value === "string"
|
|
166
|
+
? value
|
|
167
|
+
: jsonArray(value)
|
|
168
|
+
.map((block) => jsonStringField(block, "text") ?? "")
|
|
169
|
+
.join("\n")
|
|
170
|
+
|
|
171
|
+
const toolResultInLastMessage = (
|
|
172
|
+
body: JsonValue
|
|
173
|
+
): { readonly id: string; readonly content: string; readonly isError: boolean } | undefined => {
|
|
174
|
+
const message = lastMessage(body)
|
|
175
|
+
if (jsonStringField(message, "role") !== "user") {
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
for (const block of jsonArray(jsonField(message, "content"))) {
|
|
179
|
+
if (jsonStringField(block, "type") !== "tool_result") {
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
const id = jsonStringField(block, "tool_use_id")
|
|
183
|
+
if (id === undefined) {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
id,
|
|
188
|
+
content: contentBlocksText(jsonField(block, "content")),
|
|
189
|
+
isError: jsonBooleanField(block, "is_error") === true
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const userTextInLastMessage = (body: JsonValue): string =>
|
|
196
|
+
contentBlocksText(jsonField(lastMessage(body), "content"))
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The route to match, without the query string. `request.url` carries it,
|
|
200
|
+
* and pi's bundled Anthropic SDK posts to `/v1/messages?beta=true` for
|
|
201
|
+
* tool-enabled requests — routing on the raw value 404s exactly the request
|
|
202
|
+
* this bridge exists to serve.
|
|
203
|
+
*/
|
|
204
|
+
export const requestPath = (url: string | undefined): string => {
|
|
205
|
+
const path = (url ?? "/").split(/[?#]/)[0]
|
|
206
|
+
return path === undefined || path.length === 0 ? "/" : path
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const anthropicResponse = (model: string, outcome: TurnOutcome): JsonRecord => ({
|
|
210
|
+
id: `msg_${Date.now()}`,
|
|
211
|
+
type: "message",
|
|
212
|
+
role: "assistant",
|
|
213
|
+
model,
|
|
214
|
+
content:
|
|
215
|
+
outcome._tag === "Text"
|
|
216
|
+
? [{ type: "text", text: outcome.text }]
|
|
217
|
+
: [{ type: "tool_use", id: outcome.id, name: outcome.name, input: outcome.input }],
|
|
218
|
+
stop_reason: outcome._tag === "Text" ? "end_turn" : "tool_use",
|
|
219
|
+
// ACP does not currently surface per-request token counts to this bridge;
|
|
220
|
+
// zero, not omitted, since the Anthropic Messages response shape requires
|
|
221
|
+
// the field. Real usage accounting for this path is future work.
|
|
222
|
+
usage: { input_tokens: 0, output_tokens: 0 }
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Drive one turn to its outcome. Split from the response rendering because a
|
|
227
|
+
* streaming request must write `message_start` before this resolves — pi
|
|
228
|
+
* hardcodes `stream: true`, and gemini can take a while to answer.
|
|
229
|
+
*/
|
|
230
|
+
export const resolveTurn = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.resolveTurn")(function* (
|
|
231
|
+
state: BridgeState,
|
|
232
|
+
body: JsonValue
|
|
233
|
+
): Effect.fn.Return<TurnOutcome, LlmError> {
|
|
234
|
+
const continuation = toolResultInLastMessage(body)
|
|
235
|
+
let pendingToResolve: PendingToolCall | undefined
|
|
236
|
+
if (continuation !== undefined) {
|
|
237
|
+
const pending = yield* Ref.get(state.pendingToolCall)
|
|
238
|
+
if (Option.isNone(pending) || pending.value.id !== continuation.id) {
|
|
239
|
+
return yield* InvalidRequestError.make({
|
|
240
|
+
message: `no pending tool call matches tool_result id ${continuation.id}`
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
yield* Ref.set(state.pendingToolCall, Option.none())
|
|
244
|
+
pendingToResolve = pending.value
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Registered before anything that could resolve it (a resumed tool call
|
|
248
|
+
// unblocking gemini's still-in-flight prompt, or — the first turn — the
|
|
249
|
+
// forked prompt() call itself) can run: both can complete fast enough to
|
|
250
|
+
// race an outcome slot only opened afterward, silently dropping it
|
|
251
|
+
// forever with nothing left awaiting it.
|
|
252
|
+
const outcomeDeferred = yield* Deferred.make<TurnOutcome, LlmError>()
|
|
253
|
+
yield* Ref.set(state.turnOutcome, Option.some(outcomeDeferred))
|
|
254
|
+
|
|
255
|
+
if (pendingToResolve !== undefined) {
|
|
256
|
+
yield* Deferred.succeed(pendingToResolve.resultDeferred, {
|
|
257
|
+
content: continuation?.content ?? "",
|
|
258
|
+
isError: continuation?.isError ?? false
|
|
259
|
+
})
|
|
260
|
+
} else {
|
|
261
|
+
yield* Ref.set(state.tools, parseToolDefs(body))
|
|
262
|
+
const existing = yield* Ref.get(state.sessionId)
|
|
263
|
+
const sessionId =
|
|
264
|
+
existing._tag === "Some"
|
|
265
|
+
? existing.value
|
|
266
|
+
: yield* Effect.tap(state.session.newSession(state.cwd, state.mcpServerUrl), (id) =>
|
|
267
|
+
Ref.set(state.sessionId, Option.some(id))
|
|
268
|
+
)
|
|
269
|
+
const text = userTextInLastMessage(body)
|
|
270
|
+
yield* state.forkBackground(
|
|
271
|
+
state.session.prompt(sessionId, text).pipe(
|
|
272
|
+
Effect.matchEffect({
|
|
273
|
+
onSuccess: (result) =>
|
|
274
|
+
resolveTurnOutcomeSuccess(state, { _tag: "Text", text: result.text }),
|
|
275
|
+
onFailure: (error) => resolveTurnOutcomeFailure(state, error)
|
|
276
|
+
})
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const outcome = yield* Deferred.await(outcomeDeferred)
|
|
282
|
+
yield* Ref.set(state.turnOutcome, Option.none())
|
|
283
|
+
|
|
284
|
+
return outcome
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
export const requestModel = (body: JsonValue): string => jsonStringField(body, "model") ?? "gemini"
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One Anthropic Messages streaming event. pi's bundled SDK hardcodes
|
|
291
|
+
* `stream: true` on every request and there is no compatibility flag to turn
|
|
292
|
+
* it off, so this transport — not the single-JSON one — is the live path.
|
|
293
|
+
*
|
|
294
|
+
* ACP hands this bridge a finished turn rather than a token stream, so each
|
|
295
|
+
* block is emitted as one delta. That is a well-formed event sequence the
|
|
296
|
+
* client reassembles correctly; it simply arrives in a single chunk instead
|
|
297
|
+
* of token by token.
|
|
298
|
+
*/
|
|
299
|
+
export interface StreamEvent {
|
|
300
|
+
readonly event: string
|
|
301
|
+
readonly data: JsonRecord
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const emptyUsage: JsonRecord = { input_tokens: 0, output_tokens: 0 }
|
|
305
|
+
|
|
306
|
+
/** Sent before the turn resolves, so the client sees a live response. */
|
|
307
|
+
export const turnStartEvents = (id: string, model: string): ReadonlyArray<StreamEvent> => [
|
|
308
|
+
{
|
|
309
|
+
event: "message_start",
|
|
310
|
+
data: {
|
|
311
|
+
type: "message_start",
|
|
312
|
+
message: {
|
|
313
|
+
id,
|
|
314
|
+
type: "message",
|
|
315
|
+
role: "assistant",
|
|
316
|
+
model,
|
|
317
|
+
content: [],
|
|
318
|
+
stop_reason: null,
|
|
319
|
+
stop_sequence: null,
|
|
320
|
+
usage: emptyUsage
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The blocks and stop events for a resolved turn. A `tool_use` block carries
|
|
328
|
+
* an empty `input` and supplies the real arguments through `input_json_delta`,
|
|
329
|
+
* which is the shape the Anthropic client expects to accumulate.
|
|
330
|
+
*/
|
|
331
|
+
export const turnOutcomeEvents = (outcome: TurnOutcome): ReadonlyArray<StreamEvent> => {
|
|
332
|
+
const blocks: ReadonlyArray<StreamEvent> =
|
|
333
|
+
outcome._tag === "Text"
|
|
334
|
+
? [
|
|
335
|
+
{
|
|
336
|
+
event: "content_block_start",
|
|
337
|
+
data: {
|
|
338
|
+
type: "content_block_start",
|
|
339
|
+
index: 0,
|
|
340
|
+
content_block: { type: "text", text: "" }
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
event: "content_block_delta",
|
|
345
|
+
data: {
|
|
346
|
+
type: "content_block_delta",
|
|
347
|
+
index: 0,
|
|
348
|
+
delta: { type: "text_delta", text: outcome.text }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
]
|
|
352
|
+
: [
|
|
353
|
+
{
|
|
354
|
+
event: "content_block_start",
|
|
355
|
+
data: {
|
|
356
|
+
type: "content_block_start",
|
|
357
|
+
index: 0,
|
|
358
|
+
content_block: { type: "tool_use", id: outcome.id, name: outcome.name, input: {} }
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
event: "content_block_delta",
|
|
363
|
+
data: {
|
|
364
|
+
type: "content_block_delta",
|
|
365
|
+
index: 0,
|
|
366
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(outcome.input) }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
]
|
|
370
|
+
return [
|
|
371
|
+
...blocks,
|
|
372
|
+
{ event: "content_block_stop", data: { type: "content_block_stop", index: 0 } },
|
|
373
|
+
{
|
|
374
|
+
event: "message_delta",
|
|
375
|
+
data: {
|
|
376
|
+
type: "message_delta",
|
|
377
|
+
delta: {
|
|
378
|
+
stop_reason: outcome._tag === "Text" ? "end_turn" : "tool_use",
|
|
379
|
+
stop_sequence: null
|
|
380
|
+
},
|
|
381
|
+
usage: { output_tokens: 0 }
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
{ event: "message_stop", data: { type: "message_stop" } }
|
|
385
|
+
]
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A failure after `message_start` can only be reported in-band. */
|
|
389
|
+
export const turnErrorEvent = (message: string): StreamEvent => ({
|
|
390
|
+
event: "error",
|
|
391
|
+
data: { type: "error", error: { type: "api_error", message } }
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
export const messageId = (): string => `msg_${Date.now()}`
|
|
395
|
+
|
|
396
|
+
export const handleMessagesRequest = Effect.fn(
|
|
397
|
+
"@llm4ts/runner/NodeGeminiAcpBridge.handleMessagesRequest"
|
|
398
|
+
)(function* (state: BridgeState, body: JsonValue): Effect.fn.Return<JsonRecord, LlmError> {
|
|
399
|
+
const outcome = yield* resolveTurn(state, body)
|
|
400
|
+
return anthropicResponse(requestModel(body), outcome)
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const readBody = (request: IncomingMessage): Effect.Effect<string, ProviderError> =>
|
|
404
|
+
Effect.callback((resume) => {
|
|
405
|
+
const chunks: Array<Buffer> = []
|
|
406
|
+
request.on("data", (chunk: Buffer) => chunks.push(chunk))
|
|
407
|
+
request.on("end", () => resume(Effect.succeed(Buffer.concat(chunks).toString("utf8"))))
|
|
408
|
+
request.on("error", (error) =>
|
|
409
|
+
resume(ProviderError.make({ message: "failed to read request body", cause: error }))
|
|
410
|
+
)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const parseJson = (raw: string): JsonValue => JSON.parse(raw)
|
|
414
|
+
|
|
415
|
+
const parseJsonBody = (raw: string): Effect.Effect<JsonValue, InvalidRequestError> =>
|
|
416
|
+
Effect.try({
|
|
417
|
+
try: () => parseJson(raw),
|
|
418
|
+
catch: () => InvalidRequestError.make({ message: "request body is not valid JSON" })
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
const writeStreamEvent = (response: ServerResponse, event: StreamEvent): void => {
|
|
422
|
+
response.write(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Stream one turn. The head goes out before the turn resolves so the client
|
|
427
|
+
* has a live response while gemini works; after that point a failure can only
|
|
428
|
+
* be reported as an in-band `error` event, never as a status code.
|
|
429
|
+
*/
|
|
430
|
+
const respondStreaming = (
|
|
431
|
+
response: ServerResponse,
|
|
432
|
+
state: BridgeState,
|
|
433
|
+
body: JsonValue
|
|
434
|
+
): Effect.Effect<void> =>
|
|
435
|
+
Effect.sync(() => {
|
|
436
|
+
response.writeHead(200, {
|
|
437
|
+
"Content-Type": "text/event-stream",
|
|
438
|
+
"Cache-Control": "no-cache",
|
|
439
|
+
Connection: "keep-alive"
|
|
440
|
+
})
|
|
441
|
+
for (const event of turnStartEvents(messageId(), requestModel(body))) {
|
|
442
|
+
writeStreamEvent(response, event)
|
|
443
|
+
}
|
|
444
|
+
}).pipe(
|
|
445
|
+
Effect.andThen(resolveTurn(state, body)),
|
|
446
|
+
Effect.matchEffect({
|
|
447
|
+
onSuccess: (outcome) =>
|
|
448
|
+
Effect.sync(() => {
|
|
449
|
+
for (const event of turnOutcomeEvents(outcome)) {
|
|
450
|
+
writeStreamEvent(response, event)
|
|
451
|
+
}
|
|
452
|
+
response.end()
|
|
453
|
+
}),
|
|
454
|
+
onFailure: (error) =>
|
|
455
|
+
Effect.sync(() => {
|
|
456
|
+
writeStreamEvent(response, turnErrorEvent(error.message))
|
|
457
|
+
response.end()
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
const writeJson = (response: ServerResponse, status: number, body: JsonValue): void => {
|
|
463
|
+
const text = JSON.stringify(body)
|
|
464
|
+
response.writeHead(status, { "Content-Type": "application/json" })
|
|
465
|
+
response.end(text)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const errorStatus = (error: LlmError | InvalidRequestError): number =>
|
|
469
|
+
error._tag === "InvalidRequestError" ? 400 : error._tag === "AuthenticationError" ? 401 : 502
|
|
470
|
+
|
|
471
|
+
export interface GeminiAcpBridgeConfig {
|
|
472
|
+
readonly port: number
|
|
473
|
+
readonly cwd: string
|
|
474
|
+
readonly model?: string
|
|
475
|
+
readonly executor: ProcessExecutorShape
|
|
476
|
+
readonly envVars?: Readonly<Record<string, string>>
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export interface RunningGeminiAcpBridge {
|
|
480
|
+
readonly port: number
|
|
481
|
+
readonly baseUrl: string
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const bindError = (port: number, cause: unknown): ProviderError =>
|
|
485
|
+
ProviderError.make({
|
|
486
|
+
message:
|
|
487
|
+
`Gemini ACP bridge could not bind 127.0.0.1:${port} — another llm4ts run may already ` +
|
|
488
|
+
"own it (concurrent runs against one bridge are unsupported; see ADR 0016)",
|
|
489
|
+
cause
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
const acquireServer = (
|
|
493
|
+
port: number,
|
|
494
|
+
listener: (request: IncomingMessage, response: ServerResponse) => void
|
|
495
|
+
): Effect.Effect<Server, ProviderError, Scope.Scope> =>
|
|
496
|
+
Effect.acquireRelease(
|
|
497
|
+
Effect.callback<Server, ProviderError>((resume) => {
|
|
498
|
+
const server = createServer(listener)
|
|
499
|
+
const onError = (cause: Error): void => resume(Effect.fail(bindError(port, cause)))
|
|
500
|
+
server.once("error", onError)
|
|
501
|
+
server.listen(port, "127.0.0.1", () => {
|
|
502
|
+
server.off("error", onError)
|
|
503
|
+
resume(Effect.succeed(server))
|
|
504
|
+
})
|
|
505
|
+
}),
|
|
506
|
+
(server) =>
|
|
507
|
+
Effect.callback<void>((resume) => {
|
|
508
|
+
if (!server.listening) {
|
|
509
|
+
resume(Effect.void)
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
server.close(() => resume(Effect.void))
|
|
513
|
+
})
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
export const runGeminiAcpBridge = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.run")(function* (
|
|
517
|
+
config: GeminiAcpBridgeConfig
|
|
518
|
+
): Effect.fn.Return<RunningGeminiAcpBridge, LlmError, Scope.Scope> {
|
|
519
|
+
const mcpServerUrl = `http://127.0.0.1:${config.port}/mcp`
|
|
520
|
+
const session = yield* openGeminiAcpSession(
|
|
521
|
+
config.executor,
|
|
522
|
+
geminiAcpArgv(config.model),
|
|
523
|
+
config.cwd,
|
|
524
|
+
config.envVars ?? {}
|
|
525
|
+
)
|
|
526
|
+
const forkBackground = <A>(effect: Effect.Effect<A, LlmError>): Effect.Effect<void> =>
|
|
527
|
+
Effect.sync(() => {
|
|
528
|
+
Effect.runFork(effect)
|
|
529
|
+
})
|
|
530
|
+
const state = yield* makeBridgeState(session, config.cwd, mcpServerUrl, forkBackground)
|
|
531
|
+
|
|
532
|
+
const requestListener = (request: IncomingMessage, response: ServerResponse): void => {
|
|
533
|
+
const path = requestPath(request.url)
|
|
534
|
+
if (request.method !== "POST" || (path !== "/v1/messages" && path !== "/mcp")) {
|
|
535
|
+
writeJson(response, 404, { error: { message: "not found" } })
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const handled = Effect.gen(function* () {
|
|
540
|
+
const raw = yield* readBody(request)
|
|
541
|
+
const body = yield* parseJsonBody(raw)
|
|
542
|
+
if (path === "/mcp") {
|
|
543
|
+
const tools = yield* Ref.get(state.tools)
|
|
544
|
+
const mcpTools = tools.map((tool) => anthropicToolToMcpTool(state, tool))
|
|
545
|
+
const reply = yield* handleMcpRequest(body, mcpTools)
|
|
546
|
+
return yield* Effect.sync(() => writeJson(response, 200, reply ?? {}))
|
|
547
|
+
}
|
|
548
|
+
if (jsonBooleanField(body, "stream") === true) {
|
|
549
|
+
return yield* respondStreaming(response, state, body)
|
|
550
|
+
}
|
|
551
|
+
const reply = yield* handleMessagesRequest(state, body)
|
|
552
|
+
return yield* Effect.sync(() => writeJson(response, 200, reply))
|
|
553
|
+
}).pipe(
|
|
554
|
+
// Only a failure before the head went out can still choose a status;
|
|
555
|
+
// `respondStreaming` owns everything after that and never fails.
|
|
556
|
+
Effect.catch((error) =>
|
|
557
|
+
Effect.sync(() => {
|
|
558
|
+
if (!response.headersSent) {
|
|
559
|
+
writeJson(response, errorStatus(error), { error: { message: error.message } })
|
|
560
|
+
}
|
|
561
|
+
})
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
Effect.runPromise(handled).catch((defect: unknown) => {
|
|
566
|
+
if (!response.headersSent) {
|
|
567
|
+
writeJson(response, 500, { error: { message: String(defect) } })
|
|
568
|
+
}
|
|
569
|
+
})
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const server = yield* acquireServer(config.port, requestListener)
|
|
573
|
+
const address = server.address()
|
|
574
|
+
const port = address !== null && typeof address !== "string" ? address.port : config.port
|
|
575
|
+
return { port, baseUrl: `http://127.0.0.1:${port}` }
|
|
576
|
+
})
|