@xalia/agent 0.6.8 → 0.6.10

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.
Files changed (152) hide show
  1. package/.env.development +6 -0
  2. package/.env.test +7 -0
  3. package/README.md +11 -0
  4. package/context_system.md +498 -0
  5. package/dist/agent/src/agent/agent.js +169 -87
  6. package/dist/agent/src/agent/agentUtils.js +24 -18
  7. package/dist/agent/src/agent/compressingContextManager.js +10 -14
  8. package/dist/agent/src/agent/context.js +101 -127
  9. package/dist/agent/src/agent/contextWithWorkspace.js +133 -0
  10. package/dist/agent/src/agent/documentSummarizer.js +126 -0
  11. package/dist/agent/src/agent/dummyLLM.js +25 -22
  12. package/dist/agent/src/agent/imageGenLLM.js +22 -25
  13. package/dist/agent/src/agent/imageGenerator.js +2 -10
  14. package/dist/agent/src/agent/llm.js +1 -1
  15. package/dist/agent/src/agent/openAILLM.js +15 -12
  16. package/dist/agent/src/agent/openAILLMStreaming.js +73 -39
  17. package/dist/agent/src/agent/repeatLLM.js +16 -7
  18. package/dist/agent/src/agent/sudoMcpServerManager.js +21 -9
  19. package/dist/agent/src/agent/tokenCounter.js +390 -0
  20. package/dist/agent/src/agent/tokenCounter.test.js +206 -0
  21. package/dist/agent/src/agent/toolSettings.js +17 -0
  22. package/dist/agent/src/agent/tools/calculatorTool.js +45 -0
  23. package/dist/agent/src/agent/tools/contentExtractors/pdfToText.js +55 -0
  24. package/dist/agent/src/agent/tools/datetimeTool.js +38 -0
  25. package/dist/agent/src/agent/tools/fileManager/fileManagerTool.js +156 -0
  26. package/dist/agent/src/agent/tools/fileManager/index.js +31 -0
  27. package/dist/agent/src/agent/tools/fileManager/memoryFileManager.js +102 -0
  28. package/dist/agent/src/{chat/data → agent/tools/fileManager}/mimeTypes.js +3 -1
  29. package/dist/agent/src/agent/tools/fileManager/prompt.js +33 -0
  30. package/dist/agent/src/{chat/data/dbSessionFileModels.js → agent/tools/fileManager/types.js} +7 -0
  31. package/dist/agent/src/agent/tools/index.js +64 -0
  32. package/dist/agent/src/agent/tools/openUrlTool.js +57 -0
  33. package/dist/agent/src/agent/tools/renderTool.js +89 -0
  34. package/dist/agent/src/agent/tools/utils.js +61 -0
  35. package/dist/agent/src/{chat/utils/search.js → agent/tools/webSearch.js} +1 -2
  36. package/dist/agent/src/agent/tools/webSearchTool.js +40 -0
  37. package/dist/agent/src/chat/client/chatClient.js +63 -2
  38. package/dist/agent/src/chat/client/connection.js +6 -1
  39. package/dist/agent/src/chat/client/index.js +4 -1
  40. package/dist/agent/src/chat/client/sessionClient.js +28 -9
  41. package/dist/agent/src/chat/constants.js +8 -0
  42. package/dist/agent/src/chat/data/dbSessionFiles.js +11 -6
  43. package/dist/agent/src/chat/data/dbSessionMessages.js +11 -0
  44. package/dist/agent/src/chat/protocol/messages.js +9 -0
  45. package/dist/agent/src/chat/server/chatContextManager.js +186 -156
  46. package/dist/agent/src/chat/server/conversation.js +3 -0
  47. package/dist/agent/src/chat/server/imageGeneratorTools.js +39 -16
  48. package/dist/agent/src/chat/server/openAIRouterLLM.js +111 -0
  49. package/dist/agent/src/chat/server/openSession.js +253 -91
  50. package/dist/agent/src/chat/server/promptRefiner.js +86 -0
  51. package/dist/agent/src/chat/server/server.js +10 -2
  52. package/dist/agent/src/chat/server/sessionFileManager.js +22 -221
  53. package/dist/agent/src/chat/server/sessionRegistry.js +152 -6
  54. package/dist/agent/src/chat/server/sessionRegistry.test.js +1 -1
  55. package/dist/agent/src/chat/server/titleGenerator.js +112 -0
  56. package/dist/agent/src/chat/server/titleGenerator.test.js +113 -0
  57. package/dist/agent/src/chat/server/tools.js +64 -253
  58. package/dist/agent/src/chat/utils/approvalManager.js +6 -3
  59. package/dist/agent/src/chat/utils/multiAsyncQueue.js +3 -0
  60. package/dist/agent/src/test/agent.test.js +16 -17
  61. package/dist/agent/src/test/chatContextManager.test.js +44 -30
  62. package/dist/agent/src/test/clientServerConnection.test.js +1 -2
  63. package/dist/agent/src/test/compressingContextManager.test.js +22 -36
  64. package/dist/agent/src/test/context.test.js +55 -17
  65. package/dist/agent/src/test/contextTestTools.js +87 -0
  66. package/dist/agent/src/test/dbMcpServerConfigs.test.js +4 -4
  67. package/dist/agent/src/test/dbSessionFiles.test.js +17 -17
  68. package/dist/agent/src/test/testTools.js +6 -1
  69. package/dist/agent/src/test/tools.test.js +27 -9
  70. package/dist/agent/src/tool/agentChat.js +5 -2
  71. package/dist/agent/src/tool/chatMain.js +56 -15
  72. package/dist/agent/src/tool/commandPrompt.js +2 -2
  73. package/dist/agent/src/tool/files.js +7 -8
  74. package/package.json +4 -1
  75. package/scripts/test_chat +195 -173
  76. package/src/agent/agent.ts +257 -137
  77. package/src/agent/agentUtils.ts +32 -20
  78. package/src/agent/compressingContextManager.ts +13 -44
  79. package/src/agent/context.ts +165 -159
  80. package/src/agent/contextWithWorkspace.ts +162 -0
  81. package/src/agent/documentSummarizer.ts +157 -0
  82. package/src/agent/dummyLLM.ts +27 -23
  83. package/src/agent/imageGenLLM.ts +28 -32
  84. package/src/agent/imageGenerator.ts +3 -18
  85. package/src/agent/llm.ts +2 -2
  86. package/src/agent/openAILLM.ts +17 -13
  87. package/src/agent/openAILLMStreaming.ts +99 -43
  88. package/src/agent/repeatLLM.ts +19 -7
  89. package/src/agent/sudoMcpServerManager.ts +41 -20
  90. package/src/agent/test_data/harrypotter.txt +6065 -0
  91. package/src/agent/tokenCounter.test.ts +243 -0
  92. package/src/agent/tokenCounter.ts +483 -0
  93. package/src/agent/toolSettings.ts +24 -0
  94. package/src/agent/tools/calculatorTool.ts +50 -0
  95. package/src/agent/tools/contentExtractors/pdfToText.ts +60 -0
  96. package/src/agent/tools/datetimeTool.ts +41 -0
  97. package/src/agent/tools/fileManager/fileManagerTool.ts +199 -0
  98. package/src/agent/tools/fileManager/index.ts +50 -0
  99. package/src/agent/tools/fileManager/memoryFileManager.ts +120 -0
  100. package/src/{chat/data → agent/tools/fileManager}/mimeTypes.ts +3 -1
  101. package/src/agent/tools/fileManager/prompt.ts +38 -0
  102. package/src/{chat/data/dbSessionFileModels.ts → agent/tools/fileManager/types.ts} +76 -0
  103. package/src/agent/tools/index.ts +49 -0
  104. package/src/agent/tools/openUrlTool.ts +62 -0
  105. package/src/agent/tools/renderTool.ts +92 -0
  106. package/src/agent/tools/utils.ts +74 -0
  107. package/src/{chat/utils/search.ts → agent/tools/webSearch.ts} +0 -1
  108. package/src/agent/tools/webSearchTool.ts +44 -0
  109. package/src/chat/client/chatClient.ts +92 -3
  110. package/src/chat/client/connection.ts +11 -1
  111. package/src/chat/client/index.ts +3 -0
  112. package/src/chat/client/sessionClient.ts +40 -11
  113. package/src/chat/client/sessionFiles.ts +1 -1
  114. package/src/chat/constants.ts +6 -0
  115. package/src/chat/data/dataModels.ts +12 -0
  116. package/src/chat/data/dbSessionFiles.ts +12 -4
  117. package/src/chat/data/dbSessionMessages.ts +34 -0
  118. package/src/chat/protocol/messages.ts +94 -14
  119. package/src/chat/server/chatContextManager.ts +255 -221
  120. package/src/chat/server/connectionManager.ts +1 -1
  121. package/src/chat/server/conversation.ts +3 -0
  122. package/src/chat/server/imageGeneratorTools.ts +62 -30
  123. package/src/chat/server/openAIRouterLLM.ts +168 -0
  124. package/src/chat/server/openSession.ts +381 -138
  125. package/src/chat/server/promptRefiner.ts +106 -0
  126. package/src/chat/server/server.ts +9 -2
  127. package/src/chat/server/sessionFileManager.ts +35 -306
  128. package/src/chat/server/sessionRegistry.test.ts +0 -1
  129. package/src/chat/server/sessionRegistry.ts +228 -4
  130. package/src/chat/server/titleGenerator.test.ts +103 -0
  131. package/src/chat/server/titleGenerator.ts +143 -0
  132. package/src/chat/server/tools.ts +92 -281
  133. package/src/chat/utils/approvalManager.ts +9 -3
  134. package/src/chat/utils/multiAsyncQueue.ts +4 -0
  135. package/src/test/agent.test.ts +25 -30
  136. package/src/test/chatContextManager.test.ts +68 -38
  137. package/src/test/clientServerConnection.test.ts +0 -2
  138. package/src/test/compressingContextManager.test.ts +29 -34
  139. package/src/test/context.test.ts +59 -15
  140. package/src/test/contextTestTools.ts +95 -0
  141. package/src/test/dbMcpServerConfigs.test.ts +4 -4
  142. package/src/test/dbSessionFiles.test.ts +16 -16
  143. package/src/test/testTools.ts +8 -3
  144. package/src/test/tools.test.ts +30 -5
  145. package/src/tool/agentChat.ts +12 -3
  146. package/src/tool/chatMain.ts +59 -18
  147. package/src/tool/commandPrompt.ts +2 -2
  148. package/src/tool/files.ts +1 -3
  149. package/dist/agent/src/agent/tools.js +0 -44
  150. package/src/agent/tools.ts +0 -57
  151. /package/dist/agent/src/{chat/utils → agent/tools/contentExtractors}/htmlToText.js +0 -0
  152. /package/src/{chat/utils → agent/tools/contentExtractors}/htmlToText.ts +0 -0
@@ -0,0 +1,157 @@
1
+ import { getOpenAIClient } from "../chat/server/openAIRouterLLM";
2
+ import { getLogger } from "@xalia/xmcp/sdk";
3
+
4
+ const logger = getLogger();
5
+
6
+ const SUMMARY_MODEL = "google/gemini-2.5-flash";
7
+ const SUMMARY_MAX_TOKENS = 500;
8
+ const SUMMARY_TEMPERATURE = 0.3;
9
+ const SUMMARY_TIMEOUT_MS = 30000;
10
+ const MAX_CONTENT_LENGTH = 100000;
11
+
12
+ /**
13
+ * System prompt for document summarization, optimized for recall.
14
+ */
15
+ const SUMMARY_SYSTEM_PROMPT =
16
+ `You are a document summarizer optimizing for RECALL. Create a summary ` +
17
+ `(3-10 sentences) that captures:
18
+ - Main topic and purpose of the document
19
+ - Key entities (names, organizations, places, dates, numbers)
20
+ - Important concepts, terms, and topics mentioned
21
+ - Any conclusions, results, or key findings
22
+
23
+ Include specific details that would help locate this document later.
24
+ Use keywords and phrases from the original text.
25
+ Do NOT include meta-commentary about the document format.
26
+ Output ONLY the summary text.`;
27
+
28
+ export interface IDocumentSummarizer {
29
+ summarize(content: string): Promise<string>;
30
+ }
31
+
32
+ export class LLMDocumentSummarizer implements IDocumentSummarizer {
33
+ private model: string;
34
+
35
+ constructor(model: string = SUMMARY_MODEL) {
36
+ this.model = model;
37
+ }
38
+
39
+ async summarize(content: string): Promise<string> {
40
+ if (!content || content.trim().length === 0) {
41
+ return "Empty document";
42
+ }
43
+
44
+ try {
45
+ const summary = await this.summarizeWithTimeout(content);
46
+ return this.sanitizeSummary(summary);
47
+ } catch (error) {
48
+ const errorMsg = error instanceof Error ? error.message : String(error);
49
+ logger.warn(
50
+ `[DocumentSummarizer] LLM summarization failed: ${errorMsg}, ` +
51
+ `using fallback`
52
+ );
53
+ return this.fallbackSummary(content);
54
+ }
55
+ }
56
+
57
+ private async summarizeWithTimeout(content: string): Promise<string> {
58
+ const timeoutPromise = new Promise<never>((_, reject) => {
59
+ setTimeout(() => {
60
+ reject(new Error("Summary generation timeout"));
61
+ }, SUMMARY_TIMEOUT_MS);
62
+ });
63
+
64
+ const summaryPromise = this.callLLM(content);
65
+
66
+ return Promise.race([summaryPromise, timeoutPromise]);
67
+ }
68
+
69
+ private async callLLM(content: string): Promise<string> {
70
+ const client = getOpenAIClient(this.model);
71
+
72
+ const truncatedContent =
73
+ content.length > MAX_CONTENT_LENGTH
74
+ ? content.slice(0, MAX_CONTENT_LENGTH) + "\n\n[Content truncated...]"
75
+ : content;
76
+
77
+ const response = await client.chat.completions.create({
78
+ model: this.model,
79
+ messages: [
80
+ {
81
+ role: "system",
82
+ content: SUMMARY_SYSTEM_PROMPT,
83
+ },
84
+ {
85
+ role: "user",
86
+ content: `Please summarize this document:\n\n${truncatedContent}`,
87
+ },
88
+ ],
89
+ max_tokens: SUMMARY_MAX_TOKENS,
90
+ temperature: SUMMARY_TEMPERATURE,
91
+ });
92
+
93
+ const summary = response.choices[0]?.message?.content?.trim();
94
+
95
+ if (!summary) {
96
+ throw new Error("Empty response from LLM");
97
+ }
98
+
99
+ return summary;
100
+ }
101
+
102
+ private sanitizeSummary(summary: string): string {
103
+ return summary.replace(/\s+/g, " ").trim();
104
+ }
105
+
106
+ private fallbackSummary(content: string): string {
107
+ const cleaned = content.trim();
108
+
109
+ if (cleaned.length === 0) {
110
+ return "Empty document";
111
+ }
112
+
113
+ const firstParagraph = cleaned.split(/\n\n/)[0];
114
+ const maxLength = 500;
115
+
116
+ if (firstParagraph.length <= maxLength) {
117
+ return firstParagraph;
118
+ }
119
+
120
+ return cleaned.slice(0, maxLength).trim() + "...";
121
+ }
122
+ }
123
+
124
+ class FallbackDocumentSummarizer implements IDocumentSummarizer {
125
+ // eslint-disable-next-line @typescript-eslint/require-await
126
+ async summarize(content: string): Promise<string> {
127
+ const cleaned = content.trim();
128
+
129
+ if (cleaned.length === 0) {
130
+ return "Empty document";
131
+ }
132
+
133
+ const firstParagraph = cleaned.split(/\n\n/)[0];
134
+ const maxLength = 500;
135
+
136
+ if (firstParagraph.length <= maxLength) {
137
+ return firstParagraph;
138
+ }
139
+
140
+ return cleaned.slice(0, maxLength).trim() + "...";
141
+ }
142
+ }
143
+
144
+ export function createDocumentSummarizer(model?: string): IDocumentSummarizer {
145
+ if (process.env.DISABLE_LLM_SUMMARIES === "true") {
146
+ return new FallbackDocumentSummarizer();
147
+ }
148
+ return new LLMDocumentSummarizer(model);
149
+ }
150
+
151
+ /**
152
+ * Convenience function for one-off summarization.
153
+ */
154
+ export async function summarizeDocument(content: string): Promise<string> {
155
+ const summarizer = createDocumentSummarizer();
156
+ return summarizer.summarize(content);
157
+ }
@@ -81,39 +81,43 @@ export class DummyLLM implements ILLM {
81
81
  _tools?: ToolDescriptor[],
82
82
  onMessage?: (msg: string, msgEnd: boolean) => Promise<void>,
83
83
  onReasoning?: (reasoning: string) => Promise<void>
84
- ): Promise<Completion> {
84
+ ): Promise<{ stop: (msg: string) => void; completion: Promise<Completion> }> {
85
85
  await new Promise((r) => setTimeout(r, 0));
86
- assert(this.idx < this.responses.length);
87
86
 
88
87
  this.lastRequest = messages;
89
88
 
90
- for (;;) {
91
- const response = this.responses[this.idx++];
89
+ const completion: Promise<Completion> = (async () => {
90
+ for (;;) {
91
+ const idx = this.idx++;
92
+ const response = this.responses[idx % this.responses.length];
92
93
 
93
- if (response.finish_reason === "error") {
94
- throw new Error(response.message);
95
- }
94
+ if (response.finish_reason === "error") {
95
+ throw new Error(response.message);
96
+ }
96
97
 
97
- if (response.finish_reason === "reasoning") {
98
- if (onReasoning) {
99
- await onReasoning(response.message);
98
+ if (response.finish_reason === "reasoning") {
99
+ if (onReasoning) {
100
+ await onReasoning(response.message);
101
+ }
102
+ continue;
100
103
  }
101
- continue;
102
- }
103
104
 
104
- if (onMessage) {
105
- const message = response.message;
106
- void onMessage(message.content || "", true);
105
+ if (onMessage) {
106
+ const message = response.message;
107
+ void onMessage(message.content || "", true);
108
+ }
109
+
110
+ return {
111
+ id: String(idx),
112
+ choices: [response],
113
+ created: Date.now(),
114
+ model: "dummyLlmModel",
115
+ object: "chat.completion",
116
+ };
107
117
  }
118
+ })();
108
119
 
109
- return {
110
- id: String(this.idx),
111
- choices: [response],
112
- created: Date.now(),
113
- model: "dummyLlmModel",
114
- object: "chat.completion",
115
- };
116
- }
120
+ return { stop: () => {}, completion };
117
121
  }
118
122
 
119
123
  public setModel(_model: string): void {
@@ -1,6 +1,5 @@
1
1
  import { OpenAI } from "openai";
2
2
  import { strict as assert } from "assert";
3
- import { writeFileSync } from "fs";
4
3
 
5
4
  import { getLogger } from "@xalia/xmcp/sdk";
6
5
 
@@ -54,11 +53,11 @@ export class ImageGenLLM implements ILLM {
54
53
  return this.openai.baseURL;
55
54
  }
56
55
 
57
- public async getConversationResponse(
56
+ public getConversationResponse(
58
57
  messages: MessageParam[],
59
58
  tools?: ToolDescriptor[],
60
59
  onMessage?: (msg: string, end: boolean) => Promise<void>
61
- ): Promise<Completion> {
60
+ ): Promise<{ stop: (msg: string) => void; completion: Promise<Completion> }> {
62
61
  assert(!tools || tools.length === 0, "tools not supported in ImageGenLLM");
63
62
 
64
63
  // Designed for image generation using openrouter, which tweaks the Create
@@ -71,35 +70,32 @@ export class ImageGenLLM implements ILLM {
71
70
 
72
71
  logger.info(`[ImageGenLLM] params; ${JSON.stringify(params)}`);
73
72
 
74
- const completion = (await this.openai.chat.completions.create(
75
- params as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming
76
- )) as Completion;
77
-
78
- // const completion = {} as unknown as ChatCompletion;
79
-
80
- const filePath: string = "./completion.json";
81
- logger.info(`[ImageGenLLM] writing ${filePath}`);
82
- writeFileSync(filePath, JSON.stringify(completion), "utf-8");
83
- logger.info(`[ImageGenLLM] written`);
84
-
85
- // logger.debug(
86
- // `[ImageGenLLM.getConversationResponse] completion:
87
- // ${JSON.stringify(completion)}`
88
- // );
89
-
90
- if (onMessage) {
91
- const message = completion.choices[0].message;
92
- if (message.content) {
93
- await onMessage(message.content, true);
94
- }
95
- if (message.images) {
96
- message.images.forEach((image, index) => {
97
- const imageUrl = image.image_url.url; // Base64 data URL
98
- const truncated = imageUrl.substring(0, 50);
99
- logger.info(`[ImageGenLLM] ${String(index + 1)}: ${truncated}...`);
100
- });
73
+ const completion = (async () => {
74
+ const completion = (await this.openai.chat.completions.create(
75
+ params as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming
76
+ )) as Completion;
77
+
78
+ // logger.debug(
79
+ // `[ImageGenLLM.getConversationResponse] completion:
80
+ // ${JSON.stringify(completion)}`
81
+ // );
82
+
83
+ if (onMessage) {
84
+ const message = completion.choices[0].message;
85
+ if (message.content) {
86
+ await onMessage(message.content, true);
87
+ }
88
+ if (message.images) {
89
+ message.images.forEach((image, index) => {
90
+ const imageUrl = image.image_url.url; // Base64 data URL
91
+ const truncated = imageUrl.substring(0, 50);
92
+ logger.info(`[ImageGenLLM] ${String(index + 1)}: ${truncated}...`);
93
+ });
94
+ }
101
95
  }
102
- }
103
- return completion;
96
+ return completion;
97
+ })();
98
+
99
+ return Promise.resolve({ stop: () => {}, completion });
104
100
  }
105
101
  }
@@ -1,10 +1,7 @@
1
1
  import { Agent, createUserMessage } from "./agent";
2
- import { createLLM } from "./agentUtils";
3
2
  import { ContextManager } from "./context";
4
3
  import { NULL_AGENT_EVENT_HANDLER } from "./nullAgentEventHandler";
5
- import { NULL_PLATFORM } from "./nullPlatform";
6
- import { NODE_PLATFORM } from "../tool/nodePlatform";
7
- import { DEFAULT_IMAGE_GEN_MODEL } from "./imageGenLLM";
4
+ import { ILLM } from "./llm";
8
5
 
9
6
  const IMAGE_GEN_SYSTEM_PROMPT = "You are an image generator";
10
7
 
@@ -17,19 +14,7 @@ export class ImageGenerator {
17
14
  this.contextManager = contextManager;
18
15
  }
19
16
 
20
- public static async init(
21
- llmUrl: string,
22
- llmApiKey: string,
23
- model?: string
24
- ): Promise<ImageGenerator> {
25
- const development = !!process.env.DEVELOPMENT;
26
- const llm = await createLLM(
27
- llmUrl,
28
- llmApiKey,
29
- model || DEFAULT_IMAGE_GEN_MODEL,
30
- false /* stream */,
31
- development ? NODE_PLATFORM : NULL_PLATFORM // allow file loading
32
- );
17
+ public static init(llm: ILLM): ImageGenerator {
33
18
  const contextManager = new ContextManager(IMAGE_GEN_SYSTEM_PROMPT, []);
34
19
  const agent = Agent.initializeWithLLM(
35
20
  NULL_AGENT_EVENT_HANDLER,
@@ -53,7 +38,7 @@ export class ImageGenerator {
53
38
  }
54
39
 
55
40
  // Clear the context
56
- while (this.contextManager.popMessage());
41
+ this.contextManager.clear();
57
42
 
58
43
  return agentResponse.images[0].image_url.url;
59
44
  }
package/src/agent/llm.ts CHANGED
@@ -2,7 +2,7 @@ import * as openai from "./openAI";
2
2
  import { OpenAI } from "openai";
3
3
 
4
4
  export const XALIA_APP_HEADER = {
5
- "HTTP-Referer": "xalia.ai",
5
+ "HTTP-Referer": "https://xalia.ai",
6
6
  "X-Title": "Xalia",
7
7
  };
8
8
 
@@ -68,7 +68,7 @@ export interface ILLM {
68
68
  tools?: ToolDescriptor[],
69
69
  onMessage?: (msg: string, end: boolean) => Promise<void>,
70
70
  onReasoning?: (reasoning: string) => Promise<void>
71
- ): Promise<Completion>;
71
+ ): Promise<{ stop: (msg: string) => void; completion: Promise<Completion> }>;
72
72
 
73
73
  setModel(model: string): void;
74
74
  }
@@ -73,23 +73,27 @@ export class OpenAILLM implements ILLM {
73
73
  return this.openai.baseURL;
74
74
  }
75
75
 
76
- public async getConversationResponse(
76
+ public getConversationResponse(
77
77
  messages: MessageParam[],
78
78
  tools?: ToolDescriptor[],
79
79
  onMessage?: (msg: string, end: boolean) => Promise<void>
80
- ): Promise<Completion> {
81
- const completion = await this.openai.chat.completions.create({
82
- model: this.model,
83
- messages,
84
- tools,
85
- });
86
- if (onMessage) {
87
- const message = completion.choices[0].message;
88
- if (message.content) {
89
- await onMessage(message.content, true);
80
+ ): Promise<{ stop: (msg: string) => void; completion: Promise<Completion> }> {
81
+ const completion: Promise<Completion> = (async () => {
82
+ const completion = await this.openai.chat.completions.create({
83
+ model: this.model,
84
+ messages,
85
+ tools,
86
+ });
87
+ if (onMessage) {
88
+ const message = completion.choices[0].message;
89
+ if (message.content) {
90
+ await onMessage(message.content, true);
91
+ }
90
92
  }
91
- }
92
93
 
93
- return completionFromOpenAI(completion);
94
+ return completionFromOpenAI(completion);
95
+ })();
96
+
97
+ return Promise.resolve({ stop: () => {}, completion });
94
98
  }
95
99
  }
@@ -13,7 +13,6 @@ import {
13
13
  MessageParam,
14
14
  ToolDescriptor,
15
15
  } from "./llm";
16
-
17
16
  import {
18
17
  Reasoning,
19
18
  ChatCompletionChunkChoiceDeltaWithReasoning,
@@ -516,13 +515,31 @@ export class OpenAILLMStreaming implements ILLM {
516
515
  tools?: ToolDescriptor[],
517
516
  onMessage?: (msg: string, end: boolean) => Promise<void>,
518
517
  onReasoning?: (reasoning: string) => Promise<void>
519
- ): Promise<Completion> {
518
+ ): Promise<{ stop: (msg: string) => void; completion: Promise<Completion> }> {
519
+ return OpenAILLMStreaming.makeRequest(
520
+ this.openai,
521
+ this.model,
522
+ messages,
523
+ tools,
524
+ onMessage,
525
+ onReasoning
526
+ );
527
+ }
528
+
529
+ public static async makeRequest(
530
+ openai: OpenAI,
531
+ model: string,
532
+ messages: MessageParam[],
533
+ tools?: ToolDescriptor[],
534
+ onMessage?: (msg: string, end: boolean) => Promise<void>,
535
+ onReasoning?: (reasoning: string) => Promise<void>
536
+ ): Promise<{ stop: (msg: string) => void; completion: Promise<Completion> }> {
520
537
  const reasoning: Reasoning = {
521
538
  effort: "medium",
522
539
  enabled: true,
523
540
  };
524
- const chunks = await this.openai.chat.completions.create({
525
- model: this.model,
541
+ const chunks = await openai.chat.completions.create({
542
+ model: model,
526
543
  messages,
527
544
  tools,
528
545
  stream: true,
@@ -537,56 +554,95 @@ export class OpenAILLMStreaming implements ILLM {
537
554
  throw new Error("not a stream");
538
555
  }
539
556
 
540
- let aggregatedMessage: Completion | undefined;
557
+ let stopMsg: string | undefined = undefined;
541
558
 
542
- for await (const chunk of chunks) {
543
- logger.debug(`[stream] chunk: ${JSON.stringify(chunk)}`);
559
+ const stop = (msg: string) => {
560
+ stopMsg = msg;
561
+ };
544
562
 
545
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
546
- if (chunk.object !== "chat.completion.chunk") {
547
- // logger.warn("[stream]: unexpected message");
548
- continue;
549
- }
563
+ const completion: Promise<Completion> = (async () => {
564
+ // Completion built up over successive calls to processChunk.
565
+ let aggregatedMessage: Completion | undefined;
550
566
 
551
- if (!aggregatedMessage) {
552
- logger.debug(`[stream] first}`);
553
- const { initMessage } = initializeCompletion(chunk);
554
- aggregatedMessage = initMessage;
555
- } else {
556
- updateCompletion(aggregatedMessage, chunk);
557
- }
567
+ const processChunk = async (
568
+ chunk: OpenAI.Chat.Completions.ChatCompletionChunk
569
+ ) => {
570
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
571
+ if (chunk.object !== "chat.completion.chunk") {
572
+ // logger.warn("[stream]: unexpected message");
573
+ return;
574
+ }
558
575
 
559
- if (onMessage) {
560
- // Inform the call of a message fragment if it contains any text.
561
- // Note: chunks may have zero choices (e.g., usage-only chunks), so
562
- // we safely access the first choice.
576
+ if (!aggregatedMessage) {
577
+ logger.debug(`[stream] first}`);
578
+ const { initMessage } = initializeCompletion(chunk);
579
+ aggregatedMessage = initMessage;
580
+ } else {
581
+ updateCompletion(aggregatedMessage, chunk);
582
+ }
563
583
 
564
- const delta = chunk.choices[0]?.delta;
565
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
566
- if (delta?.content) {
567
- await onMessage(delta.content, false);
584
+ if (onMessage) {
585
+ // Inform the call of a message fragment if it contains any text.
586
+ // Note: chunks may have zero choices (e.g., usage-only chunks), so
587
+ // we safely access the first choice.
588
+
589
+ const delta = chunk.choices[0]?.delta;
590
+ // eslint-disable-next-line
591
+ if (delta?.content) {
592
+ await onMessage(delta.content, false);
593
+ }
568
594
  }
569
- }
570
595
 
571
- if (onReasoning) {
572
- const delta = chunk.choices[0]
573
- ?.delta as ChatCompletionChunkChoiceDeltaWithReasoning;
574
- const reasoning = choiceDeltaExtractReasoning(delta);
575
- if (reasoning) {
576
- await onReasoning(reasoning);
596
+ if (onReasoning) {
597
+ const delta = chunk.choices[0]
598
+ ?.delta as ChatCompletionChunkChoiceDeltaWithReasoning;
599
+ const reasoning = choiceDeltaExtractReasoning(delta);
600
+ if (reasoning) {
601
+ await onReasoning(reasoning);
602
+ }
577
603
  }
604
+ };
605
+
606
+ // Process each chunk, checking for a stop signal.
607
+ for await (const chunk of chunks) {
608
+ logger.debug(`[stream] chunk: ${JSON.stringify(chunk)}`);
609
+ await processChunk(chunk);
610
+
611
+ /* eslint-disable @typescript-eslint/no-unnecessary-condition */
612
+ if (stopMsg) {
613
+ const choice: OpenAI.Chat.Completions.ChatCompletionChunk.Choice = {
614
+ delta: { content: stopMsg },
615
+ finish_reason:
616
+ aggregatedMessage && aggregatedMessage.choices[0].finish_reason
617
+ ? null
618
+ : "stop",
619
+ index: 0,
620
+ };
621
+
622
+ await processChunk({
623
+ id: aggregatedMessage?.id || "user_stop_chunk",
624
+ created: aggregatedMessage?.created || Date.now(),
625
+ model: aggregatedMessage?.model || model,
626
+ object: "chat.completion.chunk",
627
+ choices: [choice],
628
+ });
629
+ break;
630
+ }
631
+ /* eslint-enable @typescript-eslint/no-unnecessary-condition */
578
632
  }
579
- }
580
633
 
581
- if (onMessage) {
582
- await onMessage("", true);
583
- }
634
+ if (onMessage) {
635
+ await onMessage("", true);
636
+ }
584
637
 
585
- logger.debug(
586
- `[stream] final message: ${JSON.stringify(aggregatedMessage)}`
587
- );
638
+ logger.debug(
639
+ `[stream] final message: ${JSON.stringify(aggregatedMessage)}`
640
+ );
641
+
642
+ assert(aggregatedMessage);
643
+ return aggregatedMessage;
644
+ })();
588
645
 
589
- assert(aggregatedMessage);
590
- return aggregatedMessage;
646
+ return { stop, completion };
591
647
  }
592
648
  }
@@ -2,8 +2,17 @@ import { Choice, Completion, ILLM, MessageParam, ToolDescriptor } from "./llm";
2
2
  import { strict as assert } from "assert";
3
3
 
4
4
  export class RepeatLLM implements ILLM {
5
+ private prefix: string;
5
6
  private idx: number = 0;
6
7
 
8
+ constructor(prefix?: string) {
9
+ if (prefix && prefix.length > 0) {
10
+ this.prefix = prefix;
11
+ } else {
12
+ this.prefix = "Message number";
13
+ }
14
+ }
15
+
7
16
  public getModel(): string {
8
17
  return "repeat";
9
18
  }
@@ -16,10 +25,10 @@ export class RepeatLLM implements ILLM {
16
25
  _messages: MessageParam[],
17
26
  _tools?: ToolDescriptor[],
18
27
  onMessage?: (msg: string, msgEnd: boolean) => Promise<void>
19
- ): Promise<Completion> {
28
+ ): Promise<{ stop: () => void; completion: Promise<Completion> }> {
20
29
  await new Promise((r) => setTimeout(r, 1000));
21
30
 
22
- const content = `Message number ${String(this.idx++)}`;
31
+ const content = `${this.prefix} ${String(this.idx++)}`;
23
32
  const response: Choice = {
24
33
  finish_reason: "stop",
25
34
  index: 0,
@@ -36,11 +45,14 @@ export class RepeatLLM implements ILLM {
36
45
  }
37
46
 
38
47
  return {
39
- id: String(this.idx),
40
- choices: [response],
41
- created: Date.now(),
42
- model: "dummyLlmModel",
43
- object: "chat.completion",
48
+ stop: () => {},
49
+ completion: Promise.resolve({
50
+ id: String(this.idx),
51
+ choices: [response],
52
+ created: Date.now(),
53
+ model: "dummyLlmModel",
54
+ object: "chat.completion",
55
+ }),
44
56
  };
45
57
  }
46
58