@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.
- package/package.json +6 -6
- package/src/aggregates/message.ts +293 -47
- package/src/chat-builder.ts +276 -83
- package/src/index.ts +4 -22
- package/src/listeners/ai-generation-listener.ts +522 -246
- package/src/react/chat-component.tsx +589 -0
- package/src/react/index.ts +2 -3
- package/src/react/use-chat.ts +1 -260
- package/src/routes/chat-stream-route.ts +4 -10
- package/src/streaming/stream-registry.ts +92 -124
- package/src/tools/ask-questions.tsx +126 -0
- package/src/routes/tool-results-route.ts +0 -49
|
@@ -1,293 +1,569 @@
|
|
|
1
1
|
/// <reference path="../arc.d.ts" />
|
|
2
|
-
import { listener, type ArcContextElement } from "@arcote.tech/arc";
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
// ───
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
ctx,
|
|
29
178
|
messageElement,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
269
|
+
usage: JSON.stringify(result.usage),
|
|
68
270
|
});
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
297
|
+
const tool = serverToolsMap.get(tc.name);
|
|
298
|
+
let resultContent: string;
|
|
299
|
+
let isError = false;
|
|
97
300
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
543
|
+
blocks: "[]",
|
|
544
|
+
model,
|
|
545
|
+
isGenerating: true,
|
|
287
546
|
});
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
}
|