@arcote.tech/arc-chat 0.5.2 → 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,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,142 @@ 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;
47
86
  }
48
87
 
49
- async function buildSystemPrompt(
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(
50
97
  instruction: ArcFunction<any> | undefined,
51
98
  ctx: any,
99
+ scopeId: string,
52
100
  ): Promise<string> {
53
101
  if (!instruction?.handler) return "";
54
102
  const instructionCtx = {
55
103
  query: (element: ArcContextElement<any>) => ctx.query(element),
56
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,
57
109
  };
58
- return (instruction.handler as Function)(instructionCtx);
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 };
59
140
  }
60
141
 
61
- // ─── AI generation loop ─────────────────────────────────────────
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
+ }
62
150
 
63
- async function runGenerationLoop(config: {
151
+ // ─── Generation loop ────────────────────────────────────────────
152
+
153
+ interface RunLoopConfig {
64
154
  ctx: any;
65
155
  messageElement: any;
66
156
  provider: LLMProvider;
67
157
  model: string;
68
- initialMessages: Message[];
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;
69
164
  toolDefs: any[] | undefined;
70
165
  serverToolsMap: Map<string, ArcToolAny>;
71
166
  interactiveToolNames: Set<string>;
@@ -73,27 +168,55 @@ async function runGenerationLoop(config: {
73
168
  scopeId: string;
74
169
  sessionId: string;
75
170
  maxExecutionCount: number;
76
- initialPreviousResponseId?: string;
77
171
  toolChoice?: "auto" | "required" | { type: "function"; name: string };
78
- }) {
172
+ instruction?: ArcFunction<any>;
173
+ }
174
+
175
+ async function runGenerationLoop(config: RunLoopConfig) {
79
176
  const {
80
- ctx, messageElement, provider, model, toolDefs,
81
- serverToolsMap, interactiveToolNames,
82
- generationMessageId, scopeId, sessionId, maxExecutionCount, toolChoice,
177
+ ctx,
178
+ messageElement,
179
+ provider,
180
+ model,
181
+ toolDefs,
182
+ serverToolsMap,
183
+ interactiveToolNames,
184
+ generationMessageId,
185
+ scopeId,
186
+ sessionId,
187
+ maxExecutionCount,
188
+ toolChoice,
189
+ instruction,
83
190
  } = config;
84
191
 
192
+ let history = config.history;
193
+ let newTurnsStartIdx = config.initialNewTurnsStartIdx;
85
194
  let executionCount = 0;
86
- let fullContent = "";
87
- let previousResponseId = config.initialPreviousResponseId;
88
- let currentMessages = config.initialMessages;
89
195
 
90
196
  try {
91
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
+
92
210
  const result = await provider.streamComplete(
93
- { model, messages: currentMessages, tools: toolDefs, previousResponseId, toolChoice },
211
+ {
212
+ model,
213
+ instructions,
214
+ conversation,
215
+ tools: toolDefs,
216
+ toolChoice,
217
+ },
94
218
  (chunk) => {
95
219
  if (chunk.type === "content_delta" && chunk.content) {
96
- fullContent += chunk.content;
97
220
  broadcast(sessionId, {
98
221
  type: "content_delta",
99
222
  sessionId,
@@ -109,19 +232,40 @@ async function runGenerationLoop(config: {
109
232
  },
110
233
  );
111
234
 
112
- if (result.content) fullContent = result.content;
113
- previousResponseId = result.responseId;
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
+ });
244
+ }
114
245
 
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
- }
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) {
123
266
  await ctx.mutate(messageElement).completeGeneration({
124
- generationMessageId, sessionId,
267
+ generationMessageId,
268
+ sessionId,
125
269
  usage: JSON.stringify(result.usage),
126
270
  });
127
271
  broadcast(sessionId, {
@@ -135,29 +279,14 @@ async function runGenerationLoop(config: {
135
279
  return;
136
280
  }
137
281
 
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));
282
+ const serverCalls = toolCalls.filter((tc) => serverToolsMap.has(tc.name));
283
+ const interactiveCalls = toolCalls.filter((tc) =>
284
+ interactiveToolNames.has(tc.name),
285
+ );
150
286
 
151
- // Execute server tools — collect ONLY new results
152
- const newToolResults: Message[] = [];
287
+ // Execute server tools — append each result to history as a separate turn
288
+ const newToolResults: ConversationTurn[] = [];
153
289
  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
290
  broadcast(sessionId, {
162
291
  type: "server_tool_start",
163
292
  sessionId,
@@ -171,7 +300,11 @@ async function runGenerationLoop(config: {
171
300
 
172
301
  if (tool) {
173
302
  try {
174
- resultContent = await tool.executeWithContext(tc.arguments, ctx, scopeId);
303
+ resultContent = await tool.executeWithContext(
304
+ tc.arguments,
305
+ ctx,
306
+ scopeId,
307
+ );
175
308
  } catch (err) {
176
309
  resultContent = `Tool error: ${err instanceof Error ? err.message : String(err)}`;
177
310
  isError = true;
@@ -182,49 +315,55 @@ async function runGenerationLoop(config: {
182
315
  }
183
316
 
184
317
  await ctx.mutate(messageElement).saveToolResult({
185
- scopeId, sessionId,
186
- toolName: tc.name, toolCallId: tc.id,
187
- content: resultContent, isError,
318
+ scopeId,
319
+ sessionId,
320
+ toolName: tc.name,
321
+ toolCallId: tc.id,
322
+ content: resultContent,
323
+ isError,
188
324
  });
189
325
 
190
326
  broadcast(sessionId, {
191
327
  type: "server_tool_result",
192
328
  sessionId,
193
329
  toolCall: tc,
194
- toolResult: { toolCallId: tc.id, name: tc.name, content: resultContent, isError },
330
+ toolResult: {
331
+ toolCallId: tc.id,
332
+ name: tc.name,
333
+ content: resultContent,
334
+ isError,
335
+ },
195
336
  executionCount,
196
337
  });
197
338
 
198
339
  newToolResults.push({
199
- role: "tool", content: resultContent,
200
- toolCallId: tc.id, name: tc.name,
340
+ role: "tool_result",
341
+ toolCallId: tc.id,
342
+ name: tc.name,
343
+ content: resultContent,
344
+ isError,
201
345
  });
202
346
  }
203
347
 
204
- // Interactive tools — save and STOP
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.
205
351
  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
352
  broadcast(sessionId, {
215
353
  type: "interactive_tool_request",
216
354
  sessionId,
217
355
  toolCalls: interactiveCalls,
218
356
  executionCount,
219
357
  });
220
- // Don't endStream — client stays connected for possible updates
221
- // Don't completeGeneration — Listener B will resume
222
358
  return;
223
359
  }
224
360
 
225
- // Next iteration: ONLY new tool results (provider has rest via previousResponseId)
226
- currentMessages = newToolResults;
227
- fullContent = "";
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
+
228
367
  executionCount++;
229
368
  }
230
369
  } catch (err) {
@@ -236,7 +375,8 @@ async function runGenerationLoop(config: {
236
375
  });
237
376
  try {
238
377
  await ctx.mutate(messageElement).completeGeneration({
239
- generationMessageId, sessionId,
378
+ generationMessageId,
379
+ sessionId,
240
380
  });
241
381
  } catch {}
242
382
  endStream(sessionId);
@@ -247,18 +387,25 @@ async function runGenerationLoop(config: {
247
387
 
248
388
  export function createAiGenerationListener(config: AiGenerationListenerConfig) {
249
389
  const {
250
- name, messageElement, resolveProvider, instruction,
251
- serverTools, interactiveTools,
252
- allQueryElements, allMutationElements, maxExecutionCount,
390
+ name,
391
+ messageElement,
392
+ resolveProvider,
393
+ instruction,
394
+ serverTools,
395
+ interactiveTools,
396
+ allQueryElements,
397
+ allMutationElements,
398
+ maxExecutionCount,
253
399
  } = config;
254
400
 
255
401
  const messageSentEvent = messageElement.getEvent("messageSent");
256
402
  const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
257
403
  const interactiveToolNames = new Set(interactiveTools.map((t) => t.name));
258
404
  const allToolsForLLM = [...serverTools, ...interactiveTools];
259
- const toolDefs = allToolsForLLM.length > 0
260
- ? allToolsForLLM.map((t) => t.toJsonSchema())
261
- : undefined;
405
+ const toolDefs =
406
+ allToolsForLLM.length > 0
407
+ ? allToolsForLLM.map((t) => t.toJsonSchema())
408
+ : undefined;
262
409
 
263
410
  return listener(`${name}AiGeneration`)
264
411
  .listenTo([messageSentEvent])
@@ -266,28 +413,56 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
266
413
  .query([messageElement, ...allQueryElements])
267
414
  .mutate([messageElement, ...allMutationElements])
268
415
  .handle(async (ctx, event) => {
269
- const { sessionId, scopeId, content: userContent, model: modelName } = event.payload;
270
-
271
- const model = modelName ?? "gpt-5.4-nano";
416
+ const {
417
+ sessionId,
418
+ scopeId,
419
+ content: userContent,
420
+ model: modelName,
421
+ } = event.payload;
422
+
423
+ const model = modelName ?? "gpt-5.4-mini";
272
424
  const provider = resolveProvider(model, scopeId);
273
425
  if (!provider) return;
274
426
 
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
- });
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
+ });
283
449
 
284
450
  await runGenerationLoop({
285
- ctx, messageElement, provider, model,
286
- initialMessages: messages,
287
- toolDefs, serverToolsMap, interactiveToolNames,
451
+ ctx,
452
+ messageElement,
453
+ provider,
454
+ model,
455
+ history,
456
+ initialNewTurnsStartIdx: newTurnsStartIdx,
457
+ toolDefs,
458
+ serverToolsMap,
459
+ interactiveToolNames,
288
460
  generationMessageId: generationResult.messageId,
289
- scopeId, sessionId, maxExecutionCount,
461
+ scopeId,
462
+ sessionId,
463
+ maxExecutionCount,
290
464
  toolChoice: config.toolChoice,
465
+ instruction,
291
466
  });
292
467
  });
293
468
  }
@@ -296,18 +471,25 @@ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
296
471
 
297
472
  export function createAiResumeListener(config: AiGenerationListenerConfig) {
298
473
  const {
299
- name, messageElement, resolveProvider, instruction,
300
- serverTools, interactiveTools,
301
- allQueryElements, allMutationElements, maxExecutionCount,
474
+ name,
475
+ messageElement,
476
+ resolveProvider,
477
+ instruction,
478
+ serverTools,
479
+ interactiveTools,
480
+ allQueryElements,
481
+ allMutationElements,
482
+ maxExecutionCount,
302
483
  } = config;
303
484
 
304
485
  const userRespondedEvent = messageElement.getEvent("userResponded");
305
486
  const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
306
487
  const interactiveToolNames = new Set(interactiveTools.map((t) => t.name));
307
488
  const allToolsForLLM = [...serverTools, ...interactiveTools];
308
- const toolDefs = allToolsForLLM.length > 0
309
- ? allToolsForLLM.map((t) => t.toJsonSchema())
310
- : undefined;
489
+ const toolDefs =
490
+ allToolsForLLM.length > 0
491
+ ? allToolsForLLM.map((t) => t.toJsonSchema())
492
+ : undefined;
311
493
 
312
494
  return listener(`${name}AiResume`)
313
495
  .listenTo([userRespondedEvent])
@@ -315,52 +497,73 @@ export function createAiResumeListener(config: AiGenerationListenerConfig) {
315
497
  .query([messageElement, ...allQueryElements])
316
498
  .mutate([messageElement, ...allMutationElements])
317
499
  .handle(async (ctx, event) => {
318
- const { sessionId, scopeId, toolCallId, toolName, content: toolResult } = event.payload;
319
-
320
- const history = await ctx.query(messageElement).getByScope({ scopeId });
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;
525
+ }
526
+ }
321
527
 
322
- // Find previousResponseId from the tool_call this responds to
323
- const matchingToolCall = [...history]
528
+ // Determine the model from the most recent assistant row in DB
529
+ const lastAssistantRow = [...dbMessages]
324
530
  .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";
531
+ .find((m: any) => m.role === "assistant" && m.model);
532
+ const model = lastAssistantRow?.model ?? "gpt-5.4-mini";
331
533
 
332
534
  const provider = resolveProvider(model, scopeId);
333
535
  if (!provider) return;
334
536
 
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
- }
537
+ // Placeholder assistant message for "AI is typing"
538
+ const generationResult = await ctx
539
+ .mutate(messageElement)
540
+ .saveAssistantMessage({
541
+ scopeId,
542
+ sessionId,
543
+ blocks: "[]",
544
+ model,
545
+ isGenerating: true,
546
+ });
351
547
 
352
- const generationResult = await ctx.mutate(messageElement).saveAssistantMessage({
353
- scopeId, sessionId, content: "", model, isGenerating: true,
354
- });
548
+ void toolName;
549
+ void toolResult;
355
550
 
356
551
  await runGenerationLoop({
357
- ctx, messageElement, provider, model,
358
- initialMessages,
359
- toolDefs, serverToolsMap, interactiveToolNames,
552
+ ctx,
553
+ messageElement,
554
+ provider,
555
+ model,
556
+ history,
557
+ initialNewTurnsStartIdx: newTurnsStartIdx,
558
+ toolDefs,
559
+ serverToolsMap,
560
+ interactiveToolNames,
360
561
  generationMessageId: generationResult.messageId,
361
- scopeId, sessionId, maxExecutionCount,
362
- initialPreviousResponseId: prevResponseId,
562
+ scopeId,
563
+ sessionId,
564
+ maxExecutionCount,
363
565
  toolChoice: config.toolChoice,
566
+ instruction,
364
567
  });
365
568
  });
366
569
  }