diff --git a/package.json b/package.json index 2c91622..45f0bac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/ai-utils", - "version": "0.1.16", + "version": "0.1.18", "description": "AI Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/antrhopic.ts b/src/antrhopic.ts index 71907f1..e55e96d 100644 --- a/src/antrhopic.ts +++ b/src/antrhopic.ts @@ -13,29 +13,25 @@ export class Anthropic extends LLMProvider { } private toStandard(history: any[]): LLMMessage[] { - const merged: any[] = []; for(let i = 0; i < history.length; i++) { - const msg = history[i]; - if(typeof msg.content != 'string') { - if(msg.role == 'assistant') { - msg.content.filter((c: any) => c.type == 'tool_use').forEach((c: any) => { - merged.push({role: 'tool', id: c.id, name: c.name, args: c.input}); + const orgI = i; + if(typeof history[orgI].content != 'string') { + if(history[orgI].role == 'assistant') { + history[orgI].content.filter((c: any) => c.type =='tool_use').forEach((c: any) => { + i++; + history.splice(i, 0, {role: 'tool', id: c.id, name: c.name, args: c.input, timestamp: Date.now()}); }); - } else if(msg.role == 'user') { - msg.content.filter((c: any) => c.type == 'tool_result').forEach((c: any) => { - const h = merged.find((h: any) => h.id == c.tool_use_id); - if(h) h[c.is_error ? 'error' : 'content'] = c.content; + } else if(history[orgI].role == 'user') { + history[orgI].content.filter((c: any) => c.type =='tool_result').forEach((c: any) => { + const h = history.find((h: any) => h.id == c.tool_use_id); + h[c.is_error ? 'error' : 'content'] = c.content; }); } - msg.content = msg.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n'); - } - if(msg.content) { - const last = merged.at(-1); - if(last && last.role == 'assistant' && msg.role == 'assistant') last.content += '\n\n' + msg.content; - else merged.push({role: msg.role, content: msg.content}); + history[orgI].content = history[orgI].content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n'); } + if(!history[orgI].timestamp) history[orgI].timestamp = Date.now(); } - return merged; + return history.filter(h => !!h.content); } private fromStandard(history: LLMMessage[]): any[] { @@ -43,8 +39,8 @@ export class Anthropic extends LLMProvider { if(history[i].role == 'tool') { const h: any = history[i]; history.splice(i, 1, - {role: 'assistant', content: [{type: 'tool_use', id: h.id, name: h.name, input: h.args}]}, - {role: 'user', content: [{type: 'tool_result', tool_use_id: h.id, is_error: !!h.error, content: h.error || h.content}]} + {role: 'assistant', content: [{type: 'tool_use', id: h.id, name: h.name, input: h.args}], timestamp: h.timestamp}, + {role: 'user', content: [{type: 'tool_result', tool_use_id: h.id, is_error: !!h.error, content: h.error || h.content}], timestamp: Date.now()} ) i++; } @@ -55,7 +51,7 @@ export class Anthropic extends LLMProvider { ask(message: string, options: LLMRequest = {}): AbortablePromise { const controller = new AbortController(); const response = new Promise(async (res, rej) => { - let history = this.fromStandard([...options.history || [], {role: 'user', content: message}]); + let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]); if(options.compress) history = await this.ai.llm.compress(history, options.compress.max, options.compress.min, options); const requestParams: any = { model: options.model || this.model, @@ -77,14 +73,11 @@ export class Anthropic extends LLMProvider { }; let resp: any; - let isFirstMessage = true; + const loopMessages: any[] = []; do { resp = await this.client.messages.create(requestParams); - if(options.stream) { - if(!isFirstMessage) options.stream({text: '\n\n'}); - isFirstMessage = false; - + if(loopMessages.length) options.stream({text: '\n\n'}); resp.content = []; for await (const chunk of resp) { if(controller.signal.aborted) break; @@ -111,9 +104,10 @@ export class Anthropic extends LLMProvider { } } + loopMessages.push({role: 'assistant', content: resp.content, timestamp: Date.now()}); const toolCalls = resp.content.filter((c: any) => c.type === 'tool_use'); if(toolCalls.length && !controller.signal.aborted) { - history.push({role: 'assistant', content: resp.content}); + history.push({role: 'assistant', content: resp.content, timestamp: Date.now()}); const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const tool = options.tools?.find(findByProp('name', toolCall.name)); if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'}; @@ -124,16 +118,18 @@ export class Anthropic extends LLMProvider { return {type: 'tool_result', tool_use_id: toolCall.id, is_error: true, content: err?.message || err?.toString() || 'Unknown'}; } })); - history.push({role: 'user', content: results}); + const userMsg = {role: 'user', content: results, timestamp: Date.now()}; + history.push(userMsg); + loopMessages.push(userMsg); requestParams.messages = history; } } while (!controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use')); + const combinedContent = loopMessages.filter(m => m.role === 'assistant') + .map(m => m.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n')) + .filter(c => c).join('\n\n'); if(options.stream) options.stream({done: true}); - res(this.toStandard([...history, { - role: 'assistant', - content: resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n') - }])); + res(this.toStandard([...history, {role: 'assistant', content: combinedContent, timestamp: Date.now()}])); }); return Object.assign(response, {abort: () => controller.abort()}); diff --git a/src/llm.ts b/src/llm.ts index 4f1702b..cd1360f 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -11,6 +11,8 @@ export type LLMMessage = { role: 'assistant' | 'system' | 'user'; /** Message content */ content: string | any; + /** Timestamp */ + timestamp: number; } | { /** Tool call */ role: 'tool'; @@ -24,6 +26,8 @@ export type LLMMessage = { content: undefined | string; /** Tool error */ error: undefined | string; + /** Timestamp */ + timestamp: number; } export type LLMOptions = { @@ -125,7 +129,7 @@ export class LLM { const recent = keep == 0 ? [] : history.slice(-keep), process = (keep == 0 ? history : history.slice(0, -keep)).filter(h => h.role === 'assistant' || h.role === 'user'); const summary = await this.summarize(process.map(m => `${m.role}: ${m.content}`).join('\n\n'), 250, options); - return [{role: 'assistant', content: `Conversation Summary: ${summary}`}, ...recent]; + return [{role: 'assistant', content: `Conversation Summary: ${summary}`, timestamp: Date.now()}, ...recent]; } /** diff --git a/src/ollama.ts b/src/ollama.ts index 9747153..f3e42df 100644 --- a/src/ollama.ts +++ b/src/ollama.ts @@ -22,8 +22,9 @@ export class Ollama extends LLMProvider { } } else if(history[i].role == 'tool') { const error = history[i].content.startsWith('{"error":'); - history[i] = {role: 'tool', name: history[i].tool_name, args: history[i].args, [error ? 'error' : 'content']: history[i].content}; + history[i] = {role: 'tool', name: history[i].tool_name, args: history[i].args, [error ? 'error' : 'content']: history[i].content, timestamp: history[i].timestamp}; } + if(!history[i]?.timestamp) history[i].timestamp = Date.now(); } return history; } @@ -31,7 +32,7 @@ export class Ollama extends LLMProvider { private fromStandard(history: LLMMessage[]): any[] { return history.map((h: any) => { if(h.role != 'tool') return h; - return {role: 'tool', tool_name: h.name, content: h.error || h.content} + return {role: 'tool', tool_name: h.name, content: h.error || h.content, timestamp: h.timestamp} }); } @@ -39,7 +40,7 @@ export class Ollama extends LLMProvider { const controller = new AbortController(); const response = new Promise(async (res, rej) => { let system = options.system || this.ai.options.system; - let history = this.fromStandard([...options.history || [], {role: 'user', content: message}]); + let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]); if(history[0].roll == 'system') { if(!system) system = history.shift(); else history.shift(); @@ -70,11 +71,12 @@ export class Ollama extends LLMProvider { })) } - // Run tool chains let resp: any; + const loopMessages: any[] = []; do { resp = await this.client.chat(requestParams); if(options.stream) { + if(loopMessages.length) options.stream({text: '\n\n'}); resp.message = {role: 'assistant', content: '', tool_calls: []}; for await (const chunk of resp) { if(controller.signal.aborted) break; @@ -87,27 +89,33 @@ export class Ollama extends LLMProvider { } } - // Run tools + loopMessages.push({role: 'assistant', content: resp.message?.content, timestamp: Date.now()}); + if(resp.message?.tool_calls?.length && !controller.signal.aborted) { - history.push(resp.message); + history.push({...resp.message, timestamp: Date.now()}); const results = await Promise.all(resp.message.tool_calls.map(async (toolCall: any) => { const tool = (options.tools || this.ai.options.tools)?.find(findByProp('name', toolCall.function.name)); - if(!tool) return {role: 'tool', tool_name: toolCall.function.name, content: '{"error": "Tool not found"}'}; + if(!tool) return {role: 'tool', tool_name: toolCall.function.name, content: '{"error": "Tool not found"}', timestamp: Date.now()}; const args = typeof toolCall.function.arguments === 'string' ? JSONAttemptParse(toolCall.function.arguments, {}) : toolCall.function.arguments; try { const result = await tool.fn(args, this.ai); - return {role: 'tool', tool_name: toolCall.function.name, args, content: JSONSanitize(result)}; + return {role: 'tool', tool_name: toolCall.function.name, args, content: JSONSanitize(result), timestamp: Date.now()}; } catch (err: any) { - return {role: 'tool', tool_name: toolCall.function.name, args, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'})}; + return {role: 'tool', tool_name: toolCall.function.name, args, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'}), timestamp: Date.now()}; } })); history.push(...results); + loopMessages.push(...results); requestParams.messages = history; } } while (!controller.signal.aborted && resp.message?.tool_calls?.length); + + const combinedContent = loopMessages.filter(m => m.role === 'assistant') + .map(m => m.content).filter(c => c).join('\n\n'); if(options.stream) options.stream({done: true}); - res(this.toStandard([...history, {role: 'assistant', content: resp.message?.content}])); + res(this.toStandard([...history, {role: 'assistant', content: combinedContent, timestamp: Date.now()}])); }); + return Object.assign(response, {abort: () => controller.abort()}); } } diff --git a/src/open-ai.ts b/src/open-ai.ts index 4ee646d..7e1b1a1 100644 --- a/src/open-ai.ts +++ b/src/open-ai.ts @@ -20,7 +20,8 @@ export class OpenAi extends LLMProvider { role: 'tool', id: tc.id, name: tc.function.name, - args: JSONAttemptParse(tc.function.arguments, {}) + args: JSONAttemptParse(tc.function.arguments, {}), + timestamp: h.timestamp })); history.splice(i, 1, ...tools); i += tools.length - 1; @@ -33,7 +34,7 @@ export class OpenAi extends LLMProvider { history.splice(i, 1); i--; } - + if(!history[i]?.timestamp) history[i].timestamp = Date.now(); } return history; } @@ -47,10 +48,12 @@ export class OpenAi extends LLMProvider { tool_calls: [{ id: h.id, type: 'function', function: { name: h.name, arguments: JSON.stringify(h.args) } }], refusal: null, annotations: [], + timestamp: h.timestamp }, { role: 'tool', tool_call_id: h.id, - content: h.error || h.content + content: h.error || h.content, + timestamp: Date.now() }); } else { result.push(h); @@ -62,7 +65,7 @@ export class OpenAi extends LLMProvider { ask(message: string, options: LLMRequest = {}): AbortablePromise { const controller = new AbortController(); const response = new Promise(async (res, rej) => { - let history = this.fromStandard([...options.history || [], {role: 'user', content: message}]); + let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]); if(options.compress) history = await this.ai.llm.compress(history, options.compress.max, options.compress.min, options); const requestParams: any = { @@ -85,44 +88,51 @@ export class OpenAi extends LLMProvider { })) }; - // Tool call and streaming logic similar to other providers let resp: any; + const loopMessages: any[] = []; do { resp = await this.client.chat.completions.create(requestParams); - - // Implement streaming and tool call handling if(options.stream) { - resp.choices = []; + if(loopMessages.length) options.stream({text: '\n\n'}); + resp.choices = [{message: {content: '', tool_calls: []}}]; for await (const chunk of resp) { if(controller.signal.aborted) break; if(chunk.choices[0].delta.content) { + resp.choices[0].message.content += chunk.choices[0].delta.content; options.stream({text: chunk.choices[0].delta.content}); } + if(chunk.choices[0].delta.tool_calls) { + resp.choices[0].message.tool_calls = chunk.choices[0].delta.tool_calls; + } } } - // Run tools + loopMessages.push({role: 'assistant', content: resp.choices[0].message.content || '', timestamp: Date.now()}); + const toolCalls = resp.choices[0].message.tool_calls || []; if(toolCalls.length && !controller.signal.aborted) { - history.push(resp.choices[0].message); + history.push({...resp.choices[0].message, timestamp: Date.now()}); const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const tool = options.tools?.find(findByProp('name', toolCall.function.name)); - if(!tool) return {role: 'tool', tool_call_id: toolCall.id, content: '{"error": "Tool not found"}'}; + if(!tool) return {role: 'tool', tool_call_id: toolCall.id, content: '{"error": "Tool not found"}', timestamp: Date.now()}; try { const args = JSONAttemptParse(toolCall.function.arguments, {}); const result = await tool.fn(args, this.ai); - return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize(result)}; + return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize(result), timestamp: Date.now()}; } catch (err: any) { - return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'})}; + return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'}), timestamp: Date.now()}; } })); history.push(...results); + loopMessages.push(...results); requestParams.messages = history; } } while (!controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length); + const combinedContent = loopMessages.filter(m => m.role === 'assistant') + .map(m => m.content).filter(c => c).join('\n\n'); if(options.stream) options.stream({done: true}); - res(this.toStandard([...history, {role: 'assistant', content: resp.choices[0].message.content || ''}])); + res(this.toStandard([...history, {role: 'assistant', content: combinedContent, timestamp: Date.now()}])); }); return Object.assign(response, {abort: () => controller.abort()});