@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.
- package/package.json +6 -6
- package/src/aggregates/message.ts +89 -72
- package/src/chat-builder.ts +42 -2
- package/src/index.ts +2 -2
- package/src/listeners/ai-generation-listener.ts +378 -152
- package/src/react/chat-component.tsx +304 -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,160 @@ 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;
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
// ───
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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,
|
|
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
|
-
|
|
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));
|
|
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 —
|
|
152
|
-
const newToolResults:
|
|
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(
|
|
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,
|
|
186
|
-
|
|
187
|
-
|
|
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: {
|
|
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: "
|
|
200
|
-
toolCallId: tc.id,
|
|
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 —
|
|
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
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
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,
|
|
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,
|
|
251
|
-
|
|
252
|
-
|
|
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 =
|
|
260
|
-
|
|
261
|
-
|
|
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 {
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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,
|
|
286
|
-
|
|
287
|
-
|
|
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,
|
|
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,
|
|
300
|
-
|
|
301
|
-
|
|
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 =
|
|
309
|
-
|
|
310
|
-
|
|
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 {
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
//
|
|
323
|
-
const
|
|
551
|
+
// Determine the model from the most recent assistant row in DB
|
|
552
|
+
const lastAssistantRow = [...dbMessages]
|
|
324
553
|
.reverse()
|
|
325
|
-
.find((
|
|
326
|
-
const
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
});
|
|
571
|
+
void toolName;
|
|
572
|
+
void toolResult;
|
|
355
573
|
|
|
356
574
|
await runGenerationLoop({
|
|
357
|
-
ctx,
|
|
358
|
-
|
|
359
|
-
|
|
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,
|
|
362
|
-
|
|
585
|
+
scopeId,
|
|
586
|
+
sessionId,
|
|
587
|
+
maxExecutionCount,
|
|
363
588
|
toolChoice: config.toolChoice,
|
|
589
|
+
instruction,
|
|
364
590
|
});
|
|
365
591
|
});
|
|
366
592
|
}
|