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

@ -25,7 +25,7 @@ async function testAbortSignal<TApi extends Api>(llm: Model<TApi>, options: Opti
const msg = await response.result();
// If we get here without throwing, the abort didn't work
expect(msg.stopReason).toBe("error");
expect(msg.stopReason).toBe("aborted");
expect(msg.content.length).toBeGreaterThan(0);
context.messages.push(msg);
@ -46,7 +46,7 @@ async function testImmediateAbort<TApi extends Api>(llm: Model<TApi>, options: O
};
const response = await complete(llm, context, { ...options, signal: controller.signal });
expect(response.stopReason).toBe("error");
expect(response.stopReason).toBe("aborted");
}
describe("AI Providers Abort Tests", () => {

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { prompt } from "../src/agent/agent.js";
import { agentLoop } from "../src/agent/agent-loop.js";
import { calculateTool } from "../src/agent/tools/calculate.js";
import type { AgentContext, AgentEvent, PromptConfig } from "../src/agent/types.js";
import { getModel } from "../src/models.js";
@ -42,7 +42,7 @@ async function calculateTest<TApi extends Api>(model: Model<TApi>, options: Opti
let finalAnswer: number | undefined;
// Execute the prompt
const stream = prompt(userPrompt, context, config);
const stream = agentLoop(userPrompt, context, config);
for await (const event of stream) {
events.push(event);
@ -55,7 +55,7 @@ async function calculateTest<TApi extends Api>(model: Model<TApi>, options: Opti
case "turn_end":
console.log(`=== Turn ${turns} ended with ${event.toolResults.length} tool results ===`);
console.log(event.assistantMessage);
console.log(event.message);
break;
case "tool_execution_end":
@ -188,7 +188,7 @@ async function abortTest<TApi extends Api>(model: Model<TApi>, options: OptionsF
let finalMessages: Message[] | undefined;
// Execute the prompt
const stream = prompt(userPrompt, context, config, abortController.signal);
const stream = agentLoop(userPrompt, context, config, abortController.signal);
// Abort after first tool execution
const abortPromise = (async () => {
@ -222,7 +222,7 @@ async function abortTest<TApi extends Api>(model: Model<TApi>, options: OptionsF
// Should have executed 1 tool call before abort
expect(toolCallCount).toBeGreaterThanOrEqual(1);
expect(assistantMessage.stopReason).toBe("error");
expect(assistantMessage.stopReason).toBe("aborted");
return {
toolCallCount,

View file

@ -21,7 +21,7 @@ async function testEmptyMessage<TApi extends Api>(llm: Model<TApi>, options: Opt
expect(response.role).toBe("assistant");
// Should handle empty string gracefully
if (response.stopReason === "error") {
expect(response.error).toBeDefined();
expect(response.errorMessage).toBeDefined();
} else {
expect(response.content).toBeDefined();
}
@ -45,7 +45,7 @@ async function testEmptyStringMessage<TApi extends Api>(llm: Model<TApi>, option
// Should handle empty string gracefully
if (response.stopReason === "error") {
expect(response.error).toBeDefined();
expect(response.errorMessage).toBeDefined();
} else {
expect(response.content).toBeDefined();
}
@ -69,7 +69,7 @@ async function testWhitespaceOnlyMessage<TApi extends Api>(llm: Model<TApi>, opt
// Should handle whitespace-only gracefully
if (response.stopReason === "error") {
expect(response.error).toBeDefined();
expect(response.errorMessage).toBeDefined();
} else {
expect(response.content).toBeDefined();
}
@ -115,7 +115,7 @@ async function testEmptyAssistantMessage<TApi extends Api>(llm: Model<TApi>, opt
// Should handle empty assistant message in context gracefully
if (response.stopReason === "error") {
expect(response.error).toBeDefined();
expect(response.errorMessage).toBeDefined();
} else {
expect(response.content).toBeDefined();
expect(response.content.length).toBeGreaterThan(0);

View file

@ -1,7 +1,7 @@
import { Type } from "@sinclair/typebox";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { StringEnum } from "../src/typebox-helpers.js";
import { StringEnum } from "../src/utils/typebox-helpers.js";
// Zod version
const zodSchema = z.object({

View file

@ -238,7 +238,7 @@ const providerContexts = {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
error: "Request was aborted",
errorMessage: "Request was aborted",
} satisfies AssistantMessage,
toolResult: null,
facts: {
@ -293,7 +293,7 @@ async function testProviderHandoff<TApi extends Api>(
// Check for error
if (response.stopReason === "error") {
console.log(`[${sourceLabel}${targetModel.provider}] Failed with error: ${response.error}`);
console.log(`[${sourceLabel}${targetModel.provider}] Failed with error: ${response.errorMessage}`);
return false;
}

View file

@ -6,8 +6,8 @@ import { fileURLToPath } from "url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { complete, stream } from "../src/stream.js";
import { StringEnum } from "../src/typebox-helpers.js";
import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "../src/types.js";
import { StringEnum } from "../src/utils/typebox-helpers.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -40,7 +40,7 @@ async function basicTextGeneration<TApi extends Api>(model: Model<TApi>, options
expect(response.content).toBeTruthy();
expect(response.usage.input + response.usage.cacheRead).toBeGreaterThan(0);
expect(response.usage.output).toBeGreaterThan(0);
expect(response.error).toBeFalsy();
expect(response.errorMessage).toBeFalsy();
expect(response.content.map((b) => (b.type === "text" ? b.text : "")).join("")).toContain("Hello test successful");
context.messages.push(response);
@ -52,7 +52,7 @@ async function basicTextGeneration<TApi extends Api>(model: Model<TApi>, options
expect(secondResponse.content).toBeTruthy();
expect(secondResponse.usage.input + secondResponse.usage.cacheRead).toBeGreaterThan(0);
expect(secondResponse.usage.output).toBeGreaterThan(0);
expect(secondResponse.error).toBeFalsy();
expect(secondResponse.errorMessage).toBeFalsy();
expect(secondResponse.content.map((b) => (b.type === "text" ? b.text : "")).join("")).toContain(
"Goodbye test successful",
);
@ -192,7 +192,7 @@ async function handleThinking<TApi extends Api>(model: Model<TApi>, options?: Op
const response = await s.result();
expect(response.stopReason, `Error: ${response.error}`).toBe("stop");
expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("stop");
expect(thinkingStarted).toBe(true);
expect(thinkingChunks.length).toBeGreaterThan(0);
expect(thinkingCompleted).toBe(true);