@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.
- package/dist/deps.d.ts +4 -4
- package/dist/deps.d.ts.map +1 -1
- package/dist/routes/completions/agent-step-runner.d.ts +49 -0
- package/dist/routes/completions/agent-step-runner.d.ts.map +1 -0
- package/dist/routes/completions/agent-step-runner.js +166 -0
- package/dist/routes/completions/agent-step-runner.js.map +1 -0
- package/dist/routes/completions/chat-handler.d.ts +51 -0
- package/dist/routes/completions/chat-handler.d.ts.map +1 -0
- package/dist/routes/completions/chat-handler.js +710 -0
- package/dist/routes/completions/chat-handler.js.map +1 -0
- package/dist/routes/completions/message-mapping.d.ts +21 -0
- package/dist/routes/completions/message-mapping.d.ts.map +1 -0
- package/dist/routes/completions/message-mapping.js +156 -0
- package/dist/routes/completions/message-mapping.js.map +1 -0
- package/dist/routes/completions/project-loop-runner.d.ts +65 -0
- package/dist/routes/completions/project-loop-runner.d.ts.map +1 -0
- package/dist/routes/completions/project-loop-runner.js +482 -0
- package/dist/routes/completions/project-loop-runner.js.map +1 -0
- package/dist/routes/completions/schemas.d.ts +242 -0
- package/dist/routes/completions/schemas.d.ts.map +1 -0
- package/dist/routes/completions/schemas.js +148 -0
- package/dist/routes/completions/schemas.js.map +1 -0
- package/dist/routes/completions/sse.d.ts +57 -0
- package/dist/routes/completions/sse.d.ts.map +1 -0
- package/dist/routes/completions/sse.js +96 -0
- package/dist/routes/completions/sse.js.map +1 -0
- package/dist/routes/completions/tool-mapping.d.ts +44 -0
- package/dist/routes/completions/tool-mapping.d.ts.map +1 -0
- package/dist/routes/completions/tool-mapping.js +154 -0
- package/dist/routes/completions/tool-mapping.js.map +1 -0
- package/dist/routes/completions.d.ts +13 -20
- package/dist/routes/completions.d.ts.map +1 -1
- package/dist/routes/completions.js +49 -1882
- package/dist/routes/completions.js.map +1 -1
- package/dist/routes/missions.d.ts +2 -2
- package/dist/routes/missions.d.ts.map +1 -1
- package/dist/routes/missions.js +2 -2
- package/dist/routes/missions.js.map +1 -1
- package/dist/routes/playbooks.d.ts +1 -1
- package/dist/routes/playbooks.d.ts.map +1 -1
- package/dist/routes/playbooks.js +1 -1
- package/dist/routes/playbooks.js.map +1 -1
- package/dist/routes/tasks.d.ts +1 -1
- package/dist/routes/tasks.d.ts.map +1 -1
- package/dist/routes/tasks.js +3 -3
- package/dist/routes/tasks.js.map +1 -1
- package/dist/schemas.d.ts +0 -13
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +1 -11
- package/dist/schemas.js.map +1 -1
- 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
|