@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/completions/chat-handler.d.ts +10 -0
- package/dist/routes/completions/chat-handler.d.ts.map +1 -1
- package/dist/routes/completions/chat-handler.js +436 -436
- package/dist/routes/completions/chat-handler.js.map +1 -1
- package/dist/routes/completions/chat-via-run-handler.d.ts +2 -1
- package/dist/routes/completions/chat-via-run-handler.d.ts.map +1 -1
- package/dist/routes/completions/chat-via-run-handler.js +102 -91
- package/dist/routes/completions/chat-via-run-handler.js.map +1 -1
- package/dist/routes/completions/durable-stream.d.ts +39 -0
- package/dist/routes/completions/durable-stream.d.ts.map +1 -0
- package/dist/routes/completions/durable-stream.js +83 -0
- package/dist/routes/completions/durable-stream.js.map +1 -0
- package/dist/routes/completions/durable-stream.test.d.ts +2 -0
- package/dist/routes/completions/durable-stream.test.d.ts.map +1 -0
- package/dist/routes/completions/durable-stream.test.js +140 -0
- package/dist/routes/completions/durable-stream.test.js.map +1 -0
- package/dist/routes/completions/project-loop-runner.d.ts +6 -2
- package/dist/routes/completions/project-loop-runner.d.ts.map +1 -1
- package/dist/routes/completions/project-loop-runner.js +121 -113
- package/dist/routes/completions/project-loop-runner.js.map +1 -1
- package/dist/routes/completions/schemas.d.ts +12 -0
- package/dist/routes/completions/schemas.d.ts.map +1 -1
- package/dist/routes/completions/schemas.js +5 -0
- package/dist/routes/completions/schemas.js.map +1 -1
- package/dist/routes/completions/schemas.test.js +20 -0
- package/dist/routes/completions/schemas.test.js.map +1 -1
- package/dist/routes/completions-durable-delivery.test.d.ts +2 -0
- package/dist/routes/completions-durable-delivery.test.d.ts.map +1 -0
- package/dist/routes/completions-durable-delivery.test.js +154 -0
- package/dist/routes/completions-durable-delivery.test.js.map +1 -0
- package/dist/routes/completions.d.ts +13 -0
- package/dist/routes/completions.d.ts.map +1 -1
- package/dist/routes/completions.js +107 -5
- package/dist/routes/completions.js.map +1 -1
- package/dist/routes/run-delivery.d.ts +15 -0
- package/dist/routes/run-delivery.d.ts.map +1 -0
- package/dist/routes/run-delivery.js +202 -0
- package/dist/routes/run-delivery.js.map +1 -0
- package/dist/routes/run-delivery.test.d.ts +2 -0
- package/dist/routes/run-delivery.test.d.ts.map +1 -0
- package/dist/routes/run-delivery.test.js +124 -0
- package/dist/routes/run-delivery.test.js.map +1 -0
- 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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
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 (
|
|
219
|
-
|
|
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:
|
|
179
|
+
await stream.writeSSE({ data: sseChunk(completionId, { content: event.text }) });
|
|
222
180
|
}
|
|
223
|
-
break;
|
|
224
181
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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 (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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, {},
|
|
399
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
457
|
-
|
|
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, {}, "
|
|
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 (
|
|
479
|
-
|
|
480
|
-
outputPolicyApplied = true;
|
|
356
|
+
else if (interactiveCall.toolName === "navigate_to") {
|
|
357
|
+
const args = interactiveCall.input;
|
|
481
358
|
await stream.writeSSE({
|
|
482
|
-
data: sseChunk(completionId, {}, "
|
|
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 (
|
|
370
|
+
else if (interactiveCall.toolName === "open_tab") {
|
|
371
|
+
const args = interactiveCall.input;
|
|
487
372
|
await stream.writeSSE({
|
|
488
|
-
data: sseChunk(completionId, {}, "
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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) {
|