@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.
- package/package.json +6 -6
- package/src/aggregates/message.ts +89 -72
- package/src/chat-builder.ts +35 -2
- package/src/index.ts +1 -1
- package/src/listeners/ai-generation-listener.ts +353 -150
- package/src/react/chat-component.tsx +227 -95
- package/src/tools/ask-questions.tsx +38 -19
|
@@ -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 {
|
|
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
|
-
// ───
|
|
28
|
+
// ─── History reconstruction ─────────────────────────────────────
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
):
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
84
|
+
|
|
85
|
+
return turns;
|
|
47
86
|
}
|
|
48
87
|
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
// ─── Generation loop ────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
interface RunLoopConfig {
|
|
64
154
|
ctx: any;
|
|
65
155
|
messageElement: any;
|
|
66
156
|
provider: LLMProvider;
|
|
67
157
|
model: string;
|
|
68
|
-
|
|
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,
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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,
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 —
|
|
152
|
-
const newToolResults:
|
|
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(
|
|
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,
|
|
186
|
-
|
|
187
|
-
|
|
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: {
|
|
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: "
|
|
200
|
-
toolCallId: tc.id,
|
|
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 —
|
|
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
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
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,
|
|
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,
|
|
251
|
-
|
|
252
|
-
|
|
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 =
|
|
260
|
-
|
|
261
|
-
|
|
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 {
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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,
|
|
286
|
-
|
|
287
|
-
|
|
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,
|
|
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,
|
|
300
|
-
|
|
301
|
-
|
|
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 =
|
|
309
|
-
|
|
310
|
-
|
|
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 {
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
//
|
|
323
|
-
const
|
|
528
|
+
// Determine the model from the most recent assistant row in DB
|
|
529
|
+
const lastAssistantRow = [...dbMessages]
|
|
324
530
|
.reverse()
|
|
325
|
-
.find((
|
|
326
|
-
const
|
|
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
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
});
|
|
548
|
+
void toolName;
|
|
549
|
+
void toolResult;
|
|
355
550
|
|
|
356
551
|
await runGenerationLoop({
|
|
357
|
-
ctx,
|
|
358
|
-
|
|
359
|
-
|
|
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,
|
|
362
|
-
|
|
562
|
+
scopeId,
|
|
563
|
+
sessionId,
|
|
564
|
+
maxExecutionCount,
|
|
363
565
|
toolChoice: config.toolChoice,
|
|
566
|
+
instruction,
|
|
364
567
|
});
|
|
365
568
|
});
|
|
366
569
|
}
|