@arcote.tech/arc-chat 0.5.0 → 0.5.2

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,293 +1,366 @@
1
1
  /// <reference path="../arc.d.ts" />
2
- import { listener, type ArcContextElement } from "@arcote.tech/arc";
3
- import type { ArcToolAny, LLMProvider, Message, ToolContext } from "@arcote.tech/arc-ai";
4
- import type { PrepareContext, PrepareParams, PrepareResult } from "../chat-builder";
5
- import {
6
- createStreamSession,
7
- getStreamSession,
8
- deleteStreamSession,
9
- } from "../streaming/stream-registry";
2
+ import { listener, type ArcContextElement, type ArcFunction } from "@arcote.tech/arc";
3
+ import type { ArcToolAny, LLMProvider, Message } from "@arcote.tech/arc-ai";
4
+ import { broadcast, endStream } from "../streaming/stream-registry";
10
5
 
11
6
  // ─── Config ─────────────────────────────────────────────────────
12
7
 
13
8
  export interface AiGenerationListenerConfig {
14
9
  name: string;
15
10
  messageElement: any;
16
- resolveProvider: (model: string) => LLMProvider | undefined;
17
- prepare?: (ctx: PrepareContext, params: PrepareParams) => Promise<PrepareResult>;
18
- tools: ArcToolAny[];
19
- clientTools: ArcToolAny[];
20
- toolMutationElements: ArcContextElement<any>[];
11
+ resolveProvider: (model: string, scopeId?: string) => LLMProvider | undefined;
12
+ instruction?: ArcFunction<any>;
13
+ serverTools: ArcToolAny[];
14
+ interactiveTools: ArcToolAny[];
15
+ allQueryElements: ArcContextElement<any>[];
16
+ allMutationElements: ArcContextElement<any>[];
21
17
  maxExecutionCount: number;
18
+ toolChoice?: "auto" | "required" | { type: "function"; name: string };
22
19
  }
23
20
 
24
- // ─── Factory ────────────────────────────────────────────────────
21
+ // ─── Utilities ──────────────────────────────────────────────────
22
+
23
+ function buildLlmMessages(
24
+ history: any[],
25
+ systemPrompt?: string,
26
+ 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) {
33
+ 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,
41
+ toolCallId: msg.toolCallId,
42
+ name: msg.toolName,
43
+ });
44
+ }
45
+ }
46
+ return messages;
47
+ }
25
48
 
26
- export function createAiGenerationListener(config: AiGenerationListenerConfig) {
27
- const {
28
- name,
29
- messageElement,
30
- resolveProvider,
31
- prepare,
32
- tools: defaultTools,
33
- clientTools: defaultClientTools,
34
- toolMutationElements,
35
- maxExecutionCount: defaultMaxExecution,
36
- } = config;
49
+ async function buildSystemPrompt(
50
+ instruction: ArcFunction<any> | undefined,
51
+ ctx: any,
52
+ ): Promise<string> {
53
+ if (!instruction?.handler) return "";
54
+ const instructionCtx = {
55
+ query: (element: ArcContextElement<any>) => ctx.query(element),
56
+ mutate: (element: ArcContextElement<any>) => ctx.mutate(element),
57
+ };
58
+ return (instruction.handler as Function)(instructionCtx);
59
+ }
37
60
 
38
- const messageSentEvent = messageElement.getEvent("messageSent");
61
+ // ─── AI generation loop ─────────────────────────────────────────
39
62
 
40
- return listener(`${name}AiGeneration`)
41
- .listenTo([messageSentEvent])
42
- .async()
43
- .query([messageElement])
44
- .mutate([messageElement, ...toolMutationElements])
45
- .handle(async (ctx, event) => {
46
- const payload = event.payload;
47
- const {
48
- sessionId,
49
- scopeId,
50
- content: userContent,
51
- model: modelName,
52
- } = payload;
53
-
54
- // 1. Get or create stream session
55
- let session = getStreamSession(sessionId);
56
- if (!session) {
57
- session = createStreamSession(sessionId);
58
- }
63
+ async function runGenerationLoop(config: {
64
+ ctx: any;
65
+ messageElement: any;
66
+ provider: LLMProvider;
67
+ model: string;
68
+ initialMessages: Message[];
69
+ toolDefs: any[] | undefined;
70
+ serverToolsMap: Map<string, ArcToolAny>;
71
+ interactiveToolNames: Set<string>;
72
+ generationMessageId: string;
73
+ scopeId: string;
74
+ sessionId: string;
75
+ maxExecutionCount: number;
76
+ initialPreviousResponseId?: string;
77
+ toolChoice?: "auto" | "required" | { type: "function"; name: string };
78
+ }) {
79
+ const {
80
+ ctx, messageElement, provider, model, toolDefs,
81
+ serverToolsMap, interactiveToolNames,
82
+ generationMessageId, scopeId, sessionId, maxExecutionCount, toolChoice,
83
+ } = config;
59
84
 
60
- // 2. Resolve provider
61
- const model = modelName ?? "gpt-4o";
62
- const provider = resolveProvider(model);
63
- if (!provider) {
64
- session.push({
65
- type: "error",
85
+ let executionCount = 0;
86
+ let fullContent = "";
87
+ let previousResponseId = config.initialPreviousResponseId;
88
+ let currentMessages = config.initialMessages;
89
+
90
+ try {
91
+ while (executionCount <= maxExecutionCount) {
92
+ const result = await provider.streamComplete(
93
+ { model, messages: currentMessages, tools: toolDefs, previousResponseId, toolChoice },
94
+ (chunk) => {
95
+ if (chunk.type === "content_delta" && chunk.content) {
96
+ fullContent += chunk.content;
97
+ broadcast(sessionId, {
98
+ type: "content_delta",
99
+ sessionId,
100
+ content: chunk.content,
101
+ });
102
+ } else if (chunk.type === "usage_update") {
103
+ broadcast(sessionId, {
104
+ type: "usage_update",
105
+ sessionId,
106
+ usage: chunk.usage,
107
+ });
108
+ }
109
+ },
110
+ );
111
+
112
+ if (result.content) fullContent = result.content;
113
+ previousResponseId = result.responseId;
114
+
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
+ }
123
+ await ctx.mutate(messageElement).completeGeneration({
124
+ generationMessageId, sessionId,
125
+ usage: JSON.stringify(result.usage),
126
+ });
127
+ broadcast(sessionId, {
128
+ type: "done",
66
129
  sessionId,
67
- error: `Provider not found for model: ${model}`,
130
+ usage: result.usage,
131
+ finishReason: result.finishReason,
132
+ executionCount,
68
133
  });
69
- session.close();
70
- deleteStreamSession(sessionId);
134
+ endStream(sessionId);
71
135
  return;
72
136
  }
73
137
 
74
- // 3. Call prepare callback to get instructions, tools, clientTools
75
- let instructions = "";
76
- let serverTools = defaultTools;
77
- let clientTools = defaultClientTools;
78
-
79
- if (prepare) {
80
- const prepareCtx: PrepareContext = {
81
- query: (element) => ctx.query(element),
82
- mutate: (element) => ctx.mutate(element),
83
- };
84
- const prepareResult = await prepare(prepareCtx, {
85
- content: userContent,
86
- identifyBy: scopeId,
87
- model,
138
+ // Save intermediate text
139
+ if (fullContent) {
140
+ await ctx.mutate(messageElement).saveAssistantMessage({
141
+ scopeId, sessionId, content: fullContent, model,
142
+ previousResponseId,
88
143
  });
89
- instructions = prepareResult.instructions;
90
- if (prepareResult.tools) serverTools = prepareResult.tools;
91
- if (prepareResult.clientTools) clientTools = prepareResult.clientTools;
144
+ fullContent = "";
92
145
  }
93
146
 
94
- // Build server tools map
95
- const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
96
- const serverToolNames = [...serverToolsMap.keys()];
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));
150
+
151
+ // Execute server tools — collect ONLY new results
152
+ const newToolResults: Message[] = [];
153
+ 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
+ });
97
160
 
98
- // Build tool defs for LLM (server + client)
99
- const allToolsForLLM = [...serverTools, ...clientTools];
100
- const toolDefs = allToolsForLLM.length > 0
101
- ? allToolsForLLM.map((t) => t.toJsonSchema())
102
- : undefined;
161
+ broadcast(sessionId, {
162
+ type: "server_tool_start",
163
+ sessionId,
164
+ toolCall: tc,
165
+ executionCount,
166
+ });
103
167
 
104
- // 4. Load conversation history
105
- const history = await ctx.query(messageElement).getByScope({ scopeId });
168
+ const tool = serverToolsMap.get(tc.name);
169
+ let resultContent: string;
170
+ let isError = false;
106
171
 
107
- // 5. Build messages array
108
- const messages: Message[] = [];
172
+ if (tool) {
173
+ try {
174
+ resultContent = await tool.executeWithContext(tc.arguments, ctx, scopeId);
175
+ } catch (err) {
176
+ resultContent = `Tool error: ${err instanceof Error ? err.message : String(err)}`;
177
+ isError = true;
178
+ }
179
+ } else {
180
+ resultContent = `Tool "${tc.name}" not found`;
181
+ isError = true;
182
+ }
183
+
184
+ await ctx.mutate(messageElement).saveToolResult({
185
+ scopeId, sessionId,
186
+ toolName: tc.name, toolCallId: tc.id,
187
+ content: resultContent, isError,
188
+ });
109
189
 
110
- if (instructions) {
111
- messages.push({ role: "system", content: instructions });
190
+ broadcast(sessionId, {
191
+ type: "server_tool_result",
192
+ sessionId,
193
+ toolCall: tc,
194
+ toolResult: { toolCallId: tc.id, name: tc.name, content: resultContent, isError },
195
+ executionCount,
196
+ });
197
+
198
+ newToolResults.push({
199
+ role: "tool", content: resultContent,
200
+ toolCallId: tc.id, name: tc.name,
201
+ });
112
202
  }
113
203
 
114
- for (const msg of history) {
115
- if (msg.role === "user" && msg.content === userContent && msg._id === payload.messageId) {
116
- continue;
204
+ // Interactive tools save and STOP
205
+ 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
+ });
117
213
  }
118
- messages.push({
119
- role: msg.role as Message["role"],
120
- content: msg.content,
214
+ broadcast(sessionId, {
215
+ type: "interactive_tool_request",
216
+ sessionId,
217
+ toolCalls: interactiveCalls,
218
+ executionCount,
121
219
  });
220
+ // Don't endStream — client stays connected for possible updates
221
+ // Don't completeGeneration — Listener B will resume
222
+ return;
122
223
  }
123
224
 
124
- messages.push({ role: "user", content: userContent });
225
+ // Next iteration: ONLY new tool results (provider has rest via previousResponseId)
226
+ currentMessages = newToolResults;
227
+ fullContent = "";
228
+ executionCount++;
229
+ }
230
+ } catch (err) {
231
+ broadcast(sessionId, {
232
+ type: "error",
233
+ sessionId,
234
+ error: `AI error: ${err instanceof Error ? err.message : String(err)}`,
235
+ executionCount,
236
+ });
237
+ try {
238
+ await ctx.mutate(messageElement).completeGeneration({
239
+ generationMessageId, sessionId,
240
+ });
241
+ } catch {}
242
+ endStream(sessionId);
243
+ }
244
+ }
125
245
 
126
- // 6. Build tool context for server tool execution
127
- const toolCtx: ToolContext = {
128
- mutate: (element) => ctx.mutate(element),
129
- query: (element) => ctx.query(element),
130
- identifyBy: scopeId,
131
- };
132
-
133
- // 7. AI generation loop
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
- });
246
+ // ─── Listener A: messageSent AI generation ────────────────────
182
247
 
183
- session.push({
184
- type: "done",
185
- sessionId,
186
- usage: result.usage,
187
- finishReason: result.finishReason,
188
- executionCount,
189
- });
190
- break;
191
- }
248
+ export function createAiGenerationListener(config: AiGenerationListenerConfig) {
249
+ const {
250
+ name, messageElement, resolveProvider, instruction,
251
+ serverTools, interactiveTools,
252
+ allQueryElements, allMutationElements, maxExecutionCount,
253
+ } = config;
192
254
 
193
- // Separate server vs client tool calls
194
- const serverCalls = result.toolCalls.filter((tc) =>
195
- serverToolNames.includes(tc.name),
196
- );
197
- const clientCalls = result.toolCalls.filter(
198
- (tc) => !serverToolNames.includes(tc.name),
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
- });
255
+ const messageSentEvent = messageElement.getEvent("messageSent");
256
+ const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
257
+ const interactiveToolNames = new Set(interactiveTools.map((t) => t.name));
258
+ const allToolsForLLM = [...serverTools, ...interactiveTools];
259
+ const toolDefs = allToolsForLLM.length > 0
260
+ ? allToolsForLLM.map((t) => t.toJsonSchema())
261
+ : undefined;
209
262
 
210
- const tool = serverToolsMap.get(tc.name);
211
- let resultContent: string;
212
- let isError = false;
213
-
214
- if (tool) {
215
- try {
216
- resultContent = await tool.executeWithContext(tc.arguments, toolCtx);
217
- } catch (err) {
218
- resultContent = `Tool execution error: ${err instanceof Error ? err.message : String(err)}`;
219
- isError = true;
220
- }
221
- } else {
222
- resultContent = `Tool "${tc.name}" not found on server`;
223
- isError = true;
224
- }
225
-
226
- session.push({
227
- type: "server_tool_result",
228
- sessionId,
229
- toolResult: {
230
- toolCallId: tc.id,
231
- name: tc.name,
232
- content: resultContent,
233
- isError,
234
- },
235
- executionCount,
236
- });
263
+ return listener(`${name}AiGeneration`)
264
+ .listenTo([messageSentEvent])
265
+ .async()
266
+ .query([messageElement, ...allQueryElements])
267
+ .mutate([messageElement, ...allMutationElements])
268
+ .handle(async (ctx, event) => {
269
+ const { sessionId, scopeId, content: userContent, model: modelName } = event.payload;
237
270
 
238
- messages.push({
239
- role: "tool",
240
- content: resultContent,
241
- toolCallId: tc.id,
242
- name: tc.name,
243
- });
244
- }
271
+ const model = modelName ?? "gpt-5.4-nano";
272
+ const provider = resolveProvider(model, scopeId);
273
+ if (!provider) return;
245
274
 
246
- // Request client tool execution
247
- if (clientCalls.length > 0) {
248
- session.push({
249
- type: "client_tool_request",
250
- sessionId,
251
- toolCalls: clientCalls,
252
- executionCount,
253
- });
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 });
254
279
 
255
- try {
256
- const clientResults =
257
- await session.waitForClientToolResults();
258
-
259
- for (const tr of clientResults) {
260
- messages.push({
261
- role: "tool",
262
- content: tr.content,
263
- toolCallId: tr.toolCallId,
264
- name: tr.name,
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
- }
280
+ const generationResult = await ctx.mutate(messageElement).saveAssistantMessage({
281
+ scopeId, sessionId, content: "", model, isGenerating: true,
282
+ });
283
+
284
+ await runGenerationLoop({
285
+ ctx, messageElement, provider, model,
286
+ initialMessages: messages,
287
+ toolDefs, serverToolsMap, interactiveToolNames,
288
+ generationMessageId: generationResult.messageId,
289
+ scopeId, sessionId, maxExecutionCount,
290
+ toolChoice: config.toolChoice,
291
+ });
292
+ });
293
+ }
294
+
295
+ // ─── Listener B: userResponded → AI resume ──────────────────────
296
+
297
+ export function createAiResumeListener(config: AiGenerationListenerConfig) {
298
+ const {
299
+ name, messageElement, resolveProvider, instruction,
300
+ serverTools, interactiveTools,
301
+ allQueryElements, allMutationElements, maxExecutionCount,
302
+ } = config;
277
303
 
278
- fullContent = "";
279
- executionCount++;
304
+ const userRespondedEvent = messageElement.getEvent("userResponded");
305
+ const serverToolsMap = new Map(serverTools.map((t) => [t.name, t]));
306
+ const interactiveToolNames = new Set(interactiveTools.map((t) => t.name));
307
+ const allToolsForLLM = [...serverTools, ...interactiveTools];
308
+ const toolDefs = allToolsForLLM.length > 0
309
+ ? allToolsForLLM.map((t) => t.toJsonSchema())
310
+ : undefined;
311
+
312
+ return listener(`${name}AiResume`)
313
+ .listenTo([userRespondedEvent])
314
+ .async()
315
+ .query([messageElement, ...allQueryElements])
316
+ .mutate([messageElement, ...allMutationElements])
317
+ .handle(async (ctx, event) => {
318
+ const { sessionId, scopeId, toolCallId, toolName, content: toolResult } = event.payload;
319
+
320
+ const history = await ctx.query(messageElement).getByScope({ scopeId });
321
+
322
+ // Find previousResponseId from the tool_call this responds to
323
+ const matchingToolCall = [...history]
324
+ .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";
331
+
332
+ const provider = resolveProvider(model, scopeId);
333
+ if (!provider) return;
334
+
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 });
280
349
  }
281
- } catch (err) {
282
- session.push({
283
- type: "error",
284
- sessionId,
285
- error: `AI generation error: ${err instanceof Error ? err.message : String(err)}`,
286
- executionCount,
287
- });
288
- } finally {
289
- session.close();
290
- deleteStreamSession(sessionId);
291
350
  }
351
+
352
+ const generationResult = await ctx.mutate(messageElement).saveAssistantMessage({
353
+ scopeId, sessionId, content: "", model, isGenerating: true,
354
+ });
355
+
356
+ await runGenerationLoop({
357
+ ctx, messageElement, provider, model,
358
+ initialMessages,
359
+ toolDefs, serverToolsMap, interactiveToolNames,
360
+ generationMessageId: generationResult.messageId,
361
+ scopeId, sessionId, maxExecutionCount,
362
+ initialPreviousResponseId: prevResponseId,
363
+ toolChoice: config.toolChoice,
364
+ });
292
365
  });
293
366
  }