refactor(ai): improve error handling and stop reason types

- Add 'aborted' as a distinct stop reason separate from 'error'
- Change AssistantMessage.error to errorMessage for clarity
- Update error event to include reason field ('error' | 'aborted')
- Map provider-specific safety/refusal reasons to 'error' stop reason
- Reorganize utility functions into utils/ directory
- Rename agent.ts to agent-loop.ts for better clarity
- Fix error handling in all providers to properly distinguish abort from error
This commit is contained in:
Mario Zechner 2025-09-18 19:57:13 +02:00
parent 293a6e878d
commit 2296dc4052
22 changed files with 703 additions and 139 deletions

View file

@ -1,11 +1,11 @@
import { EventStream } from "../event-stream.js";
import { streamSimple } from "../stream.js";
import type { AssistantMessage, Context, Message, ToolResultMessage, UserMessage } from "../types.js";
import { validateToolArguments } from "../validation.js";
import { EventStream } from "../utils/event-stream.js";
import { validateToolArguments } from "../utils/validation.js";
import type { AgentContext, AgentEvent, AgentTool, AgentToolResult, PromptConfig } from "./types.js";
// Main prompt function - returns a stream of events
export function prompt(
export function agentLoop(
prompt: UserMessage,
context: AgentContext,
config: PromptConfig,
@ -46,21 +46,29 @@ export function prompt(
firstTurn = false;
}
// Stream assistant response
const assistantMessage = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(assistantMessage);
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(message);
if (message.stopReason === "error" || message.stopReason === "aborted") {
// Stop the loop on error or abort
stream.push({ type: "turn_end", message, toolResults: [] });
stream.push({ type: "agent_end", messages: newMessages });
stream.end(newMessages);
return;
}
// Check for tool calls
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = toolCalls.length > 0;
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
// Execute tool calls
toolResults.push(...(await executeToolCalls(currentContext.tools, assistantMessage, signal, stream)));
toolResults.push(...(await executeToolCalls(currentContext.tools, message, signal, stream)));
currentContext.messages.push(...toolResults);
newMessages.push(...toolResults);
}
stream.push({ type: "turn_end", assistantMessage, toolResults: toolResults });
stream.push({ type: "turn_end", message, toolResults: toolResults });
}
stream.push({ type: "agent_end", messages: newMessages });
stream.end(newMessages);

View file

@ -1,3 +1,3 @@
export { prompt } from "./agent.js";
export { agentLoop } from "./agent-loop.js";
export * from "./tools/index.js";
export type { AgentContext, AgentEvent, AgentTool, PromptConfig } from "./types.js";

View file

@ -57,7 +57,7 @@ export type AgentEvent =
isError: boolean;
}
// Emitted when a full turn completes
| { type: "turn_end"; assistantMessage: AssistantMessage; toolResults: ToolResultMessage[] }
| { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] }
// Emitted when the agent has completed all its turns. All messages from every turn are
// contained in messages, which can be appended to the context
| { type: "agent_end"; messages: AgentContext["messages"] };

View file

@ -5,5 +5,5 @@ export * from "./providers/google.js";
export * from "./providers/openai-completions.js";
export * from "./providers/openai-responses.js";
export * from "./stream.js";
export * from "./typebox-helpers.js";
export * from "./types.js";
export * from "./utils/typebox-helpers.js";

View file

@ -2994,23 +2994,6 @@ export const MODELS = {
contextWindow: 32768,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"cohere/command-r-plus-08-2024": {
id: "cohere/command-r-plus-08-2024",
name: "Cohere: Command R+ (08-2024)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2.5,
output: 10,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4000,
} satisfies Model<"openai-completions">,
"cohere/command-r-08-2024": {
id: "cohere/command-r-08-2024",
name: "Cohere: Command R (08-2024)",
@ -3028,6 +3011,23 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 4000,
} satisfies Model<"openai-completions">,
"cohere/command-r-plus-08-2024": {
id: "cohere/command-r-plus-08-2024",
name: "Cohere: Command R+ (08-2024)",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 2.5,
output: 10,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 4000,
} satisfies Model<"openai-completions">,
"microsoft/phi-3.5-mini-128k-instruct": {
id: "microsoft/phi-3.5-mini-128k-instruct",
name: "Microsoft: Phi-3.5 Mini 128K Instruct",
@ -3130,6 +3130,23 @@ export const MODELS = {
contextWindow: 131072,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mistral-7b-instruct-v0.3": {
id: "mistralai/mistral-7b-instruct-v0.3",
name: "Mistral: Mistral 7B Instruct v0.3",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.028,
output: 0.054,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mistral-7b-instruct:free": {
id: "mistralai/mistral-7b-instruct:free",
name: "Mistral: Mistral 7B Instruct (free)",
@ -3164,23 +3181,6 @@ export const MODELS = {
contextWindow: 32768,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"mistralai/mistral-7b-instruct-v0.3": {
id: "mistralai/mistral-7b-instruct-v0.3",
name: "Mistral: Mistral 7B Instruct v0.3",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.028,
output: 0.054,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 16384,
} satisfies Model<"openai-completions">,
"microsoft/phi-3-mini-128k-instruct": {
id: "microsoft/phi-3-mini-128k-instruct",
name: "Microsoft: Phi-3 Mini 128K Instruct",
@ -3351,23 +3351,6 @@ export const MODELS = {
contextWindow: 128000,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mistral-small": {
id: "mistralai/mistral-small",
name: "Mistral Small",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.19999999999999998,
output: 0.6,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mistral-tiny": {
id: "mistralai/mistral-tiny",
name: "Mistral Tiny",
@ -3385,6 +3368,23 @@ export const MODELS = {
contextWindow: 32768,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mistral-small": {
id: "mistralai/mistral-small",
name: "Mistral Small",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: {
input: 0.19999999999999998,
output: 0.6,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 32768,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"mistralai/mixtral-8x7b-instruct": {
id: "mistralai/mixtral-8x7b-instruct",
name: "Mistral: Mixtral 8x7B Instruct",

View file

@ -4,8 +4,6 @@ import type {
MessageCreateParamsStreaming,
MessageParam,
} from "@anthropic-ai/sdk/resources/messages.js";
import { AssistantMessageEventStream } from "../event-stream.js";
import { parseStreamingJson } from "../json-parse.js";
import { calculateCost } from "../models.js";
import type {
Api,
@ -22,7 +20,9 @@ import type {
ToolCall,
ToolResultMessage,
} from "../types.js";
import { validateToolArguments } from "../validation.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { validateToolArguments } from "../utils/validation.js";
import { transformMessages } from "./transorm-messages.js";
export interface AnthropicOptions extends StreamOptions {
@ -196,13 +196,17 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unkown error ocurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
for (const block of output.content) delete (block as any).index;
output.stopReason = "error";
output.error = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", error: output.error, partial: output });
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
@ -466,7 +470,7 @@ function mapStopReason(reason: Anthropic.Messages.StopReason): StopReason {
case "tool_use":
return "toolUse";
case "refusal":
return "safety";
return "error";
case "pause_turn": // Stop is good enough -> resubmit
return "stop";
case "stop_sequence":

View file

@ -7,7 +7,6 @@ import {
GoogleGenAI,
type Part,
} from "@google/genai";
import { AssistantMessageEventStream } from "../event-stream.js";
import { calculateCost } from "../models.js";
import type {
Api,
@ -22,7 +21,8 @@ import type {
Tool,
ToolCall,
} from "../types.js";
import { validateToolArguments } from "../validation.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { validateToolArguments } from "../utils/validation.js";
import { transformMessages } from "./transorm-messages.js";
export interface GoogleOptions extends StreamOptions {
@ -226,12 +226,21 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
}
}
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unkown error ocurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
output.stopReason = "error";
output.error = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", error: output.error, partial: output });
for (const block of output.content) delete (block as any).index;
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
@ -424,7 +433,7 @@ function mapStopReason(reason: FinishReason): StopReason {
case FinishReason.SAFETY:
case FinishReason.IMAGE_SAFETY:
case FinishReason.RECITATION:
return "safety";
return "error";
case FinishReason.FINISH_REASON_UNSPECIFIED:
case FinishReason.OTHER:
case FinishReason.LANGUAGE:

View file

@ -7,8 +7,6 @@ import type {
ChatCompletionContentPartText,
ChatCompletionMessageParam,
} from "openai/resources/chat/completions.js";
import { AssistantMessageEventStream } from "../event-stream.js";
import { parseStreamingJson } from "../json-parse.js";
import { calculateCost } from "../models.js";
import type {
AssistantMessage,
@ -22,7 +20,9 @@ import type {
Tool,
ToolCall,
} from "../types.js";
import { validateToolArguments } from "../validation.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { validateToolArguments } from "../utils/validation.js";
import { transformMessages } from "./transorm-messages.js";
export interface OpenAICompletionsOptions extends StreamOptions {
@ -231,13 +231,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unkown error ocurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
return output;
} catch (error) {
output.stopReason = "error";
output.error = error instanceof Error ? error.message : String(error);
stream.push({ type: "error", error: output.error, partial: output });
for (const block of output.content) delete (block as any).index;
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();
@ -413,7 +417,7 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto
case "tool_calls":
return "toolUse";
case "content_filter":
return "safety";
return "error";
default: {
const _exhaustive: never = reason;
throw new Error(`Unhandled stop reason: ${_exhaustive}`);

View file

@ -10,8 +10,6 @@ import type {
ResponseOutputMessage,
ResponseReasoningItem,
} from "openai/resources/responses/responses.js";
import { AssistantMessageEventStream } from "../event-stream.js";
import { parseStreamingJson } from "../json-parse.js";
import { calculateCost } from "../models.js";
import type {
Api,
@ -26,7 +24,9 @@ import type {
Tool,
ToolCall,
} from "../types.js";
import { validateToolArguments } from "../validation.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { parseStreamingJson } from "../utils/json-parse.js";
import { validateToolArguments } from "../utils/validation.js";
import { transformMessages } from "./transorm-messages.js";
// OpenAI Responses-specific options
@ -268,17 +268,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
}
// Handle errors
else if (event.type === "error") {
output.stopReason = "error";
output.error = `Code ${event.code}: ${event.message}` || "Unknown error";
stream.push({ type: "error", error: output.error, partial: output });
stream.end();
return output;
throw new Error(`Error Code ${event.code}: ${event.message}` || "Unknown error");
} else if (event.type === "response.failed") {
output.stopReason = "error";
output.error = "Unknown error";
stream.push({ type: "error", error: output.error, partial: output });
stream.end();
return output;
throw new Error("Unknown error");
}
}
@ -286,12 +278,17 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unkown error ocurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
stream.end();
} catch (error) {
output.stopReason = "error";
output.error = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", error: output.error, partial: output });
for (const block of output.content) delete (block as any).index;
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
stream.push({ type: "error", reason: output.stopReason, error: output });
stream.end();
}
})();

View file

@ -1,10 +1,10 @@
import type { AssistantMessageEventStream } from "./event-stream.js";
import type { AnthropicOptions } from "./providers/anthropic.js";
import type { GoogleOptions } from "./providers/google.js";
import type { OpenAICompletionsOptions } from "./providers/openai-completions.js";
import type { OpenAIResponsesOptions } from "./providers/openai-responses.js";
import type { AssistantMessageEventStream } from "./utils/event-stream.js";
export type { AssistantMessageEventStream } from "./event-stream.js";
export type { AssistantMessageEventStream } from "./utils/event-stream.js";
export type Api = "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai";
@ -90,7 +90,7 @@ export interface Usage {
};
}
export type StopReason = "stop" | "length" | "toolUse" | "safety" | "error";
export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
export interface UserMessage {
role: "user";
@ -105,7 +105,7 @@ export interface AssistantMessage {
model: string;
usage: Usage;
stopReason: StopReason;
error?: string;
errorMessage?: string;
}
export interface ToolResultMessage<TDetails = any> {
@ -144,8 +144,8 @@ export type AssistantMessageEvent =
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| { type: "done"; reason: StopReason; message: AssistantMessage }
| { type: "error"; error: string; partial: AssistantMessage };
| { type: "done"; reason: Extract<StopReason, "stop" | "length" | "toolUse">; message: AssistantMessage }
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
// Model interface for the unified model system
export interface Model<TApi extends Api> {

View file

@ -1,4 +1,4 @@
import type { AssistantMessage, AssistantMessageEvent } from "./types.js";
import type { AssistantMessage, AssistantMessageEvent } from "../types.js";
// Generic event stream class for async iteration
export class EventStream<T, R = T> implements AsyncIterable<T> {
@ -73,7 +73,7 @@ export class AssistantMessageEventStream extends EventStream<AssistantMessageEve
if (event.type === "done") {
return event.message;
} else if (event.type === "error") {
return event.partial;
return event.error;
}
throw new Error("Unexpected event type for final result");
},

View file

@ -5,7 +5,7 @@ import addFormatsModule from "ajv-formats";
const Ajv = (AjvModule as any).default || AjvModule;
const addFormats = (addFormatsModule as any).default || addFormatsModule;
import type { Tool, ToolCall } from "./types.js";
import type { Tool, ToolCall } from "../types.js";
// Create a singleton AJV instance with formats
const ajv = new Ajv({ allErrors: true, strict: false });