Compare commits

...
3 Commits
Author SHA1 Message Date
ztimson 2d6debad86 Memorization prompt tightening
Publish Library / Build NPM Project (push) Successful in 44s
Publish Library / Tag Version (push) Successful in 13s
2026-09-20 11:27:01 -04:00
ztimson 6bed8f20b5 Recursive agents update
Publish Library / Build NPM Project (push) Successful in 36s
Publish Library / Tag Version (push) Successful in 7s
2026-09-20 00:43:52 -04:00
ztimson dc45a99b04 Bump 1.6.13
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 14s
2026-09-19 19:30:06 -04:00
3 changed files with 27 additions and 21 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.6.12", "version": "1.7.1",
"description": "AI Utility library", "description": "AI Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
+19 -13
View File
@@ -19,6 +19,13 @@ const PDF_OCR_PAGE_THRESHOLD = 12; // above this many pages, OCR scanned pages i
export type AnthropicConfig = {proto: 'anthropic', token: string | string[]}; export type AnthropicConfig = {proto: 'anthropic', token: string | string[]};
export type OpenAiConfig = {proto: 'openai', host?: string, token: string | string[]}; export type OpenAiConfig = {proto: 'openai', host?: string, token: string | string[]};
export type AgentRef = {
name: string;
description?: string;
delegate?: boolean;
fn: () => Agent | null | Promise<Agent | null>;
}
export type Agent = { export type Agent = {
name: string; name: string;
description?: string; description?: string;
@@ -29,7 +36,7 @@ export type Agent = {
skills?: Skill[] | null; skills?: Skill[] | null;
tools?: AiTool[] | null; tools?: AiTool[] | null;
mcp?: McpServer[] | null; mcp?: McpServer[] | null;
agents?: string[] | null; agents?: AgentRef[] | null;
} }
export type LLMFile = { export type LLMFile = {
@@ -106,8 +113,8 @@ export type LLMRequest = {
skills?: Skill[]; skills?: Skill[];
/** MCP servers to connect and expose as tools */ /** MCP servers to connect and expose as tools */
mcp?: McpServer[]; mcp?: McpServer[];
/** Subagents exposed as delegatable/wrapped tools */ /** Subagents exposed as delegatable/wrapped tools, resolved lazily via their `fn` */
agents?: Agent[]; agents?: AgentRef[];
/** Attach files to request */ /** Attach files to request */
files?: LLMFile[]; files?: LLMFile[];
/** @internal recursion guard for nested agent delegation */ /** @internal recursion guard for nested agent delegation */
@@ -265,22 +272,21 @@ class LLM {
}; };
} }
private setupAgent(agents: Agent[] = [], allAgents: Agent[], history: LLMMessage[], aborts: ((keep?: boolean) => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] { private setupAgent(stubs: AgentRef[] = [], history: LLMMessage[], aborts: ((keep?: boolean) => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] {
return agents.map(a => { return stubs.map(stub => {
const toolName = `${a.delegate ? '' : 'sub'}agent_${snakeCase(a.name)}`; const toolName = `${stub.delegate ? '' : 'sub'}agent_${snakeCase(stub.name)}`;
return { return {
name: toolName, name: toolName,
description: `${a.delegate ? 'Delegate to ' : ''}Subagent: ${a.description || a.name}`, description: `${stub.delegate ? 'Delegate to ' : ''}Subagent: ${stub.description || stub.name}`,
args: clean<any>({ args: clean<any>({
context: !a.delegate ? {type: 'string', description: 'Summary of related messages, samples, files, etc...', required: true} : undefined, context: !stub.delegate ? {type: 'string', description: 'Summary of related messages, samples, files, etc...', required: true} : undefined,
instructions: {type: 'string', description: 'Detailed instructions for subagent to complete', required: true}, instructions: {type: 'string', description: 'Detailed instructions for subagent to complete', required: true},
}), }),
fn: async (args: any, stream: any, ai: any, id?: string) => { fn: async (args: any, stream: any, ai: any, id?: string) => {
if(depth >= MAX_AGENT_DEPTH) return 'Max agent delegation depth exceeded'; if(depth >= MAX_AGENT_DEPTH) return 'Max agent delegation depth exceeded';
const nested = (a.agents || []) const a = await stub.fn();
.map(name => allAgents.find(x => x.name === name)) if(!a) return `Agent "${stub.name}" could not be resolved`;
.filter((x): x is Agent => !!x && x.name !== a.name);
const q = a.delegate ? '' : `${args.instructions}${args.context ? `\n\n<context>${args.context}</context>` : ''}`; const q = a.delegate ? '' : `${args.instructions}${args.context ? `\n\n<context>${args.context}</context>` : ''}`;
@@ -297,7 +303,7 @@ ${a.system}`,
mcp: a.mcp || undefined, mcp: a.mcp || undefined,
skills: a.skills || undefined, skills: a.skills || undefined,
tools: a.tools || undefined, tools: a.tools || undefined,
agents: nested, agents: a.agents || [],
_agentDepth: depth + 1, _agentDepth: depth + 1,
} as any); } as any);
aborts.push(request.abort); aborts.push(request.abort);
@@ -451,7 +457,7 @@ ${a.system}`,
// Agents // Agents
const agents = options.agents || this.ai.options?.llm?.agents; const agents = options.agents || this.ai.options?.llm?.agents;
const delegateState: {resp: string | null} = {resp: null}; const delegateState: {resp: string | null} = {resp: null};
if(agents?.length) tools.push(...this.setupAgent(agents, agents, history, nestedAborts, options._agentDepth || 0, delegateState)); if(agents?.length) tools.push(...this.setupAgent(agents, history, nestedAborts, options._agentDepth || 0, delegateState));
// Memory // Memory
const mem = MemoryManager.normalize(options.memory); const mem = MemoryManager.normalize(options.memory);
+7 -7
View File
@@ -340,22 +340,22 @@ Think of this like an Obsidian vault with a clear division of responsibility:
- A chronological, skimmable log of what actually happened: real discussions, decisions made, progress on projects, problems worked through - A chronological, skimmable log of what actually happened: real discussions, decisions made, progress on projects, problems worked through
- This is NOT a transcript, and it is NOT a step-by-step record, its a compressed log of notable events & developments - This is NOT a transcript, and it is NOT a step-by-step record, its a compressed log of notable events & developments
- One line per development is usually enough: what was worked on and the outcome, not the blow-by-blow of how - One line per development is usually enough: what was worked on and the outcome, not the blow-by-blow of how
- Skip small talk and trivial exchanges entirely. Skip anything that's purely a todo item (goes in Todo Tasks) or a durable fact about a subject (goes in Entity Dossiers) - Skip small talk and trivial exchanges entirely. Skip anything that's a todo item (goes in Todo Tasks) or a durable fact about a subject (goes in Entity Dossiers)
2. Todo Tasks 2. Todo Tasks
- Extract concrete tasks the user says need to be done, should be done, or were completed - Extract concrete tasks the user says need to be done, should be done, or were completed
- Return the task text and whether it is still todo or is done - Return the task text and whether it is still todo or is done
- A completed task should be marked done, not recreated as a new todo - A completed task should be marked done, not recreated as a new todo
- Only extract actionable tasks, not general goals or observations - Only extract actionable tasks, not general goals or observations, if none - omit returning a tasks array
- Assign each task a subject: - Assign each task a subject:
- If the task belongs to a persistent entity (a project, a class, etc.), use that entity's exact node name, or a new entity path if it doesn't exist yet - If the task belongs to a persistent entity (a project, a class, etc.), use that entity's exact node name, or a new entity path if it doesn't exist yet
- If it's a personal/life task with no entity of its own (reach out to someone, reply to an email, pay a bill, etc.), leave subject as an empty string — it belongs in the journal, not a new document - If it's a personal/life task with no entity of its own (reach out to someone, reply to an email, pay a bill, etc.), leave subject as an empty string — it belongs in the journal, not a new document
3. Entity Dossiers 3. Entity Dossiers
- Detailed dossiers with all information regarding a subject - Detailed dossiers with all factual information regarding a subject
- Record the final/end state, not intermediate changes - Record the final/end state, not intermediate changes
- Ignore assistant claims, guesses, greetings, or temporary details - Ignore assistant claims, guesses, greetings, or temporary details
- NEVER create a document for something that's only meaningful as a point in time — a single conversation, a one-off decision, a debugging session, a date. That's a journal entry, not an entity - NEVER create a dossier for something I wouldnt find in a wiki site: temporary information, debugging, guesses, conversations (this is all journal entry stuff!)
- identify its HOME ENTITY: - identify its HOME ENTITY:
- The HOME ENTITY name should always be a [abstract|pro]noun - The HOME ENTITY name should always be a [abstract|pro]noun
- The grammatical subject/owner of the fact is the strongest clue - The grammatical subject/owner of the fact is the strongest clue
@@ -379,9 +379,9 @@ Available nodes:
${this.listNodes(store.list).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'} ${this.listNodes(store.list).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}
${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`, ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
schema: { schema: {
journal: {type: 'string', description: 'Short day-to-day recap; empty if nothing happened.', required: false}, journal: {type: 'string', description: 'Short bullet point recap, omit if nothing notable happened'},
tasks: { tasks: {
type: 'array', description: 'Concrete tasks mentioned or completed in the conversation.', required: false, items: { type: 'array', description: 'Concrete tasks mentioned or completed in the conversation, omit if none', items: {
type: 'object', items: { type: 'object', items: {
subject: {type: 'string', description: 'Exact node name / new persistent entity path this task belongs to, or an empty string if this is a personal task with no entity of its own (those go in the journal)', required: true}, subject: {type: 'string', description: 'Exact node name / new persistent entity path this task belongs to, or an empty string if this is a personal task with no entity of its own (those go in the journal)', required: true},
task: {type: 'string', description: 'Concise actionable task', required: true}, task: {type: 'string', description: 'Concise actionable task', required: true},
@@ -390,7 +390,7 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
} }
}, },
buckets: { buckets: {
type: 'array', description: 'Groups of facts to remember; empty array if nothing worth storing.', items: { type: 'array', description: 'Groups of facts to remember; omit if none', items: {
type: 'object', items: { type: 'object', items: {
subject: {type: 'string', description: 'Exact node name or new persistent entity path', required: true}, subject: {type: 'string', description: 'Exact node name or new persistent entity path', required: true},
facts: {type: 'array', description: 'Facts to store here', items: {type: 'string'}}, facts: {type: 'array', description: 'Facts to store here', items: {type: 'string'}},