@arcote.tech/arc-chat 0.5.1 → 0.5.5

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.
@@ -1,293 +1,569 @@
1
1
  /// <reference path="../arc.d.ts" />
2
- import { listener, type ArcContextElement } from "@arcote.tech/arc";
3
- import type { ArcToolAny, LLMProvider, Message, ToolContext } from "@arcote.tech/arc-ai";
4
- import type { PrepareContext, PrepareParams, PrepareResult } from "../chat-builder";
5
- import {
6
- createStreamSession,
7
- getStreamSession,
8
- deleteStreamSession,
9
- } from "../streaming/stream-registry";
2
+ import { listener, type ArcContextElement, type ArcFunction } from "@arcote.tech/arc";
3
+ import type {
4
+ ArcToolAny,
5
+ AssistantContentBlock,
6
+ Conversation,
7
+ ConversationTurn,
8
+ LLMProvider,
9
+ ToolCall,
10
+ } from "@arcote.tech/arc-ai";
11
+ import { broadcast, endStream } from "../streaming/stream-registry";
10
12
 
11
13
  // ─── Config ─────────────────────────────────────────────────────
12
14
 
13
15
  export interface AiGenerationListenerConfig {
14
16
  name: string;
15
17
  messageElement: any;
16
- resolveProvider: (model: string) => LLMProvider | undefined;
17
- prepare?: (ctx: PrepareContext, params: PrepareParams) => Promise<PrepareResult>;
18
- tools: ArcToolAny[];
19
- clientTools: ArcToolAny[];
20
- toolMutationElements: ArcContextElement<any>[];
18
+ resolveProvider: (model: string, scopeId?: string) => LLMProvider | undefined;
19
+ instruction?: ArcFunction<any>;
20
+ serverTools: ArcToolAny[];
21
+ interactiveTools: ArcToolAny[];
22
+ allQueryElements: ArcContextElement<any>[];
23
+ allMutationElements: ArcContextElement<any>[];
21
24
  maxExecutionCount: number;
25
+ toolChoice?: "auto" | "required" | { type: "function"; name: string };
22
26
  }
23
27
 
24
- // ─── Factory ────────────────────────────────────────────────────
28
+ // ─── History reconstruction ─────────────────────────────────────
29
+
30
+ /**
31
+ * Convert DB message rows into a `ConversationTurn[]` that the provider
32
+ * adapter can consume directly. Each `assistant` row already carries its full
33
+ * blocks (text + tool_call) as JSON. Tool results are separate rows.
34
+ */
35
+ function buildHistory(
36
+ messages: any[],
37
+ skipMessageId?: string,
38
+ ): ConversationTurn[] {
39
+ const turns: ConversationTurn[] = [];
40
+
41
+ for (const msg of messages) {
42
+ if (msg._id === skipMessageId) continue;
43
+
44
+ // System messages are developer-injected priming prompts (stage welcome,
45
+ // startStage, etc.). The UI hides them, but the LLM needs to see them as
46
+ // conversational turns. Most providers reserve `role: "system"` for the
47
+ // top-level instruction prompt which we inject separately via
48
+ // `buildInstructions` — so for history purposes we map system rows onto
49
+ // `user` turns.
50
+ if (msg.role === "user" || msg.role === "system") {
51
+ if (typeof msg.content === "string" && msg.content.length > 0) {
52
+ turns.push({ role: "user", content: msg.content });
53
+ }
54
+ continue;
55
+ }
56
+
57
+ if (msg.role === "assistant") {
58
+ if (msg.isGenerating && !msg.blocks) continue;
59
+ let blocks: AssistantContentBlock[] = [];
60
+ if (typeof msg.blocks === "string" && msg.blocks.length > 0) {
61
+ try {
62
+ blocks = JSON.parse(msg.blocks);
63
+ } catch {
64
+ blocks = [];
65
+ }
66
+ }
67
+ turns.push({
68
+ role: "assistant",
69
+ blocks,
70
+ responseId: msg.previousResponseId,
71
+ });
72
+ continue;
73
+ }
74
+
75
+ if (msg.role === "tool_result") {
76
+ turns.push({
77
+ role: "tool_result",
78
+ toolCallId: msg.toolCallId,
79
+ name: msg.toolName,
80
+ content: msg.content ?? "",
81
+ });
82
+ }
83
+ }
84
+
85
+ return turns;
86
+ }
25
87
 
26
- export function createAiGenerationListener(config: AiGenerationListenerConfig) {
88
+ // ─── Instructions ───────────────────────────────────────────────
89
+
90
+ /**
91
+ * Render the system prompt by invoking the consumer's `instruction()` handler
92
+ * with a thin wrapper around the listener's ctx. Always called fresh — never
93
+ * cached — so dynamic state (e.g. identity just updated by a tool call) shows
94
+ * up in the next provider call.
95
+ */
96
+ async function buildInstructions(
97
+ instruction: ArcFunction<any> | undefined,
98
+ ctx: any,
99
+ scopeId: string,
100
+ ): Promise<string> {
101
+ if (!instruction?.handler) return "";
102
+ const instructionCtx = {
103
+ query: (element: ArcContextElement<any>) => ctx.query(element),
104
+ mutate: (element: ArcContextElement<any>) => ctx.mutate(element),
105
+ // The chat's `identifyBy` value (= scopeId of the conversation thread).
106
+ // Lets the consumer prompt scope its queries to the current entity.
107
+ identifyBy: scopeId,
108
+ scopeId,
109
+ };
110
+ const result = await (instruction.handler as Function)(instructionCtx);
111
+ return typeof result === "string" ? result : "";
112
+ }
113
+
114
+ // ─── Conversation mode selection ────────────────────────────────
115
+
116
+ /**
117
+ * Decide whether to ask the provider for a continuation (delta) or send the
118
+ * full conversation. Continuation is only used when the provider supports it
119
+ * AND we have a known `responseId` to anchor the request.
120
+ *
121
+ * @param history Full conversation history including any new turns appended
122
+ * for this call.
123
+ * @param newTurnsStartIdx Index in `history` where "new" turns begin
124
+ * (everything before is "already known" by the model).
125
+ */
126
+ function makeConversation(
127
+ provider: LLMProvider,
128
+ history: ConversationTurn[],
129
+ lastResponseId: string | undefined,
130
+ newTurnsStartIdx: number,
131
+ ): Conversation {
132
+ if (provider.supportsContinuation && lastResponseId) {
133
+ return {
134
+ mode: "continuation",
135
+ previousResponseId: lastResponseId,
136
+ newTurns: history.slice(newTurnsStartIdx),
137
+ };
138
+ }
139
+ return { mode: "full", turns: history };
140
+ }
141
+
142
+ /** Find the most recent `assistant` turn that carries a responseId. */
143
+ function findLastResponseId(history: ConversationTurn[]): string | undefined {
144
+ for (let i = history.length - 1; i >= 0; i--) {
145
+ const t = history[i];
146
+ if (t.role === "assistant" && t.responseId) return t.responseId;
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ // ─── Generation loop ────────────────────────────────────────────
152
+
153
+ interface RunLoopConfig {
154
+ ctx: any;
155
+ messageElement: any;
156
+ provider: LLMProvider;
157
+ model: string;
158
+ /** Full conversation history at the start of the loop (already includes the
159
+ * user/tool_result turn that triggered this generation). */
160
+ history: ConversationTurn[];
161
+ /** Index in `history` where the FIRST iteration's "new turns" start. After
162
+ * the first iteration this is recomputed from the latest responseId. */
163
+ initialNewTurnsStartIdx: number;
164
+ toolDefs: any[] | undefined;
165
+ serverToolsMap: Map<string, ArcToolAny>;
166
+ interactiveToolNames: Set<string>;
167
+ generationMessageId: string;
168
+ scopeId: string;
169
+ sessionId: string;
170
+ maxExecutionCount: number;
171
+ toolChoice?: "auto" | "required" | { type: "function"; name: string };
172
+ instruction?: ArcFunction<any>;
173
+ }
174
+
175
+ async function runGenerationLoop(config: RunLoopConfig) {
27
176
  const {
28
- name,
177
+ ctx,
29
178
  messageElement,
30
- resolveProvider,
31
- prepare,
32
- tools: defaultTools,
33
- clientTools: defaultClientTools,
34
- toolMutationElements,
35
- maxExecutionCount: defaultMaxExecution,
179
+ provider,
180
+ model,
181
+ toolDefs,
182
+ serverToolsMap,
183
+ interactiveToolNames,
184
+ generationMessageId,
185
+ scopeId,
186
+ sessionId,
187
+ maxExecutionCount,
188
+ toolChoice,
189
+ instruction,
36
190
  } = config;
37
191
 
38
- const messageSentEvent = messageElement.getEvent("messageSent");
39
-
40
- return listener(`${name}AiGeneration`)
41
- .listenTo([messageSentEvent])
42
- .async()
43
- .query([messageElement])
44
- .mutate([messageElement, ...toolMutationElements])
45
- .handle(async (ctx, event) => {
46
- const payload = event.payload;
47
- const {
48
- sessionId,
49
- scopeId,
50
- content: userContent,
51
- model: modelName,
52
- } = payload;
192
+ let history = config.history;
193
+ let newTurnsStartIdx = config.initialNewTurnsStartIdx;
194
+ let executionCount = 0;
195
+
196
+ try {
197
+ while (executionCount <= maxExecutionCount) {
198
+ // Always re-render instructions — picks up state mutated by tool calls
199
+ // in the previous iteration (e.g. updateIdentity).
200
+ const instructions = await buildInstructions(instruction, ctx, scopeId);
201
+
202
+ const lastResponseId = findLastResponseId(history);
203
+ const conversation = makeConversation(
204
+ provider,
205
+ history,
206
+ lastResponseId,
207
+ newTurnsStartIdx,
208
+ );
209
+
210
+ const result = await provider.streamComplete(
211
+ {
212
+ model,
213
+ instructions,
214
+ conversation,
215
+ tools: toolDefs,
216
+ toolChoice,
217
+ },
218
+ (chunk) => {
219
+ if (chunk.type === "content_delta" && chunk.content) {
220
+ broadcast(sessionId, {
221
+ type: "content_delta",
222
+ sessionId,
223
+ content: chunk.content,
224
+ });
225
+ } else if (chunk.type === "usage_update") {
226
+ broadcast(sessionId, {
227
+ type: "usage_update",
228
+ sessionId,
229
+ usage: chunk.usage,
230
+ });
231
+ }
232
+ },
233
+ );
53
234
 
54
- // 1. Get or create stream session
55
- let session = getStreamSession(sessionId);
56
- if (!session) {
57
- session = createStreamSession(sessionId);
235
+ // Persist this turn's assistant blocks as a single message row.
236
+ if (result.blocks.length > 0) {
237
+ await ctx.mutate(messageElement).saveAssistantMessage({
238
+ scopeId,
239
+ sessionId,
240
+ blocks: JSON.stringify(result.blocks),
241
+ model,
242
+ previousResponseId: result.responseId,
243
+ });
58
244
  }
59
245
 
60
- // 2. Resolve provider
61
- const model = modelName ?? "gpt-4o";
62
- const provider = resolveProvider(model);
63
- if (!provider) {
64
- session.push({
65
- type: "error",
246
+ // Append to local history so the next iteration sees this turn.
247
+ const assistantTurn: ConversationTurn = {
248
+ role: "assistant",
249
+ blocks: result.blocks,
250
+ responseId: result.responseId,
251
+ };
252
+ history.push(assistantTurn);
253
+
254
+ // Pull out tool calls from the blocks (preserves order, but for
255
+ // execution we only care about the set).
256
+ const toolCalls: ToolCall[] = result.blocks
257
+ .filter((b): b is Extract<AssistantContentBlock, { type: "tool_call" }> =>
258
+ b.type === "tool_call",
259
+ )
260
+ .map((b) => ({ id: b.id, name: b.name, arguments: b.arguments }));
261
+
262
+ const hasToolCalls =
263
+ result.finishReason === "tool_call" && toolCalls.length > 0;
264
+
265
+ if (!hasToolCalls) {
266
+ await ctx.mutate(messageElement).completeGeneration({
267
+ generationMessageId,
66
268
  sessionId,
67
- error: `Provider not found for model: ${model}`,
269
+ usage: JSON.stringify(result.usage),
68
270
  });
69
- session.close();
70
- deleteStreamSession(sessionId);
271
+ broadcast(sessionId, {
272
+ type: "done",
273
+ sessionId,
274
+ usage: result.usage,
275
+ finishReason: result.finishReason,
276
+ executionCount,
277
+ });
278
+ endStream(sessionId);
71
279
  return;
72
280
  }
73
281
 
74
- // 3. Call prepare callback to get instructions, tools, clientTools
75
- let instructions = "";
76
- let serverTools = defaultTools;
77
- let clientTools = defaultClientTools;
78
-
79
- if (prepare) {
80
- const prepareCtx: PrepareContext = {
81
- query: (element) => ctx.query(element),
82
- mutate: (element) => ctx.mutate(element),
83
- };
84
- const prepareResult = await prepare(prepareCtx, {
85
- content: userContent,
86
- identifyBy: scopeId,
87
- model,
282
+ const serverCalls = toolCalls.filter((tc) => serverToolsMap.has(tc.name));
283
+ const interactiveCalls = toolCalls.filter((tc) =>
284
+ interactiveToolNames.has(tc.name),
285
+ );
286
+
287
+ // Execute server tools — append each result to history as a separate turn
288
+ const newToolResults: ConversationTurn[] = [];
289
+ for (const tc of serverCalls) {
290
+ broadcast(sessionId, {
291
+ type: "server_tool_start",
292
+ sessionId,
293
+ toolCall: tc,
294
+ executionCount,
88
295
  });
89
- instructions = prepareResult.instructions;
90
- if (prepareResult.tools) serverTools = prepareResult.tools;
91
- if (prepareResult.clientTools) clientTools = prepareResult.clientTools;
92
- }
93
296
 
94
- // Build server tools map
95
- const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
96
- const serverToolNames = [...serverToolsMap.keys()];
297
+ const tool = serverToolsMap.get(tc.name);
298
+ let resultContent: string;
299
+ let isError = false;
97
300
 
98
- // Build tool defs for LLM (server + client)
99
- const allToolsForLLM = [...serverTools, ...clientTools];
100
- const toolDefs = allToolsForLLM.length > 0
101
- ? allToolsForLLM.map((t) => t.toJsonSchema())
102
- : undefined;
301
+ if (tool) {
302
+ try {
303
+ resultContent = await tool.executeWithContext(
304
+ tc.arguments,
305
+ ctx,
306
+ scopeId,
307
+ );
308
+ } catch (err) {
309
+ resultContent = `Tool error: ${err instanceof Error ? err.message : String(err)}`;
310
+ isError = true;
311
+ }
312
+ } else {
313
+ resultContent = `Tool "${tc.name}" not found`;
314
+ isError = true;
315
+ }
103
316
 
104
- // 4. Load conversation history
105
- const history = await ctx.query(messageElement).getByScope({ scopeId });
317
+ await ctx.mutate(messageElement).saveToolResult({
318
+ scopeId,
319
+ sessionId,
320
+ toolName: tc.name,
321
+ toolCallId: tc.id,
322
+ content: resultContent,
323
+ isError,
324
+ });
106
325
 
107
- // 5. Build messages array
108
- const messages: Message[] = [];
326
+ broadcast(sessionId, {
327
+ type: "server_tool_result",
328
+ sessionId,
329
+ toolCall: tc,
330
+ toolResult: {
331
+ toolCallId: tc.id,
332
+ name: tc.name,
333
+ content: resultContent,
334
+ isError,
335
+ },
336
+ executionCount,
337
+ });
109
338
 
110
- if (instructions) {
111
- messages.push({ role: "system", content: instructions });
339
+ newToolResults.push({
340
+ role: "tool_result",
341
+ toolCallId: tc.id,
342
+ name: tc.name,
343
+ content: resultContent,
344
+ isError,
345
+ });
112
346
  }
113
347
 
114
- for (const msg of history) {
115
- if (msg.role === "user" && msg.content === userContent && msg._id === payload.messageId) {
116
- continue;
117
- }
118
- messages.push({
119
- role: msg.role as Message["role"],
120
- content: msg.content,
348
+ // Interactive tools stop the loop, wait for userResponded.
349
+ // The assistant turn (with the interactive tool_call) is already
350
+ // persisted above. Listener B will resume.
351
+ if (interactiveCalls.length > 0) {
352
+ broadcast(sessionId, {
353
+ type: "interactive_tool_request",
354
+ sessionId,
355
+ toolCalls: interactiveCalls,
356
+ executionCount,
121
357
  });
358
+ return;
122
359
  }
123
360
 
124
- messages.push({ role: "user", content: userContent });
125
-
126
- // 6. Build tool context for server tool execution
127
- const toolCtx: ToolContext = {
128
- mutate: (element) => ctx.mutate(element),
129
- query: (element) => ctx.query(element),
130
- identifyBy: scopeId,
131
- };
361
+ // Append tool results to history; mark them as the "new turns" for the
362
+ // next iteration's continuation request.
363
+ const assistantTurnIdx = history.length - 1;
364
+ history.push(...newToolResults);
365
+ newTurnsStartIdx = assistantTurnIdx + 1;
366
+
367
+ executionCount++;
368
+ }
369
+ } catch (err) {
370
+ broadcast(sessionId, {
371
+ type: "error",
372
+ sessionId,
373
+ error: `AI error: ${err instanceof Error ? err.message : String(err)}`,
374
+ executionCount,
375
+ });
376
+ try {
377
+ await ctx.mutate(messageElement).completeGeneration({
378
+ generationMessageId,
379
+ sessionId,
380
+ });
381
+ } catch {}
382
+ endStream(sessionId);
383
+ }
384
+ }
132
385
 
133
- // 7. AI generation loop
134
- let executionCount = 0;
135
- let fullContent = "";
136
- let previousResponseId: string | undefined;
137
-
138
- try {
139
- while (executionCount <= defaultMaxExecution) {
140
- const result = await provider.streamComplete(
141
- { model, messages, tools: toolDefs, previousResponseId },
142
- (chunk) => {
143
- switch (chunk.type) {
144
- case "content_delta":
145
- if (chunk.content) {
146
- fullContent += chunk.content;
147
- session!.push({
148
- type: "content_delta",
149
- sessionId,
150
- content: chunk.content,
151
- });
152
- }
153
- break;
154
- case "usage_update":
155
- session!.push({
156
- type: "usage_update",
157
- sessionId,
158
- usage: chunk.usage,
159
- });
160
- break;
161
- }
162
- },
163
- );
164
-
165
- if (result.content) {
166
- fullContent = result.content;
167
- }
168
- previousResponseId = result.responseId;
169
-
170
- // No tool calls — generation complete
171
- if (
172
- result.finishReason !== "tool_call" ||
173
- result.toolCalls.length === 0
174
- ) {
175
- await ctx.mutate(messageElement).completeAssistantMessage({
176
- scopeId,
177
- sessionId,
178
- content: fullContent,
179
- model,
180
- usage: JSON.stringify(result.usage),
181
- });
386
+ // ─── Listener A: messageSent → AI generation ────────────────────
182
387
 
183
- session.push({
184
- type: "done",
185
- sessionId,
186
- usage: result.usage,
187
- finishReason: result.finishReason,
188
- executionCount,
189
- });
190
- break;
191
- }
388
+ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
389
+ const {
390
+ name,
391
+ messageElement,
392
+ resolveProvider,
393
+ instruction,
394
+ serverTools,
395
+ interactiveTools,
396
+ allQueryElements,
397
+ allMutationElements,
398
+ maxExecutionCount,
399
+ } = config;
192
400
 
193
- // Separate server vs client tool calls
194
- const serverCalls = result.toolCalls.filter((tc) =>
195
- serverToolNames.includes(tc.name),
196
- );
197
- const clientCalls = result.toolCalls.filter(
198
- (tc) => !serverToolNames.includes(tc.name),
199
- );
200
-
201
- // Execute server tools with aggregate context
202
- for (const tc of serverCalls) {
203
- session.push({
204
- type: "server_tool_start",
205
- sessionId,
206
- toolCall: tc,
207
- executionCount,
208
- });
401
+ const messageSentEvent = messageElement.getEvent("messageSent");
402
+ const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
403
+ const interactiveToolNames = new Set(interactiveTools.map((t) => t.name));
404
+ const allToolsForLLM = [...serverTools, ...interactiveTools];
405
+ const toolDefs =
406
+ allToolsForLLM.length > 0
407
+ ? allToolsForLLM.map((t) => t.toJsonSchema())
408
+ : undefined;
209
409
 
210
- const tool = serverToolsMap.get(tc.name);
211
- let resultContent: string;
212
- let isError = false;
213
-
214
- if (tool) {
215
- try {
216
- resultContent = await tool.executeWithContext(tc.arguments, toolCtx);
217
- } catch (err) {
218
- resultContent = `Tool execution error: ${err instanceof Error ? err.message : String(err)}`;
219
- isError = true;
220
- }
221
- } else {
222
- resultContent = `Tool "${tc.name}" not found on server`;
223
- isError = true;
224
- }
225
-
226
- session.push({
227
- type: "server_tool_result",
228
- sessionId,
229
- toolResult: {
230
- toolCallId: tc.id,
231
- name: tc.name,
232
- content: resultContent,
233
- isError,
234
- },
235
- executionCount,
236
- });
410
+ return listener(`${name}AiGeneration`)
411
+ .listenTo([messageSentEvent])
412
+ .async()
413
+ .query([messageElement, ...allQueryElements])
414
+ .mutate([messageElement, ...allMutationElements])
415
+ .handle(async (ctx, event) => {
416
+ const {
417
+ sessionId,
418
+ scopeId,
419
+ content: userContent,
420
+ model: modelName,
421
+ } = event.payload;
422
+
423
+ const model = modelName ?? "gpt-5.4-mini";
424
+ const provider = resolveProvider(model, scopeId);
425
+ if (!provider) return;
426
+
427
+ const dbMessages = await ctx
428
+ .query(messageElement)
429
+ .getByScope({ scopeId });
430
+
431
+ // Build the conversation history from DB. Skip the just-emitted user
432
+ // message — we'll append it explicitly so we know exactly where the
433
+ // "new turn" boundary is.
434
+ const history = buildHistory(dbMessages, event.payload.messageId);
435
+ const newTurnsStartIdx = history.length;
436
+ history.push({ role: "user", content: userContent });
437
+
438
+ // Placeholder assistant message so the UI can render "AI is typing".
439
+ // Empty blocks; the real one is saved by the loop after streaming.
440
+ const generationResult = await ctx
441
+ .mutate(messageElement)
442
+ .saveAssistantMessage({
443
+ scopeId,
444
+ sessionId,
445
+ blocks: "[]",
446
+ model,
447
+ isGenerating: true,
448
+ });
237
449
 
238
- messages.push({
239
- role: "tool",
240
- content: resultContent,
241
- toolCallId: tc.id,
242
- name: tc.name,
243
- });
244
- }
450
+ await runGenerationLoop({
451
+ ctx,
452
+ messageElement,
453
+ provider,
454
+ model,
455
+ history,
456
+ initialNewTurnsStartIdx: newTurnsStartIdx,
457
+ toolDefs,
458
+ serverToolsMap,
459
+ interactiveToolNames,
460
+ generationMessageId: generationResult.messageId,
461
+ scopeId,
462
+ sessionId,
463
+ maxExecutionCount,
464
+ toolChoice: config.toolChoice,
465
+ instruction,
466
+ });
467
+ });
468
+ }
245
469
 
246
- // Request client tool execution
247
- if (clientCalls.length > 0) {
248
- session.push({
249
- type: "client_tool_request",
250
- sessionId,
251
- toolCalls: clientCalls,
252
- executionCount,
253
- });
470
+ // ─── Listener B: userResponded → AI resume ──────────────────────
254
471
 
255
- try {
256
- const clientResults =
257
- await session.waitForClientToolResults();
258
-
259
- for (const tr of clientResults) {
260
- messages.push({
261
- role: "tool",
262
- content: tr.content,
263
- toolCallId: tr.toolCallId,
264
- name: tr.name,
265
- });
266
- }
267
- } catch (err) {
268
- session.push({
269
- type: "error",
270
- sessionId,
271
- error: `Client tool execution failed: ${err instanceof Error ? err.message : String(err)}`,
272
- executionCount,
273
- });
274
- break;
275
- }
276
- }
472
+ export function createAiResumeListener(config: AiGenerationListenerConfig) {
473
+ const {
474
+ name,
475
+ messageElement,
476
+ resolveProvider,
477
+ instruction,
478
+ serverTools,
479
+ interactiveTools,
480
+ allQueryElements,
481
+ allMutationElements,
482
+ maxExecutionCount,
483
+ } = config;
277
484
 
278
- fullContent = "";
279
- executionCount++;
485
+ const userRespondedEvent = messageElement.getEvent("userResponded");
486
+ const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
487
+ const interactiveToolNames = new Set(interactiveTools.map((t) => t.name));
488
+ const allToolsForLLM = [...serverTools, ...interactiveTools];
489
+ const toolDefs =
490
+ allToolsForLLM.length > 0
491
+ ? allToolsForLLM.map((t) => t.toJsonSchema())
492
+ : undefined;
493
+
494
+ return listener(`${name}AiResume`)
495
+ .listenTo([userRespondedEvent])
496
+ .async()
497
+ .query([messageElement, ...allQueryElements])
498
+ .mutate([messageElement, ...allMutationElements])
499
+ .handle(async (ctx, event) => {
500
+ const {
501
+ sessionId,
502
+ scopeId,
503
+ toolCallId,
504
+ toolName,
505
+ content: toolResult,
506
+ } = event.payload;
507
+
508
+ const dbMessages = await ctx
509
+ .query(messageElement)
510
+ .getByScope({ scopeId });
511
+
512
+ // Build full history. The userResponded event already created a
513
+ // tool_result row in DB, so it's part of the history naturally.
514
+ const history = buildHistory(dbMessages);
515
+
516
+ // Compute "new turns start" — index of the just-arrived tool_result.
517
+ // Anything before it is "already known" (assistant emitted the matching
518
+ // tool_call earlier and OpenAI has it server-side).
519
+ let newTurnsStartIdx = history.length;
520
+ for (let i = history.length - 1; i >= 0; i--) {
521
+ const t = history[i];
522
+ if (t.role === "tool_result" && t.toolCallId === toolCallId) {
523
+ newTurnsStartIdx = i;
524
+ break;
280
525
  }
281
- } catch (err) {
282
- session.push({
283
- type: "error",
526
+ }
527
+
528
+ // Determine the model from the most recent assistant row in DB
529
+ const lastAssistantRow = [...dbMessages]
530
+ .reverse()
531
+ .find((m: any) => m.role === "assistant" && m.model);
532
+ const model = lastAssistantRow?.model ?? "gpt-5.4-mini";
533
+
534
+ const provider = resolveProvider(model, scopeId);
535
+ if (!provider) return;
536
+
537
+ // Placeholder assistant message for "AI is typing"
538
+ const generationResult = await ctx
539
+ .mutate(messageElement)
540
+ .saveAssistantMessage({
541
+ scopeId,
284
542
  sessionId,
285
- error: `AI generation error: ${err instanceof Error ? err.message : String(err)}`,
286
- executionCount,
543
+ blocks: "[]",
544
+ model,
545
+ isGenerating: true,
287
546
  });
288
- } finally {
289
- session.close();
290
- deleteStreamSession(sessionId);
291
- }
547
+
548
+ void toolName;
549
+ void toolResult;
550
+
551
+ await runGenerationLoop({
552
+ ctx,
553
+ messageElement,
554
+ provider,
555
+ model,
556
+ history,
557
+ initialNewTurnsStartIdx: newTurnsStartIdx,
558
+ toolDefs,
559
+ serverToolsMap,
560
+ interactiveToolNames,
561
+ generationMessageId: generationResult.messageId,
562
+ scopeId,
563
+ sessionId,
564
+ maxExecutionCount,
565
+ toolChoice: config.toolChoice,
566
+ instruction,
567
+ });
292
568
  });
293
569
  }