@arcote.tech/arc-chat 0.5.2 → 0.5.6

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