@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.
@@ -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
+ })