Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/error-handling-circuit-breaker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@agentxjs/core": minor
"agentxjs": minor
---

Add AgentXError top-level error type, circuit breaker, and onError API

- New `@agentxjs/core/error` module with `AgentXError` class and `CircuitBreaker`
- `AgentXError` is a core-level type (like `AgentXPlatform`) with category, code, recoverability
- Circuit breaker protects against cascading LLM driver failures (5 failures → open → 30s cooldown)
- Message persistence failures now emit `AgentXError` via EventBus instead of being silently swallowed
- New `ax.onError(handler)` top-level API on AgentX interface for structured error handling
18 changes: 18 additions & 0 deletions bdd/journeys/developer/02-getting-started.feature
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,24 @@ Feature: Getting Started with AgentX SDK
"""
Then I can display or process the conversation history

# ============================================================================
# Error Handling
# ============================================================================

Scenario: Developer handles errors via top-level onError
Given I have an AgentX instance in any mode
When I register an error handler:
"""
ax.onError((error) => {
console.error(`[${error.category}] ${error.code}: ${error.message}`);
if (!error.recoverable) {
// Circuit is open or fatal error
}
});
"""
Then all AgentXError instances from any layer are delivered to this handler
And this is independent of stream events and Presentation API

# ============================================================================
# MCP Tools
# ============================================================================
Expand Down
89 changes: 89 additions & 0 deletions bdd/journeys/developer/03-error-handling.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
@journey @developer
Feature: Error Handling in AgentX SDK
As a developer, I need structured error handling so I can respond to
failures gracefully without digging into stream events.

# ============================================================================
# AgentXError — Top-Level Error Type
# ============================================================================

Scenario: Developer understands AgentXError as a core type
Given AgentXError is defined in @agentxjs/core/error
Then it is a top-level type like AgentXPlatform
And it extends the standard Error class
And it has these properties:
| property | type | description |
| code | string | e.g. "PERSISTENCE_FAILED"|
| category | "driver" \| "persistence" \| "connection" \| "runtime" | which layer failed |
| recoverable | boolean | should the caller retry |
| context | { agentId?, sessionId?, messageId? } | scope of the error |
| cause | Error (optional) | original error |

Scenario: Developer imports AgentXError from core
Given I need to handle errors
When I import from @agentxjs/core/error:
"""
import { AgentXError } from "@agentxjs/core/error";
"""
Then I can use AgentXError for instanceof checks and error creation

# ============================================================================
# onError — Top-Level API
# ============================================================================

Scenario: Developer subscribes to errors via onError
Given I have an AgentX instance in any mode
When I register an error handler:
"""
ax.onError((error) => {
console.error(`[${error.category}] ${error.code}: ${error.message}`);
if (!error.recoverable) {
// Circuit is open, stop sending requests
}
});
"""
Then all AgentXError instances from any layer are delivered to this handler
And this is independent of ax.on("error", ...) which handles stream events
And this is independent of presentation.onError which handles UI errors

# ============================================================================
# Circuit Breaker — Driver Layer
# ============================================================================

Scenario: Circuit breaker protects against cascading LLM failures
Given the LLM API returns consecutive errors
When the failure count reaches the threshold (default: 5)
Then the circuit opens and rejects new requests immediately
And an AgentXError is emitted:
| code | CIRCUIT_OPEN |
| category | driver |
| recoverable | false |
| message | Circuit breaker open: too many failures|

Scenario: Circuit breaker recovers after cooldown
Given the circuit is open
When the reset timeout elapses (default: 30 seconds)
Then the circuit transitions to half-open
And the next request is allowed through as a probe
And if it succeeds, the circuit closes and normal operation resumes
And if it fails, the circuit re-opens

# ============================================================================
# Persistence Errors — No Longer Silent
# ============================================================================

Scenario: Persistence failure emits AgentXError instead of silent logging
Given an agent is processing a conversation
When a message fails to persist to the session repository
Then an AgentXError is emitted via onError:
| code | PERSISTENCE_FAILED |
| category | persistence |
| recoverable | true |
And the conversation continues (persistence failure does not crash the agent)
And the error includes context with agentId and sessionId

Scenario: User message persistence failure stops the request
Given a user sends a message
When the user message fails to persist
Then the request fails with an error (not silently swallowed)
And an AgentXError is emitted with code "PERSISTENCE_FAILED"
39 changes: 39 additions & 0 deletions packages/agentx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ interface AgentX {
onAny(handler: BusEventHandler): Unsubscribe;
subscribe(sessionId: string): void;

// Error handling
onError(handler: (error: AgentXError) => void): Unsubscribe;

// Lifecycle
disconnect(): Promise<void>;
dispose(): Promise<void>;
Expand Down Expand Up @@ -158,6 +161,42 @@ const { records } = await ax.rpc<{ records: ImageRecord[] }>("image.list");
const response = await ax.rpc(request.method, request.params);
```

### Error Handling

Top-level error handler — receives structured `AgentXError` from all layers (driver, persistence, connection, runtime). Independent of stream events and Presentation API.

```typescript
import { AgentXError } from "agentxjs";

ax.onError((error) => {
console.error(`[${error.category}] ${error.code}: ${error.message}`);

if (error.code === "CIRCUIT_OPEN") {
// Too many consecutive LLM failures — stop sending requests
}

if (error.code === "PERSISTENCE_FAILED") {
// Message failed to save — conversation continues but data may be lost
}

if (!error.recoverable) {
// Fatal error — consider restarting the agent
}
});
```

**AgentXError properties:**

| Property | Type | Description |
| ------------- | -------- | ------------------------------------ |
| `code` | string | Error code (e.g. `PERSISTENCE_FAILED`) |
| `category` | string | `"driver"` \| `"persistence"` \| `"connection"` \| `"runtime"` |
| `recoverable` | boolean | Whether the caller should retry |
| `context` | object | `{ agentId?, sessionId?, imageId? }` |
| `cause` | Error? | Original error |

**Built-in circuit breaker:** After 5 consecutive driver failures, the circuit opens and rejects new requests. After 30s cooldown, one probe request is allowed through. Success closes the circuit.

### Stream Events

| Event | Data | Description |
Expand Down
9 changes: 9 additions & 0 deletions packages/agentx/src/LocalClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Implements the same AgentX interface as RemoteClient.
*/

import type { AgentXError } from "@agentxjs/core/error";
import type { BusEvent, BusEventHandler, EventBus, Unsubscribe } from "@agentxjs/core/event";
import type { RpcMethod } from "@agentxjs/core/network";
import type { AgentXRuntime } from "@agentxjs/core/runtime";
Expand Down Expand Up @@ -77,6 +78,14 @@ export class LocalClient implements AgentX {
// No-op for local mode - already subscribed via eventBus
}

// ==================== Error Handling ====================

onError(handler: (error: AgentXError) => void): Unsubscribe {
return this.runtime.platform.eventBus.on("agentx_error", (event) => {
handler(event.data as AgentXError);
});
}

// ==================== RPC ====================

async rpc<T = unknown>(method: string, params?: unknown): Promise<T> {
Expand Down
9 changes: 9 additions & 0 deletions packages/agentx/src/RemoteClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* This class focuses on business logic, not protocol details.
*/

import type { AgentXError } from "@agentxjs/core/error";
import type { BusEvent, BusEventHandler, EventBus, Unsubscribe } from "@agentxjs/core/event";
import { EventBusImpl } from "@agentxjs/core/event";
import { RpcClient } from "@agentxjs/core/network";
Expand Down Expand Up @@ -111,6 +112,14 @@ export class RemoteClient implements AgentX {
logger.debug("Subscribed to session", { sessionId });
}

// ==================== Error Handling ====================

onError(handler: (error: AgentXError) => void): Unsubscribe {
return this.eventBus.on("agentx_error", (event) => {
handler(event.data as AgentXError);
});
}

// ==================== RPC ====================

async rpc<T = unknown>(method: string, params?: unknown): Promise<T> {
Expand Down
7 changes: 7 additions & 0 deletions packages/agentx/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export function createAgentX(config?: PlatformConfig): AgentXBuilder {
getLocalClient().subscribe(sessionId);
},

onError(handler) {
return getLocalClient().onError(handler);
},

async disconnect() {
await localClient?.disconnect();
},
Expand Down Expand Up @@ -155,6 +159,9 @@ export function createAgentX(config?: PlatformConfig): AgentXBuilder {
};
}

export type { AgentXErrorCategory, AgentXErrorContext } from "@agentxjs/core/error";
// Re-export error types
export { AgentXError, AgentXErrorCode } from "@agentxjs/core/error";
// Re-export server
export { CommandHandler } from "./CommandHandler";
// Re-export Presentation types and classes
Expand Down
20 changes: 20 additions & 0 deletions packages/agentx/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { Message } from "@agentxjs/core/agent";
import type { AgentXError } from "@agentxjs/core/error";
import type { BusEvent, BusEventHandler, EventBus, Unsubscribe } from "@agentxjs/core/event";
import type { AgentXPlatform } from "@agentxjs/core/runtime";
import type { Presentation, PresentationOptions } from "./presentation";
Expand Down Expand Up @@ -334,6 +335,25 @@ export interface AgentX {
onAny(handler: BusEventHandler): Unsubscribe;
subscribe(sessionId: string): void;

// ==================== Error Handling ====================

/**
* Top-level error handler — receives all AgentXError instances from any layer.
*
* Independent of `on("error", ...)` (stream events) and `presentation.onError` (UI errors).
*
* @example
* ```typescript
* ax.onError((error) => {
* console.error(`[${error.category}] ${error.code}: ${error.message}`);
* if (!error.recoverable) {
* // Circuit is open, stop sending requests
* }
* });
* ```
*/
onError(handler: (error: AgentXError) => void): Unsubscribe;

// ==================== RPC ====================

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@
"import": "./dist/persistence/index.js",
"default": "./dist/persistence/index.js"
},
"./error": {
"types": "./dist/error/index.d.ts",
"import": "./dist/error/index.js",
"default": "./dist/error/index.js"
},
"./network": {
"types": "./dist/network/index.d.ts",
"import": "./dist/network/index.js",
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/error/AgentXError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* AgentXError — Top-level error type for the AgentX framework
*
* Like AgentXPlatform, this is a core-level type that all layers use.
* Provides structured error classification with category, code, and recoverability.
*/

// ============================================================================
// Types
// ============================================================================

/**
* Error category — which layer produced the error
*/
export type AgentXErrorCategory = "driver" | "persistence" | "connection" | "runtime";

/**
* Error context — scope information for debugging
*/
export interface AgentXErrorContext {
agentId?: string;
sessionId?: string;
imageId?: string;
containerId?: string;
messageId?: string;
}

// ============================================================================
// Error Codes
// ============================================================================

/**
* Well-known error codes
*/
export const AgentXErrorCode = {
// Driver
DRIVER_ERROR: "DRIVER_ERROR",
CIRCUIT_OPEN: "CIRCUIT_OPEN",

// Persistence
PERSISTENCE_FAILED: "PERSISTENCE_FAILED",

// Connection
CONNECTION_FAILED: "CONNECTION_FAILED",
CONNECTION_TIMEOUT: "CONNECTION_TIMEOUT",

// Runtime
RUNTIME_ERROR: "RUNTIME_ERROR",
} as const;

export type AgentXErrorCodeType = (typeof AgentXErrorCode)[keyof typeof AgentXErrorCode];

// ============================================================================
// AgentXError Class
// ============================================================================

/**
* AgentXError — structured error for all AgentX layers
*
* @example
* ```typescript
* import { AgentXError } from "@agentxjs/core/error";
*
* throw new AgentXError({
* code: "PERSISTENCE_FAILED",
* category: "persistence",
* message: "Failed to persist assistant message",
* recoverable: true,
* context: { agentId: "agent_123", sessionId: "sess_456" },
* cause: originalError,
* });
* ```
*/
export class AgentXError extends Error {
readonly code: string;
readonly category: AgentXErrorCategory;
readonly recoverable: boolean;
readonly context?: AgentXErrorContext;

constructor(options: {
code: string;
category: AgentXErrorCategory;
message: string;
recoverable: boolean;
context?: AgentXErrorContext;
cause?: Error;
}) {
super(options.message, { cause: options.cause });
this.name = "AgentXError";
this.code = options.code;
this.category = options.category;
this.recoverable = options.recoverable;
this.context = options.context;
}
}
Loading
Loading