Agent/subagent support
This commit is contained in:
166
src/llm.ts
166
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<string, {resp: string, subHistory: LLMMessage[]}[]>, 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<context>${args.context}</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<string> | 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<string, {resp: string, subHistory: LLMMessage[]}[]>();
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user