diff --git a/package.json b/package.json index efd2def..aa12931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/ai-utils", - "version": "1.2.13", + "version": "1.3.0", "description": "AI Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index 1f1e567..08b9bf5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,6 @@ export * from './antrhopic'; export * from './audio'; export * from './llm'; export * from './memory'; -export * from './memory-cache'; -export * from './memory-graph'; export * from './open-ai'; export * from './provider'; export * from './tools'; diff --git a/src/llm.ts b/src/llm.ts index e377387..a479155 100644 --- a/src/llm.ts +++ b/src/llm.ts @@ -1,17 +1,31 @@ +import {snakeCase} from '@ztimson/utils'; import {AbortablePromise, Ai} from './ai.ts'; import {Anthropic} from './antrhopic.ts'; -import {MemoryCache} from './memory-cache.ts'; import {OpenAi} from './open-ai.ts'; import {LLMProvider} from './provider.ts'; import {AiTool, AiToolArg} from './tools.ts'; import {fileURLToPath} from 'url'; import {dirname, join} from 'path'; import {spawn} from 'node:child_process'; -import {Memory, MemoryManager} from './memory.ts'; +import {Memory, MemoryCache, MemoryManager, MemoryOptions} from './memory.ts'; export type AnthropicConfig = {proto: 'anthropic', token: string}; export type OpenAiConfig = {proto: 'openai', host?: string, token: string}; +export type Agent = { + name: string; + description?: string; + model?: string | null; + temperature?: number; + system: string; + delegate?: boolean; + skills?: Skill[] | null; + tools?: AiTool[] | null; + mcp?: McpServer[] | null; + /** Explicit whitelist of agents this agent may delegate to. Default: none - must opt-in, self is always excluded */ + agents?: string[] | null; +} + export type LLMMessage = { /** Message originator */ role: 'assistant' | 'system' | 'user'; @@ -56,13 +70,17 @@ export type LLMRequest = { /** Compress old messages in the chat to free up context */ compress?: {max: number; min: number}; /** User's memory documents - RAG injected automatically each turn */ - memory?: Memory[] | MemoryCache; + memory?: Memory[] | MemoryCache | MemoryOptions; /** Model to use for memory operations */ memoryModel?: string; /** Skill documents the AI can browse and read on demand */ skills?: Skill[]; /** MCP servers to connect and expose as tools */ mcp?: McpServer[]; + /** Subagents exposed as delegatable/wrapped tools */ + agents?: Agent[]; + /** @internal recursion guard for nested agent delegation */ + _agentDepth?: number; } export type McpServer = { @@ -83,6 +101,7 @@ export type Skill = { content: string; } +const MAX_AGENT_DEPTH = 5; class LLM { private memoryManager!: MemoryManager; @@ -100,6 +119,60 @@ class LLM { this.memoryManager = new MemoryManager(this); } + /** + * Wrap agents as tools. Nested delegation is opt-in only (empty by default, like + * tools/skills/mcp) and an agent can never call itself even if explicitly whitelisted. + * Delegate results are queued in `pending` and spliced into history by `ask()` after + * the provider's own end-of-turn history sync has already run. + */ + private setupAgent(agents: Agent[] = [], allAgents: Agent[], pending: Map, aborts: (() => void)[], depth = 0): AiTool[] { + return agents.map(a => { + const toolName = `${a.delegate ? '' : 'sub'}agent_${snakeCase(a.name)}`; + return { + name: toolName, + description: `${a.delegate ? 'Delegate to ' : ''}Subagent: ${a.description || a.name}`, + args: { + context: {type: 'string', description: 'Summary of related messages, samples, files, etc...', required: true}, + instructions: {type: 'string', description: 'Detailed instructions for subagent to complete', required: true}, + }, + fn: async (args: any, stream: any) => { + if(depth >= MAX_AGENT_DEPTH) return 'Max agent delegation depth exceeded'; + + const subHistory: LLMMessage[] = []; + // Opt-in only, self always excluded regardless of whitelist + const nested = (a.agents || []) + .map(name => allAgents.find(x => x.name === name)) + .filter((x): x is Agent => !!x && x.name !== a.name); + + const request = this.ask(`${args.instructions}${args.context ? `\n\n${args.context}` : ''}`, { + system: `You are a specialized subagent. ${a.delegate ? 'Your output streams directly to the user for the remainder of this turn.' : 'You are wrapped in a tool call that will be analysis by an LLM'} +As a subagent, focus on executing your task completely using available tools and returning only the final result - no commentary, questions, or dialogue. + +${a.system}`, + model: a.model || undefined, + temperature: a.temperature, + stream: a.delegate ? stream : undefined, + history: subHistory, + mcp: a.mcp || undefined, + skills: a.skills || undefined, + tools: a.tools || undefined, + agents: nested, + _agentDepth: depth + 1, + } as any); + aborts.push(request.abort); + const resp = await request; + + if(a.delegate) { + if(!pending.has(toolName)) pending.set(toolName, []); + pending.get(toolName)!.push({resp, subHistory}); + return ''; + } + return resp; + } + }; + }); + } + private async setupMcp(servers: McpServer[] = []): Promise<{prompt: string, tools: AiTool[]}> { if(!servers?.length) return {prompt: '', tools: []}; const allTools: AiTool[] = []; @@ -170,9 +243,11 @@ class LLM { if(!this.models[m]) throw new Error(`Model does not exist: ${m}`); let request: AbortablePromise | null = null; let aborted = false; + const nestedAborts: (() => void)[] = []; const abort = () => { aborted = true; request?.abort?.(); + nestedAborts.forEach(a => a()); }; const promise = (async () => { @@ -196,22 +271,46 @@ class LLM { tools.push(...s.tools); } + // Agents + const agents = options.agents || this.ai.options?.llm?.agents; + const pendingDelegates = new Map(); + if(agents?.length) tools.push(...this.setupAgent(agents, agents, pendingDelegates, nestedAborts, options._agentDepth || 0)); + // Memory - if (options.memory) { - const mems = options.memory instanceof MemoryCache ? options.memory.memories : options.memory; + const mem = MemoryManager.normalize(options.memory); + if(mem) { + const mems = mem.memory instanceof MemoryCache ? mem.memory.memories : mem.memory; if(mems.length) { - const relevant = await this.memoryManager.recollect(message, options.memory, 5); - prompts.unshift(`You have access to the following memory files: - ${mems.map(m => `- ${m.name}: ${m.description}`).join('\n')} - ${relevant.length ? ` - Relevant memories have been preloaded: - ${relevant.map(r => ` - **${r.name}** - ${r.description} - ${r.content} - `).join('\n---\n')} - ` : ''}`.trim()); - tools.push(this.memoryManager.tools.read(options.memory)); + if(mem.inject) { + const pool = 15; // candidates considered, cheap since only refs are listed + const budget = mem.maxTokens ?? 2000; // actual content injected + const relevant = await this.memoryManager.recollect(message, mem.memory, pool); + + let used = 0; + const preloaded: typeof relevant = []; + const listed: typeof relevant = []; + for(const r of relevant) { + const t = this.estimateTokens(r.content); + if(used + t <= budget || preloaded.length === 0) { + preloaded.push(r); + used += t; + } else listed.push(r); + } + + prompts.unshift(`You have access to the following memory files: +${mems.map(m => `- ${m.name}: ${m.description}`).join('\n')} +${preloaded.length ? ` +Relevant memories have been preloaded: +${preloaded.map(r => ` +**${r.name}** +${r.description} +${r.content} +`).join('\n---\n')} +` : ''}${listed.length ? ` +Also relevant but not preloaded (use \`memory_recall\`): ${listed.map(r => r.name).join(', ')} +` : ''}`.trim()); + } + if(mem.tool) tools.push(this.memoryManager.tools.read(mem.memory)); } } @@ -219,16 +318,33 @@ class LLM { prompts.unshift(options.system || this.ai.options.llm?.system || ''); request = this.models[m].ask(message, {...options, tools, system: prompts.filter(Boolean).join('\n\n')}); - const resp = await request; + let resp = await request; + + // Spice delegated agents response into history + let lastDelegateResp: string | null = null; + if(pendingDelegates.size) { + for(let i = 0; i < history.length; i++) { + const h = history[i]; + if(h.role !== 'tool' || h.content !== '') continue; + const queue = pendingDelegates.get(h.name); + if(!queue?.length) continue; + const {resp: delegateResp, subHistory} = queue.shift()!; + const insert: LLMMessage[] = [...subHistory.filter(sh => sh.role === 'tool'), {role: 'assistant', content: delegateResp, timestamp: Date.now()}]; + history.splice(i + 1, 0, ...insert); + lastDelegateResp = delegateResp; + i += insert.length; + } + } + + // If the orchestrator added no commentary of its own, its answer IS the delegate's answer + if(typeof resp === 'string' && !resp.trim() && lastDelegateResp !== null) resp = lastDelegateResp; // Trim memory injections from history - if(options.memory) { - history.splice(0, history.length, ...history.filter(h => h.role !== 'tool' || h.name !== 'recall')); - } + if(mem?.tool) history.splice(0, history.length, ...history.filter(h => h.role !== 'tool' || h.name !== 'recall')); // Auto-memorize before compressing if(options.compress && this.estimateTokens(history) >= options.compress.max) { - if(options.memory) await this.memoryManager.memorize(history, options.memory, {model: options.memoryModel || this.defaultModel, ...options}); + if(mem?.update) await this.memoryManager.memorize(history, mem.memory, {model: options.memoryModel || this.defaultModel, ...options}); const compressed = await this.compressHistory(history, options.compress.max, options.compress.min, options); if(options.history) options.history.splice(0, options.history.length, ...compressed); } @@ -399,7 +515,6 @@ class LLM { */ fuzzyMatch(target, ...searchTerms) { if (searchTerms.length < 2) throw new Error('Requires at least 2 strings to compare'); - const levenshtein = (a, b) => { const m = a.length, n = b.length; if (!m) return n; @@ -415,13 +530,10 @@ class LLM { } return dp[m][n]; }; - const similarity = (a, b) => { a = a.toLowerCase(); b = b.toLowerCase(); - const dist = levenshtein(a, b); - return 1 - dist / Math.max(a.length, b.length, 1); + return 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1); }; - const similarities = searchTerms.map(t => similarity(target, t)); return { avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length, diff --git a/src/memory-cache.ts b/src/memory-cache.ts deleted file mode 100644 index 99bff6c..0000000 --- a/src/memory-cache.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {KDPoint, KDTree} from './kd-tree.ts'; -import {Memory, MemoryRef} from './memory.ts'; - -export class MemoryCache { - private tree: KDTree; - public memories: Memory[]; - - get length() { return this.memories.length; } - - constructor(memories: Memory[]) { - this.memories = memories; - this.tree = this.buildTree(); - } - - private buildTree(): KDTree { - const embedded = this.memories.filter(m => m.embedding?.length); - if (!embedded.length) return new KDTree(0); - - const dims = embedded[0].embedding.length; - const points: KDPoint[] = embedded.map(m => ({ - vector: m.embedding, - payload: {name: m.name, description: m.description}, - })); - - return new KDTree(dims, 'cosine', points); - } - - search(query: number[], limit: number): MemoryRef[] { - const results = this.tree.knn(query, limit); - return results.map(r => r.point.payload); - } - - add(memory: Memory): void { - this.memories.push(memory); - this.rebuild(); - } - - update(memory: Memory): void { - const idx = this.memories.findIndex(m => m.name === memory.name); - if (idx !== -1) { - this.memories[idx] = memory; - } else { - this.memories.push(memory); - } - this.rebuild(); - } - - remove(name: string): void { - const idx = this.memories.findIndex(m => m.name === name); - if (idx !== -1) { - this.memories.splice(idx, 1); - this.rebuild(); - } - } - - rebuild(): void { - this.tree = this.buildTree(); - } -} diff --git a/src/memory-graph.ts b/src/memory-graph.ts deleted file mode 100644 index a0b402e..0000000 --- a/src/memory-graph.ts +++ /dev/null @@ -1,69 +0,0 @@ -import {MemoryCache} from './memory-cache.ts'; -import {extractMetadata, Memory, MemoryNode} from './memory.ts'; - -export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[] { - const mems = memories instanceof MemoryCache ? memories.memories : memories; - const nameSet = new Set(mems.map(m => m.name)); - const ghosts = new Set(); - - const nodes: MemoryNode[] = mems.map(m => { - const {links, backlinks} = extractMetadata(m.content); - return { - name: m.name, - missing: false, - links, - backlinks, - }; - }); - - for (const node of nodes) { - for (const link of node.links) { - if (!nameSet.has(link)) ghosts.add(link); - } - } - - return [ - ...nodes, - ...[...ghosts].map(name => ({ - name, - missing: true, - links: [], - backlinks: nodes - .filter(n => n.links.includes(name)) - .map(n => n.name), - })) - ]; -} - -export function renderMemoryGraph(nodes) { - if (!nodes.length) return 'No memories yet.'; - - const groups = new Map(); - for (const node of nodes) { - const [prefix, ...rest] = node.name.split('/'); - const group = rest.length ? prefix : 'Root'; - const label = rest.length ? rest.join('/') : node.name; - if (!groups.has(group)) groups.set(group, []); - groups.get(group).push({...node, label}); - } - - const ghostCount = nodes.filter(n => n.missing).length; - const lines = [`Memory Graph (${nodes.length} nodes, ${ghostCount} ghost${ghostCount === 1 ? '' : 's'})`, '']; - - for (const group of [...groups.keys()].sort()) { - const items = groups.get(group).sort((a, b) => a.label.localeCompare(b.label)); - lines.push(`${group}/`); - items.forEach((n, i) => { - const last = i === items.length - 1; - const branch = last ? '└─' : '├─'; - const pad = last ? ' ' : '│ '; - const tag = n.missing ? ' (ghost)' : ''; - lines.push(` ${branch} ${n.label}${tag}`); - if (n.links.length) lines.push(` ${pad} → ${n.links.join(', ')}`); - if (n.backlinks.length) lines.push(` ${pad} ← ${n.backlinks.join(', ')}`); - }); - lines.push(''); - } - - return lines.join('\n').trimEnd(); -} diff --git a/src/memory.ts b/src/memory.ts index 8c472a6..a7d2be9 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -1,6 +1,143 @@ import {LLMRequest, LLMMessage} from './llm.ts'; -import {MemoryCache} from './memory-cache.ts'; import {AiTool} from './tools.ts'; +import {KDPoint, KDTree} from './kd-tree.ts'; + +export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[] { + const mems = memories instanceof MemoryCache ? memories.memories : memories; + const nameSet = new Set(mems.map(m => m.name)); + const ghosts = new Set(); + + const nodes: MemoryNode[] = mems.map(m => { + const {links, backlinks} = extractMetadata(m.content); + return { + name: m.name, + missing: false, + links, + backlinks, + }; + }); + + for (const node of nodes) { + for (const link of node.links) { + if (!nameSet.has(link)) ghosts.add(link); + } + } + + return [ + ...nodes, + ...[...ghosts].map(name => ({ + name, + missing: true, + links: [], + backlinks: nodes + .filter(n => n.links.includes(name)) + .map(n => n.name), + })) + ]; +} + +export function renderMemoryGraph(nodes) { + if (!nodes.length) return 'No memories yet.'; + + const groups = new Map(); + for (const node of nodes) { + const [prefix, ...rest] = node.name.split('/'); + const group = rest.length ? prefix : 'Root'; + const label = rest.length ? rest.join('/') : node.name; + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push({...node, label}); + } + + const ghostCount = nodes.filter(n => n.missing).length; + const lines = [`Memory Graph (${nodes.length} nodes, ${ghostCount} ghost${ghostCount === 1 ? '' : 's'})`, '']; + + for (const group of [...groups.keys()].sort()) { + const items = groups.get(group).sort((a, b) => a.label.localeCompare(b.label)); + lines.push(`${group}/`); + items.forEach((n, i) => { + const last = i === items.length - 1; + const branch = last ? '└─' : '├─'; + const pad = last ? ' ' : '│ '; + const tag = n.missing ? ' (ghost)' : ''; + lines.push(` ${branch} ${n.label}${tag}`); + if (n.links.length) lines.push(` ${pad} → ${n.links.join(', ')}`); + if (n.backlinks.length) lines.push(` ${pad} ← ${n.backlinks.join(', ')}`); + }); + lines.push(''); + } + + return lines.join('\n').trimEnd(); +} + +export class MemoryCache { + private tree: KDTree; + public memories: Memory[]; + + get length() { return this.memories.length; } + + constructor(memories: Memory[]) { + this.memories = memories; + this.tree = this.buildTree(); + } + + private buildTree(): KDTree { + const embedded = this.memories.filter(m => m.embedding?.length); + if (!embedded.length) return new KDTree(0); + + const dims = embedded[0].embedding.length; + const points: KDPoint[] = embedded.map(m => ({ + vector: m.embedding, + payload: {name: m.name, description: m.description}, + })); + + return new KDTree(dims, 'cosine', points); + } + + search(query: number[], limit: number): MemoryRef[] { + const results = this.tree.knn(query, limit); + return results.map(r => r.point.payload); + } + + add(memory: Memory): void { + this.memories.push(memory); + this.rebuild(); + } + + update(memory: Memory): void { + const idx = this.memories.findIndex(m => m.name === memory.name); + if (idx !== -1) { + this.memories[idx] = memory; + } else { + this.memories.push(memory); + } + this.rebuild(); + } + + remove(name: string): void { + const idx = this.memories.findIndex(m => m.name === name); + if (idx !== -1) { + this.memories.splice(idx, 1); + this.rebuild(); + } + } + + rebuild(): void { + this.tree = this.buildTree(); + } +} + +export type MemoryOptions = { + /** Memory object */ + memory: Memory[] | MemoryCache; + /** Inject N memories into the system prompt */ + inject?: boolean; + /** expose recall tool to LLM */ + tool?: boolean; + /** Update memory on compression */ + update?: boolean; + /** Max context size of memories to inject to each call (removed immediately after use) */ + maxTokens?: number; +} export type Memory = { name: string; @@ -83,8 +220,6 @@ function getWeekSunday(monday: string): string { return d.toISOString().slice(0, 10); } - - export class MemoryManager { private pendingMemorizations = new Mapm, inject: true, tool: true, update: true} : {inject: true, tool: true, update: true, ...m}; + } + private async createTempMemory(conversation: string): Promise { const timestamp = Date.now(); const content = `---