npm package: @gizmo-ai/runtime
Description: Minimal Redux-based runtime for agent execution with full lifecycle visibility.
Latest version: 0.2.2

# @gizmo-ai/runtime

Minimal Redux-based runtime for agent execution with full lifecycle visibility.

## What is this?

A runtime that orchestrates agent execution using Redux patterns. The core building block is the **plugin**: a small package of optional reducer, initial state, and middleware that you register with the runtime. All framework features (including the agent) are plugins, and your own features should be too.

> Need a complete example package? See [`packages/agent/README.md`](../agent/README.md) for a reference implementation that builds on this runtime.

### Plugins at a Glance

```typescript
import { createAction, whenAction, type PayloadAction, type RuntimePlugin } from "@gizmo-ai/runtime";

// Define actions using PayloadAction pattern
const costUpdated = createAction<{ cost: number }, "COST_UPDATED">("COST_UPDATED");
const approvalRequested = createAction<{ request: Action }, "APPROVAL_REQUESTED">("APPROVAL_REQUESTED");
const approvalGranted = createAction<{ id: string }, "APPROVAL_GRANTED">("APPROVAL_GRANTED");

// Cost tracking - middleware only
const costTracking: RuntimePlugin = {
  middleware: whenAction(
    (action): action is ReturnType<typeof modelCompleted> => modelCompleted.match(action),
    ({ action, api }) => {
      api.dispatch(costUpdated({ cost: action.payload.tokens * 0.00001 }));
    }
  ),
};

// Approvals - reducer + middleware with typed plugin
const approvals: RuntimePlugin<"approvals", { pending: Action[] }> = {
  key: "approvals",
  initialState: { pending: [] },
  reducer: (state = { pending: [] }, action) => {
    // Use .match() for type-safe action handling
    if (approvalRequested.match(action)) {
      return { pending: [...state.pending, action.payload.request] };
    }
    if (approvalGranted.match(action)) {
      return {
        pending: state.pending.filter((req) => req.id !== action.payload.id),
      };
    }
    return state;
  },
  middleware: whenAction(
    (action) => action.type === "TOOL_REQUESTED" && action.tool === "email",
    ({ action, api }) => {
      api.dispatch(approvalRequested({ request: action }));
    }
  ),
};

// Use as const tuple for typed state inference
const runtime = createRuntime({
  plugins: [costTracking, approvals] as const,
});

// State is fully typed - no casts needed!
const state = runtime.getState();
// state.approvals.pending is Action[] - TypeScript knows this
```

Each plugin is independent and can expose **any combination** of:

- `key` – the reducer key for mounting state (only needed when you provide `reducer` or `initialState`)
- `reducer` – manages that slice of state
- `initialState` – seed state for the slice
- `middleware` – logic that reacts to actions (single middleware or array)

### Action naming

- Core runtime actions exported from `@gizmo-ai/runtime` are prefixed with `RUNTIME_` (for example `RUNTIME_EXECUTION_STARTED`, `RUNTIME_WORK_COMPLETED`).
- Each plugin should use its own prefix to keep logs and diagnostics readable. The built-in agent plugin uses `AGENT_…` for its actions.
- When building your own plugin, pick a short, descriptive prefix (e.g. `NOTES_`, `ANALYTICS_`) for actions you introduce.

### Building a plugin

1. Define a `RuntimePlugin` object with optional `key`, `reducer`, `initialState`, and `middleware` pieces.
2. Use middleware helpers like `whenAction`, `pipeHandlers`, and your preferred action prefix to implement behaviour.
3. Register the plugin via `createRuntime({ plugins: [yourPlugin] })`.

## Core Concepts

### Execution Lifecycle

An **execution** is started by calling `runtime.execute(input)`. It can have multiple **turns** if interrupted:

```typescript
const runtime = createRuntime();

// Start execution, get turn1
const handle1 = runtime.execute("first input");
await handle1.included; // Turn1 accepted

// Interrupt with turn2 (same execution)
const handle2 = runtime.execute("second input");
await handle2.included; // Turn2 accepted
// handle1.completed resolves with { status: 'interrupted' }

// Both turns' work runs in parallel
await handle2.completed; // Resolves when ALL work completes
```

**Key insight**: Interrupts add turns to the SAME execution. Existing work continues running.

### Actions Flow Through Middleware

When you dispatch an action, it flows through the middleware chain, then to the reducer, then bubbles back up:

```
dispatch(action)
  ↓
middleware1 (before next)
  ↓
middleware2 (before next)
  ↓
middleware3 (before next)
  ↓
REDUCER (state update)
  ↑
middleware3 (after next)
  ↑
middleware2 (after next)
  ↑
middleware1 (after next)
  ↑
returns
```

Each middleware can:
- **Before next**: Inspect action, modify it, block it, dispatch other actions
- **After next**: See updated state, dispatch follow-up actions, cleanup

Example middleware:

```typescript
const loggingMiddleware = (api) => (next) => (action) => {
  console.log('Before:', action.type);
  const result = next(action); // Call next middleware/reducer
  console.log('After:', api.getState());
  return result;
};
```

### Thunks: Async Work

A **thunk** is a function that gets executed by middleware. Users dispatch thunks to add async work:

```typescript
// User dispatches a thunk (function)
runtime.dispatch(async (dispatch, getState, signal) => {
  const result = await someAsyncWork();

  // Check if aborted
  if (signal.aborted) return;

  // Dispatch more work
  dispatch(async () => {
    await moreWork(result);
  });
});
```

The thunk middleware:
1. Intercepts the function
2. Generates a unique workId
3. Dispatches `RUNTIME_WORK_STARTED` action
4. Executes the thunk
5. Dispatches `RUNTIME_WORK_COMPLETED` or `RUNTIME_WORK_FAILED`

This creates a complete audit trail of all async work.

## API

### createRuntime(config?)

Creates a runtime instance from plugins and optional additional configuration.

```typescript
import { createRuntime } from '@gizmo-ai/runtime';

const loggingPlugin = {
  middleware: whenAction(
    action => true,
    ({ action }) => console.log(`[action] ${action.type}`)
  ),
};

const runtime = createRuntime({
  plugins: [loggingPlugin],
});
```

### Matching Execution Actions

The runtime provides helpers for working with execution lifecycle actions:

```typescript
import {
  ExecutionLifecycleActionTypes,
  matchExecutionLifecycleAction,
  whenAction,
} from "@gizmo-ai/runtime";

const lifecycleLogger = {
  middleware: whenAction(
    matchExecutionLifecycleAction([
      ExecutionLifecycleActionTypes.Started,
      ExecutionLifecycleActionTypes.Completed,
      ExecutionLifecycleActionTypes.Failed,
    ]),
    ({ action }) => {
      console.log("Lifecycle event:", action.type, action);
    }
  ),
};

const runtime = createRuntime({
  plugins: [lifecycleLogger],
});
```

### RuntimePlugin

Plugins are plain objects with optional pieces. Only provide what you need:

```typescript
interface RuntimePlugin<
  Key extends string = string,
  SliceState = unknown,
  PluginAction extends Action = Action,
  RootState = unknown
> {
  key?: Key;                          // required if reducer or initialState provided
  reducer?: Reducer<SliceState, PluginAction>;
  initialState?: SliceState;
  middleware?: Middleware<RootState, PluginAction> | Array<Middleware<RootState, PluginAction>>;
}
```

- **Reducer** mounts under `key`.
- **Initial state** seeds the slice when mounted.
- **Middleware** can be a single function or an array; all are appended in registration order.

**IMPORTANT: Use typed plugins for automatic state inference:**

```typescript
// ❌ WRONG - Loses type information
export function myPlugin(): RuntimePlugin {
  return { key: "my", initialState: { count: 0 }, reducer: myReducer };
}

// ✅ CORRECT - Preserves type information
export function myPlugin(): RuntimePlugin<"my", { count: number }> {
  return { key: "my", initialState: { count: 0 }, reducer: myReducer };
}
```

When you use typed plugins with `as const` tuples, TypeScript automatically infers the combined state type. See "Typed Plugin Composition" section below.

### runtime.execute(input)

Starts or interrupts an execution, returns a handle:

```typescript
const handle = runtime.execute("user input");

// Wait for turn to be accepted
await handle.included;

// Wait for execution to complete
await handle.completed;

// Cancel execution (aborts all work)
handle.cancel();
```

**Returns**: `TurnHandle`
- `included: Promise<void>` - Resolves when turn is accepted
- `completed: Promise<ExecutionResult>` - Resolves with `{ status: "completed" | "interrupted" | "aborted" }`
- `cancel: () => void` - Aborts execution

**Completion statuses**:
- `{ status: "completed" }` – All work finished successfully.
- `{ status: "interrupted", interruptedTurnId, nextTurnId }` – Turn was superseded by a new `execute()` call.
- `{ status: "aborted" }` – Execution was cancelled via `handle.cancel()`. Work receives an abort signal.
- Work failures still reject the promise with the underlying error.

### runtime.dispatch(action | thunk)

Dispatches an action or thunk through the middleware pipeline. Most plugins contribute middleware so you rarely need to dispatch directly, but it remains available for testing or custom logic.

### runtime.getState()

Returns current state:

```typescript
const state = runtime.getState();
console.log(state.execution.state); // "pending" | "completed" | "failed" | "aborted"
console.log(state.execution.turnIds); // All turn IDs in this execution
```

### runtime.subscribe(listener)

Subscribe to state changes:

```typescript
const unsubscribe = runtime.subscribe(() => {
  console.log('State changed:', runtime.getState());
});

// Later
unsubscribe();
```

## Middleware Helpers

The runtime provides utilities for building declarative middleware:

### whenAction(predicate, handler)

Run handler when predicate matches:

```typescript
import { whenAction, pipeHandlers } from '@gizmo-ai/runtime';

const middleware = (api) => {
  const handlers = pipeHandlers(
    whenAction(
      (action) => action.type === "RUNTIME_EXECUTION_STARTED",
      ({ action, api }) => {
        console.log("Execution started:", action.executionId);
      }
    )
  );

  return (next) => (action) => {
    handlers({ action, api });
    return next(action);
  };
};
```

### pipeHandlers(...handlers)

Compose multiple handlers sequentially:

```typescript
const handlers = pipeHandlers(
  whenAction(isType("RUNTIME_EXECUTION_STARTED"), handleStart),
  whenAction(isType("RUNTIME_EXECUTION_COMPLETED"), handleComplete),
  whenAction(isType("RUNTIME_WORK_FAILED"), handleError)
);
```

### Execution Pipeline Order

`createRuntime` wires middleware in a specific sequence:

1. **Thunk middleware** – intercepts dispatched functions, tracks work, and emits `RUNTIME_WORK_*` actions.  
2. **Execution lifecycle middleware** – handles `RUNTIME_EXECUTION_*` actions (resolving handles, abort signals, cleanup).  
3. **Work tracker middleware** – counts outstanding work and dispatches `RUNTIME_EXECUTION_COMPLETED` when everything finishes.  
4. **Plugin middleware** – contributed by registered plugins (e.g., agent orchestration, analytics).  
5. **User-supplied middleware** – any additional middleware passed via `createRuntime({ middleware })`.

Lifecycle middleware only reacts to `ExecutionAction`; user actions continue to flow through untouched, ensuring execution state stays consistent before plugin logic runs.

### isType(type)

Type-safe predicate for a single action type:

```typescript
whenAction(
  isType("RUNTIME_EXECUTION_STARTED"),
  ({ action }) => {
    // action is narrowed to ExecutionStartedAction
    console.log(action.executionId, action.input);
  }
)
```

### isTypes([types])

Type-safe predicate for multiple action types:

```typescript
whenAction(
  isTypes(["RUNTIME_EXECUTION_COMPLETED", "RUNTIME_EXECUTION_FAILED"]),
  ({ action }) => {
    // action is narrowed to ExecutionCompletedAction | ExecutionFailedAction
    console.log("Execution ended:", action.executionId);
  }
)
```

### whenState(predicate)

Predicate based on state condition:

```typescript
whenAction(
  whenState(s => s.execution.state === "pending"),
  ({ action }) => {
    // Only runs when execution is pending
  }
)
```

## Common Patterns

### Pattern: Observing Actions (Before State Update)

Run logic BEFORE the reducer updates state:

```typescript
const loggingMiddleware = (api) => {
  const handlers = pipeHandlers(
    whenAction(
      (action) => action.type.startsWith("RUNTIME_EXECUTION_"),
      ({ action }) => {
        console.log(`[${action.type}]`, action);
      }
    )
  );

  return (next) => (action) => {
    handlers({ action, api }); // Log BEFORE state changes
    return next(action);
  };
};
```

### Pattern: Reacting to State Changes (After Update)

Run logic AFTER the reducer updates state:

```typescript
const alertMiddleware = (api) => {
  const handlers = pipeHandlers(
    whenAction(
      isType("RUNTIME_WORK_FAILED"),
      ({ action, api }) => {
        const state = api.getState();
        // State is already updated, can react to it
        if (state.execution.failedWorkCount > 3) {
          api.dispatch({ type: "TOO_MANY_FAILURES" });
        }
      }
    )
  );

  return (next) => (action) => {
    const result = next(action);    // Update state FIRST
    handlers({ action, api });      // React AFTER
    return result;
  };
};
```

### Pattern: Blocking Actions

Don't call `next()` to block an action from reaching the reducer:

```typescript
const validationMiddleware = (api) => (next) => (action) => {
  if (action.type === "DANGEROUS_OPERATION") {
    // Validate first
    if (!isValid(action)) {
      api.dispatch({ type: "VALIDATION_FAILED", action });
      return; // DON'T call next - blocked
    }
  }

  return next(action);
};
```

### Pattern: Retry Logic

Catch errors in thunks and retry with backoff:

```typescript
const retryMiddleware = (api) => (next) => (action) => {
  const result = next(action);

  // Listen for failures and retry
  if (action.type === "RUNTIME_WORK_FAILED") {
    const { workId, error } = action;

    api.dispatch(async (dispatch, getState, signal) => {
      let attempts = 0;
      while (attempts < 3) {
        try {
          const result = await retryWork(workId);
          dispatch({ type: "RETRY_SUCCESS", workId, result });
          return;
        } catch (error) {
          attempts++;
          if (attempts >= 3) throw error;
          await sleep(1000 * Math.pow(2, attempts));
        }
      }
    });
  }

  return result;
};
```

## Advanced Patterns

### Human-in-the-Loop (HITL)

To pause execution for human approval, use middleware that dispatches approval requests and waits externally:

```typescript
const approvalMiddleware = (approvalHandler) => (api) => (next) => (action) => {
  // Intercept specific actions that need approval
  if (action.type === 'TOOL_CALL_REQUESTED') {
    // Dispatch approval request (non-blocking)
    api.dispatch({
      type: 'APPROVAL_REQUIRED',
      toolCall: action.toolCall,
      callbackId: generateId()
    });

    // Block this action from proceeding
    return; // Don't call next!
  }

  // Handle approval response
  if (action.type === 'APPROVAL_GRANTED') {
    // Now dispatch the original action
    api.dispatch({
      type: 'TOOL_CALL_REQUESTED',
      toolCall: action.originalToolCall
    });
  }

  return next(action);
};
```

External code handles the approval UI:

```typescript
runtime.subscribe(() => {
  const state = runtime.getState();

  // When approval required
  if (state.approvals.pending.length > 0) {
    const approval = state.approvals.pending[0];

    // Show UI, get user response
    showApprovalDialog(approval).then(granted => {
      if (granted) {
        runtime.dispatch({
          type: 'APPROVAL_GRANTED',
          originalToolCall: approval.toolCall
        });
      } else {
        runtime.dispatch({ type: 'APPROVAL_REJECTED' });
      }
    });
  }
});
```

**Key insight**: Middleware can't await (they're synchronous), but they can:
1. Block actions from proceeding (don't call next)
2. Dispatch request actions
3. Wait for response actions from external code
4. Then dispatch the original action

### Custom State & Reducers

Add state by registering a plugin with `key`, `reducer`, and optional `initialState`. **Use `.match()` for type-safe action handling:**

```typescript
import { createAction, type RuntimePlugin } from "@gizmo-ai/runtime";

// Define action with PayloadAction pattern
const analyticsEvent = createAction<
  { type: string; data: unknown },
  "ANALYTICS_EVENT"
>("ANALYTICS_EVENT");

interface AnalyticsState {
  events: Array<{ type: string; data: unknown }>;
}

// Typed plugin for automatic state inference
const analyticsPlugin: RuntimePlugin<"analytics", AnalyticsState> = {
  key: "analytics",
  initialState: { events: [] },
  reducer: (state = { events: [] }, action) => {
    // Use .match() for type-safe action handling
    if (analyticsEvent.match(action)) {
      return { events: [...state.events, action.payload] };
    }
    return state;
  },
};

// Use as const for typed state
const runtime = createRuntime({
  plugins: [analyticsPlugin] as const,
});

// State is fully typed - no casts!
const state = runtime.getState();
// state.analytics.events is Array<{type: string, data: unknown}>
```

## Typed Plugin Composition

**CRITICAL: Use `as const` tuples to get automatic state inference.**

The runtime uses TypeScript's `MergePluginStates<P>` type to automatically merge state types from plugins. This only works when TypeScript knows the exact plugin types.

### The Problem

```typescript
// ❌ WRONG - Array annotation loses type information
const plugins: RuntimePlugin[] = [
  analyticsPlugin,
  costPlugin({ rate: 0.00001 }),
  agentPlugin({ model: openAI() }),
];

const runtime = createRuntime({ plugins });
const state = runtime.getState();
// state is: { execution: ExecutionState } - MISSING plugin states!
// You need: const state = runtime.getState() as { analytics: ..., cost: ..., agent: ... }
```

### The Solution

```typescript
// ✅ CORRECT - Tuple preserves exact plugin types
const plugins = [
  analyticsPlugin,
  costPlugin({ rate: 0.00001 }),
  agentPlugin({ model: openAI() }),
] as const;

const runtime = createRuntime({ plugins });
const state = runtime.getState();
// state is FULLY TYPED:
// {
//   execution: ExecutionState;
//   analytics: AnalyticsState;
//   cost: CostState;
//   agent: AgentState;
// }
// No casts needed!
```

### Why This Works

1. **`as const`** tells TypeScript to infer a readonly tuple instead of an array
2. **Tuple types preserve each element's exact type** (not just the union)
3. **`MergePluginStates<P>`** recursively extracts `{ [key]: State }` from each plugin
4. **Result**: TypeScript knows the complete state shape automatically

### Best Practices

```typescript
// ✅ Inline tuple
const runtime = createRuntime({
  plugins: [plugin1(), plugin2()] as const,
});

// ✅ Named constant
const myPlugins = [plugin1(), plugin2()] as const;
const runtime = createRuntime({ plugins: myPlugins });

// ✅ Conditional composition
const basePlugins = [analyticsPlugin, agentPlugin(config)] as const;
const plugins = withUI
  ? [uiPlugin, ...basePlugins] as const
  : basePlugins;

// ❌ NEVER annotate as RuntimePlugin[]
const plugins: RuntimePlugin[] = [...];  // Loses type info!
```

### When You Still Need Casts

If plugins are dynamically composed, you may need casts:

```typescript
// Dynamic plugins - type inference won't work
const plugins = config.plugins.map(createPlugin);  // RuntimePlugin[]

// Define expected state shape
interface MyAppState {
  execution: ExecutionState;
  agent: AgentState;
  cost?: CostState;
}

const runtime = createRuntime({ plugins });
const state = runtime.getState() as MyAppState;
```

### Middleware Composition

Plugin order is the middleware order. When you register plugins, their middleware executes in the sequence they appear:

```typescript
const loggingPlugin = { middleware: loggingMiddleware };
const metricsPlugin = { middleware: metricsMiddleware };
const validationPlugin = { middleware: validationMiddleware };

const runtime = createRuntime({
  plugins: [loggingPlugin, metricsPlugin, validationPlugin],
});
```

Action flow mirrors plugin order:

```
dispatch(action)
  → loggingMiddleware
    → metricsMiddleware
      → validationMiddleware
        → reducers
```

If a plugin needs to contribute multiple middleware functions, return an array from `middleware`.

### Work Tracking

All async work is tracked automatically:

```typescript
runtime.subscribe(() => {
  const state = runtime.getState();

  // Execution state
  console.log(state.execution.state); // "pending" | "completed" | ...
  console.log(state.execution.turnIds); // All turns in this execution
});

// Dispatch work
runtime.dispatch(async () => {
  // Thunk middleware dispatches RUNTIME_WORK_STARTED
  await doWork();
  // Thunk middleware dispatches RUNTIME_WORK_COMPLETED
});

// Tracker middleware counts pending work
// When all work completes, dispatches RUNTIME_EXECUTION_COMPLETED
```

#### Scheduling async work

When middleware needs to kick off asynchronous work, use `dispatchWork`. It wraps your thunk, guarantees the runtime tracks it, and adds helpful metadata without extra boilerplate:

```typescript
import { dispatchWork, whenAction } from "@gizmo-ai/runtime";

const modelMiddleware = whenAction(
  (action) => action.type === "AGENT_MODEL_REQUESTED",
  ({ action, api }) => {
    dispatchWork(api, {
      action,
      kind: "AGENT_MODEL",
      run: async (dispatch, getState) => {
        // Call your model provider with whatever state you need
        const response = await someModel.generate(getState().conversation, getState().tools);
        dispatch({ type: "AGENT_MODEL_RESPONDED", executionId: action.executionId, response });
      },
    });
  }
);
```

`dispatchWork` automatically records which action scheduled the work. Every `RUNTIME_WORK_*` event will include the triggering action’s ID and type so you can follow the execution tree without wiring anything by hand.

#### Work metadata

Work actions include an optional `meta` payload. The runtime records:

- `actionId` – unique ID for the action that kicked off the work.
- `actionType` – the action’s type string.
- `parentActionId` – the parent action (if the triggering action was itself dispatched by another).
- `kind` – optional label you can provide for easier grouping.

This metadata is surfaced in the logger example so you can trace which action spawned each async job and how it finished.

> Looking for a ready-made visualisation? `@gizmo-ai/agent-plugin` ships `createAgentLogger`, a plugin that renders the action/work tree in the console using this metadata.

## Testing

Tests dispatch work directly:

```typescript
test("execution lifecycle", async () => {
  const runtime = createRuntime();

  const handle = runtime.execute("test");
  await handle.included;

  // Dispatch work
  runtime.dispatch(async () => {
    await new Promise(resolve => setTimeout(resolve, 10));
  });

  // Wait for completion
  await handle.completed;

  expect(runtime.getState().execution.state).toBe("completed");
});
```

## Architecture

Built on minimal Redux primitives:

```
┌─────────────────────────────────────────┐
│ Runtime                                  │
│  - execute(input) → TurnHandle          │
│  - dispatch(action | thunk)             │
│  - getState() → State                   │
│  - subscribe(listener)                  │
└─────────────────────────────────────────┘
           ↓
┌─────────────────────────────────────────┐
│ Store (Redux)                            │
│  - Holds execution state                │
│  - Applies actions through reducers     │
└─────────────────────────────────────────┘
           ↓
┌─────────────────────────────────────────┐
│ Middleware Pipeline                      │
│  - Execution: Handle lifecycle          │
│  - Thunk: Execute async work            │
│  - Tracker: Track pending work          │
│  - Your middleware...                   │
└─────────────────────────────────────────┘
           ↓
┌─────────────────────────────────────────┐
│ Reducers                                 │
│  - execution: Update execution state    │
│  - Your reducers...                     │
└─────────────────────────────────────────┘
```

## Error Handling

The runtime provides first-class error handling with observable error actions and optional retry support.

### Error Flow

When async work (thunks) throw errors:

```
thunk throws
  → RUNTIME_WORK_ERROR dispatched (with originAction)
  → middleware can intercept/retry
  → if not handled → RUNTIME_WORK_FAILED
  → RUNTIME_EXECUTION_FAILED
```

### Retry Middleware

Automatically retry transient errors (rate limits, network issues) with exponential backoff:

```typescript
import { createRuntime, createRetryMiddleware } from "@gizmo-ai/runtime";

const runtime = createRuntime({
  plugins: [...],
  middleware: [
    createRetryMiddleware({
      maxAttempts: 3,           // Default: 3
      backoff: "exponential",   // "fixed" or "exponential"
      baseDelayMs: 1000,        // Default: 1000ms
      maxDelayMs: 30000,        // Default: 30s
      retryOn: (error) =>       // Custom predicate
        isRateLimitError(error) || isNetworkError(error),
    }),
  ],
});
```

When a retryable error occurs, `RUNTIME_WORK_RETRY` is dispatched for observability, then the original action is re-dispatched.

### Error Classification Helpers

Built-in helpers to classify errors:

```typescript
import {
  isRateLimitError,   // 429, "rate limit", "too many requests"
  isNetworkError,     // ECONNREFUSED, fetch failed, socket hang up
  isAuthError,        // 401, 403, "API key", "unauthorized"
  isTimeoutError,     // timeout, timed out, deadline exceeded
  isServerError,      // 5xx errors
  isRetryableError,   // rate limit || network || timeout || server
  classifyError,      // Returns { type, retryable }
} from "@gizmo-ai/runtime";

// Example: custom retry predicate
createRetryMiddleware({
  retryOn: (error) => {
    const { type, retryable } = classifyError(error);
    // Retry rate limits and network errors, but not auth errors
    return retryable && type !== "auth";
  },
});
```

### Manual Error Handling

To catch errors in thunks and prevent execution failure:

```typescript
runtime.dispatch(async (dispatch, getState, signal) => {
  try {
    const result = await riskyOperation();
    dispatch({ type: "OPERATION_SUCCESS", result });
  } catch (error) {
    // Caught here - execution continues
    dispatch({ type: "OPERATION_ERROR", error: error.message });
  }
});
```

### Observing Errors

Listen for error actions in middleware:

```typescript
const errorMiddleware = (api) => {
  const handlers = pipeHandlers(
    whenAction(
      isType("RUNTIME_WORK_ERROR"),
      ({ action }) => {
        // Error occurred, may be retried
        console.warn("Work error:", action.payload.error);
      }
    ),
    whenAction(
      isType("RUNTIME_EXECUTION_FAILED"),
      ({ action }) => {
        // Execution permanently failed
        console.error("Execution failed:", action.payload.error);
      }
    )
  );

  return (next) => (action) => {
    handlers({ action, api });
    return next(action);
  };
};
```

## Debugging

### Redux DevTools

The runtime is Redux-compatible. Connect Redux DevTools to:
- See every action dispatched
- Inspect state at each step
- Time-travel to any point in execution

### Action-Based Debugging

Instead of console.logs, dispatch debug actions:

```typescript
api.dispatch({
  type: "DEBUG_CHECKPOINT",
  location: "afterModelCall",
  data: relevantInfo
});
```

These show up in DevTools and create an audit trail.

### Trace Execution Flow

When debugging, gather:
1. **Action history** - Sequence of all actions
2. **State at failure** - What state looked like when it broke
3. **Expected vs actual** - What action should have happened

Trace through the action sequence to find where the flow diverged.

## Design Principles

1. **Observable by default** - Every action flows through the store, every state change is visible
2. **No magic** - Explicit actions, reducers, middleware. You see exactly what happens when
3. **Composable** - Bring your own state, reducers, middleware
4. **Testable** - Pure functions, deterministic state updates, no hidden side effects
5. **Minimal** - ~500 LOC. Redux primitives + execution lifecycle + work tracking

## Middleware Anti-Patterns and Best Practices

When building middleware, it's critical to follow Redux conventions and keep state observable. This section documents common mistakes and the correct patterns.

### The Golden Rules

1. **All state belongs in reducers** - Never use closure variables for state
2. **Each `whenAction` handler must be independent** - No shared closure state between handlers
3. **Middleware functions only pipe handlers** - No logic in the main middleware function
4. **Use `api.getState()` to read state** - Always check reducer state, not closure variables
5. **Use `dispatchWork` for async operations** - Never use `async/await` in middleware functions
6. **Action-based coordination** - Don't block by skipping `next()` without proper action coordination

### Anti-Pattern 1: Closure Variables for State

#### ❌ WRONG
```typescript
export function createMyMiddleware(config) {
  let isConnecting = false;      // ❌ Hidden state!
  let toolsReady = false;         // ❌ Not observable!

  return (api: MiddlewareAPI) => {
    const handleStart = whenAction(
      (action) => action.type === "START",
      ({ api }) => {
        if (isConnecting) return;  // ❌ Reading closure variable
        isConnecting = true;        // ❌ Mutating closure variable

        dispatchWork(api, {
          action,
          run: async () => {
            await connectToServer();
            isConnecting = false;   // ❌ Hidden state mutation
            toolsReady = true;      // ❌ Hidden state mutation
          },
        });
      }
    );

    return (next) => (action) => {
      const result = next(action);
      runHandlers({ action, api });
      return result;
    };
  };
}
```

**Problems:**
- State is hidden in closures, not observable in Redux DevTools
- Can't test state transitions
- Can't serialize/deserialize state
- Multiple instances share no state visibility

#### ✅ RIGHT
```typescript
export function createMyMiddleware(config) {
  return (api: MiddlewareAPI) => {
    const handleStart = whenAction(
      (action) => action.type === "START",
      ({ api }) => {
        const state = api.getState();  // ✅ Read from reducer

        if (state.myPlugin.connecting) return;  // ✅ Check reducer state

        api.dispatch({ type: "MY_CONNECTING_STARTED" });  // ✅ Dispatch action

        dispatchWork(api, {
          action,
          run: async () => {
            await connectToServer();
            api.dispatch({ type: "MY_CONNECTED" });  // ✅ Update via action
          },
        });
      }
    );

    const runHandlers = pipeHandlers(handleStart);

    return (next) => (action) => {
      const result = next(action);
      runHandlers({ action, api });
      return result;
    };
  };
}

// Reducer uses .match() for type-safe action handling
import { createAction } from "@gizmo-ai/runtime";

const myConnecting = createAction<void, "MY_CONNECTING_STARTED">("MY_CONNECTING_STARTED");
const myConnected = createAction<void, "MY_CONNECTED">("MY_CONNECTED");

function myReducer(state = { connecting: false, ready: false }, action) {
  if (myConnecting.match(action)) {
    return { ...state, connecting: true };
  }
  if (myConnected.match(action)) {
    return { ...state, connecting: false, ready: true };
  }
  return state;
}
```

**Benefits:**
- State visible in Redux DevTools
- Can time-travel debug
- Fully testable
- Serializable state

### Anti-Pattern 2: Dependent whenAction Handlers

#### ❌ WRONG
```typescript
export function createMyMiddleware(config) {
  const pendingActions: Action[] = [];  // ❌ Shared closure state
  let ready = false;                     // ❌ Shared between handlers

  return (api: MiddlewareAPI) => {
    const handleInit = whenAction(
      (action) => action.type === "INIT",
      ({ api }) => {
        // ... initialization ...
        ready = true;  // ❌ Mutates shared closure variable

        // Re-dispatch pending actions
        while (pendingActions.length > 0) {  // ❌ Reads shared closure array
          const pending = pendingActions.shift()!;
          api.dispatch(pending);
        }
      }
    );

    const handleOtherAction = whenAction(
      (action) => action.type === "OTHER",
      ({ api, action }) => {
        if (!ready) {  // ❌ Depends on shared closure variable
          pendingActions.push(action);  // ❌ Mutates shared closure array
          return;
        }
        // ... process action ...
      }
    );

    const runHandlers = pipeHandlers(handleInit, handleOtherAction);

    return (next) => (action) => {
      const result = next(action);
      runHandlers({ action, api });
      return result;
    };
  };
}
```

**Problems:**
- Handlers are coupled via shared closure state
- Hidden dependencies between handlers
- Can't test handlers in isolation
- Hard to debug - state mutations happen in multiple places

#### ✅ RIGHT
```typescript
export function createMyMiddleware(config) {
  return (api: MiddlewareAPI) => {
    // Each handler is INDEPENDENT - only reads state, only dispatches actions
    const handleInit = whenAction(
      (action) => action.type === "INIT",
      ({ api }) => {
        api.dispatch({ type: "MY_READY" });  // ✅ Just dispatch
      }
    );

    const handleReady = whenAction(
      (action) => action.type === "MY_READY",
      ({ api }) => {
        const state = api.getState();  // ✅ Read from reducer

        // Re-dispatch pending actions from STATE
        state.myPlugin.pendingActions.forEach((pendingAction) => {
          api.dispatch(pendingAction);
        });

        api.dispatch({ type: "MY_PENDING_CLEARED" });
      }
    );

    const handleOtherAction = whenAction(
      (action) => action.type === "OTHER",
      ({ api, action }) => {
        const state = api.getState();  // ✅ Read from reducer

        if (!state.myPlugin.ready) {
          // Store in STATE via action
          api.dispatch({ type: "MY_ACTION_PENDING", action });
          return;
        }

        // ... process action ...
      }
    );

    const runHandlers = pipeHandlers(handleInit, handleReady, handleOtherAction);

    return (next) => (action) => {
      const result = next(action);
      runHandlers({ action, api });
      return result;
    };
  };
}

// Reducer uses .match() for type-safe action handling
const myReady = createAction<void, "MY_READY">("MY_READY");
const myActionPending = createAction<{ action: Action }, "MY_ACTION_PENDING">("MY_ACTION_PENDING");
const myPendingCleared = createAction<void, "MY_PENDING_CLEARED">("MY_PENDING_CLEARED");

function myReducer(state = { ready: false, pendingActions: [] }, action) {
  if (myReady.match(action)) {
    return { ...state, ready: true };
  }
  if (myActionPending.match(action)) {
    return { ...state, pendingActions: [...state.pendingActions, action.payload.action] };
  }
  if (myPendingCleared.match(action)) {
    return { ...state, pendingActions: [] };
  }
  return state;
}
```

**Benefits:**
- Each handler is independently testable
- State transitions are explicit and observable
- No hidden dependencies
- Easy to debug - all state changes go through reducer

### Anti-Pattern 3: Logic in Middleware Function

#### ❌ WRONG
```typescript
export function createMyMiddleware(config) {
  const pendingActions: Action[] = [];
  let ready = false;

  return (api: MiddlewareAPI) => {
    const handleInit = whenAction(/* ... */);
    const runHandlers = pipeHandlers(handleInit);

    return (next) => (action) => {
      // ❌ WRONG - Logic in middleware function!
      if (action.type === "OTHER" && !ready) {
        pendingActions.push(action);
        return;  // Don't call next - blocked
      }

      // ❌ WRONG - Another check in middleware function!
      if (action.type === "READY") {
        ready = true;
        while (pendingActions.length > 0) {
          api.dispatch(pendingActions.shift()!);
        }
      }

      const result = next(action);
      runHandlers({ action, api });
      return result;
    };
  };
}
```

**Problems:**
- Logic is split between middleware function and handlers
- Harder to test and debug
- Breaks the pattern - middleware function should ONLY pipe handlers
- Mixes concerns (gating vs. handling)

#### ✅ RIGHT
```typescript
export function createMyMiddleware(config) {
  return (api: MiddlewareAPI) => {
    // ✅ All logic in independent whenAction handlers
    const handleInit = whenAction(
      (action) => action.type === "INIT",
      ({ api }) => {
        api.dispatch({ type: "MY_READY" });
      }
    );

    const handleReady = whenAction(
      (action) => action.type === "MY_READY",
      ({ api }) => {
        const state = api.getState();
        state.myPlugin.pendingActions.forEach((a) => api.dispatch(a));
        api.dispatch({ type: "MY_PENDING_CLEARED" });
      }
    );

    const handleOther = whenAction(
      (action) => action.type === "OTHER",
      ({ api, action }) => {
        const state = api.getState();
        if (!state.myPlugin.ready) {
          api.dispatch({ type: "MY_ACTION_PENDING", action });
        } else {
          // ... process ...
        }
      }
    );

    const runHandlers = pipeHandlers(handleInit, handleReady, handleOther);

    // ✅ Middleware function ONLY pipes handlers
    return (next) => (action) => {
      const result = next(action);
      runHandlers({ action, api });
      return result;
    };
  };
}
```

**Benefits:**
- Single responsibility - middleware function only pipes
- All logic in testable, independent handlers
- Follows consistent pattern
- Easy to understand and maintain

### Anti-Pattern 4: Async Middleware with Await

#### ❌ WRONG
```typescript
export function createMyMiddleware(config) {
  const connectionPromise = connectToServer();  // ❌ Async operation in closure

  return (api: MiddlewareAPI) => {
    return (next) => async (action) => {  // ❌ async middleware function!
      if (action.type === "SOME_ACTION") {
        await connectionPromise;  // ❌ BLOCKS entire middleware chain!
      }

      return next(action);
    };
  };
}
```

**Problems:**
- Blocks the entire middleware chain
- No other middleware can process actions while waiting
- Violates Redux middleware conventions (must be synchronous)
- Can't track or cancel the async work

#### ✅ RIGHT
```typescript
export function createMyMiddleware(config) {
  return (api: MiddlewareAPI) => {
    const handleStart = whenAction(
      (action) => action.type === "START",
      ({ action, api }) => {
        const state = api.getState();

        if (state.myPlugin.connecting) return;

        api.dispatch({ type: "MY_CONNECTING" });

        // ✅ Use dispatchWork for async operations
        dispatchWork(api, {
          action,
          run: async () => {
            await connectToServer();
            api.dispatch({ type: "MY_CONNECTED" });
          },
        });
      }
    );

    const runHandlers = pipeHandlers(handleStart);

    return (next) => (action) => {  // ✅ Synchronous!
      const result = next(action);
      runHandlers({ action, api });
      return result;
    };
  };
}
```

**Benefits:**
- Middleware chain stays synchronous and fast
- Async work is tracked in action tree
- Work can be cancelled via abort signal
- Follows Redux conventions

### Reference Implementation

The canonical example of correct middleware patterns is in `packages/agent-plugin/src/middleware/agent.ts`:

```typescript
export function createAgentMiddleware(config = {}) {
  const { maxLoops } = config;

  return (api: MiddlewareAPI<AgentState, AllActions>) => {
    // ✅ Each whenAction is independent
    // ✅ Only reads from api.getState()
    // ✅ Only dispatches actions
    // ✅ No shared closure state

    const handleExecutionStart = whenAction(
      (action) => action.type === "RUNTIME_EXECUTION_STARTED",
      ({ action, api }) => {
        const state = api.getState();  // ✅ Read state
        if (maxLoops !== undefined && state.loop.loopCount >= maxLoops) {
          return;
        }
        api.dispatch({ type: "AGENT_MODEL_REQUESTED", executionId });  // ✅ Dispatch
      }
    );

    const handleModelResponded = whenAction(/* ... */);
    const handleToolCompletion = whenAction(/* ... */);

    const runHandlers = pipeHandlers(
      handleExecutionStart,
      handleModelResponded,
      handleToolCompletion
    );

    // ✅ Middleware function ONLY pipes handlers
    return (next) => (action) => {
      const result = next(action);
      runHandlers({ action, api });
      return result;
    };
  };
}
```

**Key principles:**
- Each `whenAction` is a pure function (only reads state, only dispatches actions)
- No closure variables for state
- Middleware function only pipes handlers together
- All state lives in the reducer

### When You Need Action Gating

If you need to gate actions (like waiting for tools to register before allowing model requests), use action-based coordination:

1. **Store gating state in reducer** (e.g., `toolsRegistered: boolean`)
2. **Check state in handler** via `api.getState()`
3. **Dispatch request actions** when not ready
4. **Store pending actions in reducer state** (not closure)
5. **Re-dispatch pending actions** when ready

See `packages/agent-plugin/src/middleware/agent.ts` for the reference pattern.

## FAQ

**Q: Why Redux instead of simpler state management?**

Redux provides:
- Time-travel debugging (replay actions)
- Complete audit trail (all actions logged)
- Predictable state updates (pure reducers)
- Middleware for side effects (clean separation)
- Excellent testing story

**Q: When should I use middleware vs. dispatching directly?**

- **Middleware**: Cross-cutting concerns (logging, metrics, approvals, validation)
- **Direct dispatch**: Application logic, async work (thunks), testing

Users dispatch thunks. Middleware orchestrates them into tracked work.

**Q: Can middleware be async?**

No. Middleware functions are synchronous. But they can:
- Dispatch thunks (which are async)
- Dispatch actions, wait externally, dispatch response actions (HITL pattern)
- Use `setTimeout`, `queueMicrotask` for deferred work

**Q: How do interrupts work?**

- Interrupt adds a NEW turn to the SAME execution
- Previous turn's handle resolves with `{ status: "interrupted" }`
- All work from ALL turns continues running in parallel
- Execution completes when ALL work finishes

**Q: What's the difference between interrupt and abort?**

- **Interrupt**: `execute()` while pending → Adds turn, old work continues
- **Abort**: `handle.cancel()` → Fires abort signal, all work should stop, execution aborts

**Q: How do I handle errors without stopping execution?**

Catch errors inside your thunks:

```typescript
runtime.dispatch(async (dispatch) => {
  try {
    await riskyWork();
  } catch (error) {
    // Handle it - execution continues
    dispatch({ type: "RUNTIME_WORK_FAILED", error });
  }
});
```

If an error escapes a thunk, the entire execution fails.