Tools can now emit "done" event and end chat early gracefully
Publish Library / Build NPM Project (push) Successful in 1m0s
Publish Library / Tag Version (push) Successful in 9s

This commit is contained in:
2026-07-31 17:49:06 -04:00
parent 52a9e3aaa4
commit a1d438a20a
3 changed files with 42 additions and 31 deletions
+22 -16
View File
@@ -51,15 +51,16 @@ export class OpenAi extends LLMProvider {
content: null,
tool_calls: [{ id: h.id, type: 'function', function: { name: h.name, arguments: JSON.stringify(h.args) } }],
refusal: null,
annotations: []
annotations: [],
timestamp: h.timestamp,
}, {
role: 'tool',
tool_call_id: h.id,
content: h.error || h.content
content: h.error || h.content,
timestamp: h.timestamp,
});
} else {
const {timestamp, ...rest} = h;
result.push(rest);
result.push(h);
}
return result;
}, [] as any[]);
@@ -106,8 +107,9 @@ export class OpenAi extends LLMProvider {
};
}
let resp: any, isFirstMessage = true;
let resp: any, isFirstMessage = true, terminal = false;
do {
requestParams.messages = history.map(({timestamp, ...m}) => m);
resp = await this.client.chat.completions.create(requestParams).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err;
@@ -116,7 +118,7 @@ export class OpenAi extends LLMProvider {
if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'});
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) {
if(controller.signal.aborted) break;
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 tool = tools?.find(findByProp('name', 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 {
const args = JSONAttemptParse(toolCall.function.arguments, {});
const result = await tool.fn(args, options.stream, this.ai);
return {role: 'tool', tool_call_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result};
// 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(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) {
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);
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() || '';
history.push({role: 'assistant', content: textContent});
if(!terminal) {
const textContent = resp.choices[0].message.content?.trim() || '';
history.push({role: 'assistant', content: textContent, timestamp: Date.now()});
}
history = this.toStandard(history);
if(options.stream) options.stream({done: true});
if(options.history) options.history.splice(0, options.history.length, ...history);
// Return parsed JSON if schema provided
const finalContent = history.at(-1)?.content;
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
}), {abort: () => controller.abort()});