Add tool calls with agentic-gpt

This commit is contained in:
2026-02-03 14:00:37 +00:00
parent 7120d938b9
commit af440dcbc7
4 changed files with 858 additions and 70 deletions

View File

@@ -1,14 +1,82 @@
import type { SSEvent } from './sse-session.js';
export type MessageChunk = {
type: 'reasoning' | 'content';
reasoning_details?: string;
content: string;
}
/**
* JSON Schema type for tool parameter definitions
*/
export type JSONSchema = {
type: 'object' | 'string' | 'number' | 'boolean' | 'array' | 'null';
properties?: Record<string, JSONSchema>;
items?: JSONSchema;
required?: string[];
enum?: (string | number)[];
description?: string;
additionalProperties?: boolean;
[key: string]: unknown;
};
/**
* OpenAI-format tool definition using JSON Schema
*/
export type ToolDefinition = {
type: 'function';
function: {
name: string;
description: string;
parameters: JSONSchema;
strict?: boolean;
};
};
/**
* Represents a complete tool call from the LLM
*/
export type ToolCall = {
id: string;
type: 'function';
function: {
name: string;
arguments: string; // JSON string
};
};
/**
* Delta format for streaming tool calls
*/
export type ToolCallDelta = {
index: number;
id?: string;
type?: 'function';
function?: {
name?: string;
arguments?: string;
};
};
/**
* Message chunk types that can be yielded during streaming
*/
export type MessageChunk =
| {
type: 'reasoning';
reasoning_details?: string;
content: string;
}
| {
type: 'content';
content: string;
}
| {
type: 'tool_call';
toolCall: ToolCall;
};
/**
* Final result after consuming all chunks
*/
export type FinalResult = {
reasoning: string;
content: string;
toolCalls: ToolCall[];
}
export type GPTResponse = {
@@ -20,18 +88,20 @@ export type GPTResponse = {
choices: {
index: number;
delta: {
role: 'user' | 'assistant' | 'system';
content: string;
reasoning: string;
reasoning_details: {
role?: 'user' | 'assistant' | 'system';
content?: string | null;
reasoning?: string;
reasoning_details?: {
type: string;
summary: string;
}
};
tool_calls?: ToolCallDelta[];
};
finish_reason?: 'stop' | 'tool_calls' | 'length' | 'content_filter' | null;
}[];
finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | null;
native_finish_reason: string | null;
usage: {
finish_reason?: 'stop' | 'tool_calls' | 'length' | 'content_filter' | null;
native_finish_reason?: string | null;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
@@ -57,6 +127,12 @@ export class MessageResponse implements PromiseLike<FinalResult> {
private resolveResult!: (value: FinalResult) => void;
private resultPromise: Promise<FinalResult>;
private iterator: AsyncIterable<SSEvent>;
/**
* Accumulates tool calls as they stream in from the API
* Key is the tool call index, value is the partially completed tool call
*/
private toolCallsInProgress: Map<number, Partial<ToolCall>> = new Map();
constructor(iterator: AsyncIterable<SSEvent>) {
this.iterator = iterator;
@@ -73,9 +149,13 @@ export class MessageResponse implements PromiseLike<FinalResult> {
this.iteratorConsumed = true;
for await (const rawChunk of this.iterator) {
const chunk = this.parseChunk(rawChunk);
this.chunks.push(chunk);
yield chunk;
const chunks = this.parseChunk(rawChunk);
// parseChunk may return multiple chunks (e.g., when tool calls complete)
for (const chunk of chunks) {
this.chunks.push(chunk);
yield chunk;
}
}
this.resolveResult(this.buildResult());
@@ -91,8 +171,12 @@ export class MessageResponse implements PromiseLike<FinalResult> {
(async () => {
for await (const rawChunk of this.iterator) {
const chunk = this.parseChunk(rawChunk);
this.chunks.push(chunk);
const chunks = this.parseChunk(rawChunk);
// parseChunk may return multiple chunks
for (const chunk of chunks) {
this.chunks.push(chunk);
}
}
this.resolveResult(this.buildResult());
@@ -114,22 +198,37 @@ export class MessageResponse implements PromiseLike<FinalResult> {
return {
reasoning: this.chunks
.filter(c => c.type === 'reasoning')
.map(c => c.content)
.map(c => 'content' in c ? c.content : '')
.join(''),
content: this.chunks
.filter(c => c.type === 'content')
.map(c => c.content)
.map(c => 'content' in c ? c.content : '')
.join(''),
toolCalls: this.chunks
.filter(c => c.type === 'tool_call')
.map(c => 'toolCall' in c ? c.toolCall : null)
.filter((tc): tc is ToolCall => tc !== null),
};
}
private parseChunk(rawChunk: SSEvent) {
/**
* Parses a raw SSE chunk and returns one or more MessageChunks
* May return multiple chunks when tool calls complete
*/
private parseChunk(rawChunk: SSEvent): MessageChunk[] {
// console.log('Raw Chunk:', rawChunk);
if (rawChunk.data === '[DONE]') {
return {
// When stream ends, flush any pending tool calls
const completedToolCalls = this.flushToolCalls();
if (completedToolCalls.length > 0) {
return completedToolCalls;
}
return [{
type: 'content',
content: '',
} as const;
}];
}
const data = JSON.parse(rawChunk.data) as GPTResponse;
@@ -140,18 +239,104 @@ export class MessageResponse implements PromiseLike<FinalResult> {
}
const delta = choice.delta;
const finishReason = choice.finish_reason || data.finish_reason;
// Handle tool calls
if (delta.tool_calls) {
this.processToolCallDeltas(delta.tool_calls);
// If finish_reason is 'tool_calls', all tool calls are complete
if (finishReason === 'tool_calls') {
return this.flushToolCalls();
}
// Otherwise, don't yield anything yet (still accumulating)
return [];
}
// Handle reasoning chunks
if (delta.reasoning) {
return {
const chunk: MessageChunk = {
type: 'reasoning',
content: delta.reasoning,
reasoning_details: delta.reasoning_details.summary,
} as const;
} else {
return {
};
// Add reasoning_details if present
if (delta.reasoning_details?.summary) {
chunk.reasoning_details = delta.reasoning_details.summary;
}
return [chunk];
}
// Handle content chunks
if (delta.content !== undefined && delta.content !== null) {
return [{
type: 'content',
content: delta.content,
} as const;
}];
}
// Empty chunk (e.g., role assignment)
return [];
}
/**
* Processes tool call deltas and accumulates them
*/
private processToolCallDeltas(deltas: ToolCallDelta[]): void {
for (const delta of deltas) {
const index = delta.index;
if (!this.toolCallsInProgress.has(index)) {
// Start a new tool call
this.toolCallsInProgress.set(index, {
id: delta.id || '',
type: 'function',
function: {
name: delta.function?.name || '',
arguments: delta.function?.arguments || '',
},
});
} else {
// Accumulate arguments for existing tool call
const existing = this.toolCallsInProgress.get(index)!;
if (delta.function?.arguments) {
existing.function!.arguments += delta.function.arguments;
}
// Update other fields if provided
if (delta.id) {
existing.id = delta.id;
}
if (delta.function?.name) {
existing.function!.name = delta.function.name;
}
}
}
}
/**
* Flushes all accumulated tool calls and returns them as chunks
*/
private flushToolCalls(): MessageChunk[] {
const chunks: MessageChunk[] = [];
// Convert accumulated tool calls to chunks
for (const [index, toolCall] of this.toolCallsInProgress.entries()) {
// Validate that the tool call is complete
if (toolCall.id && toolCall.function?.name && toolCall.function?.arguments !== undefined) {
chunks.push({
type: 'tool_call',
toolCall: toolCall as ToolCall,
});
}
}
// Clear the accumulator
this.toolCallsInProgress.clear();
return chunks;
}
}