llm_meta_widget 0.1.0

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,717 @@
1
+ // Browser client for the meta-server's client-orchestrated flow.
2
+ //
3
+ // One request = one LLM turn. Streams the reply chunks via callbacks; resolves
4
+ // with the final content + any tool_calls the LLM emitted (unexecuted). The
5
+ // dispatcher that turns tool_calls into local action invocations (or remote
6
+ // MCP round-trips) is layered on top; see runChatLoop below.
7
+ //
8
+ // SHIP TARGETS: the llm_meta_client engine's chat host, and pages that embed
9
+ // the widget directly (e.g. PubDictionaries' text_annotation view). Kept as a
10
+ // single self-contained ES module so the copy-vendored PubDictionaries build
11
+ // stays a one-file drop-in.
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // singleLlmCall — one turn, streamed
15
+ // ---------------------------------------------------------------------------
16
+ //
17
+ // Contract:
18
+ // const result = await singleLlmCall({
19
+ // baseUrl: 'https://meta-server.example',
20
+ // apiKeyUuid: 'ollama-local', // or a real llm_api_key uuid
21
+ // modelName: 'qwen3-6-35b-fast',
22
+ // messages: [{role: 'user', content: 'hi'}],
23
+ // toolIds: [12, 34], // optional; MCP tools registered
24
+ // // on the meta-server
25
+ // localTools: [ // optional; inline schemas the
26
+ // { name: 'add_dictionaries', // client declares for its own
27
+ // description: '...', // page-embedded actions (window.
28
+ // inputSchema: {...JSON Schema...} } // aiActions) that it dispatches
29
+ // ], // itself after the LLM emits.
30
+ // generationSettings: {temperature: 0.7},// optional
31
+ // bearerToken: 'eyJhbGci...', // optional; anonymous if omitted
32
+ // onTextDelta: (str) => {},
33
+ // onThinkingDelta: (str) => {},
34
+ // onToolCall: (tc) => {}, // { id, name, arguments }
35
+ // onPhase: (name) => {}, // 'thinking' | 'tool_execution' | ...
36
+ // signal: abortController.signal
37
+ // })
38
+ // // result: { content, finishReason, toolCalls }
39
+ //
40
+ // Rejects if the server emits an `error` SSE event, or on network / abort.
41
+ export async function singleLlmCall({
42
+ baseUrl,
43
+ apiKeyUuid,
44
+ modelName,
45
+ messages,
46
+ toolIds = [],
47
+ localTools = [],
48
+ generationSettings = {},
49
+ bearerToken,
50
+ onTextDelta,
51
+ onThinkingDelta,
52
+ onToolCall,
53
+ onPhase,
54
+ signal,
55
+ }) {
56
+ if (!baseUrl || !apiKeyUuid || !modelName) {
57
+ throw new Error("singleLlmCall: baseUrl, apiKeyUuid, modelName are required")
58
+ }
59
+ if (!Array.isArray(messages) || messages.length === 0) {
60
+ throw new Error("singleLlmCall: messages must be a non-empty array")
61
+ }
62
+
63
+ const url = `${baseUrl.replace(/\/$/, "")}/api/llm_api_keys/${encodeURIComponent(
64
+ apiKeyUuid
65
+ )}/models/${encodeURIComponent(modelName)}/single_llm_calls`
66
+
67
+ const headers = { "Content-Type": "application/json", Accept: "text/event-stream" }
68
+ if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`
69
+
70
+ const body = JSON.stringify({
71
+ messages,
72
+ tool_ids: toolIds,
73
+ // Convert camelCase → snake_case at the wire boundary; the server's
74
+ // strong-params permits `input_schema:` and rejects `inputSchema:`.
75
+ local_tools: (localTools || []).map((t) => ({
76
+ name: t.name,
77
+ description: t.description,
78
+ input_schema: t.input_schema || t.inputSchema,
79
+ })),
80
+ generation_settings: generationSettings,
81
+ })
82
+
83
+ const response = await fetch(url, { method: "POST", headers, body, signal })
84
+ if (!response.ok) {
85
+ // Rails may render an HTML error page for routing / auth failures before
86
+ // the SSE stream ever starts. Surface the status so callers can distinguish
87
+ // "endpoint not there" from "stream ran but errored mid-flight".
88
+ const text = await response.text().catch(() => "")
89
+ throw new Error(`singleLlmCall: HTTP ${response.status} ${response.statusText}${text ? " — " + text.slice(0, 200) : ""}`)
90
+ }
91
+ if (!response.body) {
92
+ throw new Error("singleLlmCall: response has no body (streaming unsupported?)")
93
+ }
94
+
95
+ const toolCalls = []
96
+ let content = ""
97
+ let finishReason = null
98
+
99
+ // The `done` frame carries the authoritative content string too, but we
100
+ // still concatenate deltas because the callback consumer usually wants
101
+ // realtime rendering. The final content-from-`done` wins in case the LLM's
102
+ // final content differs from the streamed sum (which happens with some
103
+ // providers that emit their final text after tool_calls).
104
+
105
+ for await (const event of parseSseStream(response.body, signal)) {
106
+ switch (event.name) {
107
+ case "text_delta": {
108
+ const delta = event.data?.delta
109
+ if (typeof delta === "string" && delta.length > 0) {
110
+ content += delta
111
+ onTextDelta?.(delta)
112
+ }
113
+ break
114
+ }
115
+ case "thinking_delta": {
116
+ const delta = event.data?.delta
117
+ if (typeof delta === "string" && delta.length > 0) {
118
+ onThinkingDelta?.(delta)
119
+ }
120
+ break
121
+ }
122
+ case "tool_call": {
123
+ const tc = event.data?.tool_call
124
+ if (tc && tc.name) {
125
+ toolCalls.push(tc)
126
+ onToolCall?.(tc)
127
+ }
128
+ break
129
+ }
130
+ case "phase": {
131
+ const name = event.data?.name
132
+ if (typeof name === "string") onPhase?.(name)
133
+ break
134
+ }
135
+ case "done": {
136
+ // Server-declared final content + finish_reason. Prefer these over
137
+ // the delta-sum when they disagree (see comment above).
138
+ if (typeof event.data?.content === "string") content = event.data.content
139
+ if (event.data?.finish_reason != null) finishReason = event.data.finish_reason
140
+ return { content, finishReason, toolCalls }
141
+ }
142
+ case "error": {
143
+ const code = event.data?.code || "server_error"
144
+ const message = event.data?.message || "meta-server emitted an error event"
145
+ const err = new Error(`singleLlmCall: ${code}: ${message}`)
146
+ err.code = code
147
+ throw err
148
+ }
149
+ // Unknown event names are ignored on purpose — the server may add new
150
+ // ones (e.g. a future `usage` frame) without breaking older clients.
151
+ }
152
+ }
153
+
154
+ // Stream closed without a `done` frame. Treat as an error so the caller
155
+ // doesn't silently accept a truncated response as success.
156
+ throw new Error("singleLlmCall: stream closed without done event")
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // parseSseStream — async iterator over parsed SSE events
161
+ // ---------------------------------------------------------------------------
162
+ //
163
+ // SSE framing (per whatwg / EventSource spec):
164
+ // - Frames are separated by a blank line (`\n\n`, possibly `\r\n\r\n`).
165
+ // - Within a frame, each line is `field: value` or `:comment` (ignored).
166
+ // - `event:` sets the event name (default `message`).
167
+ // - `data:` lines accumulate; multiple `data:` lines concatenate with `\n`.
168
+ // - `id:` and `retry:` are ignored here (we're not using automatic reconnect).
169
+ //
170
+ // Emits: { name: string, data: object | null }
171
+ // data is JSON.parse'd if it looks like an object/array; malformed JSON is
172
+ // surfaced as { name: 'error', data: {code, message} } so callers can bail.
173
+ export async function* parseSseStream(readableStream, signal) {
174
+ const reader = readableStream.getReader()
175
+ const decoder = new TextDecoder("utf-8")
176
+ let buffer = ""
177
+
178
+ const onAbort = () => { try { reader.cancel() } catch { /* noop */ } }
179
+ signal?.addEventListener("abort", onAbort)
180
+
181
+ try {
182
+ while (true) {
183
+ const { value, done } = await reader.read()
184
+ if (done) break
185
+ buffer += decoder.decode(value, { stream: true })
186
+
187
+ // Split on frame boundary. Keep the trailing partial in `buffer`.
188
+ let sepIdx
189
+ // Accept CRLF or LF-only boundaries; the meta-server emits LF-only but
190
+ // proxies (nginx, cloudflare) sometimes reformat.
191
+ while (
192
+ (sepIdx = indexOfEither(buffer, "\n\n", "\r\n\r\n")) !== -1
193
+ ) {
194
+ const rawFrame = buffer.slice(0, sepIdx)
195
+ buffer = buffer.slice(
196
+ sepIdx + (buffer.slice(sepIdx, sepIdx + 4) === "\r\n\r\n" ? 4 : 2)
197
+ )
198
+ const parsed = parseSseFrame(rawFrame)
199
+ if (parsed) yield parsed
200
+ }
201
+ }
202
+ // Flush any trailing frame that wasn't followed by a blank line.
203
+ if (buffer.trim().length > 0) {
204
+ const parsed = parseSseFrame(buffer)
205
+ if (parsed) yield parsed
206
+ }
207
+ } finally {
208
+ signal?.removeEventListener("abort", onAbort)
209
+ try { reader.releaseLock() } catch { /* noop */ }
210
+ }
211
+ }
212
+
213
+ function indexOfEither(str, a, b) {
214
+ const ai = str.indexOf(a)
215
+ const bi = str.indexOf(b)
216
+ if (ai === -1) return bi
217
+ if (bi === -1) return ai
218
+ return Math.min(ai, bi)
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // runTurn — one LLM turn + fire-and-forget local dispatch
223
+ // ---------------------------------------------------------------------------
224
+ //
225
+ // The Piece-B primitive: driven by `singleLlmCall`, then after `done` invokes
226
+ // any tool_calls that name an entry in `aiActions` (window.aiActions on the
227
+ // host page). Sequential invocation preserves the order the LLM emitted them
228
+ // in — avoids races on shared DOM state (e.g. two aiActions mutating the same
229
+ // selection list).
230
+ //
231
+ // Fire-and-forget = no result is fed back to the LLM. If a caller wants
232
+ // LLM-loop semantics (tool result → follow-up LLM turn), that's Piece D.
233
+ //
234
+ // Contract:
235
+ // const result = await runTurn({
236
+ // ...singleLlmCall opts,
237
+ // aiActions: window.aiActions // { name: (args) => any|Promise }
238
+ // })
239
+ // // result: { content, finishReason, toolCalls,
240
+ // // dispatched: [{ toolCall, value|error }],
241
+ // // skipped: [ toolCall ] }
242
+ //
243
+ // `dispatched` covers everything invoked (whether it succeeded or threw);
244
+ // `skipped` covers tool_calls whose name isn't in aiActions — Piece D turns
245
+ // those into MCP proxy round-trips. Errors from aiActions are captured, NOT
246
+ // thrown, so one broken action doesn't stop the rest of the batch.
247
+ export async function runTurn(opts) {
248
+ const { aiActions = {}, ...singleOpts } = opts
249
+ const result = await singleLlmCall(singleOpts)
250
+ const { dispatched, skipped } = await dispatchLocalToolCalls(result.toolCalls, aiActions)
251
+ return { ...result, dispatched, skipped }
252
+ }
253
+
254
+ // Standalone dispatcher — exposed for tests, and for Piece D which wants to
255
+ // separate the local phase from its own remote-loop logic.
256
+ export async function dispatchLocalToolCalls(toolCalls, aiActions) {
257
+ const dispatched = []
258
+ const skipped = []
259
+ for (const tc of toolCalls || []) {
260
+ const handler = aiActions?.[tc.name]
261
+ if (typeof handler !== "function") {
262
+ skipped.push(tc)
263
+ continue
264
+ }
265
+ const args = coerceArguments(tc.arguments)
266
+ try {
267
+ const value = await handler(args)
268
+ dispatched.push({ toolCall: tc, value })
269
+ } catch (error) {
270
+ dispatched.push({ toolCall: tc, error })
271
+ }
272
+ }
273
+ return { dispatched, skipped }
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // runChatLoop — Piece D: client-orchestrated multi-turn loop
278
+ // ---------------------------------------------------------------------------
279
+ //
280
+ // Wraps `singleLlmCall` in an actual loop so the LLM can call tools, see
281
+ // their results, and produce a synthesized final response.
282
+ //
283
+ // Handles all three tool classes (see memory: project_mcp_tool_classes):
284
+ // Class 1 — remoteTools[]: hub-registered; POST via meta-server proxy
285
+ // Class 2 — hostWideTools[]: well-known; POST directly to host's /mcp
286
+ // Class 3 — aiActions: page-embedded; invoke JS in-process
287
+ //
288
+ // Per round:
289
+ // 1. call `singleLlmCall` with merged local_tools (Class 2 + 3 schemas) +
290
+ // tool_ids (Class 1 references)
291
+ // 2. classify emitted tool_calls by name (aiActions → hostWide → remote → unknown)
292
+ // 3. dispatch Class 3 fire-and-forget
293
+ // 4. dispatch Class 2 via direct MCP; Class 1 via meta-server proxy;
294
+ // collect results in emission order
295
+ // 5. if any round-trip results, append assistant-with-tool_calls turn and
296
+ // one role:tool message per result; loop
297
+ // 6. terminate when no round-trip results (Class 3 alone doesn't loop),
298
+ // or on hitting maxRounds
299
+ //
300
+ // Contract:
301
+ // const result = await runChatLoop({
302
+ // ...singleLlmCall opts,
303
+ // aiActions: window.aiActions,
304
+ // hostWideTools: [{ name, description, input_schema, endpoint }, ...],
305
+ // remoteTools: [{ id, name, description, input_schema }, ...],
306
+ // maxRounds: 10,
307
+ // onRoundStart: (idx) => {},
308
+ // onTextDelta: (delta, roundIdx) => {}
309
+ // })
310
+ // // result: {
311
+ // // content, finishReason,
312
+ // // rounds: [{ round, ..., localCalls, hostWideCalls, remoteCalls, unknownCalls }],
313
+ // // dispatched: [{ toolCall, value|error }],
314
+ // // skipped: [ toolCall ]
315
+ // // }
316
+ //
317
+ // NOTE on tool-name mapping: Class 1 remoteTools[].name must match the name
318
+ // the LLM sees, which is what the server declares to the provider (may be
319
+ // sanitized by McpToolAdapter). Class 2 and Class 3 tool names go through
320
+ // unmodified — LLM sees them as declared in local_tools.
321
+ export async function runChatLoop(opts) {
322
+ const {
323
+ aiActions = {},
324
+ remoteTools = [],
325
+ hostWideTools = [],
326
+ maxRounds = 10,
327
+ signal,
328
+ onRoundStart,
329
+ onTextDelta,
330
+ onThinkingDelta,
331
+ onToolCall,
332
+ onPhase,
333
+ ...singleOpts
334
+ } = opts
335
+
336
+ const messages = [ ...(singleOpts.messages || []) ]
337
+ const remoteByName = Object.fromEntries((remoteTools || []).map((t) => [ t.name, t ]))
338
+ // Class 2: host-wide MCP tools (well-known). Map name→{endpoint, ...} for
339
+ // dispatch. If the same name appears in aiActions, Class 3 wins there
340
+ // (checked first in the classifier below); if it also appears in remoteTools,
341
+ // Class 2 wins over Class 1 (host-owned is more direct).
342
+ const hostWideByName = Object.fromEntries((hostWideTools || []).map((t) => [ t.name, t ]))
343
+ const toolIds = [ ...(singleOpts.toolIds || []), ...(remoteTools || []).map((t) => t.id) ]
344
+
345
+ // Class 2 schemas ride inline via local_tools (LLM sees them like Class 3).
346
+ // De-dupe by name; Class 3 wins so we don't clobber an aiAction's schema.
347
+ const inlineByName = {}
348
+ for (const t of singleOpts.localTools || []) inlineByName[t.name] = t
349
+ for (const t of hostWideTools || []) {
350
+ if (!inlineByName[t.name]) inlineByName[t.name] = { name: t.name, description: t.description, input_schema: t.input_schema }
351
+ }
352
+ const mergedLocalTools = Object.values(inlineByName)
353
+
354
+ const rounds = []
355
+ const allDispatched = []
356
+ const allSkipped = []
357
+ let lastResult = null
358
+
359
+ // Client-side de-dupe guard. Written for models that seemed to re-invoke
360
+ // the same tool with the same arguments after receiving its result.
361
+ // Much of that turned out to be the hub, not the model: single_llm_turn!
362
+ // reported every tool call in the history it was given, so the call the
363
+ // widget had just dispatched came back as a "new" one (fixed in
364
+ // llm_meta_server 4441f61, 2026-09-20). Kept as defence in depth — a
365
+ // genuinely repetitive model, or an older hub, still gets caught here. Track every (name, args-JSON) tuple we've already dispatched
366
+ // this loop; when the LLM emits a duplicate, skip it. A round in which
367
+ // ALL tool_calls are duplicates has nothing left to dispatch — treat as
368
+ // the LLM's implicit "I'm done" and terminate with a distinct reason
369
+ // (widget can render silently since the earlier round already produced
370
+ // whatever text/action the user gets).
371
+ const seenToolCalls = new Set()
372
+ const toolKey = (tc) => {
373
+ let args
374
+ try { args = coerceArguments(tc.arguments) } catch { args = tc.arguments }
375
+ let serialized
376
+ try {
377
+ // Stable-order stringify — sort top-level keys so semantically-
378
+ // equivalent tool_calls (same content, different key order in the
379
+ // LLM's emission) hash to the same string.
380
+ if (args && typeof args === "object" && !Array.isArray(args)) {
381
+ const sorted = Object.keys(args).sort().reduce((o, k) => { o[k] = args[k]; return o }, {})
382
+ serialized = JSON.stringify(sorted)
383
+ } else {
384
+ serialized = JSON.stringify(args)
385
+ }
386
+ } catch { serialized = String(args) }
387
+ return tc.name + "" + serialized
388
+ }
389
+
390
+ for (let round = 0; round < maxRounds; round++) {
391
+ // Bail immediately on external abort — don't start a new LLM turn if
392
+ // the user hit Clear / navigated away between rounds.
393
+ if (signal?.aborted) throw new DOMException("aborted", "AbortError")
394
+ onRoundStart?.(round)
395
+
396
+ const turnResult = await singleLlmCall({
397
+ ...singleOpts,
398
+ messages,
399
+ toolIds,
400
+ localTools: mergedLocalTools,
401
+ signal,
402
+ onTextDelta: onTextDelta ? (d) => onTextDelta(d, round) : undefined,
403
+ onThinkingDelta: onThinkingDelta ? (d) => onThinkingDelta(d, round) : undefined,
404
+ onToolCall: onToolCall ? (t) => onToolCall(t, round) : undefined,
405
+ onPhase: onPhase ? (n) => onPhase(n, round) : undefined
406
+ })
407
+ lastResult = turnResult
408
+
409
+ // Classify tool_calls by class. Precedence: Class 3 (aiActions, no
410
+ // network, same-page) → Class 2 (host-wide well-known, direct MCP) →
411
+ // Class 1 (hub-registered, meta-server proxy) → unknown. Duplicates
412
+ // of prior rounds are filtered first — see seenToolCalls above.
413
+ const localCalls = [] // Class 3
414
+ const hostWideCalls = [] // Class 2
415
+ const remoteCalls = [] // Class 1
416
+ const unknownCalls = []
417
+ const duplicateCalls = []
418
+ const emittedCount = (turnResult.toolCalls || []).length
419
+ for (const tc of turnResult.toolCalls || []) {
420
+ const key = toolKey(tc)
421
+ if (seenToolCalls.has(key)) { duplicateCalls.push(tc); continue }
422
+ seenToolCalls.add(key)
423
+ if (typeof aiActions[tc.name] === "function") localCalls.push(tc)
424
+ else if (hostWideByName[tc.name]) hostWideCalls.push(tc)
425
+ else if (remoteByName[tc.name]) remoteCalls.push(tc)
426
+ else unknownCalls.push(tc)
427
+ }
428
+ // If the LLM emitted tool_calls but ALL were duplicates, it's stuck
429
+ // re-calling. Terminate now instead of dispatching + looping again.
430
+ const allDuplicates = emittedCount > 0 && duplicateCalls.length === emittedCount
431
+ if (allDuplicates) {
432
+ rounds.push({
433
+ round, content: turnResult.content, finishReason: turnResult.finishReason,
434
+ localCalls: [], hostWideCalls: [], remoteCalls: [], unknownCalls: [], duplicateCalls,
435
+ toolCalls: turnResult.toolCalls
436
+ })
437
+ return {
438
+ content: turnResult.content,
439
+ finishReason: turnResult.finishReason,
440
+ rounds, dispatched: allDispatched, skipped: allSkipped,
441
+ stopped_reason: "duplicate_tool_calls"
442
+ }
443
+ }
444
+
445
+ // Class 3: locals — fire-and-forget
446
+ const localOut = await dispatchLocalToolCalls(localCalls, aiActions)
447
+ allDispatched.push(...localOut.dispatched)
448
+
449
+ // Class 2: host-wide — direct MCP JSON-RPC POST to the host's own endpoint
450
+ const roundTripResults = []
451
+ for (const tc of hostWideCalls) {
452
+ const tool = hostWideByName[tc.name]
453
+ const args = coerceArguments(tc.arguments)
454
+ try {
455
+ const value = await callMcpTool({ endpoint: tool.endpoint, name: tc.name, args, signal })
456
+ allDispatched.push({ toolCall: tc, value })
457
+ roundTripResults.push({ tc, result: value })
458
+ } catch (error) {
459
+ allDispatched.push({ toolCall: tc, error })
460
+ roundTripResults.push({ tc, result: { error: String(error.message || error) } })
461
+ }
462
+ }
463
+
464
+ // Class 1: remote — meta-server proxy round-trip. Order preserved.
465
+ for (const tc of remoteCalls) {
466
+ const tool = remoteByName[tc.name]
467
+ const args = coerceArguments(tc.arguments)
468
+ try {
469
+ const value = await dispatchRemoteToolCall({
470
+ baseUrl: singleOpts.baseUrl,
471
+ bearerToken: singleOpts.bearerToken,
472
+ toolId: tool.id,
473
+ args: args,
474
+ signal
475
+ })
476
+ allDispatched.push({ toolCall: tc, value })
477
+ roundTripResults.push({ tc, result: value })
478
+ } catch (error) {
479
+ allDispatched.push({ toolCall: tc, error })
480
+ // Feed the error text back to the LLM as the tool result — better
481
+ // than dropping it (the LLM can react, apologize, retry differently).
482
+ roundTripResults.push({ tc, result: { error: String(error.message || error) } })
483
+ }
484
+ }
485
+
486
+ allSkipped.push(...unknownCalls)
487
+ rounds.push({
488
+ round, content: turnResult.content, finishReason: turnResult.finishReason,
489
+ localCalls, hostWideCalls, remoteCalls, unknownCalls,
490
+ toolCalls: turnResult.toolCalls
491
+ })
492
+
493
+ // Terminate when there's nothing to feed back. Locals (Class 3) are
494
+ // fire-and-forget; unknowns can't be handled; only Class 2 + Class 1
495
+ // execution produces tool results the LLM should see.
496
+ if (roundTripResults.length === 0) {
497
+ return {
498
+ content: turnResult.content,
499
+ finishReason: turnResult.finishReason,
500
+ rounds, dispatched: allDispatched, skipped: allSkipped
501
+ }
502
+ }
503
+
504
+ // Build follow-up: assistant-with-tool_calls + one tool-result per
505
+ // round-tripped call (both Class 2 and Class 1). Server-side:
506
+ // LlmRbFacade#messages_to_llm_objects preserves the assistant-with-
507
+ // tool_calls entry via LLM::Message.extra[:tool_calls], and
508
+ // split_history_from_current_input bundles the trailing tool-results
509
+ // as the input to the next session.chat call.
510
+ messages.push({
511
+ role: "assistant",
512
+ content: turnResult.content || "",
513
+ tool_calls: turnResult.toolCalls
514
+ })
515
+ for (const { tc, result } of roundTripResults) {
516
+ messages.push({
517
+ role: "tool",
518
+ tool_call_id: tc.id || "",
519
+ name: tc.name,
520
+ content: typeof result === "string" ? result : JSON.stringify(result)
521
+ })
522
+ }
523
+ }
524
+
525
+ // Hit the cap without terminating. Return what we have + a diagnostic flag
526
+ // so the widget can render "stopped after N rounds" instead of hanging.
527
+ return {
528
+ content: lastResult?.content || "",
529
+ finishReason: lastResult?.finishReason || null,
530
+ rounds, dispatched: allDispatched, skipped: allSkipped,
531
+ stopped_reason: `max_rounds (${maxRounds}) exceeded`
532
+ }
533
+ }
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // Class 2: host-wide MCP tools (well-known + direct dispatch)
537
+ // ---------------------------------------------------------------------------
538
+ //
539
+ // See memory: project_mcp_tool_classes. Class 2 tools are declared by the
540
+ // HOST site at `${origin}/.well-known/mcp.json` and executed by the widget
541
+ // posting JSON-RPC 2.0 `tools/call` DIRECTLY to the host's own MCP endpoint
542
+ // — no meta-server proxy in the loop. This dissolves the "widget-to-meta-
543
+ // server auth" question for same-origin host-owned tools: browser session
544
+ // cookies flow through automatically.
545
+ //
546
+ // Manifest shape (widget accepts):
547
+ // {
548
+ // "servers": [
549
+ // { "name": "pubdictionaries",
550
+ // "url": "/mcp", // relative or absolute
551
+ // "tools": [
552
+ // { "name": "text_annotation",
553
+ // "description": "...",
554
+ // "input_schema": {...} }
555
+ // ] }
556
+ // ]
557
+ // }
558
+ //
559
+ // The `url` is resolved against the manifest URL's origin, so a same-origin
560
+ // host can just say `/mcp` and the widget fills in the rest.
561
+
562
+ // Fetch a well-known manifest and normalize it: absolute URLs, flat tool list
563
+ // with the owning server's endpoint attached to each entry for dispatch.
564
+ // Fails gracefully — returns [] on network / parse error so a missing or
565
+ // malformed manifest doesn't kill the widget's boot.
566
+ export async function fetchMcpManifest(manifestUrl) {
567
+ let manifest
568
+ try {
569
+ const response = await fetch(manifestUrl)
570
+ if (!response.ok) return []
571
+ manifest = await response.json()
572
+ } catch { return [] }
573
+
574
+ const base = new URL(manifestUrl)
575
+ const out = []
576
+ for (const server of manifest?.servers || []) {
577
+ let endpoint
578
+ try { endpoint = new URL(server.url, base).toString() } catch { continue }
579
+ for (const tool of server.tools || []) {
580
+ if (!tool.name) continue
581
+ out.push({
582
+ name: tool.name,
583
+ description: tool.description || "",
584
+ input_schema: tool.input_schema || tool.inputSchema || { type: "object", properties: {} },
585
+ endpoint,
586
+ serverName: server.name || null
587
+ })
588
+ }
589
+ }
590
+ return out
591
+ }
592
+
593
+ // JSON-RPC 2.0 `tools/call` POST to an MCP endpoint. Returns the parsed
594
+ // `result` value (or throws on JSON-RPC error / HTTP failure). Supports
595
+ // both JSON and SSE responses (MCP over HTTP allows either).
596
+ let _mcpReqId = 0
597
+ export async function callMcpTool({ endpoint, name, args, signal }) {
598
+ const response = await fetch(endpoint, {
599
+ method: "POST",
600
+ headers: {
601
+ "Content-Type": "application/json",
602
+ "Accept": "application/json, text/event-stream"
603
+ },
604
+ body: JSON.stringify({
605
+ jsonrpc: "2.0",
606
+ id: ++_mcpReqId,
607
+ method: "tools/call",
608
+ params: { name, arguments: args || {} }
609
+ }),
610
+ signal,
611
+ // Send session cookies for same-origin MCP endpoints. Cross-origin CORS
612
+ // with credentials requires the server to echo Access-Control-Allow-
613
+ // Credentials: true — which most MCP servers won't. This is fine: for
614
+ // cross-origin the widget doesn't send cookies; the server enforces
615
+ // its own auth (API key in header, etc.) if it wants any.
616
+ credentials: "same-origin"
617
+ })
618
+ if (!response.ok) {
619
+ const text = await response.text().catch(() => "")
620
+ throw new Error(`callMcpTool(${name}): HTTP ${response.status}${text ? " — " + text.slice(0, 200) : ""}`)
621
+ }
622
+
623
+ const contentType = response.headers.get("content-type") || ""
624
+
625
+ if (contentType.includes("application/json")) {
626
+ const body = await response.json()
627
+ if (body?.error) {
628
+ throw new Error(`callMcpTool(${name}): ${body.error.message || "JSON-RPC error"}`)
629
+ }
630
+ return body?.result
631
+ }
632
+
633
+ if (contentType.includes("text/event-stream")) {
634
+ // MCP over SSE — each SSE `data:` frame is a JSON-RPC message.
635
+ for await (const evt of parseSseStream(response.body, signal)) {
636
+ const payload = evt.data
637
+ if (!payload || typeof payload !== "object") continue
638
+ if (payload.error) {
639
+ throw new Error(`callMcpTool(${name}): ${payload.error.message || "JSON-RPC error"}`)
640
+ }
641
+ if ("result" in payload) return payload.result
642
+ }
643
+ throw new Error(`callMcpTool(${name}): SSE stream ended without result`)
644
+ }
645
+
646
+ throw new Error(`callMcpTool(${name}): unexpected content-type ${contentType}`)
647
+ }
648
+
649
+ // Standalone remote dispatcher — POSTs one tool_call to the meta-server's
650
+ // MCP proxy endpoint. Exposed for tests + reuse.
651
+ export async function dispatchRemoteToolCall({ baseUrl, bearerToken, toolId, args, signal }) {
652
+ if (!baseUrl || toolId == null) {
653
+ throw new Error("dispatchRemoteToolCall: baseUrl and toolId are required")
654
+ }
655
+ const url = `${baseUrl.replace(/\/$/, "")}/api/mcp_tools/${encodeURIComponent(toolId)}/call`
656
+ const headers = { "Content-Type": "application/json" }
657
+ if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`
658
+
659
+ const response = await fetch(url, {
660
+ method: "POST",
661
+ headers,
662
+ body: JSON.stringify({ arguments: args || {} }),
663
+ signal
664
+ })
665
+ if (!response.ok) {
666
+ const text = await response.text().catch(() => "")
667
+ throw new Error(`dispatchRemoteToolCall: HTTP ${response.status} ${response.statusText}${text ? " — " + text.slice(0, 200) : ""}`)
668
+ }
669
+ const body = await response.json()
670
+ return body.result
671
+ }
672
+
673
+ // llm.rb's Session#extract_tool_calls delivers `arguments` as a parsed object
674
+ // (the OpenAI/Anthropic/Gemini adapters all parse before we see them). But
675
+ // SOMETIMES a provider passes through a raw JSON string (Gemini streaming
676
+ // edge cases have done this in the past). Coerce defensively so aiActions
677
+ // always receive an object.
678
+ function coerceArguments(raw) {
679
+ if (raw == null) return {}
680
+ if (typeof raw === "object") return raw
681
+ if (typeof raw === "string") {
682
+ try { return JSON.parse(raw) } catch { return { _raw: raw } }
683
+ }
684
+ return { _raw: raw }
685
+ }
686
+
687
+ function parseSseFrame(raw) {
688
+ let name = "message"
689
+ const dataLines = []
690
+
691
+ for (const line of raw.split("\n")) {
692
+ if (line.length === 0 || line.startsWith(":")) continue
693
+ const colon = line.indexOf(":")
694
+ const field = colon === -1 ? line : line.slice(0, colon)
695
+ // Per spec: single leading space after the colon is stripped.
696
+ let value = colon === -1 ? "" : line.slice(colon + 1)
697
+ if (value.startsWith(" ")) value = value.slice(1)
698
+
699
+ if (field === "event") name = value
700
+ else if (field === "data") dataLines.push(value)
701
+ // id / retry ignored
702
+ }
703
+
704
+ if (dataLines.length === 0 && name === "message") return null
705
+
706
+ const dataStr = dataLines.join("\n")
707
+ let data = null
708
+ if (dataStr.length > 0) {
709
+ try {
710
+ data = JSON.parse(dataStr)
711
+ } catch (e) {
712
+ // Emit the raw string — callers that care can inspect it.
713
+ data = { _raw: dataStr, _parseError: e.message }
714
+ }
715
+ }
716
+ return { name, data }
717
+ }