Compare commits

...

2 Commits

Author SHA1 Message Date
a1d438a20a Tools can now emit "done" event and end chat early gracefully
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m0s
Publish Library / Tag Version (push) Successful in 9s
2026-07-31 17:49:06 -04:00
52a9e3aaa4 Fixed history poisoning on empty tool response
All checks were successful
Publish Library / Build NPM Project (push) Successful in 51s
Publish Library / Tag Version (push) Successful in 13s
2026-07-30 22:12:49 -04:00
3 changed files with 45 additions and 34 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.2.10", "version": "1.2.12",
"description": "AI Utility library", "description": "AI Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@@ -21,10 +21,10 @@ export class Anthropic extends LLMProvider {
messages.push(<any>{timestamp, ...h}); messages.push(<any>{timestamp, ...h});
} else { } else {
const textContent = h.content?.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n'); const textContent = h.content?.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
if(textContent) messages.push({timestamp, role: h.role, content: textContent}); if(textContent) messages.push({role: h.role, content: textContent, timestamp: timestamp});
h.content.forEach((c: any) => { h.content.forEach((c: any) => {
if(c.type == 'tool_use') { if(c.type == 'tool_use') {
messages.push({timestamp, role: 'tool', id: c.id, name: c.name, args: c.input, content: undefined}); messages.push({role: 'tool', id: c.id, name: c.name, args: c.input, timestamp: c.timestamp, content: undefined});
} else if(c.type == 'tool_result') { } else if(c.type == 'tool_result') {
const m: any = messages.findLast(m => (<any>m).id == c.tool_use_id); const m: any = messages.findLast(m => (<any>m).id == c.tool_use_id);
if(m) m[c.is_error ? 'error' : 'content'] = c.content; if(m) m[c.is_error ? 'error' : 'content'] = c.content;
@@ -46,7 +46,7 @@ export class Anthropic extends LLMProvider {
i++; i++;
} }
} }
return history.map(({timestamp, ...h}) => h); return history;
} }
ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> { ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
@@ -83,8 +83,9 @@ export class Anthropic extends LLMProvider {
}; };
} }
let resp: any, isFirstMessage = true; let resp: any, isFirstMessage = true, terminal = false;
do { do {
requestParams.messages = history.map(({timestamp, ...m}) => m);
resp = await this.client.messages.create(requestParams).catch(err => { resp = await this.client.messages.create(requestParams).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`; err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err; throw err;
@@ -113,7 +114,7 @@ export class Anthropic extends LLMProvider {
} }
} else if(chunk.type === 'content_block_stop') { } else if(chunk.type === 'content_block_stop') {
const last = resp.content.at(-1); const last = resp.content.at(-1);
if(last.input != null) last.input = last.input ? JSONAttemptParse(last.input, {}) : {}; if(last?.input != null) last.input = last.input ? JSONAttemptParse(last.input, {}) : {};
} else if(chunk.type === 'message_stop') { } else if(chunk.type === 'message_stop') {
break; break;
} }
@@ -123,31 +124,35 @@ export class Anthropic extends LLMProvider {
// Run tools // Run tools
const toolCalls = resp.content.filter((c: any) => c.type === 'tool_use'); const toolCalls = resp.content.filter((c: any) => c.type === 'tool_use');
if(toolCalls.length && !controller.signal.aborted) { 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 results = await Promise.all(toolCalls.map(async (toolCall: any) => {
const tool = tools.find(findByProp('name', toolCall.name)); const tool = tools.find(findByProp('name', toolCall.name));
if(options.stream) options.stream({tool: toolCall.name}); if(options.stream) options.stream({tool: toolCall.name});
if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'}; if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'};
try { try {
const result = await tool.fn(toolCall.input, options?.stream, this.ai); // Wrap stream so a tool's `done` ends turn gracefully
const toolStream = options.stream && ((chunk: any) => {
if(chunk.done) { terminal = true; return; }
options.stream!(chunk);
});
const result = await tool.fn(toolCall.input, toolStream, this.ai);
return {type: 'tool_result', tool_use_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result}; return {type: 'tool_result', tool_use_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result};
} catch (err: any) { } catch (err: any) {
return {type: 'tool_result', tool_use_id: toolCall.id, is_error: true, content: err?.message || err?.toString() || 'Unknown'}; return {type: 'tool_result', tool_use_id: toolCall.id, is_error: true, content: err?.message || err?.toString() || 'Unknown'};
} }
})); }));
history.push({role: 'user', content: results}); history.push({role: 'user', content: results, timestamp: Date.now()});
requestParams.messages = history; requestParams.messages = history;
} }
} while (!controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use')); } while (!terminal && !controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use'));
const textContent = resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n'); if(!terminal) {
history.push({role: 'assistant', content: textContent}); const textContent = resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
history.push({role: 'assistant', content: textContent, timestamp: Date.now()});
}
history = this.toStandard(history); history = this.toStandard(history);
if(options.stream) options.stream({done: true}); if(options.stream) options.stream({done: true});
if(options.history) options.history.splice(0, options.history.length, ...history); if(options.history) options.history.splice(0, options.history.length, ...history);
// Return parsed JSON if schema provided
const finalContent = history.at(-1)?.content; const finalContent = history.at(-1)?.content;
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent); res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
}), {abort: () => controller.abort()}); }), {abort: () => controller.abort()});

View File

@@ -29,11 +29,11 @@ export class OpenAi extends LLMProvider {
})); }));
history.splice(i, 1, ...tools); history.splice(i, 1, ...tools);
i += tools.length - 1; i += tools.length - 1;
} else if(h.role === 'tool' && h.content) { } else if(h.role === 'tool') {
const record = history.find(h2 => h.tool_call_id == h2.id); const record = history.find(h2 => h.tool_call_id == h2.id);
if(record) { if(record) {
if(h.content.includes('"error":')) record.error = h.content; if(h.content?.includes('"error":')) record.error = h.content;
else record.content = h.content; else record.content = h.content || '';
} }
history.splice(i, 1); history.splice(i, 1);
i--; i--;
@@ -51,15 +51,16 @@ export class OpenAi extends LLMProvider {
content: null, content: null,
tool_calls: [{ id: h.id, type: 'function', function: { name: h.name, arguments: JSON.stringify(h.args) } }], tool_calls: [{ id: h.id, type: 'function', function: { name: h.name, arguments: JSON.stringify(h.args) } }],
refusal: null, refusal: null,
annotations: [] annotations: [],
timestamp: h.timestamp,
}, { }, {
role: 'tool', role: 'tool',
tool_call_id: h.id, tool_call_id: h.id,
content: h.error || h.content content: h.error || h.content,
timestamp: h.timestamp,
}); });
} else { } else {
const {timestamp, ...rest} = h; result.push(h);
result.push(rest);
} }
return result; return result;
}, [] as any[]); }, [] as any[]);
@@ -106,8 +107,9 @@ export class OpenAi extends LLMProvider {
}; };
} }
let resp: any, isFirstMessage = true; let resp: any, isFirstMessage = true, terminal = false;
do { do {
requestParams.messages = history.map(({timestamp, ...m}) => m);
resp = await this.client.chat.completions.create(requestParams).catch(err => { resp = await this.client.chat.completions.create(requestParams).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`; err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err; throw err;
@@ -116,7 +118,7 @@ export class OpenAi extends LLMProvider {
if(options.stream) { if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'}); if(!isFirstMessage) options.stream({text: '\n\n'});
else isFirstMessage = false; else isFirstMessage = false;
resp.choices = [{message: {role: 'assistant', content: '', tool_calls: []}}]; resp.choices = [{message: {role: 'assistant', content: '', tool_calls: [], timestamp: Date.now()}}];
for await (const chunk of resp) { for await (const chunk of resp) {
if(controller.signal.aborted) break; if(controller.signal.aborted) break;
if(chunk.choices[0].delta.content) { if(chunk.choices[0].delta.content) {
@@ -158,28 +160,32 @@ export class OpenAi extends LLMProvider {
const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
const tool = tools?.find(findByProp('name', toolCall.function.name)); const tool = tools?.find(findByProp('name', toolCall.function.name));
if(options.stream) options.stream({tool: toolCall.function.name}); if(options.stream) options.stream({tool: 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 { try {
const args = JSONAttemptParse(toolCall.function.arguments, {}); const args = JSONAttemptParse(toolCall.function.arguments, {});
const result = await tool.fn(args, options.stream, this.ai); // Wrap stream so a tool's `done` ends turn gracefully
return {role: 'tool', tool_call_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result}; const toolStream = options.stream && ((chunk: any) => {
if(chunk.done) { terminal = true; return; }
options.stream!(chunk);
});
const result = await tool.fn(args, toolStream, this.ai);
return {role: 'tool', tool_call_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result, timestamp: Date.now()};
} catch (err: any) { } 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); history.push(...results);
requestParams.messages = history; requestParams.messages = history;
} }
} while (!controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length); } while (!terminal && !controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length);
const textContent = resp.choices[0].message.content?.trim() || ''; if(!terminal) {
history.push({role: 'assistant', content: textContent}); const textContent = resp.choices[0].message.content?.trim() || '';
history.push({role: 'assistant', content: textContent, timestamp: Date.now()});
}
history = this.toStandard(history); history = this.toStandard(history);
if(options.stream) options.stream({done: true}); if(options.stream) options.stream({done: true});
if(options.history) options.history.splice(0, options.history.length, ...history); if(options.history) options.history.splice(0, options.history.length, ...history);
// Return parsed JSON if schema provided
const finalContent = history.at(-1)?.content; const finalContent = history.at(-1)?.content;
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent); res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
}), {abort: () => controller.abort()}); }), {abort: () => controller.abort()});