@polpo-ai/server 0.15.75 → 0.15.77

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 (47) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/routes/completions/chat-handler.d.ts +10 -0
  6. package/dist/routes/completions/chat-handler.d.ts.map +1 -1
  7. package/dist/routes/completions/chat-handler.js +436 -436
  8. package/dist/routes/completions/chat-handler.js.map +1 -1
  9. package/dist/routes/completions/chat-via-run-handler.d.ts +2 -1
  10. package/dist/routes/completions/chat-via-run-handler.d.ts.map +1 -1
  11. package/dist/routes/completions/chat-via-run-handler.js +102 -91
  12. package/dist/routes/completions/chat-via-run-handler.js.map +1 -1
  13. package/dist/routes/completions/durable-stream.d.ts +39 -0
  14. package/dist/routes/completions/durable-stream.d.ts.map +1 -0
  15. package/dist/routes/completions/durable-stream.js +83 -0
  16. package/dist/routes/completions/durable-stream.js.map +1 -0
  17. package/dist/routes/completions/durable-stream.test.d.ts +2 -0
  18. package/dist/routes/completions/durable-stream.test.d.ts.map +1 -0
  19. package/dist/routes/completions/durable-stream.test.js +140 -0
  20. package/dist/routes/completions/durable-stream.test.js.map +1 -0
  21. package/dist/routes/completions/project-loop-runner.d.ts +6 -2
  22. package/dist/routes/completions/project-loop-runner.d.ts.map +1 -1
  23. package/dist/routes/completions/project-loop-runner.js +121 -113
  24. package/dist/routes/completions/project-loop-runner.js.map +1 -1
  25. package/dist/routes/completions/schemas.d.ts +12 -0
  26. package/dist/routes/completions/schemas.d.ts.map +1 -1
  27. package/dist/routes/completions/schemas.js +5 -0
  28. package/dist/routes/completions/schemas.js.map +1 -1
  29. package/dist/routes/completions/schemas.test.js +20 -0
  30. package/dist/routes/completions/schemas.test.js.map +1 -1
  31. package/dist/routes/completions-durable-delivery.test.d.ts +2 -0
  32. package/dist/routes/completions-durable-delivery.test.d.ts.map +1 -0
  33. package/dist/routes/completions-durable-delivery.test.js +154 -0
  34. package/dist/routes/completions-durable-delivery.test.js.map +1 -0
  35. package/dist/routes/completions.d.ts +13 -0
  36. package/dist/routes/completions.d.ts.map +1 -1
  37. package/dist/routes/completions.js +107 -5
  38. package/dist/routes/completions.js.map +1 -1
  39. package/dist/routes/run-delivery.d.ts +15 -0
  40. package/dist/routes/run-delivery.d.ts.map +1 -0
  41. package/dist/routes/run-delivery.js +202 -0
  42. package/dist/routes/run-delivery.js.map +1 -0
  43. package/dist/routes/run-delivery.test.d.ts +2 -0
  44. package/dist/routes/run-delivery.test.d.ts.map +1 -0
  45. package/dist/routes/run-delivery.test.js +124 -0
  46. package/dist/routes/run-delivery.test.js.map +1 -0
  47. package/package.json +7 -7
@@ -37,494 +37,494 @@ function clientSideToolNames(exec) {
37
37
  }
38
38
  /** Streaming chat mode — SSE stream of OpenAI-format chunks. */
39
39
  export function streamChatCompletion(c, exec) {
40
- const { deps, body, completionId, agentMode, fullSystemPrompt, m: primaryModel, providerOpts: primaryProviderOpts, modelToolChoice, effectiveTools, effectiveToolExecutor, extraAiTools, isInteractiveFn, aiMessages, sessionStore, sessionId, onResponseFinished, } = exec;
41
- const aiTools = mergeAiTools(exec);
42
- let m = primaryModel;
43
- let providerOpts = primaryProviderOpts;
44
- const modelSelection = exec.modelSelection ?? modelSelectionForResolvedModel(primaryModel);
45
- const reasoning = exec.agentConfig?.reasoning ?? deps.getConfig()?.settings?.reasoning;
46
- const outputMode = streamingOutputPolicyMode(deps.runOutputPolicy);
47
- const structuredResponse = isStructuredResponseFormat(body.response_format);
48
40
  return streamSSE(c, async (stream) => {
49
- // Abort controller: cancelled when the client disconnects (closes SSE)
50
41
  const abortController = new AbortController();
51
42
  stream.onAbort(() => { abortController.abort(); });
52
- // SSE heartbeat: write a comment (`: ping`) every 20s to prevent
53
- // proxy idle timeouts (nginx 60s, Cloudflare 100s) during long tool
54
- // execution pauses. SSE comments are invisible to compliant clients.
55
- // WritableStream serializes writes, so heartbeats cannot interleave
56
- // mid-payload with writeSSE calls.
57
43
  const heartbeatInterval = setInterval(() => {
58
44
  if (abortController.signal.aborted) {
59
45
  clearInterval(heartbeatInterval);
60
46
  return;
61
47
  }
62
- stream.write(": ping\n\n").catch(() => {
63
- clearInterval(heartbeatInterval);
64
- });
48
+ stream.write(": ping\n\n").catch(() => clearInterval(heartbeatInterval));
65
49
  }, 20_000);
66
- await stream.writeSSE({ data: sseChunk(completionId, { role: "assistant" }) });
67
- // Reserve a placeholder message in the store BEFORE streaming.
68
- // This guarantees the assistant message exists even if the client disconnects.
69
- let assistantMsgId = null;
70
- if (sessionStore && sessionId) {
71
- const placeholder = await sessionStore.addMessage(sessionId, "assistant", "");
72
- assistantMsgId = placeholder.id;
50
+ try {
51
+ await executeStreamingChatCompletion(stream, exec, abortController.signal);
73
52
  }
74
- const messages = [...aiMessages];
75
- let finalText = "";
76
- let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
77
- const toolCallsAccum = [];
78
- let lastProviderMetadata;
79
- let outputPolicyApplied = false;
80
- let suggestions = [];
81
- const finalizeOutput = async (validateStructured = true) => {
82
- if (outputPolicyApplied)
83
- return;
84
- finalText = await applyCompletionOutputPolicy({
85
- outputPolicy: deps.runOutputPolicy,
86
- text: finalText,
87
- mode: outputMode === "buffer" ? "enforce" : "audit",
88
- runtimePlan: exec.runtimePlan,
89
- agent: body.agent,
90
- sessionId,
91
- signal: abortController.signal,
53
+ finally {
54
+ clearInterval(heartbeatInterval);
55
+ }
56
+ });
57
+ }
58
+ /** Transport-independent producer used by attached and durable followers. */
59
+ export async function executeStreamingChatCompletion(stream, exec, signal) {
60
+ const { deps, body, completionId, agentMode, fullSystemPrompt, m: primaryModel, providerOpts: primaryProviderOpts, modelToolChoice, effectiveTools, effectiveToolExecutor, extraAiTools, isInteractiveFn, aiMessages, sessionStore, sessionId, onResponseFinished, } = exec;
61
+ const aiTools = mergeAiTools(exec);
62
+ let m = primaryModel;
63
+ let providerOpts = primaryProviderOpts;
64
+ const modelSelection = exec.modelSelection ?? modelSelectionForResolvedModel(primaryModel);
65
+ const reasoning = exec.agentConfig?.reasoning ?? deps.getConfig()?.settings?.reasoning;
66
+ const outputMode = streamingOutputPolicyMode(deps.runOutputPolicy);
67
+ const structuredResponse = isStructuredResponseFormat(body.response_format);
68
+ await stream.writeSSE({ data: sseChunk(completionId, { role: "assistant" }) });
69
+ // Reserve a placeholder message in the store BEFORE streaming.
70
+ // This guarantees the assistant message exists even if the client disconnects.
71
+ let assistantMsgId = null;
72
+ if (sessionStore && sessionId) {
73
+ const placeholder = await sessionStore.addMessage(sessionId, "assistant", "");
74
+ assistantMsgId = placeholder.id;
75
+ }
76
+ const messages = [...aiMessages];
77
+ let finalText = "";
78
+ let totalUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
79
+ const toolCallsAccum = [];
80
+ let lastProviderMetadata;
81
+ let outputPolicyApplied = false;
82
+ let suggestions = [];
83
+ const finalizeOutput = async (validateStructured = true) => {
84
+ if (outputPolicyApplied)
85
+ return;
86
+ finalText = await applyCompletionOutputPolicy({
87
+ outputPolicy: deps.runOutputPolicy,
88
+ text: finalText,
89
+ mode: outputMode === "buffer" ? "enforce" : "audit",
90
+ runtimePlan: exec.runtimePlan,
91
+ agent: body.agent,
92
+ sessionId,
93
+ signal,
94
+ });
95
+ if (validateStructured) {
96
+ finalText = await finalizeResponseFormatText(body.response_format, finalText);
97
+ }
98
+ outputPolicyApplied = true;
99
+ if ((outputMode === "buffer" || structuredResponse) && finalText) {
100
+ await stream.writeSSE({ data: sseChunk(completionId, { content: finalText }) });
101
+ }
102
+ };
103
+ try {
104
+ for (let turn = 0; turn < MAX_TURNS; turn++) {
105
+ // Bail out early if the client already disconnected
106
+ if (signal.aborted)
107
+ break;
108
+ // Compact context if approaching the model's context window limit.
109
+ // Under threshold this is just a cheap token estimation — zero LLM calls.
110
+ const compactionResult = await compactIfNeeded({
111
+ systemPrompt: fullSystemPrompt,
112
+ messages,
113
+ tools: exec.activeCompactionTools?.() ?? effectiveTools,
114
+ config: {
115
+ contextWindow: m.contextWindow ?? 200_000,
116
+ maxOutputTokens: m.maxTokens ?? 8192,
117
+ },
118
+ summarize: buildSummarizeFn(m, providerOpts),
119
+ mode: "chat",
120
+ onCompaction: async (event) => {
121
+ await stream.writeSSE({
122
+ data: sseChunk(completionId, {}, null, {
123
+ compaction: {
124
+ phase: event.phase,
125
+ tokensBefore: event.tokensBefore,
126
+ tokensAfter: event.tokensAfter,
127
+ tokensReclaimed: event.tokensReclaimed,
128
+ messagesBefore: event.messagesBefore,
129
+ messagesAfter: event.messagesAfter,
130
+ },
131
+ }),
132
+ });
133
+ },
92
134
  });
93
- if (validateStructured) {
94
- finalText = await finalizeResponseFormatText(body.response_format, finalText);
95
- }
96
- outputPolicyApplied = true;
97
- if ((outputMode === "buffer" || structuredResponse) && finalText) {
98
- await stream.writeSSE({ data: sseChunk(completionId, { content: finalText }) });
135
+ if (compactionResult.compacted) {
136
+ messages.splice(0, messages.length, ...compactionResult.messages);
99
137
  }
100
- };
101
- try {
102
- for (let turn = 0; turn < MAX_TURNS; turn++) {
103
- // Bail out early if the client already disconnected
104
- if (abortController.signal.aborted)
105
- break;
106
- // Compact context if approaching the model's context window limit.
107
- // Under threshold this is just a cheap token estimation — zero LLM calls.
108
- const compactionResult = await compactIfNeeded({
109
- systemPrompt: fullSystemPrompt,
110
- messages,
111
- tools: exec.activeCompactionTools?.() ?? effectiveTools,
112
- config: {
113
- contextWindow: m.contextWindow ?? 200_000,
114
- maxOutputTokens: m.maxTokens ?? 8192,
115
- },
116
- summarize: buildSummarizeFn(m, providerOpts),
117
- mode: "chat",
118
- onCompaction: async (event) => {
119
- await stream.writeSSE({
120
- data: sseChunk(completionId, {}, null, {
121
- compaction: {
122
- phase: event.phase,
123
- tokensBefore: event.tokensBefore,
124
- tokensAfter: event.tokensAfter,
125
- tokensReclaimed: event.tokensReclaimed,
126
- messagesBefore: event.messagesBefore,
127
- messagesAfter: event.messagesAfter,
128
- },
129
- }),
130
- });
131
- },
132
- });
133
- if (compactionResult.compacted) {
134
- messages.splice(0, messages.length, ...compactionResult.messages);
135
- }
136
- let turnText = "";
137
- let streamError;
138
- // Per-tool-call streaming state for this turn: the tool name (learned
139
- // at tool-input-start) and the raw args JSON accumulated from the
140
- // token-by-token input deltas, so the UI can render the call as it
141
- // builds up instead of waiting for the whole turn to finish.
142
- const toolCallNames = new Map();
143
- const toolCallArgsText = new Map();
144
- const resolvedAttempts = new Map();
145
- const result = await runModelPolicyTurn({
146
- selection: modelSelection,
147
- resolveAttempt: async (attempt) => {
148
- const resolvedAttempt = attempt.index === 0 || !agentMode || !exec.agentConfig
149
- ? { model: primaryModel, providerOptions: primaryProviderOpts }
150
- : await deps.resolveAgentModel(agentConfigForModelAttempt(exec.agentConfig, attempt.model), reasoning);
151
- resolvedAttempts.set(attempt.index, resolvedAttempt);
152
- return {
153
- model: resolvedAttempt.model.aiModel,
154
- maxOutputTokens: resolvedAttempt.model.maxTokens,
155
- providerOptions: resolvedAttempt.providerOptions,
156
- };
157
- },
158
- preserveSingleAttemptError: true,
159
- system: fullSystemPrompt,
160
- messages,
161
- tools: aiTools,
162
- ...(exec.activeToolNames ? { activeTools: exec.activeToolNames() } : {}),
163
- ...(modelToolChoice ? { toolChoice: modelToolChoice } : {}),
164
- ...(exec.modelOutput ? { output: exec.modelOutput } : {}),
165
- abortSignal: abortController.signal,
166
- }, async (event) => {
167
- if (abortController.signal.aborted)
168
- return;
169
- if (event.type === "reasoning-delta") {
170
- await stream.writeSSE({ data: sseChunk(completionId, {}, null, { thinking: event.text }) });
171
- }
172
- else if (event.type === "text-delta") {
173
- turnText += event.text;
174
- if (!structuredResponse)
175
- finalText += event.text;
176
- if (outputMode !== "buffer" && !structuredResponse) {
177
- await stream.writeSSE({ data: sseChunk(completionId, { content: event.text }) });
178
- }
179
- }
180
- else if (event.type === "tool-input-start") {
181
- // Emit early "preparing" signal — the LLM has started generating a tool call
182
- // but arguments are not yet complete. Lets the UI show immediate feedback.
183
- toolCallNames.set(event.id, event.name);
184
- await stream.writeSSE({
185
- data: sseChunk(completionId, {}, null, {
186
- tool_call: { id: event.id, name: event.name, state: "preparing" },
187
- }),
188
- });
189
- }
190
- else if (event.type === "tool-input-delta") {
191
- // Stream the argument tokens as they arrive. Accumulate the raw
192
- // JSON and forward it so the client can show the tool input
193
- // building up live. Still "preparing": args aren't final yet.
194
- const acc = (toolCallArgsText.get(event.id) ?? "") + event.delta;
195
- toolCallArgsText.set(event.id, acc);
196
- await stream.writeSSE({
197
- data: sseChunk(completionId, {}, null, {
198
- tool_call: {
199
- id: event.id,
200
- name: toolCallNames.get(event.id) ?? "",
201
- state: "preparing",
202
- argumentsText: acc,
203
- },
204
- }),
205
- });
206
- }
207
- else if (event.type === "finish") {
208
- // Capture error from finish reason if applicable
209
- if (event.finishReason === "error") {
210
- streamError = "Model returned an error";
211
- }
212
- }
213
- });
214
- // If aborted, stop the loop — skip error/tool processing
215
- if (abortController.signal.aborted) {
216
- break;
138
+ let turnText = "";
139
+ let streamError;
140
+ // Per-tool-call streaming state for this turn: the tool name (learned
141
+ // at tool-input-start) and the raw args JSON accumulated from the
142
+ // token-by-token input deltas, so the UI can render the call as it
143
+ // builds up instead of waiting for the whole turn to finish.
144
+ const toolCallNames = new Map();
145
+ const toolCallArgsText = new Map();
146
+ const resolvedAttempts = new Map();
147
+ const result = await runModelPolicyTurn({
148
+ selection: modelSelection,
149
+ resolveAttempt: async (attempt) => {
150
+ const resolvedAttempt = attempt.index === 0 || !agentMode || !exec.agentConfig
151
+ ? { model: primaryModel, providerOptions: primaryProviderOpts }
152
+ : await deps.resolveAgentModel(agentConfigForModelAttempt(exec.agentConfig, attempt.model), reasoning);
153
+ resolvedAttempts.set(attempt.index, resolvedAttempt);
154
+ return {
155
+ model: resolvedAttempt.model.aiModel,
156
+ maxOutputTokens: resolvedAttempt.model.maxTokens,
157
+ providerOptions: resolvedAttempt.providerOptions,
158
+ };
159
+ },
160
+ preserveSingleAttemptError: true,
161
+ system: fullSystemPrompt,
162
+ messages,
163
+ tools: aiTools,
164
+ ...(exec.activeToolNames ? { activeTools: exec.activeToolNames() } : {}),
165
+ ...(modelToolChoice ? { toolChoice: modelToolChoice } : {}),
166
+ ...(exec.modelOutput ? { output: exec.modelOutput } : {}),
167
+ abortSignal: signal,
168
+ }, async (event) => {
169
+ if (signal.aborted)
170
+ return;
171
+ if (event.type === "reasoning-delta") {
172
+ await stream.writeSSE({ data: sseChunk(completionId, {}, null, { thinking: event.text }) });
217
173
  }
218
- if (streamError) {
219
- finalText += `\n\nError: ${streamError}`;
174
+ else if (event.type === "text-delta") {
175
+ turnText += event.text;
176
+ if (!structuredResponse)
177
+ finalText += event.text;
220
178
  if (outputMode !== "buffer" && !structuredResponse) {
221
- await stream.writeSSE({ data: sseChunk(completionId, { content: `\n\nError: ${streamError}` }) });
179
+ await stream.writeSSE({ data: sseChunk(completionId, { content: event.text }) });
222
180
  }
223
- break;
224
181
  }
225
- const toolCalls = result.toolCalls;
226
- if (structuredResponse && toolCalls.length === 0) {
227
- turnText = await serializeModelOutput(body.response_format, result.output, turnText);
228
- finalText += turnText;
229
- }
230
- const usage = result.usage;
231
- const selectedResolved = resolvedAttempts.get(result.selectedAttempt.index);
232
- if (selectedResolved) {
233
- m = selectedResolved.model;
234
- providerOpts = selectedResolved.providerOptions;
235
- }
236
- totalUsage = {
237
- inputTokens: (totalUsage.inputTokens ?? 0) + (usage.inputTokens ?? 0),
238
- outputTokens: (totalUsage.outputTokens ?? 0) + (usage.outputTokens ?? 0),
239
- totalTokens: (totalUsage.totalTokens ?? 0) + (usage.totalTokens ?? 0),
240
- };
241
- lastProviderMetadata = result.providerMetadata;
242
- await appendModelResponseMessages(messages, result, turnText, toolCalls, exec.contextTrust ?? "off");
243
- if (toolCalls.length === 0)
244
- break;
245
- const dispatchableToolCalls = toolCalls.filter((call) => !isInvalidModelToolCall(call));
246
- for (const call of toolCalls.filter(isInvalidModelToolCall)) {
247
- const event = invalidModelToolCallEvent(call);
248
- toolCallsAccum.push(event);
182
+ else if (event.type === "tool-input-start") {
183
+ // Emit early "preparing" signal the LLM has started generating a tool call
184
+ // but arguments are not yet complete. Lets the UI show immediate feedback.
185
+ toolCallNames.set(event.id, event.name);
249
186
  await stream.writeSSE({
250
187
  data: sseChunk(completionId, {}, null, {
251
- tool_call: event,
188
+ tool_call: { id: event.id, name: event.name, state: "preparing" },
252
189
  }),
253
190
  });
254
191
  }
255
- if (dispatchableToolCalls.length === 0)
256
- continue;
257
- // ── Client-side tools return to client as standard tool_calls ──
258
- const clientSideCall = dispatchableToolCalls.find((tc) => clientSideToolNames(exec).has(tc.toolName));
259
- if (clientSideCall) {
260
- // Persist for session history
261
- toolCallsAccum.push({
262
- id: clientSideCall.toolCallId,
263
- name: clientSideCall.toolName,
264
- arguments: clientSideCall.input,
265
- state: "interrupted",
266
- });
267
- await finalizeOutput(false);
268
- // Send as standard OpenAI tool_calls finish reason
192
+ else if (event.type === "tool-input-delta") {
193
+ // Stream the argument tokens as they arrive. Accumulate the raw
194
+ // JSON and forward it so the client can show the tool input
195
+ // building up live. Still "preparing": args aren't final yet.
196
+ const acc = (toolCallArgsText.get(event.id) ?? "") + event.delta;
197
+ toolCallArgsText.set(event.id, acc);
269
198
  await stream.writeSSE({
270
- data: JSON.stringify({
271
- id: completionId,
272
- object: "chat.completion.chunk",
273
- choices: [{
274
- index: 0,
275
- delta: {
276
- role: "assistant",
277
- tool_calls: [{
278
- index: 0,
279
- id: clientSideCall.toolCallId,
280
- type: "function",
281
- function: {
282
- name: clientSideCall.toolName,
283
- arguments: JSON.stringify(clientSideCall.input),
284
- },
285
- }],
286
- },
287
- finish_reason: "tool_calls",
288
- }],
199
+ data: sseChunk(completionId, {}, null, {
200
+ tool_call: {
201
+ id: event.id,
202
+ name: toolCallNames.get(event.id) ?? "",
203
+ state: "preparing",
204
+ argumentsText: acc,
205
+ },
289
206
  }),
290
207
  });
291
- await stream.writeSSE({ data: "[DONE]" });
292
- return;
293
208
  }
294
- // Check for interactive tools — only in orchestrator mode (agents don't have interactive tools)
295
- const interactiveCall = agentMode ? undefined : dispatchableToolCalls.find((tc) => isInteractiveFn?.(tc.toolName));
296
- if (interactiveCall) {
297
- // Persist the interactive tool call so it survives session reload
298
- toolCallsAccum.push({
299
- id: interactiveCall.toolCallId,
300
- name: interactiveCall.toolName,
301
- arguments: interactiveCall.input,
302
- state: "interrupted",
303
- });
304
- await finalizeOutput(false);
305
- if (interactiveCall.toolName === "ask_user") {
306
- const questions = interactiveCall.input?.questions ?? [];
307
- await stream.writeSSE({
308
- data: sseChunk(completionId, {}, "ask_user", { ask_user: { questions } }),
309
- });
209
+ else if (event.type === "finish") {
210
+ // Capture error from finish reason if applicable
211
+ if (event.finishReason === "error") {
212
+ streamError = "Model returned an error";
310
213
  }
311
- else if (interactiveCall.toolName === "create_mission") {
312
- const args = interactiveCall.input;
313
- let missionData;
314
- try {
315
- missionData = JSON.parse(args.data);
316
- }
317
- catch {
318
- missionData = args.data;
319
- }
320
- await stream.writeSSE({
321
- data: sseChunk(completionId, {}, "mission_preview", {
322
- mission_preview: {
323
- name: args.name,
324
- data: missionData,
325
- prompt: args.prompt,
326
- },
327
- }),
328
- });
329
- }
330
- else if (interactiveCall.toolName === "set_vault_entry") {
331
- const args = interactiveCall.input;
332
- await stream.writeSSE({
333
- data: sseChunk(completionId, {}, "vault_preview", {
334
- vault_preview: {
335
- agent: args.agent,
336
- service: args.service,
337
- type: args.type,
338
- label: args.label,
339
- credentials: args.credentials,
340
- },
341
- }),
342
- });
343
- }
344
- else if (interactiveCall.toolName === "open_file") {
345
- const args = interactiveCall.input;
346
- await stream.writeSSE({
347
- data: sseChunk(completionId, {}, "open_file", {
348
- open_file: {
349
- path: args.path,
350
- },
351
- }),
352
- });
353
- }
354
- else if (interactiveCall.toolName === "navigate_to") {
355
- const args = interactiveCall.input;
356
- await stream.writeSSE({
357
- data: sseChunk(completionId, {}, "navigate_to", {
358
- navigate_to: {
359
- target: args.target,
360
- id: args.id,
361
- name: args.name,
362
- path: args.path,
363
- highlight: args.highlight,
364
- },
365
- }),
366
- });
367
- }
368
- else if (interactiveCall.toolName === "open_tab") {
369
- const args = interactiveCall.input;
370
- await stream.writeSSE({
371
- data: sseChunk(completionId, {}, "open_tab", {
372
- open_tab: {
373
- url: args.url,
374
- label: args.label,
214
+ }
215
+ });
216
+ // If aborted, stop the loop — skip error/tool processing
217
+ if (signal.aborted) {
218
+ break;
219
+ }
220
+ if (streamError) {
221
+ finalText += `\n\nError: ${streamError}`;
222
+ if (outputMode !== "buffer" && !structuredResponse) {
223
+ await stream.writeSSE({ data: sseChunk(completionId, { content: `\n\nError: ${streamError}` }) });
224
+ }
225
+ break;
226
+ }
227
+ const toolCalls = result.toolCalls;
228
+ if (structuredResponse && toolCalls.length === 0) {
229
+ turnText = await serializeModelOutput(body.response_format, result.output, turnText);
230
+ finalText += turnText;
231
+ }
232
+ const usage = result.usage;
233
+ const selectedResolved = resolvedAttempts.get(result.selectedAttempt.index);
234
+ if (selectedResolved) {
235
+ m = selectedResolved.model;
236
+ providerOpts = selectedResolved.providerOptions;
237
+ }
238
+ totalUsage = {
239
+ inputTokens: (totalUsage.inputTokens ?? 0) + (usage.inputTokens ?? 0),
240
+ outputTokens: (totalUsage.outputTokens ?? 0) + (usage.outputTokens ?? 0),
241
+ totalTokens: (totalUsage.totalTokens ?? 0) + (usage.totalTokens ?? 0),
242
+ };
243
+ lastProviderMetadata = result.providerMetadata;
244
+ await appendModelResponseMessages(messages, result, turnText, toolCalls, exec.contextTrust ?? "off");
245
+ if (toolCalls.length === 0)
246
+ break;
247
+ const dispatchableToolCalls = toolCalls.filter((call) => !isInvalidModelToolCall(call));
248
+ for (const call of toolCalls.filter(isInvalidModelToolCall)) {
249
+ const event = invalidModelToolCallEvent(call);
250
+ toolCallsAccum.push(event);
251
+ await stream.writeSSE({
252
+ data: sseChunk(completionId, {}, null, {
253
+ tool_call: event,
254
+ }),
255
+ });
256
+ }
257
+ if (dispatchableToolCalls.length === 0)
258
+ continue;
259
+ // ── Client-side tools — return to client as standard tool_calls ──
260
+ const clientSideCall = dispatchableToolCalls.find((tc) => clientSideToolNames(exec).has(tc.toolName));
261
+ if (clientSideCall) {
262
+ // Persist for session history
263
+ toolCallsAccum.push({
264
+ id: clientSideCall.toolCallId,
265
+ name: clientSideCall.toolName,
266
+ arguments: clientSideCall.input,
267
+ state: "interrupted",
268
+ });
269
+ await finalizeOutput(false);
270
+ // Send as standard OpenAI tool_calls finish reason
271
+ await stream.writeSSE({
272
+ data: JSON.stringify({
273
+ id: completionId,
274
+ object: "chat.completion.chunk",
275
+ choices: [{
276
+ index: 0,
277
+ delta: {
278
+ role: "assistant",
279
+ tool_calls: [{
280
+ index: 0,
281
+ id: clientSideCall.toolCallId,
282
+ type: "function",
283
+ function: {
284
+ name: clientSideCall.toolName,
285
+ arguments: JSON.stringify(clientSideCall.input),
286
+ },
287
+ }],
375
288
  },
376
- }),
377
- });
378
- }
379
- await stream.writeSSE({ data: "[DONE]" });
380
- return; // finally block will persist whatever finalText we have
289
+ finish_reason: "tool_calls",
290
+ }],
291
+ }),
292
+ });
293
+ await stream.writeSSE({ data: "[DONE]" });
294
+ return;
295
+ }
296
+ // Check for interactive tools — only in orchestrator mode (agents don't have interactive tools)
297
+ const interactiveCall = agentMode ? undefined : dispatchableToolCalls.find((tc) => isInteractiveFn?.(tc.toolName));
298
+ if (interactiveCall) {
299
+ // Persist the interactive tool call so it survives session reload
300
+ toolCallsAccum.push({
301
+ id: interactiveCall.toolCallId,
302
+ name: interactiveCall.toolName,
303
+ arguments: interactiveCall.input,
304
+ state: "interrupted",
305
+ });
306
+ await finalizeOutput(false);
307
+ if (interactiveCall.toolName === "ask_user") {
308
+ const questions = interactiveCall.input?.questions ?? [];
309
+ await stream.writeSSE({
310
+ data: sseChunk(completionId, {}, "ask_user", { ask_user: { questions } }),
311
+ });
381
312
  }
382
- // Provider tools (extraAiTools) are executed by the SDK / gateway.
383
- // Their tool results are already preserved in responseMessages,
384
- // so only record them for observability and skip local dispatch.
385
- const providerToolNames = new Set(Object.keys(extraAiTools ?? {}));
386
- const providerToolResults = indexToolResultsByCallId(result.toolResults);
387
- for (const call of dispatchableToolCalls) {
388
- // Stop executing tools if client disconnected
389
- if (abortController.signal.aborted)
390
- break;
391
- const callArgs = call.input;
392
- if (providerToolNames.has(call.toolName)) {
393
- recordProviderToolCall(toolCallsAccum, call, providerToolResults);
394
- continue;
313
+ else if (interactiveCall.toolName === "create_mission") {
314
+ const args = interactiveCall.input;
315
+ let missionData;
316
+ try {
317
+ missionData = JSON.parse(args.data);
318
+ }
319
+ catch {
320
+ missionData = args.data;
395
321
  }
396
- // Notify client that a tool is being called
397
322
  await stream.writeSSE({
398
- data: sseChunk(completionId, {}, null, {
399
- tool_call: { id: call.toolCallId, name: call.toolName, arguments: callArgs, state: "calling" },
323
+ data: sseChunk(completionId, {}, "mission_preview", {
324
+ mission_preview: {
325
+ name: args.name,
326
+ data: missionData,
327
+ prompt: args.prompt,
328
+ },
400
329
  }),
401
330
  });
402
- const result = await effectiveToolExecutor(call.toolName, callArgs, {
403
- callId: call.toolCallId,
404
- signal: abortController.signal,
405
- });
406
- const isError = result.startsWith("Error:");
407
- emitFileChanged(call.toolName, callArgs, result, deps.emit);
408
- // Accumulate for persistence
409
- toolCallsAccum.push({
410
- id: call.toolCallId,
411
- name: call.toolName,
412
- arguments: callArgs,
413
- result,
414
- state: isError ? "error" : "completed",
415
- });
416
- // Notify client with tool result (skip if aborted mid-tool)
417
- if (!abortController.signal.aborted) {
418
- await stream.writeSSE({
419
- data: sseChunk(completionId, {}, null, {
420
- tool_call: { id: call.toolCallId, name: call.toolName, result, state: isError ? "error" : "completed" },
421
- }),
422
- });
423
- }
424
- // Push tool result message in AI SDK format
425
- messages.push({
426
- role: "tool",
427
- content: [{
428
- type: "tool-result",
429
- toolCallId: call.toolCallId,
430
- toolName: call.toolName,
431
- output: isError
432
- ? {
433
- type: "error-text",
434
- value: exec.contextTrust === "enforce"
435
- ? renderRuntimeToolResult(call.toolName, call.toolCallId, result)
436
- : result,
437
- }
438
- : {
439
- type: "text",
440
- value: exec.contextTrust === "enforce"
441
- ? renderRuntimeToolResult(call.toolName, call.toolCallId, result)
442
- : result,
443
- },
444
- }],
445
- });
446
331
  }
447
- }
448
- if (!abortController.signal.aborted) {
449
- await finalizeOutput();
450
- suggestions = await suggestionsForCompletion(exec, finalText, abortController.signal);
451
- if (suggestions.length > 0) {
332
+ else if (interactiveCall.toolName === "set_vault_entry") {
333
+ const args = interactiveCall.input;
452
334
  await stream.writeSSE({
453
- data: ssePolpoChunk(completionId, { suggestions }),
335
+ data: sseChunk(completionId, {}, "vault_preview", {
336
+ vault_preview: {
337
+ agent: args.agent,
338
+ service: args.service,
339
+ type: args.type,
340
+ label: args.label,
341
+ credentials: args.credentials,
342
+ },
343
+ }),
454
344
  });
455
345
  }
456
- await stream.writeSSE({ data: sseChunk(completionId, {}, "stop") });
457
- await stream.writeSSE({ data: "[DONE]" });
458
- }
459
- }
460
- catch (err) {
461
- // Suppress AbortError — expected when client disconnects
462
- if ((err instanceof DOMException && err.name === "AbortError") || abortController.signal.aborted) {
463
- // fall through to finally — no SSE error event needed
464
- }
465
- else {
466
- // Friendly model_not_found surface — gateway returns 404 for
467
- // renamed/deprecated SKUs (e.g. xai/grok-4-fast after the 4.1
468
- // rename). Without this catch the error propagates as a 500.
469
- const structuredOutputError = structuredOutputErrorEnvelope(err, structuredResponse);
470
- const guardrailError = guardrailErrorEnvelope(err);
471
- const notFound = modelNotFoundEnvelope(err, m?.id, body.agent);
472
- if (structuredOutputError) {
346
+ else if (interactiveCall.toolName === "open_file") {
347
+ const args = interactiveCall.input;
473
348
  await stream.writeSSE({
474
- data: sseChunk(completionId, {}, "stop", { error: structuredOutputError }),
349
+ data: sseChunk(completionId, {}, "open_file", {
350
+ open_file: {
351
+ path: args.path,
352
+ },
353
+ }),
475
354
  });
476
- await stream.writeSSE({ data: "[DONE]" });
477
355
  }
478
- else if (guardrailError) {
479
- finalText = guardrailError.message;
480
- outputPolicyApplied = true;
356
+ else if (interactiveCall.toolName === "navigate_to") {
357
+ const args = interactiveCall.input;
481
358
  await stream.writeSSE({
482
- data: sseChunk(completionId, {}, "stop", { error: guardrailError }),
359
+ data: sseChunk(completionId, {}, "navigate_to", {
360
+ navigate_to: {
361
+ target: args.target,
362
+ id: args.id,
363
+ name: args.name,
364
+ path: args.path,
365
+ highlight: args.highlight,
366
+ },
367
+ }),
483
368
  });
484
- await stream.writeSSE({ data: "[DONE]" });
485
369
  }
486
- else if (notFound) {
370
+ else if (interactiveCall.toolName === "open_tab") {
371
+ const args = interactiveCall.input;
487
372
  await stream.writeSSE({
488
- data: sseChunk(completionId, {}, "stop", { error: notFound }),
373
+ data: sseChunk(completionId, {}, "open_tab", {
374
+ open_tab: {
375
+ url: args.url,
376
+ label: args.label,
377
+ },
378
+ }),
489
379
  });
490
- await stream.writeSSE({ data: "[DONE]" });
491
380
  }
492
- else {
493
- const error = modelErrorEnvelope(err);
494
- const text = `Model request failed: ${error.message}`;
495
- finalText = outputMode === "buffer" ? text : finalText + text;
496
- await stream.writeSSE({ data: sseChunk(completionId, { content: text }) });
497
- await stream.writeSSE({ data: sseChunk(completionId, {}, "stop", { error }) });
498
- await stream.writeSSE({ data: "[DONE]" });
381
+ await stream.writeSSE({ data: "[DONE]" });
382
+ return; // finally block will persist whatever finalText we have
383
+ }
384
+ // Provider tools (extraAiTools) are executed by the SDK / gateway.
385
+ // Their tool results are already preserved in responseMessages,
386
+ // so only record them for observability and skip local dispatch.
387
+ const providerToolNames = new Set(Object.keys(extraAiTools ?? {}));
388
+ const providerToolResults = indexToolResultsByCallId(result.toolResults);
389
+ for (const call of dispatchableToolCalls) {
390
+ // Stop executing tools if client disconnected
391
+ if (signal.aborted)
392
+ break;
393
+ const callArgs = call.input;
394
+ if (providerToolNames.has(call.toolName)) {
395
+ recordProviderToolCall(toolCallsAccum, call, providerToolResults);
396
+ continue;
397
+ }
398
+ // Notify client that a tool is being called
399
+ await stream.writeSSE({
400
+ data: sseChunk(completionId, {}, null, {
401
+ tool_call: { id: call.toolCallId, name: call.toolName, arguments: callArgs, state: "calling" },
402
+ }),
403
+ });
404
+ const result = await effectiveToolExecutor(call.toolName, callArgs, {
405
+ callId: call.toolCallId,
406
+ signal,
407
+ });
408
+ const isError = result.startsWith("Error:");
409
+ emitFileChanged(call.toolName, callArgs, result, deps.emit);
410
+ // Accumulate for persistence
411
+ toolCallsAccum.push({
412
+ id: call.toolCallId,
413
+ name: call.toolName,
414
+ arguments: callArgs,
415
+ result,
416
+ state: isError ? "error" : "completed",
417
+ });
418
+ // Notify client with tool result (skip if aborted mid-tool)
419
+ if (!signal.aborted) {
420
+ await stream.writeSSE({
421
+ data: sseChunk(completionId, {}, null, {
422
+ tool_call: { id: call.toolCallId, name: call.toolName, result, state: isError ? "error" : "completed" },
423
+ }),
424
+ });
499
425
  }
426
+ // Push tool result message in AI SDK format
427
+ messages.push({
428
+ role: "tool",
429
+ content: [{
430
+ type: "tool-result",
431
+ toolCallId: call.toolCallId,
432
+ toolName: call.toolName,
433
+ output: isError
434
+ ? {
435
+ type: "error-text",
436
+ value: exec.contextTrust === "enforce"
437
+ ? renderRuntimeToolResult(call.toolName, call.toolCallId, result)
438
+ : result,
439
+ }
440
+ : {
441
+ type: "text",
442
+ value: exec.contextTrust === "enforce"
443
+ ? renderRuntimeToolResult(call.toolName, call.toolCallId, result)
444
+ : result,
445
+ },
446
+ }],
447
+ });
500
448
  }
501
449
  }
502
- finally {
503
- clearInterval(heartbeatInterval);
504
- // Always persist the assistant response — even on disconnect.
505
- // (Vault credentials are redacted inside persistAssistantMessage.)
506
- await persistAssistantMessage(sessionStore, sessionId, assistantMsgId, finalText, toolCallsAccum, suggestions.length > 0 ? { suggestions } : undefined);
507
- // Notify consumer (e.g. metering) — fire-and-forget
508
- try {
509
- deps.onCompletionFinished?.({
510
- usage: totalUsage,
511
- model: m.id ?? m.provider,
512
- resolvedModel: completionResolvedModelInfo(m),
513
- agent: body.agent,
514
- sessionId: sessionId ?? undefined,
515
- user: body.user,
516
- providerMetadata: lastProviderMetadata,
450
+ if (!signal.aborted) {
451
+ await finalizeOutput();
452
+ suggestions = await suggestionsForCompletion(exec, finalText, signal);
453
+ if (suggestions.length > 0) {
454
+ await stream.writeSSE({
455
+ data: ssePolpoChunk(completionId, { suggestions }),
517
456
  });
518
457
  }
519
- catch { /* never fail on callback */ }
520
- // Close per-request resources (MCP transports, etc.). Errors
521
- // are intentionally swallowed — a stuck cleanup must not block
522
- // the response from finishing.
523
- if (onResponseFinished) {
524
- onResponseFinished().catch(() => { });
458
+ await stream.writeSSE({ data: sseChunk(completionId, {}, "stop") });
459
+ await stream.writeSSE({ data: "[DONE]" });
460
+ }
461
+ }
462
+ catch (err) {
463
+ // Suppress AbortError — expected when client disconnects
464
+ if ((err instanceof DOMException && err.name === "AbortError") || signal.aborted) {
465
+ // fall through to finally — no SSE error event needed
466
+ }
467
+ else {
468
+ // Friendly model_not_found surface — gateway returns 404 for
469
+ // renamed/deprecated SKUs (e.g. xai/grok-4-fast after the 4.1
470
+ // rename). Without this catch the error propagates as a 500.
471
+ const structuredOutputError = structuredOutputErrorEnvelope(err, structuredResponse);
472
+ const guardrailError = guardrailErrorEnvelope(err);
473
+ const notFound = modelNotFoundEnvelope(err, m?.id, body.agent);
474
+ if (structuredOutputError) {
475
+ await stream.writeSSE({
476
+ data: sseChunk(completionId, {}, "stop", { error: structuredOutputError }),
477
+ });
478
+ await stream.writeSSE({ data: "[DONE]" });
479
+ }
480
+ else if (guardrailError) {
481
+ finalText = guardrailError.message;
482
+ outputPolicyApplied = true;
483
+ await stream.writeSSE({
484
+ data: sseChunk(completionId, {}, "stop", { error: guardrailError }),
485
+ });
486
+ await stream.writeSSE({ data: "[DONE]" });
487
+ }
488
+ else if (notFound) {
489
+ await stream.writeSSE({
490
+ data: sseChunk(completionId, {}, "stop", { error: notFound }),
491
+ });
492
+ await stream.writeSSE({ data: "[DONE]" });
493
+ }
494
+ else {
495
+ const error = modelErrorEnvelope(err);
496
+ const text = `Model request failed: ${error.message}`;
497
+ finalText = outputMode === "buffer" ? text : finalText + text;
498
+ await stream.writeSSE({ data: sseChunk(completionId, { content: text }) });
499
+ await stream.writeSSE({ data: sseChunk(completionId, {}, "stop", { error }) });
500
+ await stream.writeSSE({ data: "[DONE]" });
525
501
  }
526
502
  }
527
- });
503
+ }
504
+ finally {
505
+ // Always persist the assistant response — even on disconnect.
506
+ // (Vault credentials are redacted inside persistAssistantMessage.)
507
+ await persistAssistantMessage(sessionStore, sessionId, assistantMsgId, finalText, toolCallsAccum, suggestions.length > 0 ? { suggestions } : undefined);
508
+ // Notify consumer (e.g. metering) — fire-and-forget
509
+ try {
510
+ deps.onCompletionFinished?.({
511
+ usage: totalUsage,
512
+ model: m.id ?? m.provider,
513
+ resolvedModel: completionResolvedModelInfo(m),
514
+ agent: body.agent,
515
+ sessionId: sessionId ?? undefined,
516
+ user: body.user,
517
+ providerMetadata: lastProviderMetadata,
518
+ });
519
+ }
520
+ catch { /* never fail on callback */ }
521
+ // Close per-request resources (MCP transports, etc.). Errors
522
+ // are intentionally swallowed — a stuck cleanup must not block
523
+ // the response from finishing.
524
+ if (onResponseFinished) {
525
+ onResponseFinished().catch(() => { });
526
+ }
527
+ }
528
528
  }
529
529
  /** Non-streaming chat mode — single OpenAI-format JSON response. */
530
530
  export async function runNonStreamingChatCompletion(c, exec) {