@polpo-ai/server 0.11.0 → 0.12.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.
Files changed (51) hide show
  1. package/dist/deps.d.ts +4 -4
  2. package/dist/deps.d.ts.map +1 -1
  3. package/dist/routes/completions/agent-step-runner.d.ts +49 -0
  4. package/dist/routes/completions/agent-step-runner.d.ts.map +1 -0
  5. package/dist/routes/completions/agent-step-runner.js +166 -0
  6. package/dist/routes/completions/agent-step-runner.js.map +1 -0
  7. package/dist/routes/completions/chat-handler.d.ts +51 -0
  8. package/dist/routes/completions/chat-handler.d.ts.map +1 -0
  9. package/dist/routes/completions/chat-handler.js +710 -0
  10. package/dist/routes/completions/chat-handler.js.map +1 -0
  11. package/dist/routes/completions/message-mapping.d.ts +21 -0
  12. package/dist/routes/completions/message-mapping.d.ts.map +1 -0
  13. package/dist/routes/completions/message-mapping.js +156 -0
  14. package/dist/routes/completions/message-mapping.js.map +1 -0
  15. package/dist/routes/completions/project-loop-runner.d.ts +65 -0
  16. package/dist/routes/completions/project-loop-runner.d.ts.map +1 -0
  17. package/dist/routes/completions/project-loop-runner.js +482 -0
  18. package/dist/routes/completions/project-loop-runner.js.map +1 -0
  19. package/dist/routes/completions/schemas.d.ts +242 -0
  20. package/dist/routes/completions/schemas.d.ts.map +1 -0
  21. package/dist/routes/completions/schemas.js +148 -0
  22. package/dist/routes/completions/schemas.js.map +1 -0
  23. package/dist/routes/completions/sse.d.ts +57 -0
  24. package/dist/routes/completions/sse.d.ts.map +1 -0
  25. package/dist/routes/completions/sse.js +96 -0
  26. package/dist/routes/completions/sse.js.map +1 -0
  27. package/dist/routes/completions/tool-mapping.d.ts +44 -0
  28. package/dist/routes/completions/tool-mapping.d.ts.map +1 -0
  29. package/dist/routes/completions/tool-mapping.js +154 -0
  30. package/dist/routes/completions/tool-mapping.js.map +1 -0
  31. package/dist/routes/completions.d.ts +13 -20
  32. package/dist/routes/completions.d.ts.map +1 -1
  33. package/dist/routes/completions.js +49 -1882
  34. package/dist/routes/completions.js.map +1 -1
  35. package/dist/routes/missions.d.ts +2 -2
  36. package/dist/routes/missions.d.ts.map +1 -1
  37. package/dist/routes/missions.js +2 -2
  38. package/dist/routes/missions.js.map +1 -1
  39. package/dist/routes/playbooks.d.ts +1 -1
  40. package/dist/routes/playbooks.d.ts.map +1 -1
  41. package/dist/routes/playbooks.js +1 -1
  42. package/dist/routes/playbooks.js.map +1 -1
  43. package/dist/routes/tasks.d.ts +1 -1
  44. package/dist/routes/tasks.d.ts.map +1 -1
  45. package/dist/routes/tasks.js +3 -3
  46. package/dist/routes/tasks.js.map +1 -1
  47. package/dist/schemas.d.ts +0 -13
  48. package/dist/schemas.d.ts.map +1 -1
  49. package/dist/schemas.js +1 -11
  50. package/dist/schemas.js.map +1 -1
  51. package/package.json +3 -3
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Standard chat-mode execution for the completions endpoint.
3
+ *
4
+ * Owns the multi-turn tool loop over streamText/generateText in both
5
+ * streaming (SSE) and non-streaming variants: client-side tools,
6
+ * interactive orchestrator tools, provider-executed tools, context
7
+ * compaction, session persistence, and metering callbacks.
8
+ */
9
+ import { streamSSE } from "hono/streaming";
10
+ import { compactIfNeeded } from "@polpo-ai/core";
11
+ import { streamText, generateText } from "ai";
12
+ import { buildSummarizeFn, MAX_TURNS } from "./agent-step-runner.js";
13
+ import { appendModelResponseMessages } from "./message-mapping.js";
14
+ import { completionResponse, modelNotFoundEnvelope, sseChunk } from "./sse.js";
15
+ import { CLIENT_SIDE_TOOLS, CLIENT_SIDE_TOOL_NAMES, emitFileChanged, indexToolResultsByCallId, recordProviderToolCall, redactVaultToolCalls, toAITools, } from "./tool-mapping.js";
16
+ /**
17
+ * Merge the effective tool palette for the LLM.
18
+ *
19
+ * Convert Polpo tools to AI SDK format (no execute — manual execution).
20
+ * Client-side tools (ask_user_question, etc.) stop the server loop and
21
+ * return to the client as standard tool_calls.
22
+ * `extraAiTools` are provider-executed (e.g. Vercel Gateway native
23
+ * tools) — already in AI SDK shape, must NOT be Polpo-converted.
24
+ */
25
+ function mergeAiTools(exec) {
26
+ return {
27
+ ...toAITools(exec.effectiveTools),
28
+ ...(exec.extraAiTools ?? {}),
29
+ ...CLIENT_SIDE_TOOLS,
30
+ };
31
+ }
32
+ /** Streaming chat mode — SSE stream of OpenAI-format chunks. */
33
+ export function streamChatCompletion(c, exec) {
34
+ const { deps, body, completionId, agentMode, fullSystemPrompt, m, providerOpts, modelToolChoice, effectiveTools, effectiveToolExecutor, extraAiTools, isInteractiveFn, aiMessages, sessionStore, sessionId, onResponseFinished, } = exec;
35
+ const aiTools = mergeAiTools(exec);
36
+ return streamSSE(c, async (stream) => {
37
+ // Abort controller: cancelled when the client disconnects (closes SSE)
38
+ const abortController = new AbortController();
39
+ stream.onAbort(() => { abortController.abort(); });
40
+ // SSE heartbeat: write a comment (`: ping`) every 20s to prevent
41
+ // proxy idle timeouts (nginx 60s, Cloudflare 100s) during long tool
42
+ // execution pauses. SSE comments are invisible to compliant clients.
43
+ // WritableStream serializes writes, so heartbeats cannot interleave
44
+ // mid-payload with writeSSE calls.
45
+ const heartbeatInterval = setInterval(() => {
46
+ if (abortController.signal.aborted) {
47
+ clearInterval(heartbeatInterval);
48
+ return;
49
+ }
50
+ stream.write(": ping\n\n").catch(() => {
51
+ clearInterval(heartbeatInterval);
52
+ });
53
+ }, 20_000);
54
+ await stream.writeSSE({ data: sseChunk(completionId, { role: "assistant" }) });
55
+ // Reserve a placeholder message in the store BEFORE streaming.
56
+ // This guarantees the assistant message exists even if the client disconnects.
57
+ let assistantMsgId = null;
58
+ if (sessionStore && sessionId) {
59
+ const placeholder = await sessionStore.addMessage(sessionId, "assistant", "");
60
+ assistantMsgId = placeholder.id;
61
+ }
62
+ const messages = [...aiMessages];
63
+ let finalText = "";
64
+ let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
65
+ const toolCallsAccum = [];
66
+ let lastProviderMetadata;
67
+ try {
68
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
69
+ // Bail out early if the client already disconnected
70
+ if (abortController.signal.aborted)
71
+ break;
72
+ // Compact context if approaching the model's context window limit.
73
+ // Under threshold this is just a cheap token estimation — zero LLM calls.
74
+ const compactionResult = await compactIfNeeded({
75
+ systemPrompt: fullSystemPrompt,
76
+ messages,
77
+ tools: effectiveTools,
78
+ config: {
79
+ contextWindow: m.contextWindow ?? 200_000,
80
+ maxOutputTokens: m.maxTokens ?? 8192,
81
+ },
82
+ summarize: buildSummarizeFn(m, providerOpts),
83
+ mode: "chat",
84
+ onCompaction: async (event) => {
85
+ await stream.writeSSE({
86
+ data: sseChunk(completionId, {}, null, {
87
+ compaction: {
88
+ phase: event.phase,
89
+ tokensBefore: event.tokensBefore,
90
+ tokensAfter: event.tokensAfter,
91
+ tokensReclaimed: event.tokensReclaimed,
92
+ messagesBefore: event.messagesBefore,
93
+ messagesAfter: event.messagesAfter,
94
+ },
95
+ }),
96
+ });
97
+ },
98
+ });
99
+ if (compactionResult.compacted) {
100
+ messages.splice(0, messages.length, ...compactionResult.messages);
101
+ }
102
+ const result = streamText({
103
+ model: m.aiModel,
104
+ system: fullSystemPrompt,
105
+ messages,
106
+ tools: aiTools,
107
+ ...(modelToolChoice ? { toolChoice: modelToolChoice } : {}),
108
+ maxOutputTokens: m.maxTokens,
109
+ providerOptions: providerOpts,
110
+ abortSignal: abortController.signal,
111
+ });
112
+ let turnText = "";
113
+ let streamError;
114
+ for await (const part of result.fullStream) {
115
+ if (abortController.signal.aborted)
116
+ break;
117
+ if (part.type === "reasoning-delta") {
118
+ await stream.writeSSE({ data: sseChunk(completionId, {}, null, { thinking: part.text }) });
119
+ }
120
+ else if (part.type === "text-delta") {
121
+ turnText += part.text;
122
+ await stream.writeSSE({ data: sseChunk(completionId, { content: part.text }) });
123
+ }
124
+ else if (part.type === "tool-input-start") {
125
+ // Emit early "preparing" signal — the LLM has started generating a tool call
126
+ // but arguments are not yet complete. Lets the UI show immediate feedback.
127
+ await stream.writeSSE({
128
+ data: sseChunk(completionId, {}, null, {
129
+ tool_call: { id: part.id, name: part.toolName, state: "preparing" },
130
+ }),
131
+ });
132
+ }
133
+ else if (part.type === "finish") {
134
+ // Capture error from finish reason if applicable
135
+ if (part.finishReason === "error") {
136
+ streamError = "Model returned an error";
137
+ }
138
+ }
139
+ }
140
+ // If aborted, stop the loop — skip error/tool processing
141
+ if (abortController.signal.aborted) {
142
+ finalText += turnText;
143
+ break;
144
+ }
145
+ if (streamError) {
146
+ finalText += `\n\nError: ${streamError}`;
147
+ await stream.writeSSE({ data: sseChunk(completionId, { content: `\n\nError: ${streamError}` }) });
148
+ break;
149
+ }
150
+ // Get tool calls and usage after stream completes
151
+ const toolCalls = await result.toolCalls;
152
+ const usage = await result.usage;
153
+ totalUsage = {
154
+ inputTokens: (totalUsage.inputTokens ?? 0) + (usage.inputTokens ?? 0),
155
+ outputTokens: (totalUsage.outputTokens ?? 0) + (usage.outputTokens ?? 0),
156
+ totalTokens: (totalUsage.totalTokens ?? 0) + (usage.totalTokens ?? 0),
157
+ };
158
+ try {
159
+ lastProviderMetadata = (await result.providerMetadata);
160
+ }
161
+ catch { /* best effort */ }
162
+ await appendModelResponseMessages(messages, result, turnText, toolCalls);
163
+ finalText += turnText;
164
+ if (toolCalls.length === 0)
165
+ break;
166
+ // ── Client-side tools — return to client as standard tool_calls ──
167
+ const clientSideCall = toolCalls.find((tc) => CLIENT_SIDE_TOOL_NAMES.has(tc.toolName));
168
+ if (clientSideCall) {
169
+ // Persist for session history
170
+ toolCallsAccum.push({
171
+ id: clientSideCall.toolCallId,
172
+ name: clientSideCall.toolName,
173
+ arguments: clientSideCall.input,
174
+ state: "interrupted",
175
+ });
176
+ // Send as standard OpenAI tool_calls finish reason
177
+ await stream.writeSSE({
178
+ data: JSON.stringify({
179
+ id: completionId,
180
+ object: "chat.completion.chunk",
181
+ choices: [{
182
+ index: 0,
183
+ delta: {
184
+ role: "assistant",
185
+ tool_calls: [{
186
+ index: 0,
187
+ id: clientSideCall.toolCallId,
188
+ type: "function",
189
+ function: {
190
+ name: clientSideCall.toolName,
191
+ arguments: JSON.stringify(clientSideCall.input),
192
+ },
193
+ }],
194
+ },
195
+ finish_reason: "tool_calls",
196
+ }],
197
+ }),
198
+ });
199
+ await stream.writeSSE({ data: "[DONE]" });
200
+ return;
201
+ }
202
+ // Check for interactive tools — only in orchestrator mode (agents don't have interactive tools)
203
+ const interactiveCall = agentMode ? undefined : toolCalls.find((tc) => isInteractiveFn?.(tc.toolName));
204
+ if (interactiveCall) {
205
+ // Persist the interactive tool call so it survives session reload
206
+ toolCallsAccum.push({
207
+ id: interactiveCall.toolCallId,
208
+ name: interactiveCall.toolName,
209
+ arguments: interactiveCall.input,
210
+ state: "interrupted",
211
+ });
212
+ if (interactiveCall.toolName === "ask_user") {
213
+ const questions = interactiveCall.input?.questions ?? [];
214
+ await stream.writeSSE({
215
+ data: sseChunk(completionId, {}, "ask_user", { ask_user: { questions } }),
216
+ });
217
+ }
218
+ else if (interactiveCall.toolName === "create_mission") {
219
+ const args = interactiveCall.input;
220
+ let missionData;
221
+ try {
222
+ missionData = JSON.parse(args.data);
223
+ }
224
+ catch {
225
+ missionData = args.data;
226
+ }
227
+ await stream.writeSSE({
228
+ data: sseChunk(completionId, {}, "mission_preview", {
229
+ mission_preview: {
230
+ name: args.name,
231
+ data: missionData,
232
+ prompt: args.prompt,
233
+ },
234
+ }),
235
+ });
236
+ }
237
+ else if (interactiveCall.toolName === "set_vault_entry") {
238
+ const args = interactiveCall.input;
239
+ await stream.writeSSE({
240
+ data: sseChunk(completionId, {}, "vault_preview", {
241
+ vault_preview: {
242
+ agent: args.agent,
243
+ service: args.service,
244
+ type: args.type,
245
+ label: args.label,
246
+ credentials: args.credentials,
247
+ },
248
+ }),
249
+ });
250
+ }
251
+ else if (interactiveCall.toolName === "open_file") {
252
+ const args = interactiveCall.input;
253
+ await stream.writeSSE({
254
+ data: sseChunk(completionId, {}, "open_file", {
255
+ open_file: {
256
+ path: args.path,
257
+ },
258
+ }),
259
+ });
260
+ }
261
+ else if (interactiveCall.toolName === "navigate_to") {
262
+ const args = interactiveCall.input;
263
+ await stream.writeSSE({
264
+ data: sseChunk(completionId, {}, "navigate_to", {
265
+ navigate_to: {
266
+ target: args.target,
267
+ id: args.id,
268
+ name: args.name,
269
+ path: args.path,
270
+ highlight: args.highlight,
271
+ },
272
+ }),
273
+ });
274
+ }
275
+ else if (interactiveCall.toolName === "open_tab") {
276
+ const args = interactiveCall.input;
277
+ await stream.writeSSE({
278
+ data: sseChunk(completionId, {}, "open_tab", {
279
+ open_tab: {
280
+ url: args.url,
281
+ label: args.label,
282
+ },
283
+ }),
284
+ });
285
+ }
286
+ await stream.writeSSE({ data: "[DONE]" });
287
+ return; // finally block will persist whatever finalText we have
288
+ }
289
+ // Provider tools (extraAiTools) are executed by the SDK / gateway.
290
+ // Their tool results are already preserved in responseMessages,
291
+ // so only record them for observability and skip local dispatch.
292
+ const providerToolNames = new Set(Object.keys(extraAiTools ?? {}));
293
+ let providerToolResults = new Map();
294
+ try {
295
+ const settled = (await result.toolResults);
296
+ providerToolResults = indexToolResultsByCallId(settled);
297
+ }
298
+ catch { /* best effort */ }
299
+ for (const call of toolCalls) {
300
+ // Stop executing tools if client disconnected
301
+ if (abortController.signal.aborted)
302
+ break;
303
+ const callArgs = call.input;
304
+ if (providerToolNames.has(call.toolName)) {
305
+ recordProviderToolCall(toolCallsAccum, call, providerToolResults);
306
+ continue;
307
+ }
308
+ // Notify client that a tool is being called
309
+ await stream.writeSSE({
310
+ data: sseChunk(completionId, {}, null, {
311
+ tool_call: { id: call.toolCallId, name: call.toolName, arguments: callArgs, state: "calling" },
312
+ }),
313
+ });
314
+ const result = await effectiveToolExecutor(call.toolName, callArgs);
315
+ const isError = result.startsWith("Error:");
316
+ emitFileChanged(call.toolName, callArgs, result, deps.emit);
317
+ // Accumulate for persistence
318
+ toolCallsAccum.push({
319
+ id: call.toolCallId,
320
+ name: call.toolName,
321
+ arguments: callArgs,
322
+ result,
323
+ state: isError ? "error" : "completed",
324
+ });
325
+ // Notify client with tool result (skip if aborted mid-tool)
326
+ if (!abortController.signal.aborted) {
327
+ await stream.writeSSE({
328
+ data: sseChunk(completionId, {}, null, {
329
+ tool_call: { id: call.toolCallId, name: call.toolName, result, state: isError ? "error" : "completed" },
330
+ }),
331
+ });
332
+ }
333
+ // Push tool result message in AI SDK format
334
+ messages.push({
335
+ role: "tool",
336
+ content: [{
337
+ type: "tool-result",
338
+ toolCallId: call.toolCallId,
339
+ toolName: call.toolName,
340
+ output: isError
341
+ ? { type: "error-text", value: result }
342
+ : { type: "text", value: result },
343
+ }],
344
+ });
345
+ }
346
+ }
347
+ if (!abortController.signal.aborted) {
348
+ await stream.writeSSE({ data: sseChunk(completionId, {}, "stop") });
349
+ await stream.writeSSE({ data: "[DONE]" });
350
+ }
351
+ }
352
+ catch (err) {
353
+ // Suppress AbortError — expected when client disconnects
354
+ if ((err instanceof DOMException && err.name === "AbortError") || abortController.signal.aborted) {
355
+ // fall through to finally — no SSE error event needed
356
+ }
357
+ else {
358
+ // Friendly model_not_found surface — gateway returns 404 for
359
+ // renamed/deprecated SKUs (e.g. xai/grok-4-fast after the 4.1
360
+ // rename). Without this catch the error propagates as a 500.
361
+ const notFound = modelNotFoundEnvelope(err, m?.id, body.agent);
362
+ if (notFound) {
363
+ await stream.writeSSE({
364
+ data: sseChunk(completionId, {}, "stop", { error: notFound }),
365
+ });
366
+ await stream.writeSSE({ data: "[DONE]" });
367
+ }
368
+ else {
369
+ throw err;
370
+ }
371
+ }
372
+ }
373
+ finally {
374
+ clearInterval(heartbeatInterval);
375
+ // Always persist the assistant response — even on disconnect.
376
+ // SECURITY: Redact vault credentials before persisting to SQLite
377
+ const safeToolCalls = redactVaultToolCalls(toolCallsAccum);
378
+ if (sessionStore && sessionId && assistantMsgId) {
379
+ if (finalText.trim()) {
380
+ await sessionStore.updateMessage(sessionId, assistantMsgId, finalText.trim(), safeToolCalls);
381
+ }
382
+ else {
383
+ await sessionStore.updateMessage(sessionId, assistantMsgId, "", safeToolCalls);
384
+ }
385
+ }
386
+ // Notify consumer (e.g. metering) — fire-and-forget
387
+ try {
388
+ deps.onCompletionFinished?.({
389
+ usage: totalUsage,
390
+ model: m.id ?? m.provider,
391
+ agent: body.agent,
392
+ sessionId: sessionId ?? undefined,
393
+ user: body.user,
394
+ providerMetadata: lastProviderMetadata,
395
+ });
396
+ }
397
+ catch { /* never fail on callback */ }
398
+ // Close per-request resources (MCP transports, etc.). Errors
399
+ // are intentionally swallowed — a stuck cleanup must not block
400
+ // the response from finishing.
401
+ if (onResponseFinished) {
402
+ onResponseFinished().catch(() => { });
403
+ }
404
+ }
405
+ });
406
+ }
407
+ /** Non-streaming chat mode — single OpenAI-format JSON response. */
408
+ export async function runNonStreamingChatCompletion(c, exec) {
409
+ const { deps, body, completionId, agentMode, fullSystemPrompt, m, providerOpts, modelToolChoice, effectiveTools, effectiveToolExecutor, extraAiTools, isInteractiveFn, aiMessages, sessionStore, sessionId, onResponseFinished, } = exec;
410
+ const aiTools = mergeAiTools(exec);
411
+ // Reserve placeholder so the message is visible even if the request is interrupted
412
+ let assistantMsgId = null;
413
+ if (sessionStore && sessionId) {
414
+ const placeholder = await sessionStore.addMessage(sessionId, "assistant", "");
415
+ assistantMsgId = placeholder.id;
416
+ }
417
+ const messages = [...aiMessages];
418
+ let finalText = "";
419
+ let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
420
+ const toolCallsAccum = [];
421
+ let lastProviderMetadata;
422
+ try {
423
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
424
+ // Compact context if approaching the model's context window limit.
425
+ // Under threshold this is just a cheap token estimation — zero LLM calls.
426
+ const compactionResult = await compactIfNeeded({
427
+ systemPrompt: fullSystemPrompt,
428
+ messages,
429
+ tools: effectiveTools,
430
+ config: {
431
+ contextWindow: m.contextWindow ?? 200_000,
432
+ maxOutputTokens: m.maxTokens ?? 8192,
433
+ },
434
+ summarize: buildSummarizeFn(m, providerOpts),
435
+ mode: "chat",
436
+ // Non-streaming: no SSE to write to, compaction is silent
437
+ });
438
+ if (compactionResult.compacted) {
439
+ messages.splice(0, messages.length, ...compactionResult.messages);
440
+ }
441
+ const genResult = await generateText({
442
+ model: m.aiModel,
443
+ system: fullSystemPrompt,
444
+ messages,
445
+ tools: aiTools,
446
+ ...(modelToolChoice ? { toolChoice: modelToolChoice } : {}),
447
+ maxOutputTokens: m.maxTokens,
448
+ providerOptions: providerOpts,
449
+ });
450
+ const turnText = genResult.text;
451
+ const usage = genResult.usage;
452
+ totalUsage = {
453
+ inputTokens: (totalUsage.inputTokens ?? 0) + (usage.inputTokens ?? 0),
454
+ outputTokens: (totalUsage.outputTokens ?? 0) + (usage.outputTokens ?? 0),
455
+ totalTokens: (totalUsage.totalTokens ?? 0) + (usage.totalTokens ?? 0),
456
+ };
457
+ try {
458
+ lastProviderMetadata = genResult.providerMetadata;
459
+ }
460
+ catch { /* best effort */ }
461
+ await appendModelResponseMessages(messages, genResult, turnText, genResult.toolCalls);
462
+ finalText += turnText;
463
+ const toolCalls = genResult.toolCalls;
464
+ if (toolCalls.length === 0)
465
+ break;
466
+ // ── Client-side tools — return to client as standard tool_calls ──
467
+ const clientSideCall = toolCalls.find((tc) => CLIENT_SIDE_TOOL_NAMES.has(tc.toolName));
468
+ if (clientSideCall) {
469
+ toolCallsAccum.push({
470
+ id: clientSideCall.toolCallId,
471
+ name: clientSideCall.toolName,
472
+ arguments: clientSideCall.input,
473
+ state: "interrupted",
474
+ });
475
+ // Persist before returning
476
+ if (sessionStore && sessionId) {
477
+ const assistantMsg = finalText + (turnText ? "" : "");
478
+ if (assistantMsg) {
479
+ await sessionStore.addMessage(sessionId, "assistant", assistantMsg, toolCallsAccum);
480
+ }
481
+ }
482
+ return c.json({
483
+ id: completionId,
484
+ object: "chat.completion",
485
+ created: Math.floor(Date.now() / 1000),
486
+ model: "polpo",
487
+ choices: [{
488
+ index: 0,
489
+ message: {
490
+ role: "assistant",
491
+ content: finalText || null,
492
+ tool_calls: [{
493
+ id: clientSideCall.toolCallId,
494
+ type: "function",
495
+ function: {
496
+ name: clientSideCall.toolName,
497
+ arguments: JSON.stringify(clientSideCall.input),
498
+ },
499
+ }],
500
+ },
501
+ finish_reason: "tool_calls",
502
+ }],
503
+ usage: {
504
+ prompt_tokens: totalUsage.inputTokens ?? 0,
505
+ completion_tokens: totalUsage.outputTokens ?? 0,
506
+ total_tokens: totalUsage.totalTokens ?? 0,
507
+ },
508
+ });
509
+ }
510
+ // Check for interactive tools — only in orchestrator mode (agents don't have interactive tools)
511
+ const interactiveCall = agentMode ? undefined : toolCalls.find((tc) => isInteractiveFn?.(tc.toolName));
512
+ if (interactiveCall) {
513
+ // Persist the interactive tool call so it survives session reload
514
+ toolCallsAccum.push({
515
+ id: interactiveCall.toolCallId,
516
+ name: interactiveCall.toolName,
517
+ arguments: interactiveCall.input,
518
+ state: "interrupted",
519
+ });
520
+ const baseResponse = {
521
+ id: completionId,
522
+ object: "chat.completion",
523
+ created: Math.floor(Date.now() / 1000),
524
+ model: "polpo",
525
+ usage: {
526
+ prompt_tokens: totalUsage.inputTokens ?? 0,
527
+ completion_tokens: totalUsage.outputTokens ?? 0,
528
+ total_tokens: totalUsage.totalTokens ?? 0,
529
+ },
530
+ };
531
+ if (interactiveCall.toolName === "ask_user") {
532
+ const questions = interactiveCall.input?.questions ?? [];
533
+ return c.json({
534
+ ...baseResponse,
535
+ choices: [{
536
+ index: 0,
537
+ message: { role: "assistant", content: finalText },
538
+ finish_reason: "ask_user",
539
+ ask_user: { questions },
540
+ }],
541
+ });
542
+ }
543
+ if (interactiveCall.toolName === "create_mission") {
544
+ const args = interactiveCall.input;
545
+ let missionData;
546
+ try {
547
+ missionData = JSON.parse(args.data);
548
+ }
549
+ catch {
550
+ missionData = args.data;
551
+ }
552
+ return c.json({
553
+ ...baseResponse,
554
+ choices: [{
555
+ index: 0,
556
+ message: { role: "assistant", content: finalText },
557
+ finish_reason: "mission_preview",
558
+ mission_preview: {
559
+ name: args.name,
560
+ data: missionData,
561
+ prompt: args.prompt,
562
+ },
563
+ }],
564
+ });
565
+ }
566
+ if (interactiveCall.toolName === "set_vault_entry") {
567
+ const args = interactiveCall.input;
568
+ return c.json({
569
+ ...baseResponse,
570
+ choices: [{
571
+ index: 0,
572
+ message: { role: "assistant", content: finalText },
573
+ finish_reason: "vault_preview",
574
+ vault_preview: {
575
+ agent: args.agent,
576
+ service: args.service,
577
+ type: args.type,
578
+ label: args.label,
579
+ credentials: args.credentials,
580
+ },
581
+ }],
582
+ });
583
+ }
584
+ if (interactiveCall.toolName === "open_file") {
585
+ const args = interactiveCall.input;
586
+ return c.json({
587
+ ...baseResponse,
588
+ choices: [{
589
+ index: 0,
590
+ message: { role: "assistant", content: finalText },
591
+ finish_reason: "open_file",
592
+ open_file: {
593
+ path: args.path,
594
+ },
595
+ }],
596
+ });
597
+ }
598
+ if (interactiveCall.toolName === "navigate_to") {
599
+ const args = interactiveCall.input;
600
+ return c.json({
601
+ ...baseResponse,
602
+ choices: [{
603
+ index: 0,
604
+ message: { role: "assistant", content: finalText },
605
+ finish_reason: "navigate_to",
606
+ navigate_to: {
607
+ target: args.target,
608
+ id: args.id,
609
+ name: args.name,
610
+ path: args.path,
611
+ highlight: args.highlight,
612
+ },
613
+ }],
614
+ });
615
+ }
616
+ if (interactiveCall.toolName === "open_tab") {
617
+ const args = interactiveCall.input;
618
+ return c.json({
619
+ ...baseResponse,
620
+ choices: [{
621
+ index: 0,
622
+ message: { role: "assistant", content: finalText },
623
+ finish_reason: "open_tab",
624
+ open_tab: {
625
+ url: args.url,
626
+ label: args.label,
627
+ },
628
+ }],
629
+ });
630
+ }
631
+ // Note: finally block persists finalText + toolCallsAccum
632
+ }
633
+ // Provider tools (extraAiTools) are executed by the SDK / gateway.
634
+ // Their tool results are already preserved in responseMessages,
635
+ // so only record them for observability and skip local dispatch.
636
+ const providerToolNames = new Set(Object.keys(extraAiTools ?? {}));
637
+ const providerToolResults = indexToolResultsByCallId(genResult.toolResults);
638
+ for (const call of toolCalls) {
639
+ const callArgs = call.input;
640
+ if (providerToolNames.has(call.toolName)) {
641
+ recordProviderToolCall(toolCallsAccum, call, providerToolResults);
642
+ continue;
643
+ }
644
+ const result = await effectiveToolExecutor(call.toolName, callArgs);
645
+ const isError = result.startsWith("Error:");
646
+ emitFileChanged(call.toolName, callArgs, result, deps.emit);
647
+ // Accumulate for persistence
648
+ toolCallsAccum.push({
649
+ id: call.toolCallId,
650
+ name: call.toolName,
651
+ arguments: callArgs,
652
+ result,
653
+ state: isError ? "error" : "completed",
654
+ });
655
+ // Push tool result message in AI SDK format
656
+ messages.push({
657
+ role: "tool",
658
+ content: [{
659
+ type: "tool-result",
660
+ toolCallId: call.toolCallId,
661
+ toolName: call.toolName,
662
+ output: isError
663
+ ? { type: "error-text", value: result }
664
+ : { type: "text", value: result },
665
+ }],
666
+ });
667
+ }
668
+ }
669
+ return c.json(completionResponse(completionId, finalText, totalUsage));
670
+ }
671
+ catch (err) {
672
+ // Friendly model_not_found surface — gateway returns 404 for
673
+ // renamed/deprecated SKUs (e.g. xai/grok-4-fast after the 4.1
674
+ // rename). Without this catch the error propagates as a 500.
675
+ const notFound = modelNotFoundEnvelope(err, m?.id, body.agent);
676
+ if (notFound) {
677
+ return c.json({ error: notFound }, 400);
678
+ }
679
+ throw err;
680
+ }
681
+ finally {
682
+ // Always persist the final text + tool calls — even on early return (ask_user) or error
683
+ // SECURITY: Redact vault credentials before persisting to SQLite
684
+ const safeToolCalls = redactVaultToolCalls(toolCallsAccum);
685
+ if (sessionStore && sessionId && assistantMsgId) {
686
+ if (finalText.trim()) {
687
+ await sessionStore.updateMessage(sessionId, assistantMsgId, finalText.trim(), safeToolCalls);
688
+ }
689
+ else {
690
+ await sessionStore.updateMessage(sessionId, assistantMsgId, "[Response interrupted]", safeToolCalls);
691
+ }
692
+ }
693
+ // Notify consumer (e.g. metering) — fire-and-forget
694
+ try {
695
+ deps.onCompletionFinished?.({
696
+ usage: totalUsage,
697
+ model: m.id ?? m.provider,
698
+ agent: body.agent,
699
+ sessionId: sessionId ?? undefined,
700
+ user: body.user,
701
+ providerMetadata: lastProviderMetadata,
702
+ });
703
+ }
704
+ catch { /* never fail on callback */ }
705
+ if (onResponseFinished) {
706
+ onResponseFinished().catch(() => { });
707
+ }
708
+ }
709
+ }
710
+ //# sourceMappingURL=chat-handler.js.map