From 0b1c0c95f7b410e4bacfcaeb6048785d2b1f3e8f Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Wed, 18 Feb 2026 21:59:18 -0800 Subject: [PATCH 01/39] feat: support multiple api providers --- CLAUDE.md | 254 ++------- README.md | 164 +++++- bun.lock | 3 + package.json | 16 +- packages/agentry/build.ts | 5 +- packages/agentry/package.json | 11 + packages/agentry/src/anthropic.ts | 41 ++ packages/agentry/src/components/AgentTool.tsx | 4 +- packages/agentry/src/components/MCP.tsx | 4 +- packages/agentry/src/components/Message.tsx | 4 +- packages/agentry/src/components/Tool.tsx | 4 +- packages/agentry/src/components/hooks.ts | 5 +- packages/agentry/src/components/index.ts | 6 - .../agentry/src/components/jsx-elements.d.ts | 18 +- packages/agentry/src/constants.ts | 11 +- packages/agentry/src/debug.ts | 7 +- .../agentry/src/execution/ExecutionEngine.ts | 487 ++++++------------ packages/agentry/src/execution/conditions.ts | 27 +- .../src/execution/createEngineConfig.ts | 18 +- .../src/handles/AbstractAgentHandle.ts | 22 +- packages/agentry/src/handles/AgentHandle.ts | 56 +- .../agentry/src/handles/SubagentHandle.ts | 32 +- packages/agentry/src/index.ts | 8 +- .../agentry/src/instances/createInstance.ts | 15 +- packages/agentry/src/instances/types.ts | 39 +- packages/agentry/src/openai.ts | 39 ++ packages/agentry/src/providers/anthropic.ts | 205 ++++++++ .../agentry/src/providers/clientResolver.ts | 75 +++ packages/agentry/src/providers/index.ts | 13 + packages/agentry/src/providers/openai.ts | 236 +++++++++ packages/agentry/src/providers/types.ts | 41 ++ packages/agentry/src/reconciler/reconciler.ts | 17 +- packages/agentry/src/reconciler/renderer.ts | 24 +- packages/agentry/src/reconciler/utils.ts | 15 +- packages/agentry/src/run/agent.ts | 37 +- packages/agentry/src/run/createAI.ts | 83 +++ packages/agentry/src/run/index.ts | 14 +- packages/agentry/src/run/runAgentFunction.ts | 29 +- packages/agentry/src/run/subagent.ts | 11 +- packages/agentry/src/store.ts | 10 +- .../agentry/src/tools/agentSyntheticTool.ts | 5 +- packages/agentry/src/tools/defineTool.ts | 11 +- packages/agentry/src/tools/memoryTool.ts | 12 +- packages/agentry/src/types/agent.ts | 22 +- packages/agentry/src/types/agentTool.ts | 4 +- packages/agentry/src/types/index.ts | 12 + packages/agentry/src/types/json.ts | 7 + packages/agentry/src/types/lifecycle.ts | 15 +- packages/agentry/src/types/messages.ts | 98 +++- packages/agentry/src/types/provider.ts | 1 + packages/agentry/src/types/state.ts | 6 +- packages/agentry/src/types/tools.ts | 46 +- packages/agentry/tests/compaction.test.ts | 6 +- packages/agentry/tests/conditions.test.tsx | 32 +- .../agentry/tests/dynamic-components.test.tsx | 2 +- packages/agentry/tests/hooks.test.tsx | 14 +- .../agentry/tests/openai-provider.test.tsx | 97 ++++ packages/agentry/tests/run-agent.test.tsx | 14 +- packages/agentry/tests/runtime.test.tsx | 54 +- packages/agentry/tests/subagents.test.tsx | 17 +- packages/agentry/tests/utils/index.ts | 1 + packages/agentry/tests/utils/openaiMock.ts | 36 ++ .../src/{ => anthropic}/cache-ephemeral.tsx | 0 .../examples/src/{ => anthropic}/thinking.tsx | 2 +- packages/examples/src/chatbot.tsx | 34 +- packages/examples/src/constants.ts | 1 + .../examples/src/conversation-persistence.tsx | 22 +- .../examples/src/cross-provider/subagents.tsx | 64 +++ packages/examples/src/demo.tsx | 26 +- packages/examples/src/interactive.tsx | 30 +- packages/examples/src/mcp.tsx | 26 +- packages/examples/src/openai/basic.tsx | 35 ++ .../examples/src/openai/subtools-smoke.tsx | 51 ++ packages/examples/src/tweet.tsx | 81 +++ packages/examples/src/web-search.tsx | 26 +- packages/examples/tsconfig.json | 7 +- 76 files changed, 2158 insertions(+), 869 deletions(-) create mode 100644 packages/agentry/src/anthropic.ts create mode 100644 packages/agentry/src/openai.ts create mode 100644 packages/agentry/src/providers/anthropic.ts create mode 100644 packages/agentry/src/providers/clientResolver.ts create mode 100644 packages/agentry/src/providers/index.ts create mode 100644 packages/agentry/src/providers/openai.ts create mode 100644 packages/agentry/src/providers/types.ts create mode 100644 packages/agentry/src/run/createAI.ts create mode 100644 packages/agentry/src/types/json.ts create mode 100644 packages/agentry/src/types/provider.ts create mode 100644 packages/agentry/tests/openai-provider.test.tsx create mode 100644 packages/agentry/tests/utils/openaiMock.ts rename packages/examples/src/{ => anthropic}/cache-ephemeral.tsx (100%) rename packages/examples/src/{ => anthropic}/thinking.tsx (99%) create mode 100644 packages/examples/src/cross-provider/subagents.tsx create mode 100644 packages/examples/src/openai/basic.tsx create mode 100644 packages/examples/src/openai/subtools-smoke.tsx create mode 100644 packages/examples/src/tweet.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 29679de..01de671 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,235 +2,81 @@ ## Overview -Agentry is a React reconciler-based framework for declarative AI agent orchestration. It uses a custom React reconciler to translate JSX into agent execution plans, bringing React's declarative component model to AI agent systems. +Agentry is a React reconciler-based framework for declarative AI agent orchestration. +It supports multiple providers (Anthropic + OpenAI) through a shared core API with a single `run` plus `createAI` defaults. ## Development Commands ### Testing -- `bun test` - Run all tests across packages -- `bun test --timeout 100 ./packages/**/tests` - Full test suite (as configured) -- `bun test packages/core/tests/tools.test.ts` - Run a specific test file +- `bun test` - Run the full Agentry test suite (`packages/agentry/tests`) +- `bun test packages/agentry/tests/openai-provider.test.tsx` - OpenAI provider tests +- `bun test packages/agentry/tests/runtime.test.tsx` - Anthropic/core runtime coverage ### Type Checking & Linting -- `bun run typecheck` - Type check all packages (runs `tsc --noEmit`) -- `bun run lint` - Lint codebase with ESLint (max 0 warnings) -- `bun run lint:fix` - Auto-fix linting issues -- `bun run format` - Check formatting with Prettier -- `bun run format:fix` - Auto-format with Prettier +- `bun run typecheck` - Type check all workspace packages +- `bun run lint` - Lint source and examples +- `bun run lint:fix` - Auto-fix lint issues +- `bun run format` - Check formatting +- `bun run format:fix` - Auto-format ### Running Examples -Examples are in `packages/examples/src/`. Run with: +Examples live in `packages/examples/src/`. ```bash bun run example: -# e.g., bun run example:basic, bun run example:subagents +# provider-agnostic: example:basic +# provider-specific: example:anthropic:chatbot, example:openai:basic ``` -Or directly: +## Repository Layout -```bash -bun run packages/examples/src/.tsx -``` - -## Architecture - -### Package Structure (Monorepo with Bun Workspaces) - -- **`packages/core`** - Core reconciler and execution engine - - `src/reconciler/` - React reconciler implementation that translates JSX to agent instances - - `src/execution/` - ExecutionEngine that manages agent/API interaction lifecycle - - `src/handles/` - AgentHandle and SubagentHandle for controlling agent execution - - `src/instances/` - Instance types representing reconciled elements (AgentInstance, ToolInstance, etc.) - - `src/tools/` - Tool definition and execution utilities - - `src/run/` - Running functions: `agent.ts`, `subagent.ts`, `runAgentFunction.ts` - - `src/types/` - Shared TypeScript types - - `tests/` - Core functionality tests - -- **`packages/components`** - React components for agent orchestration - - JSX components: ``, ``, ``, ``, ``, ``, ``, `` - - Built-in tools: ``, ``, ``, `` - - Hooks: `useExecutionState()`, `useMessages()`, `useAgentState()` - -- **`packages/agentry`** - Main entry point package - - Re-exports from `@agentry/core` and `@agentry/components` - - Single import point for users: `import { run, Agent, ... } from 'agentry'` - -- **`packages/shared`** - Shared constants - - `MODEL`, `TEST_MODEL` constants - -- **`packages/examples`** - Example applications demonstrating framework features - -### Key Architectural Concepts - -**React Reconciler Integration** - -- The reconciler (`packages/core/src/reconciler/reconciler.ts`) translates JSX into instance objects -- Instance types (`packages/core/src/instances/types.ts`) represent the reconciled tree structure -- The `createInstance` function builds instances from element types and props -- Instances contain both component configuration and runtime state - -**Execution Flow** - -1. `run()` creates an AgentHandle with the JSX element -2. AgentHandle uses the reconciler to build an AgentInstance tree -3. ExecutionEngine manages the conversation loop with the Anthropic API -4. Tool calls trigger handler execution and potential re-renders (for dynamic tools) -5. State updates via hooks can modify the instance tree mid-execution - -**Subagents (AgentTool)** - -- Subagents are created using `` component, NOT by directly nesting `` in `` -- AgentTool takes an `agent` prop - a function that receives parsed parameters and returns an `` JSX element -- When the parent calls the tool, the framework creates a SubagentHandle to execute the agent -- SubagentHandle renders the JSX returned by the `agent` function and manages its lifecycle -- Subagent results flow back as tool results to parent agent - -Example pattern: - -```tsx - ( - - You are a research expert. - Research the topic: {input.topic} - - )} -/> -``` - -**Programmatic Agent Spawning** - -- Tool handlers receive a `ToolContext` with a `runAgent()` function -- `runAgent()` allows programmatically creating and executing agents from within tool handlers -- Spawned agents run to completion and return their full `AgentResult` to the handler -- Results are only visible to the tool handler (not to Claude in the parent conversation) -- Supports conditional spawning, parallel execution, and custom configuration per spawned agent -- Implemented via `createRunAgent()` which creates a bound function from execution context -- The spawned agent is executed as a subagent using `SubagentHandle` - -Example pattern: - -```tsx - { - const result = await context.runAgent( - - You are an analyst - Analyze: {input.data} - , - { maxTokens: 2048 }, // Optional config override - ) - return result.content - }} -/> -``` - -**Conditional Rendering (Condition)** - -- Condition components enable conditional rendering based on state or natural language intent -- Conditions can wrap any agent components (System, Context, Tools, etc.) -- Boolean conditions (`when={boolean}`) evaluated synchronously before each API call -- Natural language conditions (`when="..."`) evaluated via LLM based on conversation context -- Condition evaluation happens in `ExecutionEngine.evaluateAllConditions()` before building the API request -- Multiple conditions can be active simultaneously (parallel evaluation) -- Condition evaluation implemented in `evaluateConditions()` which: - - First checks all boolean conditions synchronously - - Then evaluates NL conditions via single batched LLM call - - Updates `isActive` state on each ConditionInstance -- ConditionInstance tracks `isActive` which is checked by collectors -- Collectors (`collectChild`) only collect children from conditions where `isActive === true` -- Conditions can be nested - both parent and child must be active for grandchildren to be collected - -Example pattern: - -```tsx - - You are authenticated - - - - - - - -{/* Nested conditions */} - - - - - -``` - -**Dynamic Tools** - -- Tools can be added/removed during execution using React state (useState) -- State changes trigger reconciler updates, modifying the instance tree -- Next API call includes the updated tool set - -**Batch vs Interactive Mode** - -- **Batch mode** (default): Runs to completion, returns AgentResult -- **Interactive mode**: Returns AgentHandle for multi-turn conversations - -## Testing - -Tests use Bun's built-in test runner. Mock client available via `createStepMockClient` from `tests/utils`. - -Common test patterns: - -- Import test utilities: `import { createStepMockClient } from './utils'` -- Use `expect` from `bun:test` -- Mock Anthropic responses for deterministic testing - -## TypeScript Configuration - -- Target: ESNext with bundler module resolution -- Strict mode enabled with additional checks (`noUncheckedIndexedAccess`, `noImplicitOverride`) -- JSX runtime: `react-jsx` with React 19 -- Module: `Preserve` (uses TypeScript's new module preservation) -- No emit mode (bundler handles compilation) +- `packages/agentry` - Framework package + - `src/reconciler` - custom React reconciler + - `src/execution` - execution loop and condition evaluation + - `src/handles` - agent/subagent handles + - `src/providers` - provider adapters (`anthropic`, `openai`) + - `src/components` - JSX API (`Agent`, `Tool`, `AgentTool`, etc.) + - `src/tools` - tool definition/parsing/execution helpers + - `src/run` - `run`, `createAgent`, `runAgent` wiring + - `tests` - unit/integration tests (including OpenAI provider tests) +- `packages/examples` - runnable examples and DX references -## Important Implementation Notes +## Current Architecture Notes -**Tool Definition** +### Providers and Exports -- Use `defineTool()` for programmatic regular tool creation with Zod schemas -- Use `` JSX for inline declarative tool registration -- Use `defineAgentTool()` for programmatic subagent tool creation -- Use `` JSX for inline declarative subagent registration -- Handler/agent function params are automatically inferred from Zod schema types -- Tool handlers receive `ToolContext` which includes `runAgent()` for programmatic agent running -- `runAgent()` is created via `createRunAgent()` and bound to the execution context (client, model, signal) +- Root `agentry` exports provider-agnostic primitives (`run`, `createAI`, `Agent`, `Tool`, hooks). +- Provider modules: + - `agentry/anthropic` exports Anthropic client factory + Anthropic built-ins (`WebSearch`, `CodeExecution`, `Memory`, `MCP`) + - `agentry/openai` exports OpenAI client factory + OpenAI-compatible built-ins (`WebSearch`, `CodeExecution`, `MCP`) +- Built-ins are treated as regular tools at runtime; there is no hardcoded per-provider capability matrix in the engine. +- Provider selection is resolved from `` (and subagents), while `run`/`createAI` provide clients and runtime defaults. -**Instance Tree Mutations** +### SDK Type Reuse -- The reconciler maintains an instance tree that can be mutated during execution -- Always use the reconciler's update/append/remove methods (via collections handlers) -- Manual instance modifications should go through proper reconciler channels +- Prefer SDK-owned tool/request/response types where possible. +- Avoid redefining provider-specific wire types when SDK types are available. +- Keep framework-level normalized types focused on orchestration concerns only. -**Agent State Management** +### Subagents -- AgentStore (Zustand) manages global agent state -- ExecutionEngine emits state change events -- Hooks subscribe to store updates for reactive behavior +- `` supports declarative subagents. +- `context.runAgent(...)` supports programmatic subagent spawning. +- Cross-provider subagents are supported (for example, OpenAI parent + Anthropic child, and vice versa). -**Error Handling** +### Conditions -- Tools should return error strings rather than throw (allows agent to see and handle errors) -- ExecutionEngine catches and transitions to 'error' state on critical failures -- SubagentHandle automatically cleans up on completion or error -- Run agents (via `runAgent()`) handle errors the same way - errors can be caught in the tool handler +- Boolean conditions evaluate synchronously before each API call. +- Natural-language conditions are evaluated in a batched model call. +- Conditions can be nested; active parent + active child is required for nested content. -**Run Functions** +## Implementation Guidelines -- `run/agent.ts` - Main `run()` function and `createAgent()` utility -- `run/subagent.ts` - Internal `runSubagent()` function for executing subagents -- `run/runAgent.ts` - `createRunAgent()` function for programmatic agent running -- All exports are re-exported from `run/index.ts` for backward compatibility +- Keep strict typing (`no any` / `no unknown` type annotations in implementation code paths). +- Prefer provider SDK types over duplicated local provider shapes. +- Return tool errors as strings so the model can recover. +- Preserve dynamic tool behavior (state-driven add/remove during execution). +- Do not reintroduce provider capability flags for built-ins unless explicitly requested. diff --git a/README.md b/README.md index 21cc0ec..f634d4a 100644 --- a/README.md +++ b/README.md @@ -19,17 +19,15 @@ Agentry adapts React’s component model for AI agents. Define behavior declarat > [!WARNING] > This library is in active development. -> [!NOTE] -> Agentry currently only supports Anthropic models. - ## Quick Start ### Installation ```bash bun add agentry react zod -# Set your Anthropic API key +# Set one or both provider keys export ANTHROPIC_API_KEY="sk-ant-***" +export OPENAI_API_KEY="sk-***" ``` Next, in your `tsconfig.json`: @@ -52,10 +50,11 @@ In `agent.tsx`: ```tsx import { run, Agent, System, Tools, Tool, Message } from 'agentry' +import { anthropic } from 'agentry/anthropic' import { z } from 'zod' const result = await run( - + You are a helpful math assistant What is 42 + 17? , + { + clients: { anthropic: anthropic() }, + }, ) console.log(result.content) @@ -98,11 +100,90 @@ bun run agent.tsx - **Type-safe tools** - Handler params inferred from Zod schemas - **Streaming support** - Stream responses - **Programmatic agent spawning** - Spawn and execute agents on-demand from tool handlers using `context.runAgent()` +- **Cross-provider subagents** - Mix providers across parent/subagent boundaries - **Compaction control** - Automatic message compaction for long conversations to manage context window usage - **Conditional rendering** - Use `` to conditionally render agent components based on state or natural language intent - **Structured outputs** - Use `strict` on tools - **Prompt caching** - Supports Anthropic's prompt caching -- **Built-in tools** - ``, ``, ``, `` +- **Provider-specific built-ins** - Import built-ins from `agentry/anthropic` or `agentry/openai` +- **SDK-native tool shapes** - Built-ins reuse provider SDK types where available + +## Providers + +Agentry supports multiple providers with a single declarative API. + +- Root `agentry` exports the provider-agnostic core (`run`, `Agent`, hooks, custom tools). +- Provider modules export provider clients and built-ins: + - `agentry/anthropic` + - `agentry/openai` +- Built-ins are treated as regular tools in execution (no per-provider capability matrix to maintain). + +### Root + explicit provider + +```tsx +import { run, Agent, Message } from 'agentry' +import { openai } from 'agentry/openai' + +const result = await run( + + Hello + , + { + clients: { openai: openai() }, + }, +) +``` + +### `createAI` defaults + +```tsx +import { createAI, Agent, Message, Tools } from 'agentry' +import { anthropic, WebSearch } from 'agentry/anthropic' + +const ai = createAI({ + clients: { anthropic: anthropic() }, +}) + +const result = await ai.run( + + + + + Find the latest React release notes + , +) +``` + +### Cross-provider subagents + +`` and `context.runAgent(...)` can run subagents on a different provider than the parent: + +```tsx +import { createAI, Agent, AgentTool, Message, Tools } from 'agentry' +import { openai } from 'agentry/openai' + +const ai = createAI({ + clients: { openai: openai() }, +}) + +await ai.run( + + + ( + + Research: {topic} + + )} + /> + + Use claude_researcher for React 19 updates. + , +) +``` ## Examples @@ -119,17 +200,26 @@ Want to see code? See [examples/](/packages/examples/src) | [`mcp.tsx`](packages/examples/src/mcp.tsx) | MCP server integration | | [`chatbot.tsx`](packages/examples/src/chatbot.tsx) | Terminal-based chatbot | | [`create-subagent.tsx`](packages/examples/src/create-subagent.tsx) | Dynamic subagent creation | -| [`cache-ephemeral.tsx`](packages/examples/src/cache-ephemeral.tsx) | Prompt caching with ephemeral content | +| [`anthropic/cache-ephemeral.tsx`](packages/examples/src/anthropic/cache-ephemeral.tsx) | Prompt caching with ephemeral content | | [`conditions.tsx`](packages/examples/src/conditions.tsx) | State-based and NL condition rendering | -| [`thinking.tsx`](packages/examples/src/thinking.tsx) | Extended thinking with interleaved support | +| [`anthropic/thinking.tsx`](packages/examples/src/anthropic/thinking.tsx) | Extended thinking with interleaved support | | [`workflow.tsx`](packages/examples/src/workflow.tsx) | Interactive authentication workflow | | [`conversation-persistence.tsx`](packages/examples/src/conversation-persistence.tsx) | Conversation save/load | +| [`openai/basic.tsx`](packages/examples/src/openai/basic.tsx) | OpenAI Responses API basic usage | +| [`cross-provider/subagents.tsx`](packages/examples/src/cross-provider/subagents.tsx) | OpenAI parent + Anthropic subagents | Run an example: ```bash echo "ANTHROPIC_API_KEY=sk-ant-***" > .env +echo "OPENAI_API_KEY=sk-***" >> .env bun run example:basic +# OpenAI examples: +bun run example:openai:basic +# provider-agnostic examples (set EXAMPLE_PROVIDER=openai if needed): +bun run example:chatbot +# Anthropic-specific examples: +bun run example:anthropic:thinking ``` ## Core Concepts @@ -139,13 +229,18 @@ bun run example:basic **Batch mode** (default) - Runs to completion: ```tsx -const result = await run(...) +const result = await run(..., { + clients: { anthropic: anthropic() }, +}) ``` **Interactive mode** - Returns a handle for ongoing interaction: ```tsx -const agent = await run(..., { mode: 'interactive' }) +const agent = await run(..., { + mode: 'interactive', + clients: { anthropic: anthropic() }, +}) await agent.sendMessage('Hello') for await (const event of agent.stream('Tell me more')) { if (event.type === 'text') process.stdout.write(event.text) @@ -368,26 +463,57 @@ Runs an agent and returns a result or handle. ```tsx // Batch mode -const result: AgentResult = await run(...) +const result: AgentResult = await run(..., { + clients: { anthropic: anthropic() }, +}) // Interactive mode -const handle: AgentHandle = await run(..., { +const handle: AgentHandle = await run(..., { mode: 'interactive', + clients: { anthropic: anthropic() }, }) ``` **Options:** - `mode?: 'batch' | 'interactive'` - Execution mode (default: `'batch'`) -- `client?: Anthropic` - Custom Anthropic client +- `client?: Anthropic | OpenAI` - Single provider client (backward-compatible shortcut) +- `clients?: { anthropic?: Anthropic; openai?: OpenAI }` - Provider client map + - provider is chosen from `` + - if omitted, provider clients are created from environment variables by default + +### `createAI(defaults)` + +Create a defaults-bound runner so you can use `ai.run(...)` and `ai.createAgent(...)`. + +```tsx +import { createAI, Agent, Message } from 'agentry' +import { openai } from 'agentry/openai' + +const ai = createAI({ + clients: { openai: openai() }, +}) + +const result = await ai.run( + + Hello + , +) +``` ### Components +Built-ins are provider-owned exports: + +- `WebSearch` from `agentry/anthropic` or `agentry/openai` +- `CodeExecution` and `MCP` from `agentry/anthropic` or `agentry/openai` +- `Memory` from `agentry/anthropic` + #### `` | Prop | Type | Description | | -------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `model` | `string` | Claude model (e.g. `claude-sonnet-4-5`) | +| `model` | `string` | Provider model id (e.g. `claude-sonnet-4-5`, `gpt-4.1-mini`) | | `name?` | `string` | Agent identifier | | `description?` | `string` | Agent description | | `maxTokens?` | `number` | Max output tokens (default: `4096`) | @@ -527,16 +653,20 @@ Tool handlers receive a `context` object: | Property | Type | Description | | ----------- | -------------------------------------------------------------------------- | ----------------------------- | | `agentName` | `string` | Name of the current agent | -| `client` | `Anthropic` | Anthropic client instance | +| `provider` | `'anthropic' \| 'openai'` | Current provider | +| `client?` | `Anthropic \| OpenAI` | Current provider client | +| `clients?` | `{ anthropic?: Anthropic; openai?: OpenAI }` | Provider client map | | `model?` | `string` | Current agent's model | | `signal?` | `AbortSignal` | Abort signal for cancellation | -| `metadata?` | `Record` | Custom metadata | +| `metadata?` | `JsonObject` | Custom JSON-like metadata | | `runAgent` | `(agent: ReactElement, options?: RunAgentOptions) => Promise` | Run an agent programmatically | **RunAgentOptions:** | Field | Type | Description | | -------------- | ------------- | ----------------------- | +| `provider?` | `'anthropic' \| 'openai'` | Override provider | +| `clients?` | `{ anthropic?: Anthropic; openai?: OpenAI }` | Override client map | | `model?` | `string` | Override parent's model | | `maxTokens?` | `number` | Override max tokens | | `temperature?` | `number` | Override temperature | @@ -547,7 +677,7 @@ Tool handlers receive a `context` object: - Node.js 18+ or Bun - React 19+ - TypeScript 5+ -- Anthropic API Key +- Provider API key(s): Anthropic and/or OpenAI ## Development diff --git a/bun.lock b/bun.lock index ce793ab..d37f6b7 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.1", "eventemitter3": "^5.0.1", + "openai": "^6.22.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "zustand": "^5.0.3", @@ -165,6 +166,8 @@ "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "openai": ["openai@6.22.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw=="], + "oxfmt": ["oxfmt@0.32.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.32.0", "@oxfmt/binding-android-arm64": "0.32.0", "@oxfmt/binding-darwin-arm64": "0.32.0", "@oxfmt/binding-darwin-x64": "0.32.0", "@oxfmt/binding-freebsd-x64": "0.32.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.32.0", "@oxfmt/binding-linux-arm-musleabihf": "0.32.0", "@oxfmt/binding-linux-arm64-gnu": "0.32.0", "@oxfmt/binding-linux-arm64-musl": "0.32.0", "@oxfmt/binding-linux-ppc64-gnu": "0.32.0", "@oxfmt/binding-linux-riscv64-gnu": "0.32.0", "@oxfmt/binding-linux-riscv64-musl": "0.32.0", "@oxfmt/binding-linux-s390x-gnu": "0.32.0", "@oxfmt/binding-linux-x64-gnu": "0.32.0", "@oxfmt/binding-linux-x64-musl": "0.32.0", "@oxfmt/binding-openharmony-arm64": "0.32.0", "@oxfmt/binding-win32-arm64-msvc": "0.32.0", "@oxfmt/binding-win32-ia32-msvc": "0.32.0", "@oxfmt/binding-win32-x64-msvc": "0.32.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-KArQhGzt/Y8M1eSAX98Y8DLtGYYDQhkR55THUPY5VNcpFQ+9nRZkL3ULXhagHMD2hIvjy8JSeEQEP5/yYJSrLA=="], "oxlint": ["oxlint@1.47.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.47.0", "@oxlint/binding-android-arm64": "1.47.0", "@oxlint/binding-darwin-arm64": "1.47.0", "@oxlint/binding-darwin-x64": "1.47.0", "@oxlint/binding-freebsd-x64": "1.47.0", "@oxlint/binding-linux-arm-gnueabihf": "1.47.0", "@oxlint/binding-linux-arm-musleabihf": "1.47.0", "@oxlint/binding-linux-arm64-gnu": "1.47.0", "@oxlint/binding-linux-arm64-musl": "1.47.0", "@oxlint/binding-linux-ppc64-gnu": "1.47.0", "@oxlint/binding-linux-riscv64-gnu": "1.47.0", "@oxlint/binding-linux-riscv64-musl": "1.47.0", "@oxlint/binding-linux-s390x-gnu": "1.47.0", "@oxlint/binding-linux-x64-gnu": "1.47.0", "@oxlint/binding-linux-x64-musl": "1.47.0", "@oxlint/binding-openharmony-arm64": "1.47.0", "@oxlint/binding-win32-arm64-msvc": "1.47.0", "@oxlint/binding-win32-ia32-msvc": "1.47.0", "@oxlint/binding-win32-x64-msvc": "1.47.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-v7xkK1iv1qdvTxJGclM97QzN8hHs5816AneFAQ0NGji1BMUquhiDAhXpMwp8+ls16uRVJtzVHxP9pAAXblDeGA=="], diff --git a/package.json b/package.json index ccc4832..ecbb558 100644 --- a/package.json +++ b/package.json @@ -26,20 +26,24 @@ "format": "oxfmt --check packages/agentry/src packages/examples/src", "format:fix": "oxfmt --write packages/agentry/src packages/examples/src", "example:basic": "bun run packages/examples/src/basic.tsx", - "example:chatbot": "bun run packages/examples/src/chatbot.tsx", "example:create-ephemeral-subagent": "bun run packages/examples/src/create-ephemeral-subagent.tsx", "example:create-subagent": "bun run packages/examples/src/create-subagent.tsx", "example:dynamic-tools": "bun run packages/examples/src/dynamic-tools.tsx", "example:hooks": "bun run packages/examples/src/hooks.tsx", + "example:subagents": "bun run packages/examples/src/subagents.tsx", + "example:ctf": "bun run packages/examples/src/ctf.tsx", + "example:anthropic:thinking": "bun run packages/examples/src/anthropic/thinking.tsx", + "example:programmatic-spawn": "bun run packages/examples/src/programmatic-spawn.tsx", + "example:openai:basic": "bun run packages/examples/src/openai/basic.tsx", + "example:openai:subtools": "bun run packages/examples/src/openai/subtools-smoke.tsx", + "example:cross-provider:subagents": "bun run packages/examples/src/cross-provider/subagents.tsx", + "example:anthropic:cache-ephemeral": "bun run packages/examples/src/anthropic/cache-ephemeral.tsx", + "example:chatbot": "bun run packages/examples/src/chatbot.tsx", "example:interactive": "bun run packages/examples/src/interactive.tsx", "example:mcp": "bun run packages/examples/src/mcp.tsx", - "example:subagents": "bun run packages/examples/src/subagents.tsx", "example:web-search": "bun run packages/examples/src/web-search.tsx", - "example:ctf": "bun run packages/examples/src/ctf.tsx", "example:demo": "bun run packages/examples/src/demo.tsx", - "example:thinking": "bun run packages/examples/src/thinking.tsx", - "example:programmatic-spawn": "bun run packages/examples/src/programmatic-spawn.tsx", - "example:cache-ephemeral": "bun run packages/examples/src/cache-ephemeral.tsx", + "example:tweet": "bun run packages/examples/src/tweet.tsx", "example:router": "bun run packages/examples/src/router-demo.tsx" }, "type": "module", diff --git a/packages/agentry/build.ts b/packages/agentry/build.ts index 4ca17cb..04e2bb7 100644 --- a/packages/agentry/build.ts +++ b/packages/agentry/build.ts @@ -6,19 +6,20 @@ await rm('dist', { recursive: true, force: true }) console.log('Building ESM bundle...') const result = await Bun.build({ - entrypoints: ['./src/index.ts'], + entrypoints: ['./src/index.ts', './src/openai.ts', './src/anthropic.ts'], outdir: './dist', format: 'esm', target: 'node', sourcemap: 'external', minify: false, naming: { - entry: 'index.js', + entry: '[dir]/[name].js', }, external: [ 'react', 'zod', '@anthropic-ai/sdk', + 'openai', 'eventemitter3', 'react-reconciler', 'scheduler', diff --git a/packages/agentry/package.json b/packages/agentry/package.json index 967fa64..3ac43a5 100644 --- a/packages/agentry/package.json +++ b/packages/agentry/package.json @@ -30,6 +30,16 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./openai": { + "types": "./dist/openai.d.ts", + "import": "./dist/openai.js", + "default": "./dist/openai.js" + }, + "./anthropic": { + "types": "./dist/anthropic.d.ts", + "import": "./dist/anthropic.js", + "default": "./dist/anthropic.js" } }, "files": [ @@ -46,6 +56,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.1", "eventemitter3": "^5.0.1", + "openai": "^6.22.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "zustand": "^5.0.3" diff --git a/packages/agentry/src/anthropic.ts b/packages/agentry/src/anthropic.ts new file mode 100644 index 0000000..288050e --- /dev/null +++ b/packages/agentry/src/anthropic.ts @@ -0,0 +1,41 @@ +import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk' +import { + Agent, + Tool, + AgentTool, + System, + Context, + Message, + Tools, + Condition, + useExecutionState, + useMessages, + useAgentState, +} from './components' +import { WebSearch } from './components/built-ins/WebSearch' +import { CodeExecution } from './components/built-ins/CodeExecution' +import { Memory } from './components/built-ins/Memory' +import { MCP } from './components/MCP' + +export function anthropic(options?: ClientOptions): Anthropic { + return new Anthropic(options) +} + +export { + Agent, + Tool, + AgentTool, + System, + Context, + Message, + Tools, + Condition, + WebSearch, + CodeExecution, + Memory, + MCP, + useExecutionState, + useMessages, + useAgentState, +} +export type { AgentResult } from './types' diff --git a/packages/agentry/src/components/AgentTool.tsx b/packages/agentry/src/components/AgentTool.tsx index e9ab56b..5184015 100644 --- a/packages/agentry/src/components/AgentTool.tsx +++ b/packages/agentry/src/components/AgentTool.tsx @@ -52,7 +52,7 @@ export function AgentTool( if ('parameters' in props && 'jsonSchema' in props) { return ( } + agentTool={props as InternalAgentTool} key={props.name} /> ) @@ -62,7 +62,7 @@ export function AgentTool( return ( } + agentTool={agentTool as InternalAgentTool} key={agentTool.name} /> ) diff --git a/packages/agentry/src/components/MCP.tsx b/packages/agentry/src/components/MCP.tsx index b54189c..40da26f 100644 --- a/packages/agentry/src/components/MCP.tsx +++ b/packages/agentry/src/components/MCP.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { BetaRequestMCPServerToolConfiguration } from '@anthropic-ai/sdk/resources/beta' +import type { MCPServerConfig } from '../instances/types' export interface MCPProps { /** Name of the MCP server */ @@ -9,7 +9,7 @@ export interface MCPProps { /** Authorization token for the MCP server */ authorization_token?: string /** Tool configuration (allowed tools, enabled status) */ - tool_configuration?: BetaRequestMCPServerToolConfiguration + tool_configuration?: MCPServerConfig['tool_configuration'] } /** diff --git a/packages/agentry/src/components/Message.tsx b/packages/agentry/src/components/Message.tsx index 71d01d1..ad2fee4 100644 --- a/packages/agentry/src/components/Message.tsx +++ b/packages/agentry/src/components/Message.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from 'react' -import type { BetaContentBlockParam } from '@anthropic-ai/sdk/resources/beta' +import type { AgentContentBlock } from '../types/messages' export interface MessageProps { role: 'user' | 'assistant' children?: ReactNode - content?: string | BetaContentBlockParam[] + content?: string | AgentContentBlock[] } /** diff --git a/packages/agentry/src/components/Tool.tsx b/packages/agentry/src/components/Tool.tsx index 0e5a387..9cd9da5 100644 --- a/packages/agentry/src/components/Tool.tsx +++ b/packages/agentry/src/components/Tool.tsx @@ -36,10 +36,10 @@ export function Tool( props: ToolProps, ): ReactNode { if ('parameters' in props && 'jsonSchema' in props) { - return } key={props.name} /> + return } const tool = defineTool(props) - return } key={tool.name} /> + return } diff --git a/packages/agentry/src/components/hooks.ts b/packages/agentry/src/components/hooks.ts index adff61a..e2453ee 100644 --- a/packages/agentry/src/components/hooks.ts +++ b/packages/agentry/src/components/hooks.ts @@ -6,7 +6,8 @@ import { type AgentStoreState, } from '../store' import { AgentContext, AgentProvider } from '../context' -import type { AgentState, BetaMessageParam } from '../types' +import type { AgentState } from '../types' +import type { AgentMessageParam } from '../types/messages' export { createAgentStore, @@ -60,7 +61,7 @@ export function useExecutionState(): AgentState { * } * ``` */ -export function useMessages(): BetaMessageParam[] { +export function useMessages(): AgentMessageParam[] { const store = useAgentStore() return useStore(store, ({ messages }) => messages) } diff --git a/packages/agentry/src/components/index.ts b/packages/agentry/src/components/index.ts index 662b781..2c86820 100644 --- a/packages/agentry/src/components/index.ts +++ b/packages/agentry/src/components/index.ts @@ -9,10 +9,4 @@ export { Message } from './Message.tsx' export { Tools } from './Tools.tsx' export { Condition } from './Condition.tsx' -export { WebSearch } from './built-ins/WebSearch.tsx' -export { CodeExecution } from './built-ins/CodeExecution.tsx' -export { Memory } from './built-ins/Memory.tsx' - -export { MCP } from './MCP.tsx' - export { useExecutionState, useMessages, useAgentState } from './hooks' diff --git a/packages/agentry/src/components/jsx-elements.d.ts b/packages/agentry/src/components/jsx-elements.d.ts index 32b2dc8..ad3b5f9 100644 --- a/packages/agentry/src/components/jsx-elements.d.ts +++ b/packages/agentry/src/components/jsx-elements.d.ts @@ -1,11 +1,9 @@ import type { ReactNode } from 'react' import type { InternalTool, InternalAgentTool } from '../types' -import type { - BetaToolUnion, - BetaRequestMCPServerToolConfiguration, - BetaContentBlockParam, -} from '@anthropic-ai/sdk/resources/beta' +import type { SdkTool } from '../types/tools' import type { AgentComponentPublicProps } from './Agent.tsx' +import type { MCPServerConfig } from '../instances/types' +import type { AgentContentBlock } from '../types/messages' export interface AgentryElements { agent: Omit & { @@ -15,11 +13,11 @@ export interface AgentryElements { agentNode?: ReactNode } - tool: { tool: InternalTool; key?: string } + tool: { tool: InternalTool; key?: string } - agent_tool: { agentTool: InternalAgentTool; key?: string } + agent_tool: { agentTool: InternalAgentTool; key?: string } - sdk_tool: { tool: BetaToolUnion; key?: string } + sdk_tool: { tool: SdkTool; key?: string } system: { children: ReactNode; cache?: 'ephemeral' } @@ -28,7 +26,7 @@ export interface AgentryElements { message: { role: 'user' | 'assistant' children?: ReactNode - rawContent?: string | BetaContentBlockParam[] + rawContent?: string | AgentContentBlock[] } tools: { children?: ReactNode } @@ -37,7 +35,7 @@ export interface AgentryElements { name: string url: string authorization_token?: string - tool_configuration?: BetaRequestMCPServerToolConfiguration + tool_configuration?: MCPServerConfig['tool_configuration'] key?: string } diff --git a/packages/agentry/src/constants.ts b/packages/agentry/src/constants.ts index d16811a..af01700 100644 --- a/packages/agentry/src/constants.ts +++ b/packages/agentry/src/constants.ts @@ -1,5 +1,12 @@ -export const MODEL = 'claude-haiku-4-5' -export const TEST_MODEL = 'claude-3-5-haiku-20241022' +export const ANTHROPIC_MODEL = 'claude-haiku-4-5' +export const OPENAI_MODEL = 'gpt-5-mini' + +export const ANTHROPIC_TEST_MODEL = 'claude-3-5-haiku-20241022' +export const OPENAI_TEST_MODEL = 'gpt-5-mini' + +// Backward-compatible aliases +export const MODEL = ANTHROPIC_MODEL +export const TEST_MODEL = ANTHROPIC_TEST_MODEL /** * Anthropic API beta feature flags diff --git a/packages/agentry/src/debug.ts b/packages/agentry/src/debug.ts index 4cc792f..721eaba 100644 --- a/packages/agentry/src/debug.ts +++ b/packages/agentry/src/debug.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from './types/json' /** * Debug logging utility for agentry * @@ -16,7 +17,11 @@ const isDebug = process.env.DEBUG === 'true' || process.env.DEBUG === '1' * @param message - Log message * @param data - Optional data to log */ -export function debug(category: string, message: string, data?: unknown): void { +export function debug( + category: string, + message: string, + data?: JsonValue | Record | object, +): void { if (!isDebug) return const prefix = `[agentry:${category}]` diff --git a/packages/agentry/src/execution/ExecutionEngine.ts b/packages/agentry/src/execution/ExecutionEngine.ts index 4cc641c..f641e5c 100644 --- a/packages/agentry/src/execution/ExecutionEngine.ts +++ b/packages/agentry/src/execution/ExecutionEngine.ts @@ -1,18 +1,4 @@ -import Anthropic from '@anthropic-ai/sdk' import { EventEmitter } from 'eventemitter3' -import type { - BetaMessage, - BetaMessageParam, - BetaToolUnion, - BetaToolResultBlockParam, - BetaTextBlock, - BetaContentBlock, - BetaContentBlockParam, - BetaTextBlockParam, - BetaRequestMCPServerURLDefinition, - BetaMemoryTool20250818, - BetaThinkingConfigParam, -} from '@anthropic-ai/sdk/resources/beta' import { yieldToSchedulerImmediate } from '../scheduler' import type { AgentState, @@ -20,14 +6,12 @@ import type { AgentResult, PendingToolCall, ToolContext, - CompactionControl, Model, OnStepFinishResult, StepToolCall, StepToolResult, ThinkingConfig, } from '../types' -import { ANTHROPIC_BETAS } from '../constants' import type { AgentInstance } from '../instances' import { isMessageInstance } from '../instances' import { evaluateConditions } from './conditions' @@ -36,58 +20,32 @@ import { extractToolUses, extractText, isMemoryTool, - isCodeExecutionTool, } from '../types' -import type { SdkTool } from '../types' -import { toApiTool, executeTool } from '../tools' -import { executeMemoryTool } from '../tools/memoryTool' +import { executeTool } from '../tools' +import { executeMemoryTool, type MemoryToolInput } from '../tools/memoryTool' import { createRunAgent } from '../run/runAgentFunction' import { debug } from '../debug' import { buildSystemPrompt } from './createEngineConfig' import { flushSync } from '../reconciler/renderer' import type { AgentStore } from '../store' import { collectChild } from '../reconciler/collectors' - -/** - * Sanitize content blocks from API responses to be safe for sending back as parameters. - * Removes response-only fields like 'parsed' that are not allowed in request parameters. - */ -function sanitizeContentBlocks( - content: BetaContentBlock[], -): BetaContentBlockParam[] { - return content.map((block) => { - if (block.type === 'text') { - // structured outputs sets parsed_output field (parsed is deprecated) - // we cannot mutate since the SDK response is frozen - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { parsed_output, ...rest } = block as BetaTextBlockParam & { - parsed_output?: unknown - } - return rest as BetaTextBlockParam - } - return block as BetaContentBlockParam - }) -} - -// subset of Anthropic's MessageCreateParams -interface CreateMessageParams { - model: string - max_tokens: number - system?: SystemPrompt - messages: BetaMessageParam[] - tools?: BetaToolUnion[] - mcp_servers?: BetaRequestMCPServerURLDefinition[] - stop_sequences?: string[] - temperature?: number - betas?: string[] - thinking?: BetaThinkingConfigParam - stream?: boolean -} +import type { + AgentMessage, + AgentMessageParam, + ToolResultContentBlock, +} from '../types/messages' +import type { ProviderName } from '../types/provider' +import type { ProviderAdapter, ProviderClientMap } from '../providers/types' +import { createDefaultAdapters } from '../providers' +import type Anthropic from '@anthropic-ai/sdk' +import type { BetaMessageParam, BetaTextBlock } from '@anthropic-ai/sdk/resources/beta' +import type { Model as AnthropicModel } from '@anthropic-ai/sdk/resources/messages' +import type { z } from 'zod' export interface ExecutionEngineEvents { stateChange: (state: AgentState) => void stream: (event: AgentStreamEvent) => void - message: (message: BetaMessage) => void + message: (message: AgentMessage) => void complete: (result: AgentResult) => void error: (error: Error) => void stepFinish: (result: OnStepFinishResult) => void @@ -101,14 +59,33 @@ export type SystemPrompt = cache_control?: { type: 'ephemeral' } }> +const DEFAULT_TOKEN_THRESHOLD = 100_000 + +const DEFAULT_SUMMARY_PROMPT = `You have been working on the task described above but have not yet completed it. Write a continuation summary that will allow you (or another instance of yourself) to resume work efficiently in a future context window where the conversation history will be replaced with this summary. Your summary should be structured, concise, and actionable. Include: +1. Task Overview +2. Current State +3. Important Discoveries +4. Next Steps +5. Context to Preserve +Be concise but complete. Write in a way that enables immediate resumption of the task. +Wrap your summary in tags.` + export interface ExecutionEngineConfig { - client: Anthropic + provider: ProviderName + client: ProviderClientMap[ProviderName] + clients?: Partial + adapters?: Record model: Model maxTokens: number system?: SystemPrompt stream?: boolean maxIterations?: number - compactionControl?: CompactionControl + compactionControl?: { + enabled: boolean + contextTokenThreshold?: number + model?: Model + summaryPrompt?: string + } stopSequences?: string[] temperature?: number agentName?: string @@ -118,47 +95,31 @@ export interface ExecutionEngineConfig { betas?: string[] } -const DEFAULT_TOKEN_THRESHOLD = 100_000 - -function hasName(t: unknown): t is { name: string } { +function hasName(t: object | null): t is { name: string } { return typeof t === 'object' && t !== null && 'name' in t } -/** - * Convert SdkTool to Anthropic API format (BetaToolUnion) - * Strips memoryHandlers from MemoryTool before sending to API - */ -function toApiSdkTool(sdkTool: SdkTool): BetaToolUnion { - if (isMemoryTool(sdkTool)) { - // Strip memoryHandlers - it's not part of the API contract - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { memoryHandlers, ...apiTool } = sdkTool - return apiTool as BetaMemoryTool20250818 - } - // CodeExecutionTool and WebSearchTool are already in the correct format - return sdkTool as BetaToolUnion +function isMemoryToolInput(input: z.output): input is MemoryToolInput { + return ( + typeof input === 'object' && + input !== null && + 'command' in input && + typeof input.command === 'string' + ) } -const DEFAULT_SUMMARY_PROMPT = `You have been working on the task described above but have not yet completed it. Write a continuation summary that will allow you (or another instance of yourself) to resume work efficiently in a future context window where the conversation history will be replaced with this summary. Your summary should be structured, concise, and actionable. Include: -1. Task Overview -2. Current State -3. Important Discoveries -4. Next Steps -5. Context to Preserve -Be concise but complete. Write in a way that enables immediate resumption of the task. -Wrap your summary in tags.` - /** * execution engine handles the conversation loop with Claude * * inspired by BetaToolRunner but with our own interface and React integration */ export class ExecutionEngine extends EventEmitter { - private client: Anthropic + private client: ProviderClientMap[ProviderName] + private adapter: ProviderAdapter private config: ExecutionEngineConfig private store: AgentStore private iterationCount = 0 - private lastMessage: BetaMessage | null = null + private lastMessage: AgentMessage | null = null private aborted = false private agentInstance: AgentInstance private toolExecutionTimes = new Map() @@ -166,7 +127,16 @@ export class ExecutionEngine extends EventEmitter { constructor(config: ExecutionEngineConfig) { super() this.client = config.client - this.config = config + if (!config.provider) { + throw new Error('Provider is required in execution engine config.') + } + const provider = config.provider + const adapters = config.adapters ?? createDefaultAdapters() + if (!adapters[provider]) { + throw new Error(`No provider adapter configured for "${provider}"`) + } + this.adapter = adapters[provider]! + this.config = { ...config, provider, adapters, clients: config.clients ?? {} } this.store = config.store this.agentInstance = config.agentInstance } @@ -175,7 +145,7 @@ export class ExecutionEngine extends EventEmitter { return this.store.getState().executionState } - get messages(): readonly BetaMessageParam[] { + get messages(): readonly AgentMessageParam[] { return this.store.getState().messages } @@ -183,7 +153,7 @@ export class ExecutionEngine extends EventEmitter { this.config = { ...this.config, ...updates } } - pushMessage(message: BetaMessageParam): void { + pushMessage(message: AgentMessageParam): void { this.store.getState().actions.pushMessage(message) } @@ -193,82 +163,14 @@ export class ExecutionEngine extends EventEmitter { this.emit('stateChange', newState) } - private async buildParams(): Promise { - const tools: BetaToolUnion[] = [] - - const { - tools: internalTools = [], - sdkTools = [], - mcpServers = [], - } = this.agentInstance - tools.push(...internalTools.map(toApiTool)) - tools.push(...sdkTools.map(toApiSdkTool)) - - if (mcpServers?.length) { - for (const server of mcpServers) { - tools.push({ - type: 'mcp_toolset', - mcp_server_name: server.name, - }) - } - } - - const betas: Set = new Set(this.config.betas ?? []) - if (mcpServers?.length) { - betas.add(ANTHROPIC_BETAS.MCP_CLIENT) - } - if (sdkTools.some(isCodeExecutionTool)) { - betas.add(ANTHROPIC_BETAS.CODE_EXECUTION) - } - if (sdkTools.some(isMemoryTool)) { - betas.add(ANTHROPIC_BETAS.CONTEXT_MANAGEMENT) - } - if (internalTools.some(({ strict }) => strict)) { - betas.add(ANTHROPIC_BETAS.STRUCTURED_OUTPUTS) - } - - if ( - this.config.thinking?.type === 'enabled' && - this.config.thinking?.interleaved !== false - ) { - betas.add(ANTHROPIC_BETAS.INTERLEAVED_THINKING) - } - - const system = buildSystemPrompt(this.agentInstance) - - const apiThinking: BetaThinkingConfigParam | undefined = this.config - .thinking - ? this.config.thinking.type === 'enabled' - ? { - type: this.config.thinking.type, - budget_tokens: this.config.thinking.budget_tokens, - } - : { type: this.config.thinking.type } - : undefined - - const params: CreateMessageParams = { - model: this.config.model, - max_tokens: this.config.maxTokens, - system, - messages: this.messages as BetaMessageParam[], - tools: tools.length > 0 ? tools : undefined, - mcp_servers: mcpServers?.length ? mcpServers : undefined, - stop_sequences: this.config.stopSequences, - temperature: this.config.temperature, - betas: betas.size > 0 ? Array.from(betas) : undefined, - thinking: apiThinking, - } - - return params - } - private async evaluateAllConditions(options?: { evaluateNL?: boolean }): Promise { return evaluateConditions( this.agentInstance, - this.messages as BetaMessageParam[], - this.client, + this.messages as AgentMessageParam[], + this.config.clients ?? {}, + this.config.provider, this.config.model, undefined, // signal options, @@ -321,9 +223,9 @@ export class ExecutionEngine extends EventEmitter { this.lastMessage = message this.emit('message', message) - const assistantMessage: BetaMessageParam = { + const assistantMessage: AgentMessageParam = { role: 'assistant', - content: sanitizeContentBlocks(message.content), + content: message.content, } this.pushMessage(assistantMessage) @@ -339,7 +241,7 @@ export class ExecutionEngine extends EventEmitter { const toolResults = await this.executeTools(pendingTools) - const toolResultMessage: BetaMessageParam = { + const toolResultMessage: AgentMessageParam = { role: 'user', content: toolResults, } @@ -350,7 +252,6 @@ export class ExecutionEngine extends EventEmitter { // force React to commit any pending state updates from tool handlers flushSync(() => {}) await yieldToSchedulerImmediate() - await this.checkAndCompact() const stepResult = this.buildStepFinishResult( @@ -382,95 +283,45 @@ export class ExecutionEngine extends EventEmitter { } } - private async makeApiCall( - abortController: AbortController, - ): Promise { - const params = await this.buildParams() - - debug('api', `Request #${this.iterationCount}`, { - model: params.model, - tools: params.tools?.map((t) => ('name' in t ? t.name : t.type)), - messageCount: params.messages.length, - system: params.system - ? typeof params.system === 'string' - ? `${params.system}` - : params.system.map(({ text }) => text).join('\n') - : undefined, - ...(params.mcp_servers?.length - ? { mcpServers: params.mcp_servers?.map((s) => s.name) } - : {}), - }) - + private async makeApiCall(abortController: AbortController): Promise { + const system = buildSystemPrompt(this.agentInstance) const startTime = performance.now() - let response: BetaMessage - if (this.config.stream) { - response = await this.streamApiCall(params, abortController) - } else { - response = await this.client.beta.messages.create( - { ...params, stream: false }, - { signal: abortController.signal }, - ) - } + const response = await this.adapter.createTurn(this.client, { + model: this.config.model, + maxTokens: this.config.maxTokens, + system, + messages: this.messages as AgentMessageParam[], + tools: this.agentInstance.tools, + sdkTools: this.agentInstance.sdkTools, + mcpServers: this.agentInstance.mcpServers, + stopSequences: this.config.stopSequences, + temperature: this.config.temperature, + thinking: this.config.thinking, + betas: this.config.betas, + stream: this.config.stream, + signal: abortController.signal, + onStream: (event) => this.emit('stream', event), + }) const durationMs = Math.round(performance.now() - startTime) - debug('api', `Response #${this.iterationCount}`, { durationMs, - stopReason: response.stop_reason, - toolUses: extractToolUses(response).map((t) => t.name), - textLength: extractText(response).length, - cacheCreation: response.usage.cache_creation_input_tokens, - cacheRead: response.usage.cache_read_input_tokens, + stopReason: response.message.stop_reason, + toolUses: extractToolUses(response.message).map((t) => t.name), + textLength: extractText(response.message).length, }) - - return response - } - - private async streamApiCall( - params: CreateMessageParams, - abortController: AbortController, - ): Promise { - const stream = this.client.beta.messages.stream(params, { - signal: abortController.signal, - }) - - stream.on('text', (text, snapshot) => { - this.emit('stream', { type: 'text', text, accumulated: snapshot }) - }) - - stream.on('thinking', (thinking) => { - this.emit('stream', { type: 'thinking', text: thinking }) - }) - - stream.on('contentBlock', (block) => { - if (block.type === 'tool_use') { - this.emit('stream', { - type: 'tool_use_start', - toolName: block.name, - toolId: block.id, - }) - } - }) - - stream.on('inputJson', () => {}) - - const finalMessage = await stream.finalMessage() - - this.emit('stream', { - type: 'message_complete', - stopReason: finalMessage.stop_reason ?? 'unknown', - }) - - return finalMessage + return response.message } private async executeTools( pendingTools: PendingToolCall[], - ): Promise { + ): Promise { this.transition({ type: 'tools_executing', pendingTools }) const currentState = this.executionState const context: ToolContext = { agentName: this.config.agentName ?? 'agent', + provider: this.config.provider, + clients: this.config.clients, client: this.client, model: this.config.model, signal: @@ -478,7 +329,8 @@ export class ExecutionEngine extends EventEmitter { ? currentState.abortController.signal : undefined, runAgent: createRunAgent({ - client: this.client, + provider: this.config.provider, + clients: this.config.clients, model: this.config.model, signal: currentState.status === 'streaming' @@ -524,11 +376,11 @@ export class ExecutionEngine extends EventEmitter { }) return { - type: 'tool_result' as const, + type: 'tool_result', tool_use_id: toolCall.id, content: result, is_error: isError ? true : undefined, - } + } as ToolResultContentBlock } // check if it's an SDK tool @@ -539,9 +391,17 @@ export class ExecutionEngine extends EventEmitter { if (sdkTool) { // Memory tool requires client-side handlers if (isMemoryTool(sdkTool) && sdkTool.memoryHandlers) { + if (!isMemoryToolInput(toolCall.input)) { + return { + type: 'tool_result' as const, + tool_use_id: toolCall.id, + content: 'Error: Invalid memory tool input payload', + is_error: true, + } + } const { result, isError } = await executeMemoryTool( sdkTool, - toolCall.input as Parameters[1], + toolCall.input, ) const executionTime = performance.now() - startTime @@ -556,11 +416,11 @@ export class ExecutionEngine extends EventEmitter { }) return { - type: 'tool_result' as const, + type: 'tool_result', tool_use_id: toolCall.id, content: result, is_error: isError ? true : undefined, - } + } as ToolResultContentBlock } // Other SDK tools are handled by Anthropic server-side @@ -576,11 +436,11 @@ export class ExecutionEngine extends EventEmitter { }) return { - type: 'tool_result' as const, + type: 'tool_result', tool_use_id: toolCall.id, content: errorMessage, is_error: true, - } + } as ToolResultContentBlock } const errorMessage = `Error: Tool '${toolCall.name}' not found` @@ -595,11 +455,11 @@ export class ExecutionEngine extends EventEmitter { }) return { - type: 'tool_result' as const, + type: 'tool_result', tool_use_id: toolCall.id, content: errorMessage, is_error: true, - } + } as ToolResultContentBlock }), ) @@ -607,17 +467,17 @@ export class ExecutionEngine extends EventEmitter { } private buildStepFinishResult( - message: BetaMessage, - toolUses: Array<{ id: string; name: string; input: unknown }>, - toolResults: BetaToolResultBlockParam[], + message: AgentMessage, + toolUses: Array<{ id: string; name: string; input: z.output }>, + toolResults: ToolResultContentBlock[], ): OnStepFinishResult { const text = extractText(message) - const thinkingBlock = message.content.find( - (b): b is Extract => - b.type === 'thinking', - ) - const thinking = thinkingBlock?.thinking + const thinkingBlock = message.content.find((b) => b.type === 'thinking') + const thinking = + thinkingBlock?.type === 'thinking' + ? (thinkingBlock.thinking as string | undefined) + : undefined const toolCalls: StepToolCall[] = toolUses.map((tu) => ({ id: tu.id, @@ -657,15 +517,57 @@ export class ExecutionEngine extends EventEmitter { } } + private buildResult(): AgentResult { + if (!this.lastMessage) { + throw new Error('No message received') + } + + const thinkingBlock = this.lastMessage.content.find( + (b) => b.type === 'thinking', + ) + const thinking = + thinkingBlock?.type === 'thinking' + ? (thinkingBlock.thinking as string | undefined) + : undefined + + return { + content: extractText(this.lastMessage), + messages: [...this.messages], + usage: { + inputTokens: this.lastMessage.usage.input_tokens, + outputTokens: this.lastMessage.usage.output_tokens, + cacheCreationInputTokens: + this.lastMessage.usage.cache_creation_input_tokens ?? undefined, + cacheReadInputTokens: + this.lastMessage.usage.cache_read_input_tokens ?? undefined, + }, + thinking, + stopReason: this.lastMessage.stop_reason, + } + } + + abort(): void { + this.aborted = true + const currentState = this.executionState + if (currentState.status === 'streaming') { + currentState.abortController.abort() + } + const error = new Error('Execution aborted') + this.transition({ type: 'error', error }) + this.emit('error', error) + } + private async checkAndCompact(): Promise { const compactionControl = this.config.compactionControl if (!compactionControl?.enabled) { return false } - if (!this.lastMessage) { return false } + if (this.config.provider !== 'anthropic') { + return false + } const totalTokens = this.lastMessage.usage.input_tokens + @@ -680,34 +582,16 @@ export class ExecutionEngine extends EventEmitter { return false } - const model = compactionControl.model ?? this.config.model + const model = (compactionControl.model ?? this.config.model) as AnthropicModel const summaryPrompt = compactionControl.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT + const client = this.client as Anthropic const currentMessages = [...this.messages] - const lastMessage = currentMessages[currentMessages.length - 1] - if ( - lastMessage?.role === 'assistant' && - Array.isArray(lastMessage.content) - ) { - const nonToolBlocks = lastMessage.content.filter( - (block: { type: string }) => block.type !== 'tool_use', - ) - if (nonToolBlocks.length === 0) { - currentMessages.pop() - } else { - currentMessages[currentMessages.length - 1] = { - ...lastMessage, - content: nonToolBlocks, - } - } - } - - const startTime = performance.now() - const response = await this.client.beta.messages.create({ + const response = await client.beta.messages.create({ model, messages: [ - ...currentMessages, + ...(currentMessages as BetaMessageParam[]), { role: 'user', content: [{ type: 'text', text: summaryPrompt }], @@ -715,14 +599,10 @@ export class ExecutionEngine extends EventEmitter { ], max_tokens: this.config.maxTokens, }) - const durationMs = Math.round(performance.now() - startTime) - - debug('api', `Compaction response`, { durationMs, model }) const summaryBlock = response.content.find( (block): block is BetaTextBlock => block.type === 'text', ) - if (!summaryBlock) { return false } @@ -733,45 +613,6 @@ export class ExecutionEngine extends EventEmitter { content: [{ type: 'text', text: summaryBlock.text }], }, ]) - return true } - - private buildResult(): AgentResult { - if (!this.lastMessage) { - throw new Error('No message received') - } - - const thinkingBlock = this.lastMessage.content.find( - (b): b is Extract => - b.type === 'thinking', - ) - const thinking = thinkingBlock?.thinking - - return { - content: extractText(this.lastMessage), - messages: [...this.messages], - usage: { - inputTokens: this.lastMessage.usage.input_tokens, - outputTokens: this.lastMessage.usage.output_tokens, - cacheCreationInputTokens: - this.lastMessage.usage.cache_creation_input_tokens ?? undefined, - cacheReadInputTokens: - this.lastMessage.usage.cache_read_input_tokens ?? undefined, - }, - thinking, - stopReason: this.lastMessage.stop_reason, - } - } - - abort(): void { - this.aborted = true - const currentState = this.executionState - if (currentState.status === 'streaming') { - currentState.abortController.abort() - } - const error = new Error('Execution aborted') - this.transition({ type: 'error', error }) - this.emit('error', error) - } } diff --git a/packages/agentry/src/execution/conditions.ts b/packages/agentry/src/execution/conditions.ts index 0d69bd1..e9dc8a9 100644 --- a/packages/agentry/src/execution/conditions.ts +++ b/packages/agentry/src/execution/conditions.ts @@ -1,8 +1,11 @@ import type Anthropic from '@anthropic-ai/sdk' -import type { BetaMessageParam } from '../types/messages' +import type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta' +import type { AgentMessageParam } from '../types/messages' import type { ConditionInstance, Instance } from '../instances/types' import { isConditionInstance } from '../instances/types' import { debug } from '../debug' +import type { ProviderClientMap } from '../providers/types' +import type { ProviderName } from '../types/provider' /** * Find all condition instances in the tree @@ -29,8 +32,9 @@ export function findAllConditions(root: Instance): ConditionInstance[] { */ export async function evaluateConditions( root: Instance, - messages: BetaMessageParam[], - client: Anthropic, + messages: AgentMessageParam[], + clients: Partial, + provider: ProviderName, model: string, signal?: AbortSignal, options?: { evaluateNL?: boolean }, @@ -63,6 +67,13 @@ export async function evaluateConditions( const nlConditions = conditions.filter((c) => typeof c.when === 'string') if (nlConditions.length > 0 && options?.evaluateNL !== false) { + if (provider !== 'anthropic') { + return hasChanges + } + const client = clients.anthropic as Anthropic | undefined + if (!client) { + return hasChanges + } const nlResults = await evaluateNaturalLanguageConditions( nlConditions, messages, @@ -98,7 +109,7 @@ export async function evaluateConditions( */ async function evaluateNaturalLanguageConditions( conditions: ConditionInstance[], - messages: BetaMessageParam[], + messages: AgentMessageParam[], client: Anthropic, model: string, signal?: AbortSignal, @@ -123,7 +134,7 @@ ${conditionDescriptions} Return ALL indices of conditions that are TRUE based on the current conversation state.`, messages: [ - ...evalMessages, + ...(evalMessages as BetaMessageParam[]), { role: 'user', content: 'Which conditions are true?', @@ -181,9 +192,7 @@ Return ALL indices of conditions that are TRUE based on the current conversation } // ensure messages don't start with a tool_result (which requires a preceding tool_use) -function ensureValidMessageStart( - messages: BetaMessageParam[], -): BetaMessageParam[] { +function ensureValidMessageStart(messages: AgentMessageParam[]): AgentMessageParam[] { if (messages.length === 0) return messages // find the first user message that doesn't contain tool_results @@ -198,7 +207,7 @@ function ensureValidMessageStart( // check if it has tool_results (which would need a preceding tool_use) const hasToolResult = msg.content.some( - (block) => block.type === 'tool_result', + (block) => typeof block === 'object' && block !== null && block.type === 'tool_result', ) if (!hasToolResult) { return messages.slice(i) diff --git a/packages/agentry/src/execution/createEngineConfig.ts b/packages/agentry/src/execution/createEngineConfig.ts index 4ea39c5..5cdcd78 100644 --- a/packages/agentry/src/execution/createEngineConfig.ts +++ b/packages/agentry/src/execution/createEngineConfig.ts @@ -1,12 +1,13 @@ -import type Anthropic from '@anthropic-ai/sdk' import type { AgentInstance } from '../instances/types' import type { AgentStore } from '../store' import { createAgentStore } from '../store' import type { ExecutionEngineConfig } from './ExecutionEngine' +import type { ProviderClientMap, ProviderAdapter } from '../providers/types' export interface EngineConfigOptions { agent: AgentInstance - client: Anthropic + clients: Partial + adapters: Record store?: AgentStore } @@ -54,13 +55,24 @@ export function buildSystemPrompt( export function createEngineConfig( options: EngineConfigOptions, ): EngineConfigResult { - const { agent, client } = options + const { agent, clients, adapters } = options const store = options.store ?? createAgentStore() const system = buildSystemPrompt(agent) + const provider = agent.props.provider + if (!provider) { + throw new Error('Provider is required on agent props.') + } + const client = (agent.client ?? clients[provider]) as ExecutionEngineConfig['client'] + if (!client) { + throw new Error(`No client configured for provider "${provider}"`) + } const config = { + provider, + clients, + adapters, client, model: agent.props.model, maxTokens: agent.props.maxTokens ?? 4096, diff --git a/packages/agentry/src/handles/AbstractAgentHandle.ts b/packages/agentry/src/handles/AbstractAgentHandle.ts index 58aecac..6f89048 100644 --- a/packages/agentry/src/handles/AbstractAgentHandle.ts +++ b/packages/agentry/src/handles/AbstractAgentHandle.ts @@ -1,9 +1,8 @@ -import Anthropic from '@anthropic-ai/sdk' import { EventEmitter } from 'eventemitter3' import { createElement, type ReactNode } from 'react' import type { AgentInstance } from '../instances' import type { AgentResult, AgentStreamEvent } from '../types/agent' -import type { BetaMessageParam } from '../types/messages' +import type { AgentMessageParam } from '../types/messages' import type { AgentStore } from '../store' import type { OnStepFinishResult } from '../types/lifecycle' import { @@ -20,6 +19,7 @@ import { import { isProcessing, type AgentState } from '../types/state' import { yieldToScheduler } from '../scheduler' import { AgentProvider } from '../context' +import type { ProviderAdapter, ProviderClientMap } from '../providers/types' export interface AgentHandleEvents { stateChange: (state: AgentState) => void @@ -36,18 +36,21 @@ export interface AgentHandleEvents { export abstract class AbstractAgentHandle extends EventEmitter { protected containerInfo: ContainerInfo protected engine: ExecutionEngine | null = null - protected client: Anthropic + protected clients: Partial + protected adapters: Record protected running = false protected store: AgentStore protected instance: AgentInstance | null = null constructor( - client: Anthropic, + clients: Partial, + adapters: Record, containerInfo: ContainerInfo, store: AgentStore, ) { super() - this.client = client + this.clients = clients + this.adapters = adapters this.containerInfo = containerInfo this.store = store } @@ -56,7 +59,7 @@ export abstract class AbstractAgentHandle extends EventEmitter + } = {}, mode: 'batch' | 'interactive' = 'batch', ) { - const anthropicClient = client ?? new Anthropic() + let providerHint: ProviderName | undefined + let unresolvedClient: ProviderClientMap[ProviderName] | undefined + const clients: Partial = { ...options.clients } + if (options.client) { + providerHint = inferProviderFromClient(options.client) + if (providerHint) { + setProviderClient(clients, providerHint, options.client) + } else { + unresolvedClient = options.client + } + } const store = createAgentStore() const rootAgent: AgentInstance = { type: 'agent', props: { - model: MODEL, + provider: undefined, + model: providerHint === 'openai' ? OPENAI_MODEL : ANTHROPIC_MODEL, maxTokens: 4096, stream: true, }, - client: anthropicClient, + client: + providerHint ? getProviderClient(clients, providerHint) : undefined, engine: null, systemParts: [], tools: [], @@ -47,9 +71,10 @@ export class AgentHandle extends AbstractAgentHandle { const containerInfo = createContainer(rootAgent) - super(anthropicClient, containerInfo, store) + super(clients, createDefaultAdapters(), containerInfo, store) this.element = element this.mode = mode + this.unresolvedClient = unresolvedClient } update(element: ReactNode): void { @@ -72,7 +97,7 @@ export class AgentHandle extends AbstractAgentHandle { protected override beforeExecution( _agent: AgentInstance, _config: ExecutionEngineConfig, - messages: readonly BetaMessageParam[], + messages: readonly AgentMessageParam[], ): void { // only validate in batch mode if (this.mode === 'batch' && messages.length === 0) { @@ -95,6 +120,19 @@ export class AgentHandle extends AbstractAgentHandle { throw new Error('No agent element found in tree') } + const provider = agent.props.provider + if (!provider) { + throw new Error('Provider is required on the rendered agent.') + } + if (!agent.props.model) { + throw new Error('Model is required on the rendered agent.') + } + if (!getProviderClient(this.clients, provider) && this.unresolvedClient) { + setProviderClient(this.clients, provider, this.unresolvedClient) + this.unresolvedClient = undefined + } + agent.client = ensureProviderClient(this.clients, provider) + return agent } } diff --git a/packages/agentry/src/handles/SubagentHandle.ts b/packages/agentry/src/handles/SubagentHandle.ts index 7533f7b..79bb9e3 100644 --- a/packages/agentry/src/handles/SubagentHandle.ts +++ b/packages/agentry/src/handles/SubagentHandle.ts @@ -1,4 +1,3 @@ -import Anthropic from '@anthropic-ai/sdk' import { createContainer } from '../reconciler' import { createAgentStore } from '../store' import { @@ -6,9 +5,13 @@ import { type SubagentInstance, isAgentInstance, } from '../instances/types' -import type { BetaMessageParam } from '../types/messages' +import type { AgentMessageParam } from '../types/messages' import { AbstractAgentHandle } from './AbstractAgentHandle' import type { ExecutionEngineConfig } from '../execution/ExecutionEngine' +import type { ProviderClientMap } from '../providers/types' +import type { ProviderName } from '../types/provider' +import { createDefaultAdapters } from '../providers' +import { ensureProviderClient, setProviderClient } from '../providers/clientResolver' export class SubagentHandle extends AbstractAgentHandle { private subagent: SubagentInstance @@ -18,11 +21,22 @@ export class SubagentHandle extends AbstractAgentHandle { constructor( subagent: SubagentInstance, options: { - client: Anthropic + client?: ProviderClientMap[ProviderName] + clients?: Partial + provider?: ProviderName signal?: AbortSignal }, ) { - const { client, signal } = options + const { signal } = options + const provider = options.provider ?? subagent.props.provider + if (!provider) { + throw new Error('Provider is required for the subagent.') + } + const clients: Partial = { ...options.clients } + if (options.client) { + setProviderClient(clients, provider, options.client) + } + const client = ensureProviderClient(clients, provider) const store = createAgentStore() @@ -42,7 +56,7 @@ export class SubagentHandle extends AbstractAgentHandle { const containerInfo = createContainer(container) - super(client, containerInfo, store) + super(clients, createDefaultAdapters(), containerInfo, store) this.subagent = subagent @@ -63,7 +77,7 @@ export class SubagentHandle extends AbstractAgentHandle { protected override beforeExecution( _agent: AgentInstance, _config: ExecutionEngineConfig, - messages: readonly BetaMessageParam[], + messages: readonly AgentMessageParam[], ): void { // subagents always need messages if (messages.length === 0) { @@ -107,6 +121,12 @@ export class SubagentHandle extends AbstractAgentHandle { ...(stream !== undefined && { stream }), }) + const provider = agentInstance.props.provider + if (!provider) { + throw new Error('Provider is required on the subagent instance.') + } + agentInstance.client = ensureProviderClient(this.clients, provider) + return agentInstance } diff --git a/packages/agentry/src/index.ts b/packages/agentry/src/index.ts index 147e112..931e691 100644 --- a/packages/agentry/src/index.ts +++ b/packages/agentry/src/index.ts @@ -1,4 +1,4 @@ -export { run, createAgent } from './run' +export { run, createAgent, createAI } from './run' export { AgentHandle } from './handles' export { defineTool, defineAgentTool } from './tools' export { @@ -10,12 +10,8 @@ export { Message, Tools, Condition, - WebSearch, - CodeExecution, - Memory, - MCP, useExecutionState, useMessages, useAgentState, } from './components' -export type { AgentResult } from './types' +export type { AgentResult, AgentMessageParam, ProviderName } from './types' diff --git a/packages/agentry/src/instances/createInstance.ts b/packages/agentry/src/instances/createInstance.ts index 30793ff..79aa5a0 100644 --- a/packages/agentry/src/instances/createInstance.ts +++ b/packages/agentry/src/instances/createInstance.ts @@ -1,4 +1,3 @@ -import Anthropic from '@anthropic-ai/sdk' import type React from 'react' import type { Instance, @@ -38,6 +37,7 @@ interface SubagentCreationProps extends Omit< } interface PropagatedSettings { + provider?: AgentProps['provider'] stream?: boolean temperature?: number stopSequences?: string[] @@ -76,7 +76,7 @@ export type ElementProps = export function createInstance( type: ElementType, props: ElementProps, - rootContainer: Instance | unknown, + rootContainer: Instance | object | null, // eslint-disable-next-line @typescript-eslint/no-unused-vars _hostContext: PropagatedSettings = {}, ): Instance { @@ -108,7 +108,7 @@ export function createInstance( function createAgentInstance( props: AgentComponentProps, - rootContainer?: unknown, + rootContainer?: Instance | object | null, ): AgentInstance { if ( !rootContainer || @@ -119,12 +119,14 @@ function createAgentInstance( throw new Error('No store found in root container.') } - const client = props.client ?? rootContainer.client ?? new Anthropic() + const provider = props.provider ?? rootContainer.props.provider + const client = props.client ?? rootContainer.client const store = rootContainer.store const instance: AgentInstance = { type: 'agent', props: { + provider, model: props.model, name: props.name, description: props.description, @@ -288,13 +290,13 @@ export function createSubagentInstance( inherited: PropagatedSettings = {}, ): SubagentInstance { if (!props.name) { - throw new Error('Child agents must have a name property') + throw new Error('Child agents must have a name.') } const model = props.model ?? inherited.model if (!model) { throw new Error( - `Subagent "${props.name}" requires a model. Either provide model in props or ensure parent agent has a model.`, + `Subagent "${props.name}" requires a model. Provide one on the subagent or parent agent.`, ) } @@ -303,6 +305,7 @@ export function createSubagentInstance( name: props.name, description: props.description, props: { + provider: props.provider ?? inherited.provider, model, name: props.name, description: props.description, diff --git a/packages/agentry/src/instances/types.ts b/packages/agentry/src/instances/types.ts index 0b4e94e..b186e4f 100644 --- a/packages/agentry/src/instances/types.ts +++ b/packages/agentry/src/instances/types.ts @@ -1,9 +1,4 @@ -import type Anthropic from '@anthropic-ai/sdk' -import type { - BetaMessageParam, - BetaContentBlockParam, - BetaRequestMCPServerURLDefinition, -} from '@anthropic-ai/sdk/resources/beta' +import type { AgentMessageParam, AgentContentBlock } from '../types/messages' import type { AgentProps, InternalTool, @@ -14,6 +9,7 @@ import type { import type { ExecutionEngine } from '../execution' import type { AgentStore } from '../store' import type { z } from 'zod' +import type { ProviderClientMap } from '../providers/types' export interface BaseInstance { type: string @@ -23,12 +19,12 @@ export interface BaseInstance { export interface AgentInstance extends BaseInstance { type: 'agent' props: AgentProps - client: Anthropic + client: ProviderClientMap[keyof ProviderClientMap] | undefined engine: ExecutionEngine | null systemParts: Array<{ content: string; cache?: 'ephemeral' }> tools: InternalTool[] sdkTools: SdkTool[] - mcpServers: BetaRequestMCPServerURLDefinition[] + mcpServers: MCPServerConfig[] children: Instance[] store: AgentStore } @@ -57,12 +53,23 @@ export interface ContextInstance extends BaseInstance { export interface MessageInstance extends BaseInstance { type: 'message' - message: BetaMessageParam + message: AgentMessageParam +} + +export interface MCPServerConfig { + type: 'url' + name: string + url: string + authorization_token?: string + tool_configuration?: { + enabled?: boolean + allowed_tools?: string[] + } } export interface MCPServerInstance extends BaseInstance { type: 'mcp_server' - config: BetaRequestMCPServerURLDefinition + config: MCPServerConfig } export interface ToolsContainerInstance extends BaseInstance { @@ -79,7 +86,7 @@ export interface SubagentInstance extends BaseInstance { systemParts: Array<{ content: string; cache?: 'ephemeral' }> tools: InternalTool[] sdkTools: SdkTool[] - mcpServers: BetaRequestMCPServerURLDefinition[] + mcpServers: MCPServerConfig[] agentNode: React.ReactNode | null } @@ -88,7 +95,7 @@ export interface AgentToolInstance extends BaseInstance { name: string description: string parameters: z.ZodType - jsonSchema: Record + jsonSchema: Record agent: AgentToolFunction } @@ -116,7 +123,7 @@ export type Instance = | ConditionInstance export interface AgentComponentProps extends AgentProps { - client?: Anthropic + client?: ProviderClientMap[keyof ProviderClientMap] children?: React.ReactNode } @@ -145,14 +152,14 @@ export interface ContextComponentProps { export interface MessageComponentProps { role: 'user' | 'assistant' children?: React.ReactNode - rawContent?: string | BetaContentBlockParam[] + rawContent?: string | AgentContentBlock[] } export interface MCPServerComponentProps { name: string url: string authorization_token?: string - tool_configuration?: BetaRequestMCPServerURLDefinition['tool_configuration'] + tool_configuration?: MCPServerConfig['tool_configuration'] } export interface ToolsContainerProps { @@ -230,7 +237,7 @@ export function isConditionInstance( return instance.type === 'condition' } -export function isInstance(value: unknown): value is Instance { +export function isInstance(value: object | null): value is Instance { return ( typeof value === 'object' && value !== null && diff --git a/packages/agentry/src/openai.ts b/packages/agentry/src/openai.ts new file mode 100644 index 0000000..953b5e7 --- /dev/null +++ b/packages/agentry/src/openai.ts @@ -0,0 +1,39 @@ +import OpenAI, { type ClientOptions } from 'openai' +import { + Agent, + Tool, + AgentTool, + System, + Context, + Message, + Tools, + Condition, + useExecutionState, + useMessages, + useAgentState, +} from './components' +import { WebSearch } from './components/built-ins/WebSearch' +import { CodeExecution } from './components/built-ins/CodeExecution' +import { MCP } from './components/MCP' + +export function openai(options?: ClientOptions): OpenAI { + return new OpenAI(options) +} + +export { + Agent, + Tool, + AgentTool, + System, + Context, + Message, + Tools, + Condition, + WebSearch, + CodeExecution, + MCP, + useExecutionState, + useMessages, + useAgentState, +} +export type { AgentResult } from './types' diff --git a/packages/agentry/src/providers/anthropic.ts b/packages/agentry/src/providers/anthropic.ts new file mode 100644 index 0000000..385f9e6 --- /dev/null +++ b/packages/agentry/src/providers/anthropic.ts @@ -0,0 +1,205 @@ +import type Anthropic from '@anthropic-ai/sdk' +import type { + BetaContentBlock, + BetaContentBlockParam, + MessageCreateParams, + BetaMessage, + BetaMessageParam, + BetaToolUnion, + BetaMemoryTool20250818, +} from '@anthropic-ai/sdk/resources/beta' +import type { Model as AnthropicModel } from '@anthropic-ai/sdk/resources/messages' +import { ANTHROPIC_BETAS } from '../constants' +import type { AgentContentBlock, AgentMessageParam } from '../types/messages' +import type { ProviderAdapter, NormalizedTurnRequest, NormalizedTurnResponse } from './types' +import { isCodeExecutionTool, isMemoryTool } from '../types/tools' +import { toApiTool } from '../tools' +import type { SdkTool } from '../types/tools' +import type { JsonObject } from '../types/json' + +function toAnthropicMessage(message: AgentMessageParam): BetaMessageParam { + if (typeof message.content === 'string') { + return { role: message.role, content: message.content } + } + const content: BetaContentBlockParam[] = [] + for (const block of message.content) { + if (block.type === 'text') { + content.push({ type: 'text', text: block.text }) + continue + } + if (block.type === 'thinking') { + // Anthropic thinking blocks in request history require provider-generated signatures. + // We keep only user/assistant text and tool blocks when replaying history. + continue + } + if (block.type === 'tool_use') { + if ( + !('id' in block) || + !('name' in block) || + typeof block.id !== 'string' || + typeof block.name !== 'string' + ) { + continue + } + content.push({ + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input, + }) + continue + } + if (block.type === 'tool_result') { + if ( + !('tool_use_id' in block) || + typeof block.tool_use_id !== 'string' + ) { + continue + } + content.push({ + type: 'tool_result', + tool_use_id: block.tool_use_id, + content: 'content' in block && block.content !== undefined ? block.content : '', + is_error: + 'is_error' in block && typeof block.is_error === 'boolean' + ? block.is_error + : undefined, + }) + } + // Ignore provider-specific blocks that do not map to Anthropic input. + } + return { role: message.role, content } +} + +function toAgentBlocks(content: BetaContentBlock[]): AgentContentBlock[] { + const blocks: AgentContentBlock[] = [] + for (const block of content) { + if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text }) + continue + } + if (block.type === 'thinking') { + blocks.push({ type: 'thinking', thinking: block.thinking }) + continue + } + if (block.type === 'tool_use') { + const input = + typeof block.input === 'object' && block.input !== null + ? (block.input as JsonObject) + : {} + blocks.push({ + type: 'tool_use', + id: block.id, + name: block.name, + input, + }) + } + } + return blocks +} + +function toApiSdkTool(sdkTool: SdkTool): BetaToolUnion { + if (isMemoryTool(sdkTool)) { + const { memoryHandlers: _memoryHandlers, ...apiTool } = sdkTool + return apiTool as BetaMemoryTool20250818 + } + return sdkTool as BetaToolUnion +} + +export const anthropicAdapter: ProviderAdapter = { + name: 'anthropic', + async createTurn( + client: Anthropic, + request: NormalizedTurnRequest, + ): Promise { + const tools: BetaToolUnion[] = [ + ...request.tools.map(toApiTool), + ...request.sdkTools.map(toApiSdkTool), + ...request.mcpServers.map((server) => ({ + type: 'mcp_toolset' as const, + mcp_server_name: server.name, + })), + ] + + const betas = new Set(request.betas ?? []) + if (request.mcpServers.length > 0) { + betas.add(ANTHROPIC_BETAS.MCP_CLIENT) + } + if (request.sdkTools.some(isCodeExecutionTool)) { + betas.add(ANTHROPIC_BETAS.CODE_EXECUTION) + } + if (request.sdkTools.some(isMemoryTool)) { + betas.add(ANTHROPIC_BETAS.CONTEXT_MANAGEMENT) + } + if (request.tools.some((tool) => tool.strict)) { + betas.add(ANTHROPIC_BETAS.STRUCTURED_OUTPUTS) + } + if ( + request.thinking?.type === 'enabled' && + request.thinking?.interleaved !== false + ) { + betas.add(ANTHROPIC_BETAS.INTERLEAVED_THINKING) + } + + const params: MessageCreateParams = { + model: request.model as AnthropicModel, + max_tokens: request.maxTokens, + system: request.system, + messages: request.messages.map(toAnthropicMessage), + tools: tools.length > 0 ? tools : undefined, + mcp_servers: request.mcpServers.length > 0 ? request.mcpServers : undefined, + stop_sequences: request.stopSequences, + temperature: request.temperature, + betas: betas.size > 0 ? Array.from(betas) : undefined, + thinking: request.thinking + ? request.thinking.type === 'enabled' + ? { + type: request.thinking.type, + budget_tokens: request.thinking.budget_tokens, + } + : { type: request.thinking.type } + : undefined, + } + + let response: BetaMessage + if (request.stream) { + const stream = client.beta.messages.stream(params, { signal: request.signal }) + stream.on('text', (text, snapshot) => { + request.onStream({ type: 'text', text, accumulated: snapshot }) + }) + stream.on('thinking', (thinking) => { + request.onStream({ type: 'thinking', text: thinking }) + }) + stream.on('contentBlock', (block) => { + if (block.type === 'tool_use') { + request.onStream({ + type: 'tool_use_start', + toolName: block.name, + toolId: block.id, + }) + } + }) + response = await stream.finalMessage() + request.onStream({ + type: 'message_complete', + stopReason: response.stop_reason ?? 'unknown', + }) + } else { + response = await client.beta.messages.create({ ...params, stream: false }, { signal: request.signal }) + } + + return { + message: { + content: toAgentBlocks(response.content), + stop_reason: response.stop_reason, + usage: { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + cache_creation_input_tokens: + response.usage.cache_creation_input_tokens ?? null, + cache_read_input_tokens: response.usage.cache_read_input_tokens ?? null, + }, + }, + } + }, +} diff --git a/packages/agentry/src/providers/clientResolver.ts b/packages/agentry/src/providers/clientResolver.ts new file mode 100644 index 0000000..dbb2262 --- /dev/null +++ b/packages/agentry/src/providers/clientResolver.ts @@ -0,0 +1,75 @@ +import Anthropic from '@anthropic-ai/sdk' +import OpenAI from 'openai' +import type { ProviderName } from '../types/provider' +import type { ProviderClientMap } from './types' + +type ProviderClient = ProviderClientMap[ProviderName] + +const sharedDefaultClients: Partial = {} + +export function inferProviderFromClient( + client?: ProviderClient, +): ProviderName | undefined { + if (!client) return undefined + if (client instanceof Anthropic) return 'anthropic' + if (client instanceof OpenAI) return 'openai' + return undefined +} + +export function setProviderClient( + clients: Partial, + provider: ProviderName, + client: ProviderClient, +): void { + Object.assign( + clients, + provider === 'anthropic' ? { anthropic: client } : { openai: client }, + ) +} + +export function getProviderClient( + clients: Partial, + provider: ProviderName, +): ProviderClient | undefined { + return provider === 'anthropic' ? clients.anthropic : clients.openai +} + +function getSharedDefaultClient(provider: ProviderName): ProviderClient { + if (provider === 'anthropic') { + if (!process.env.ANTHROPIC_API_KEY) { + throw new Error( + 'No Anthropic client configured. Provide clients.anthropic or set ANTHROPIC_API_KEY.', + ) + } + if (!sharedDefaultClients.anthropic) { + sharedDefaultClients.anthropic = new Anthropic() + } + return sharedDefaultClients.anthropic + } + + if (!process.env.OPENAI_API_KEY) { + throw new Error( + 'No OpenAI client configured. Provide clients.openai or set OPENAI_API_KEY.', + ) + } + if (!sharedDefaultClients.openai) { + sharedDefaultClients.openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }) + } + return sharedDefaultClients.openai +} + +export function ensureProviderClient( + clients: Partial, + provider: ProviderName, +): ProviderClient { + const configured = getProviderClient(clients, provider) + if (configured) { + return configured + } + + const sharedDefault = getSharedDefaultClient(provider) + setProviderClient(clients, provider, sharedDefault) + return sharedDefault +} diff --git a/packages/agentry/src/providers/index.ts b/packages/agentry/src/providers/index.ts new file mode 100644 index 0000000..afa20c0 --- /dev/null +++ b/packages/agentry/src/providers/index.ts @@ -0,0 +1,13 @@ +import { anthropicAdapter } from './anthropic' +import { openaiAdapter } from './openai' +import type { ProviderAdapter } from './types' + +export { anthropicAdapter, openaiAdapter } +export type { ProviderAdapter, ProviderClientMap } from './types' + +export function createDefaultAdapters(): Record { + return { + anthropic: anthropicAdapter, + openai: openaiAdapter, + } +} diff --git a/packages/agentry/src/providers/openai.ts b/packages/agentry/src/providers/openai.ts new file mode 100644 index 0000000..e615a8d --- /dev/null +++ b/packages/agentry/src/providers/openai.ts @@ -0,0 +1,236 @@ +import type OpenAI from 'openai' +import type { ProviderAdapter, NormalizedTurnRequest, NormalizedTurnResponse } from './types' +import type { AgentContentBlock, AgentMessageParam } from '../types/messages' +import type { JsonObject } from '../types/json' +import { isCodeExecutionTool, isWebSearchTool } from '../types/tools' + +type OpenAIResponseCreateParams = OpenAI.Responses.ResponseCreateParamsNonStreaming +type OpenAIResponseResult = OpenAI.Responses.Response +type OpenAIInputItem = + | { role: 'user' | 'assistant'; content: string } + | { type: 'function_call'; call_id: string; name: string; arguments: string } + | { type: 'function_call_output'; call_id: string; output: string } + +function stringifyContent( + content: + | string + | Array<{ type: string; text?: string }> + | undefined, +): string { + if (!content) return '' + if (typeof content === 'string') return content + return content + .filter((block) => block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text ?? '') + .join('\n') +} + +function toOpenAIInput(messages: AgentMessageParam[]): OpenAIInputItem[] { + const input: OpenAIInputItem[] = [] + for (const message of messages) { + if (typeof message.content === 'string') { + input.push({ + role: message.role, + content: message.content, + }) + continue + } + + const text = message.content + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + if (text) { + input.push({ + role: message.role, + content: text, + }) + } + + for (const block of message.content) { + if ( + block.type === 'tool_use' && + 'id' in block && + typeof block.id === 'string' && + 'name' in block && + typeof block.name === 'string' + ) { + input.push({ + type: 'function_call', + call_id: block.id, + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }) + continue + } + if ( + block.type === 'tool_result' && + 'tool_use_id' in block && + typeof block.tool_use_id === 'string' + ) { + input.push({ + type: 'function_call_output', + call_id: block.tool_use_id, + output: stringifyContent('content' in block ? block.content : undefined), + }) + } + } + } + return input +} + +function parseOpenAIResponse(response: OpenAIResponseResult): NormalizedTurnResponse { + const content: AgentContentBlock[] = [] + const output = Array.isArray(response.output) ? response.output : [] + for (const item of output) { + if (item.type === 'message' && Array.isArray(item.content)) { + for (const part of item.content) { + if (part.type === 'output_text' && typeof part.text === 'string') { + content.push({ type: 'text', text: part.text }) + } + } + } else if (item.type === 'function_call') { + let input: JsonObject = {} + if (typeof item.arguments === 'string') { + try { + const parsed = JSON.parse(item.arguments) + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + input = parsed as JsonObject + } + } catch { + input = {} + } + } + content.push({ + type: 'tool_use', + id: item.call_id ?? item.id ?? `call_${Date.now()}`, + name: item.name ?? 'tool', + input, + }) + } else if (item.type === 'reasoning' && typeof item.summary === 'string') { + content.push({ type: 'thinking', thinking: item.summary }) + } + } + + const stopReason = content.some((block) => block.type === 'tool_use') + ? 'tool_use' + : 'end_turn' + + return { + message: { + content, + stop_reason: stopReason, + usage: { + input_tokens: response.usage?.input_tokens ?? 0, + output_tokens: response.usage?.output_tokens ?? 0, + }, + }, + } +} + +function toOpenAITools(request: NormalizedTurnRequest): OpenAI.Responses.Tool[] { + const tools: OpenAI.Responses.Tool[] = request.tools.map((tool) => ({ + type: 'function', + name: tool.name, + description: tool.description, + parameters: tool.jsonSchema as OpenAI.Responses.FunctionTool['parameters'], + strict: tool.strict ?? null, + })) + + for (const sdkTool of request.sdkTools) { + if (isCodeExecutionTool(sdkTool)) { + tools.push({ + type: 'code_interpreter', + container: { type: 'auto' }, + } satisfies OpenAI.Responses.Tool.CodeInterpreter) + continue + } + + if (isWebSearchTool(sdkTool)) { + tools.push({ + type: 'web_search', + search_context_size: 'medium', + filters: sdkTool.allowed_domains + ? { allowed_domains: sdkTool.allowed_domains } + : undefined, + user_location: sdkTool.user_location + ? { + type: 'approximate', + city: sdkTool.user_location.city ?? null, + region: sdkTool.user_location.region ?? null, + country: sdkTool.user_location.country ?? null, + timezone: sdkTool.user_location.timezone ?? null, + } + : undefined, + } satisfies OpenAI.Responses.WebSearchTool) + } + } + + for (const server of request.mcpServers) { + tools.push({ + type: 'mcp', + server_label: server.name, + server_url: server.url, + authorization: server.authorization_token, + allowed_tools: server.tool_configuration?.allowed_tools, + require_approval: 'never', + } satisfies OpenAI.Responses.Tool.Mcp) + } + + return tools +} + +export const openaiAdapter: ProviderAdapter = { + name: 'openai', + async createTurn( + client: OpenAI, + request: NormalizedTurnRequest, + ): Promise { + const tools = toOpenAITools(request) + + const systemText = + typeof request.system === 'string' + ? request.system + : request.system?.map((s) => s.text).join('\n') + + const input = toOpenAIInput(request.messages) + const payload: OpenAIResponseCreateParams = { + model: request.model, + input, + tools: tools.length ? tools : undefined, + max_output_tokens: request.maxTokens, + temperature: request.temperature, + stream: false, + } + + if (systemText) { + payload.instructions = systemText + } + + const response = await client.responses.create(payload, { + signal: request.signal, + }) + const normalized = parseOpenAIResponse(response) + const text = normalized.message.content + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + if (text) { + request.onStream({ type: 'text', text, accumulated: text }) + } + for (const block of normalized.message.content) { + if (block.type === 'tool_use') { + request.onStream({ + type: 'tool_use_start', + toolName: block.name, + toolId: block.id, + }) + } + } + request.onStream({ + type: 'message_complete', + stopReason: normalized.message.stop_reason ?? 'unknown', + }) + return normalized + }, +} diff --git a/packages/agentry/src/providers/types.ts b/packages/agentry/src/providers/types.ts new file mode 100644 index 0000000..99750da --- /dev/null +++ b/packages/agentry/src/providers/types.ts @@ -0,0 +1,41 @@ +import type Anthropic from '@anthropic-ai/sdk' +import type OpenAI from 'openai' +import type { AgentStreamEvent, Model, ThinkingConfig } from '../types' +import type { AgentMessage, AgentMessageParam } from '../types/messages' +import type { InternalTool, SdkTool } from '../types/tools' +import type { MCPServerConfig } from '../instances/types' +import type { ProviderName } from '../types/provider' + +export interface ProviderClientMap { + anthropic: Anthropic + openai: OpenAI +} + +export interface NormalizedTurnRequest { + model: Model + maxTokens: number + system?: string | Array<{ type: 'text'; text: string; cache_control?: { type: 'ephemeral' } }> + messages: AgentMessageParam[] + tools: InternalTool[] + sdkTools: SdkTool[] + mcpServers: MCPServerConfig[] + stopSequences?: string[] + temperature?: number + thinking?: ThinkingConfig + betas?: string[] + stream?: boolean + signal: AbortSignal + onStream: (event: AgentStreamEvent) => void +} + +export interface NormalizedTurnResponse { + message: AgentMessage +} + +export interface ProviderAdapter { + name: ProviderName + createTurn( + client: ProviderClientMap[ProviderName], + request: NormalizedTurnRequest, + ): Promise +} diff --git a/packages/agentry/src/reconciler/reconciler.ts b/packages/agentry/src/reconciler/reconciler.ts index e3ec5d4..31312fb 100644 --- a/packages/agentry/src/reconciler/reconciler.ts +++ b/packages/agentry/src/reconciler/reconciler.ts @@ -64,13 +64,19 @@ function createReconciler< PublicInstance > { // Type assertion needed due to complex generic types in react-reconciler - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const reconciler = ReactReconciler(config as any) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return reconciler as any + const reconciler = ReactReconciler(config as never) + return reconciler as ReactReconciler.Reconciler< + Container, + Instance, + TextInstance, + SuspenseInstance, + FormInstance, + PublicInstance + > } interface PropagatedSettings { + provider?: AgentProps['provider'] stream?: boolean temperature?: number stopSequences?: string[] @@ -127,7 +133,7 @@ export const reconciler = createReconciler< // so we have to cast from the public React.Context type HostTransitionContext: createContext( null, - ) as unknown as ReactReconciler.ReactContext, + ) as never as ReactReconciler.ReactContext, setCurrentUpdatePriority() {}, // todo(investigate): why not 32 / DefaultEventPriority? getCurrentUpdatePriority: () => 1, @@ -157,6 +163,7 @@ export const reconciler = createReconciler< shouldSetTextContent: () => false, getRootHostContext(rootContainer) { return { + provider: rootContainer.props.provider, stream: rootContainer.props.stream, temperature: rootContainer.props.temperature, stopSequences: rootContainer.props.stopSequences, diff --git a/packages/agentry/src/reconciler/renderer.ts b/packages/agentry/src/reconciler/renderer.ts index de310fe..ebe2895 100644 --- a/packages/agentry/src/reconciler/renderer.ts +++ b/packages/agentry/src/reconciler/renderer.ts @@ -4,23 +4,25 @@ import type { AgentInstance } from '../instances' export interface ContainerInfo { container: AgentInstance - fiber: unknown + fiber: ReturnType | null } export function createContainer(agentInstance: AgentInstance): ContainerInfo { - const createContainerFn = reconciler.createContainer as unknown as ( - ...args: unknown[] - ) => unknown + const createContainerFn = reconciler.createContainer as ( + ...args: Array< + AgentInstance | number | string | boolean | null | ((error: Error) => void) + > + ) => ReturnType const fiber = createContainerFn( agentInstance, - ConcurrentRoot, // Use ConcurrentRoot for better scheduling and transitions - null, // hydrationCallbacks - false, // isStrictMode - null, // concurrentUpdatesByDefaultOverride - '', // identifierPrefix - (error: Error) => console.error('Recoverable error:', error), // onRecoverableError - null, // transitionCallbacks + ConcurrentRoot, + null, + false, + null, + '', + (error: Error) => console.error('Recoverable error:', error), + null, ) return { diff --git a/packages/agentry/src/reconciler/utils.ts b/packages/agentry/src/reconciler/utils.ts index 55a1f3f..2326f0c 100644 --- a/packages/agentry/src/reconciler/utils.ts +++ b/packages/agentry/src/reconciler/utils.ts @@ -1,4 +1,5 @@ import { scheduleOnIdle } from '../scheduler' +import type { JsonValue } from '../types/json' const RESERVED_PROPS = [ 'children', @@ -11,8 +12,8 @@ const RESERVED_PROPS = [ ] as const function deepEqual( - a: unknown, - b: unknown, + a: JsonValue | undefined, + b: JsonValue | undefined, seen = new WeakSet(), ): boolean { if (a === b) return true @@ -34,8 +35,8 @@ function deepEqual( } if (typeof a === 'object' && typeof b === 'object') { - const aObj = a as Record - const bObj = b as Record + const aObj = a as Record + const bObj = b as Record const aKeys = Object.keys(aObj) const bKeys = Object.keys(bObj) @@ -58,11 +59,11 @@ export function diffProps( oldProps: T, newProps: T, ): { changes: Partial; hasChanges: boolean } { - const changes: Record = {} + const changes: Record = {} let hasChanges = false - const oldRecord = oldProps as Record - const newRecord = newProps as Record + const oldRecord = oldProps as Record + const newRecord = newProps as Record for (const key of Object.keys(newRecord)) { if ((RESERVED_PROPS as readonly string[]).includes(key)) continue diff --git a/packages/agentry/src/run/agent.ts b/packages/agentry/src/run/agent.ts index ca038d5..db2ab95 100644 --- a/packages/agentry/src/run/agent.ts +++ b/packages/agentry/src/run/agent.ts @@ -1,21 +1,32 @@ -import Anthropic from '@anthropic-ai/sdk' import type { ReactNode } from 'react' import { AgentHandle } from '../handles' +import type { AgentResult } from '../types' +import type { ProviderName } from '../types/provider' +import type { ProviderClientMap } from '../providers/types' export interface RunOptions { - /** anthropic client instance */ - client?: Anthropic + /** single provider client (backward-compatible) */ + client?: ProviderClientMap[ProviderName] + /** provider clients */ + clients?: Partial /** execution mode */ mode?: 'batch' | 'interactive' } +export interface CreateAgentOptions { + /** single provider client (backward-compatible) */ + client?: ProviderClientMap[ProviderName] + /** provider clients */ + clients?: Partial +} + /** * run an agent element and return a handle or result * * @example batch mode (default) - runs to completion * ```ts * const result = await run( - * + * * You are a helpful assistant * Hello! * @@ -26,7 +37,7 @@ export interface RunOptions { * @example interactive mode - returns handle for ongoing interaction * ```ts * const agent = await run( - * + * * You are a helpful assistant * * , @@ -49,7 +60,7 @@ export interface RunOptions { export async function run( element: ReactNode, options?: RunOptions & { mode?: 'batch' }, -): Promise +): Promise export async function run( element: ReactNode, options: RunOptions & { mode: 'interactive' }, @@ -57,10 +68,14 @@ export async function run( export async function run( element: ReactNode, options: RunOptions = {}, -): Promise { - const { mode = 'batch', client } = options +): Promise { + const { mode = 'batch' } = options - const handle = new AgentHandle(element, client, mode) + const handle = new AgentHandle( + element, + { client: options.client, clients: options.clients }, + mode, + ) if (mode === 'interactive') { return handle @@ -80,7 +95,7 @@ export async function run( */ export function createAgent( element: ReactNode, - options?: { client?: Anthropic }, + options?: CreateAgentOptions, ): AgentHandle { - return new AgentHandle(element, options?.client) + return new AgentHandle(element, options) } diff --git a/packages/agentry/src/run/createAI.ts b/packages/agentry/src/run/createAI.ts new file mode 100644 index 0000000..b5a228a --- /dev/null +++ b/packages/agentry/src/run/createAI.ts @@ -0,0 +1,83 @@ +import type { ReactNode } from 'react' +import { run as runBase, createAgent as createAgentBase } from './agent' +import { AgentHandle } from '../handles' +import type { AgentResult } from '../types' +import type { ProviderClientMap } from '../providers/types' +import type { CreateAgentOptions, RunOptions } from './agent' + +export interface AIDefaults { + clients?: Partial + mode?: 'batch' | 'interactive' +} + +export interface AIBoundRunOptions { + clients?: Partial + mode?: 'batch' | 'interactive' +} + +export interface AIBoundCreateAgentOptions { + clients?: Partial +} + +export interface AI { + run( + element: ReactNode, + options?: AIBoundRunOptions & { mode?: 'batch' }, + ): Promise + run( + element: ReactNode, + options: AIBoundRunOptions & { mode: 'interactive' }, + ): Promise + createAgent(element: ReactNode, options?: AIBoundCreateAgentOptions): AgentHandle +} + +function mergeRunOptions(defaults: AIDefaults, options?: AIBoundRunOptions): RunOptions { + return { + clients: { + ...defaults.clients, + ...options?.clients, + }, + mode: options?.mode ?? defaults.mode, + } +} + +function mergeCreateAgentOptions( + defaults: AIDefaults, + options?: AIBoundCreateAgentOptions, +): CreateAgentOptions { + return { + clients: { + ...defaults.clients, + ...options?.clients, + }, + } +} + +export function createAI(defaults: AIDefaults): AI { + async function run( + element: ReactNode, + options?: AIBoundRunOptions & { mode?: 'batch' }, + ): Promise + async function run( + element: ReactNode, + options: AIBoundRunOptions & { mode: 'interactive' }, + ): Promise + async function run( + element: ReactNode, + options?: AIBoundRunOptions, + ): Promise { + const merged = mergeRunOptions(defaults, options) + + if (merged.mode === 'interactive') { + return runBase(element, { ...merged, mode: 'interactive' }) + } + + return runBase(element, { ...merged, mode: merged.mode ?? 'batch' }) + } + + function createAgent(element: ReactNode, options?: AIBoundCreateAgentOptions): AgentHandle { + return createAgentBase(element, mergeCreateAgentOptions(defaults, options)) + } + + return { run, createAgent } +} diff --git a/packages/agentry/src/run/index.ts b/packages/agentry/src/run/index.ts index ea601bb..1b45986 100644 --- a/packages/agentry/src/run/index.ts +++ b/packages/agentry/src/run/index.ts @@ -1,4 +1,16 @@ -export { run, createAgent, type RunOptions } from './agent' +export { + run, + createAgent, + type RunOptions, + type CreateAgentOptions, +} from './agent' +export { + createAI, + type AI, + type AIDefaults, + type AIBoundRunOptions, + type AIBoundCreateAgentOptions, +} from './createAI' export { runSubagent, type RunSubagentOptions } from './subagent' diff --git a/packages/agentry/src/run/runAgentFunction.ts b/packages/agentry/src/run/runAgentFunction.ts index 379c1ab..aae7393 100644 --- a/packages/agentry/src/run/runAgentFunction.ts +++ b/packages/agentry/src/run/runAgentFunction.ts @@ -1,13 +1,18 @@ -import Anthropic from '@anthropic-ai/sdk' import type React from 'react' import { SubagentHandle } from '../handles' import type { AgentResult, Model } from '../types' import { createSubagentInstance } from '../instances/createInstance' +import type { ProviderName } from '../types/provider' +import type { ProviderClientMap } from '../providers/types' /** * Options for spawning an agent programmatically */ export interface RunAgentOptions { + /** Override provider */ + provider?: ProviderName + /** Override client set */ + clients?: Partial /** Override parent's model */ model?: Model /** Override maxTokens (defaults to half parent's) */ @@ -22,7 +27,9 @@ export interface RunAgentOptions { * Context for creating a spawn agent function */ export interface RunAgentContext { - client: Anthropic + clients?: Partial + client?: ProviderClientMap[ProviderName] + provider?: ProviderName model?: Model signal?: AbortSignal } @@ -57,6 +64,19 @@ export function createRunAgent(context: RunAgentContext) { agentElement: React.ReactElement, options: RunAgentOptions = {}, ): Promise { + const provider = options.provider ?? context.provider + if (!provider) { + throw new Error('Provider is required for runAgent.') + } + const clients = + options.clients ?? + context.clients ?? + (context.client + ? ({ + [provider]: context.client, + } as Partial) + : {}) + const elementProps = agentElement.props as { name?: string maxTokens?: number @@ -66,17 +86,20 @@ export function createRunAgent(context: RunAgentContext) { { name: elementProps.name || `spawned_${Date.now()}`, agentNode: agentElement, + provider, maxTokens: options.maxTokens ?? elementProps.maxTokens, temperature: options.temperature ?? elementProps.temperature, stream: false, }, { + provider, model: options.model || context.model, }, ) const handle = new SubagentHandle(subagent, { - client: context.client, + provider, + clients, signal: options.signal || context.signal, }) diff --git a/packages/agentry/src/run/subagent.ts b/packages/agentry/src/run/subagent.ts index 58b3589..ab36ca1 100644 --- a/packages/agentry/src/run/subagent.ts +++ b/packages/agentry/src/run/subagent.ts @@ -1,11 +1,16 @@ -import Anthropic from '@anthropic-ai/sdk' import { SubagentHandle } from '../handles' import type { AgentResult } from '../types' import type { SubagentInstance } from '../instances' +import type { ProviderClientMap } from '../providers/types' +import type { ProviderName } from '../types/provider' export interface RunSubagentOptions { - /** anthropic client instance */ - client: Anthropic + /** single provider client (backward-compatible) */ + client?: ProviderClientMap[ProviderName] + /** provider clients */ + clients?: Partial + /** provider for subagent execution */ + provider?: ProviderName /** abort signal for cancellation */ signal?: AbortSignal } diff --git a/packages/agentry/src/store.ts b/packages/agentry/src/store.ts index 1a96a64..8f1b933 100644 --- a/packages/agentry/src/store.ts +++ b/packages/agentry/src/store.ts @@ -1,15 +1,15 @@ import { createStore, type StoreApi } from 'zustand/vanilla' import type { AgentState } from './types/state' -import type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta' +import type { AgentMessageParam } from './types/messages' export interface AgentStoreState { executionState: AgentState - messages: BetaMessageParam[] + messages: AgentMessageParam[] actions: { setExecutionState: (state: AgentState) => void - pushMessage: (message: BetaMessageParam) => void - removeMessage: (message: BetaMessageParam) => void - setMessages: (messages: BetaMessageParam[]) => void + pushMessage: (message: AgentMessageParam) => void + removeMessage: (message: AgentMessageParam) => void + setMessages: (messages: AgentMessageParam[]) => void } } diff --git a/packages/agentry/src/tools/agentSyntheticTool.ts b/packages/agentry/src/tools/agentSyntheticTool.ts index f75371c..ea137d4 100644 --- a/packages/agentry/src/tools/agentSyntheticTool.ts +++ b/packages/agentry/src/tools/agentSyntheticTool.ts @@ -35,15 +35,18 @@ export const createAgentSyntheticTool = ( { name: agentTool.name, description: agentTool.description, + provider: toolContext.provider, agentNode: agentElement, }, { + provider: toolContext.provider, model: toolContext.model, }, ) const result = await runSubagent(subagent, { - client: toolContext.client, + provider: subagent.props.provider, + clients: toolContext.clients, signal: toolContext.signal, }) diff --git a/packages/agentry/src/tools/defineTool.ts b/packages/agentry/src/tools/defineTool.ts index e2d18ff..128562d 100644 --- a/packages/agentry/src/tools/defineTool.ts +++ b/packages/agentry/src/tools/defineTool.ts @@ -33,7 +33,10 @@ export function defineTool(options: { }): InternalTool> { const { name, description, parameters, strict, handler } = options - const jsonSchema = z.toJSONSchema(parameters) as Record + const jsonSchema = z.toJSONSchema(parameters) as Record< + string, + object | string | number | boolean | null + > if (strict && jsonSchema.type === 'object') { jsonSchema.additionalProperties = false @@ -67,7 +70,7 @@ export function toApiTool(tool: InternalTool): BetaTool { */ export function parseToolInput( tool: InternalTool, - input: unknown, + input: z.output, ): | { success: true; data: TInput } | { @@ -77,7 +80,7 @@ export function parseToolInput( } } { const schema = tool.parameters as { - safeParse: (input: unknown) => { + safeParse: (input: z.output) => { success: boolean data?: TInput error?: { @@ -109,7 +112,7 @@ export function formatValidationError(error: { */ export async function executeTool( tool: InternalTool, - input: unknown, + input: z.output, context: ToolContext, ): Promise<{ result: ToolResult; isError: boolean }> { const parseResult = parseToolInput(tool, input) diff --git a/packages/agentry/src/tools/memoryTool.ts b/packages/agentry/src/tools/memoryTool.ts index 8c62bbb..e6f6199 100644 --- a/packages/agentry/src/tools/memoryTool.ts +++ b/packages/agentry/src/tools/memoryTool.ts @@ -44,7 +44,7 @@ export async function executeMemoryTool( switch (input.command) { case 'view': { if (!handlers.onView) { - throw new Error('onView handler not provided') + throw new Error('Missing onView handler.') } result = await Promise.resolve( handlers.onView({ @@ -56,7 +56,7 @@ export async function executeMemoryTool( } case 'create': { if (!handlers.onCreate) { - throw new Error('onCreate handler not provided') + throw new Error('Missing onCreate handler.') } result = await Promise.resolve( handlers.onCreate({ @@ -68,7 +68,7 @@ export async function executeMemoryTool( } case 'str_replace': { if (!handlers.onStrReplace) { - throw new Error('onStrReplace handler not provided') + throw new Error('Missing onStrReplace handler.') } result = await Promise.resolve( handlers.onStrReplace({ @@ -81,7 +81,7 @@ export async function executeMemoryTool( } case 'insert': { if (!handlers.onInsert) { - throw new Error('onInsert handler not provided') + throw new Error('Missing onInsert handler.') } result = await Promise.resolve( handlers.onInsert({ @@ -94,7 +94,7 @@ export async function executeMemoryTool( } case 'delete': { if (!handlers.onDelete) { - throw new Error('onDelete handler not provided') + throw new Error('Missing onDelete handler.') } result = await Promise.resolve( handlers.onDelete({ @@ -105,7 +105,7 @@ export async function executeMemoryTool( } case 'rename': { if (!handlers.onRename) { - throw new Error('onRename handler not provided') + throw new Error('Missing onRename handler.') } result = await Promise.resolve( handlers.onRename({ diff --git a/packages/agentry/src/types/agent.ts b/packages/agentry/src/types/agent.ts index 0b677dc..b2c8a4f 100644 --- a/packages/agentry/src/types/agent.ts +++ b/packages/agentry/src/types/agent.ts @@ -1,17 +1,19 @@ -import type { Model } from '@anthropic-ai/sdk/resources/messages' -import type { - BetaMessageParam, - BetaThinkingConfigParam, -} from '@anthropic-ai/sdk/resources/beta' import type { OnStepFinishResult } from './lifecycle' +import type { AgentMessageParam } from './messages' +import type { ProviderName } from './provider' +import type { Model as AnthropicModel } from '@anthropic-ai/sdk/resources/messages' +import type OpenAI from 'openai' -export type { Model } +export type Model = + | AnthropicModel + | NonNullable -export type ThinkingConfig = BetaThinkingConfigParam & { - interleaved?: false -} +export type ThinkingConfig = + | { type: 'enabled'; budget_tokens: number; interleaved?: false } + | { type: 'disabled' } export interface AgentProps { + provider?: ProviderName model: Model name?: string @@ -47,7 +49,7 @@ export type AgentStreamEvent = export interface AgentResult { content: string - messages: BetaMessageParam[] + messages: AgentMessageParam[] usage: { inputTokens: number outputTokens: number diff --git a/packages/agentry/src/types/agentTool.ts b/packages/agentry/src/types/agentTool.ts index 26d434a..d6c8177 100644 --- a/packages/agentry/src/types/agentTool.ts +++ b/packages/agentry/src/types/agentTool.ts @@ -24,10 +24,10 @@ export interface DefineAgentToolOptions { * Internal representation of an agent tool with JSON schema * This is what gets stored in the reconciler instance */ -export interface InternalAgentTool { +export interface InternalAgentTool> { name: string description: string parameters: z.ZodType - jsonSchema: Record + jsonSchema: Record agent: AgentToolFunction> } diff --git a/packages/agentry/src/types/index.ts b/packages/agentry/src/types/index.ts index 5a64dbb..f6e5fba 100644 --- a/packages/agentry/src/types/index.ts +++ b/packages/agentry/src/types/index.ts @@ -1,9 +1,16 @@ export { + type AgentMessage, + type AgentMessageParam, + type AgentContentBlock, type BetaMessage, type BetaMessageParam, type BetaContentBlock, type BetaToolUseBlock, type BetaTextBlock, + type ToolUseContentBlock, + type TextContentBlock, + type ToolResultContentBlock, + type ThinkingContentBlock, isToolUseBlock, isTextBlock, extractText, @@ -26,6 +33,7 @@ export { type MemoryHandlers, type RunAgentOptions, isCodeExecutionTool, + isWebSearchTool, isMemoryTool, } from './tools' @@ -44,6 +52,10 @@ export { isProcessing, } from './state' +export { + type ProviderName, +} from './provider' + export { type Model, type AgentProps, diff --git a/packages/agentry/src/types/json.ts b/packages/agentry/src/types/json.ts new file mode 100644 index 0000000..77b49ab --- /dev/null +++ b/packages/agentry/src/types/json.ts @@ -0,0 +1,7 @@ +export type JsonPrimitive = string | number | boolean | null +export type JsonValue = JsonPrimitive | JsonObject | JsonArray +export type JsonArray = JsonValue[] + +export interface JsonObject { + [key: string]: JsonValue +} diff --git a/packages/agentry/src/types/lifecycle.ts b/packages/agentry/src/types/lifecycle.ts index 2ac0f96..0c688e2 100644 --- a/packages/agentry/src/types/lifecycle.ts +++ b/packages/agentry/src/types/lifecycle.ts @@ -1,19 +1,16 @@ -import type { - BetaMessage, - BetaMessageParam, - BetaToolResultBlockParam, -} from '@anthropic-ai/sdk/resources/beta' +import type { AgentMessage, AgentMessageParam } from './messages' +import type { z } from 'zod' export interface StepToolCall { id: string name: string - input: unknown + input: z.output } export interface StepToolResult { toolCallId: string toolName: string - result: BetaToolResultBlockParam['content'] + result: string | Array<{ type: 'text'; text: string }> isError: boolean executionTime?: number // milliseconds } @@ -52,10 +49,10 @@ export interface OnStepFinishResult { usage: StepUsage /** Full message from Claude API (advanced use) */ - message: BetaMessage + message: AgentMessage /** Immutable snapshot of full conversation history */ - messages: readonly BetaMessageParam[] + messages: readonly AgentMessageParam[] /** Timestamp when step finished */ timestamp: Date diff --git a/packages/agentry/src/types/messages.ts b/packages/agentry/src/types/messages.ts index 481daee..2b81f1a 100644 --- a/packages/agentry/src/types/messages.ts +++ b/packages/agentry/src/types/messages.ts @@ -1,36 +1,88 @@ +import type { JsonObject } from './json' import type { - BetaMessage, - BetaMessageParam, - BetaContentBlock, - BetaToolUseBlock, - BetaTextBlock, + BetaContentBlock as AnthropicBetaContentBlock, + BetaContentBlockParam as AnthropicBetaContentBlockParam, } from '@anthropic-ai/sdk/resources/beta' -export type { - BetaMessage, - BetaMessageParam, - BetaContentBlock, - BetaToolUseBlock, - BetaTextBlock, +export interface TextContentBlock { + type: 'text' + text: string } -export function isToolUseBlock( - block: BetaContentBlock, -): block is BetaToolUseBlock { - return block.type === 'tool_use' +export interface ThinkingContentBlock { + type: 'thinking' + thinking: string } -export function isTextBlock(block: BetaContentBlock): block is BetaTextBlock { - return block.type === 'text' +export interface ToolUseContentBlock { + type: 'tool_use' + id: string + name: string + input: JsonObject } -export function extractText(message: BetaMessage): string { - return message.content - .filter(isTextBlock) - .map((block) => block.text) - .join('') +export interface ToolResultContentBlock { + type: 'tool_result' + tool_use_id: string + content: string | Array<{ type: 'text'; text: string }> + is_error?: boolean } -export function extractToolUses(message: BetaMessage): BetaToolUseBlock[] { +export type AgentContentBlock = + | TextContentBlock + | ThinkingContentBlock + | ToolUseContentBlock + | ToolResultContentBlock + | AnthropicBetaContentBlock + | AnthropicBetaContentBlockParam + +export interface AgentMessageParam { + role: 'user' | 'assistant' + content: string | AgentContentBlock[] +} + +export interface AgentMessage { + content: AgentContentBlock[] + stop_reason: string | null + usage: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens?: number | null + cache_read_input_tokens?: number | null + } +} + +// Backward-compatible aliases retained for test and migration stability. +export type BetaMessageParam = AgentMessageParam +export type BetaMessage = AgentMessage +export type BetaContentBlock = AgentContentBlock +export type BetaToolUseBlock = ToolUseContentBlock +export type BetaTextBlock = TextContentBlock + +export function isToolUseBlock(block: AgentContentBlock): block is ToolUseContentBlock { + return ( + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'tool_use' + ) +} + +export function isTextBlock( + block: AgentContentBlock, +): block is TextContentBlock { + return ( + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'text' + ) +} + +export function extractText(message: AgentMessage): string { + return message.content.filter(isTextBlock).map((block) => block.text).join('') +} + +export function extractToolUses(message: AgentMessage): ToolUseContentBlock[] { return message.content.filter(isToolUseBlock) } diff --git a/packages/agentry/src/types/provider.ts b/packages/agentry/src/types/provider.ts new file mode 100644 index 0000000..0f97fb4 --- /dev/null +++ b/packages/agentry/src/types/provider.ts @@ -0,0 +1 @@ +export type ProviderName = 'anthropic' | 'openai' diff --git a/packages/agentry/src/types/state.ts b/packages/agentry/src/types/state.ts index 52bada8..1952bc4 100644 --- a/packages/agentry/src/types/state.ts +++ b/packages/agentry/src/types/state.ts @@ -1,4 +1,4 @@ -import type { BetaMessage } from '@anthropic-ai/sdk/resources/beta' +import type { AgentMessage } from './messages' import type { PendingToolCall, ToolExecutionResult } from './tools' export type AgentState = @@ -6,7 +6,7 @@ export type AgentState = | { status: 'streaming'; abortController: AbortController } | { status: 'waiting_for_tools'; pendingTools: PendingToolCall[] } | { status: 'executing_tools'; pendingTools: PendingToolCall[] } - | { status: 'completed'; finalMessage: BetaMessage } + | { status: 'completed'; finalMessage: AgentMessage } | { status: 'error'; error: Error } export type StateTransition = @@ -14,7 +14,7 @@ export type StateTransition = | { type: 'tools_requested'; pendingTools: PendingToolCall[] } | { type: 'tools_executing'; pendingTools: PendingToolCall[] } | { type: 'tools_completed'; results: ToolExecutionResult[] } - | { type: 'completed'; finalMessage: BetaMessage } + | { type: 'completed'; finalMessage: AgentMessage } | { type: 'error'; error: Error } export function initialState(): AgentState { diff --git a/packages/agentry/src/types/tools.ts b/packages/agentry/src/types/tools.ts index ddc6f43..952921f 100644 --- a/packages/agentry/src/types/tools.ts +++ b/packages/agentry/src/types/tools.ts @@ -1,12 +1,14 @@ import type { ReactElement } from 'react' import type { z } from 'zod' -import type Anthropic from '@anthropic-ai/sdk' import type { - BetaToolResultBlockParam, - BetaWebSearchTool20250305, + BetaCodeExecutionTool20250825, BetaMemoryTool20250818, + BetaWebSearchTool20250305, } from '@anthropic-ai/sdk/resources/beta' import type { Model, AgentResult } from './agent' +import type { ProviderName } from './provider' +import type { ProviderClientMap } from '../providers/types' +import type { JsonObject } from './json' export interface MemoryHandlers { /** Handler for viewing directory contents or file contents */ @@ -40,24 +42,10 @@ export interface MemoryHandlers { }) => Promise | string } -export type ToolResult = string | BetaToolResultBlockParam['content'] - -/** - * Code execution tool - enables code execution capability - */ -export interface CodeExecutionTool { - type: 'code_execution_20250825' - name: 'code_execution' -} +export type ToolResult = string | Array<{ type: 'text'; text: string }> -/** - * Web search tool - enables web search capability - */ +export type CodeExecutionTool = BetaCodeExecutionTool20250825 export type WebSearchTool = BetaWebSearchTool20250305 - -/** - * Memory tool - enables memory capability with client-side handlers - */ export type MemoryTool = BetaMemoryTool20250818 & { memoryHandlers?: MemoryHandlers } @@ -92,6 +80,10 @@ export function isMemoryTool(tool: SdkTool): tool is MemoryTool { * Options for running an agent programmatically from a tool handler */ export interface RunAgentOptions { + /** Override provider */ + provider?: ProviderName + /** Override provider clients */ + clients?: Partial /** Override parent's model */ model?: Model /** Override maxTokens (defaults to half parent's) */ @@ -104,11 +96,14 @@ export interface RunAgentOptions { export interface ToolContext { agentName: string - client: Anthropic + provider?: ProviderName + clients?: Partial + /** Client for current provider (backward-compatible alias) */ + client?: ProviderClientMap[ProviderName] model?: Model // abort signal for cancellation signal?: AbortSignal - metadata?: Record + metadata?: JsonObject /** * Programmatically run an agent from within a tool handler. * The spawned agent runs to completion and returns its result. @@ -137,7 +132,7 @@ export interface ToolContext { ) => Promise } -export interface RunnableTool { +export interface RunnableTool> { name: string description: string parameters: z.ZodType @@ -147,8 +142,9 @@ export interface RunnableTool { ) => Promise | ToolResult } -export interface InternalTool extends RunnableTool { - jsonSchema: Record +export interface InternalTool> + extends RunnableTool { + jsonSchema: Record strict?: boolean } @@ -169,7 +165,7 @@ export function isRunnableTool(tool: ToolUnion): tool is InternalTool { export interface PendingToolCall { id: string name: string - input: unknown + input: z.output } export interface ToolExecutionResult { diff --git a/packages/agentry/tests/compaction.test.ts b/packages/agentry/tests/compaction.test.ts index 19a7463..f37b0c4 100644 --- a/packages/agentry/tests/compaction.test.ts +++ b/packages/agentry/tests/compaction.test.ts @@ -30,7 +30,7 @@ test('compactionControl compacts messages when threshold is exceeded', async () const agentInstance: AgentInstance = { type: 'agent', - props: { model: TEST_MODEL, maxTokens: 100 }, + props: { provider: 'anthropic', model: TEST_MODEL, maxTokens: 100 }, client, engine: null, systemParts: [], @@ -43,6 +43,7 @@ test('compactionControl compacts messages when threshold is exceeded', async () } const engine = new ExecutionEngine({ + provider: 'anthropic', client, model: TEST_MODEL, maxTokens: 100, @@ -135,7 +136,7 @@ test('compactionControl does nothing when under threshold', async () => { const agentInstance: AgentInstance = { type: 'agent', - props: { model: TEST_MODEL, maxTokens: 100 }, + props: { provider: 'anthropic', model: TEST_MODEL, maxTokens: 100 }, client, engine: null, systemParts: [], @@ -148,6 +149,7 @@ test('compactionControl does nothing when under threshold', async () => { } const engine = new ExecutionEngine({ + provider: 'anthropic', client, model: TEST_MODEL, maxTokens: 100, diff --git a/packages/agentry/tests/conditions.test.tsx b/packages/agentry/tests/conditions.test.tsx index 4397cc2..d5ad4aa 100644 --- a/packages/agentry/tests/conditions.test.tsx +++ b/packages/agentry/tests/conditions.test.tsx @@ -19,7 +19,7 @@ describe('Condition', () => { const TestAgent = () => { const [isActive] = useState(true) return ( - + Hello Active mode @@ -48,7 +48,7 @@ describe('Condition', () => { const TestAgent = () => { const [isActive] = useState(false) return ( - + Hello Active mode @@ -78,7 +78,7 @@ describe('Condition', () => { const [condition1] = useState(true) const [condition2] = useState(true) return ( - + Test Route 1 active @@ -109,7 +109,7 @@ describe('Condition', () => { const TestAgent = () => { const [isAuthenticated] = useState(false) return ( - + Help me @@ -157,7 +157,7 @@ describe('Condition', () => { const TestAgent = () => { const [isAuthenticated, setIsAuthenticated] = useState(false) return ( - + Authenticate me @@ -214,7 +214,7 @@ describe('Condition', () => { const TestAgent = () => { const [condition] = useState(false) return ( - + Base system prompt Hello @@ -243,7 +243,7 @@ describe('Condition', () => { const TestAgent = () => { const [isActive] = useState(true) return ( - + Test Context 1 @@ -285,7 +285,7 @@ describe('Condition', () => { const TestAgent = () => { const [isActive] = useState(true) return ( - + Hello Route 1 @@ -326,7 +326,7 @@ describe('Condition', () => { const TestAgent = () => { const [isAuthenticated] = useState(true) return ( - + Can you calculate 5 times 8 for me? User is authenticated @@ -389,7 +389,7 @@ describe('Condition', () => { it('should activate multiple natural language routes simultaneously', async () => { const TestAgent = () => { return ( - + Calculate 2+2 and tell me about math Math mode @@ -463,7 +463,7 @@ describe('Condition', () => { const [isAuthenticated] = useState(true) const [isPremium] = useState(true) return ( - + Hello User is authenticated @@ -506,7 +506,7 @@ describe('Condition', () => { const [isAuthenticated] = useState(true) const [isPremium] = useState(false) return ( - + Hello User is authenticated @@ -556,7 +556,7 @@ describe('Condition', () => { const [isAuthenticated] = useState(false) const [isPremium] = useState(true) return ( - + Hello @@ -589,7 +589,7 @@ describe('Condition', () => { const [isPremium] = useState(true) const [isAdmin] = useState(true) return ( - + Hello Authenticated @@ -634,7 +634,7 @@ describe('Condition', () => { const TestAgent = () => { const [isAuthenticated] = useState(true) return ( - + I want to calculate 10 + 5 User is authenticated @@ -696,7 +696,7 @@ describe('Condition', () => { const [isAuthenticated, setIsAuthenticated] = useState(false) const [hasPermission] = useState(true) return ( - + Authenticate and perform action diff --git a/packages/agentry/tests/dynamic-components.test.tsx b/packages/agentry/tests/dynamic-components.test.tsx index c81de59..9742e52 100644 --- a/packages/agentry/tests/dynamic-components.test.tsx +++ b/packages/agentry/tests/dynamic-components.test.tsx @@ -25,7 +25,7 @@ test('state changes trigger reconciler updates', async () => { }) return ( - + Counter is at {count} diff --git a/packages/agentry/tests/hooks.test.tsx b/packages/agentry/tests/hooks.test.tsx index 526cef1..3507668 100644 --- a/packages/agentry/tests/hooks.test.tsx +++ b/packages/agentry/tests/hooks.test.tsx @@ -29,7 +29,7 @@ test('useExecutionState tracks status transitions', async () => { ]) const runPromise = run( - + Test , @@ -54,7 +54,7 @@ test('useExecutionState with createStateWatcher helper', async () => { ]) const runPromise = run( - + Test , @@ -84,7 +84,7 @@ test('useMessages accumulates conversation history', async () => { ]) const runPromise = run( - + Hello , @@ -108,7 +108,7 @@ test('useMessages with createMessageCollector helper', async () => { ]) const runPromise = run( - + Test message , @@ -144,7 +144,7 @@ test('useAgentState provides full state access', async () => { ]) const runPromise = run( - + Test , @@ -189,7 +189,7 @@ test('multiple components can subscribe to same state', async () => { ]) const runPromise = run( - + Test @@ -235,7 +235,7 @@ test('state updates during tool execution', async () => { ]) const runPromise = run( - + diff --git a/packages/agentry/tests/openai-provider.test.tsx b/packages/agentry/tests/openai-provider.test.tsx new file mode 100644 index 0000000..a4dab60 --- /dev/null +++ b/packages/agentry/tests/openai-provider.test.tsx @@ -0,0 +1,97 @@ +import { test, expect } from 'bun:test' +import { z } from 'zod' +import { run, Agent, Message, Tools, AgentTool } from '../src' +import { createOpenAIMockClient } from './utils' +import { TEST_MODEL } from '../src/constants' + +test('openai provider runs basic response flow', async () => { + const { client, calls } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Hello from OpenAI' }], + }, + ], + }, + ]) + + const result = await run( + + Hello + , + { + clients: { openai: client }, + }, + ) + + expect(result.content).toBe('Hello from OpenAI') + expect(calls.length).toBe(1) +}) + +test('openai parent can run anthropic AgentTool subagent', async () => { + const { client: openaiClient } = createOpenAIMockClient([ + { + output: [ + { + type: 'function_call', + call_id: 'call_1', + name: 'researcher', + arguments: JSON.stringify({ topic: 'Bun runtime' }), + }, + ], + }, + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Final combined answer' }], + }, + ], + }, + ]) + + const anthropicClient = { + beta: { + messages: { + create: async () => ({ + content: [{ type: 'text', text: 'Anthropic subagent result' }], + stop_reason: 'end_turn', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + }, + }), + }, + }, + } as any + + const runPromise = run( + + + ( + + Research: {topic} + + )} + /> + + Please research Bun runtime + , + { + clients: { + openai: openaiClient, + anthropic: anthropicClient, + }, + }, + ) + + const result = await runPromise + expect(result.content).toBe('Final combined answer') +}) diff --git a/packages/agentry/tests/run-agent.test.tsx b/packages/agentry/tests/run-agent.test.tsx index 8a7e108..3e7fa15 100644 --- a/packages/agentry/tests/run-agent.test.tsx +++ b/packages/agentry/tests/run-agent.test.tsx @@ -22,7 +22,7 @@ test('runAgent executes subagent and returns result', async () => { ]) const runPromise = run( - + Test agent with spawn capability { ]) const runPromise = run( - + Parallel analysis coordinator { }) as typeof client.beta.messages.stream const runPromise = run( - + Test { }) as typeof client.beta.messages.create const runPromise = run( - + Test { }) as typeof client.beta.messages.create const runPromise = run( - + Test error handling { ]) const runPromise = run( - + Test { ]) const runPromise = run( - + Conditional spawner { ]) const runPromise = run( - + You continue conversations {/* pre-loaded conversation history */} What is 2+2? @@ -47,7 +47,13 @@ test('run creates an agent and executes in batch mode', async () => { ]) const runPromise = run( - + You are a test assistant. Be very brief. Say hello in 3 words , @@ -88,7 +94,7 @@ test('run handles tools correctly', async () => { ]) const runPromise = run( - + You are a test assistant. Use the get_info tool. @@ -115,7 +121,7 @@ test('interactive mode allows multiple turns', async () => { ]) const agentPromise = run( - + You are a test assistant. Be very concise. , { mode: 'interactive', client }, @@ -150,7 +156,7 @@ test('stream() accepts message parameter for first turn', async () => { ]) const agentPromise = run( - + You are a test assistant. Be very concise. , { mode: 'interactive', client }, @@ -181,7 +187,7 @@ test('stream() works with message for subsequent turns', async () => { ]) const agentPromise = run( - + You are a test assistant. Be very concise. , { mode: 'interactive', client }, @@ -242,7 +248,7 @@ test('handles multiple tool calls in sequence', async () => { ]) const runPromise = run( - + @@ -277,7 +283,13 @@ test('respects maxIterations limit', async () => { }) const runPromise = run( - + @@ -345,7 +357,7 @@ test('tool schemas in API requests are complete', async () => { ]) const runPromise = run( - + You are a test assistant. @@ -392,7 +404,7 @@ test('batch mode errors when agent has no messages', async () => { await expect( run( - + You are helpful {/* No components */} , @@ -405,7 +417,7 @@ test('interactive mode does NOT error when agent has no messages', async () => { const { client } = createStepMockClient([]) const agent = await run( - + You are helpful , { client, mode: 'interactive' }, @@ -418,7 +430,7 @@ test('interactive mode does NOT error when agent has no messages', async () => { test('subagent errors when it has no messages', async () => { const SubAgent = () => ( - + I am a subagent {/* No components - should trigger validation error */} @@ -434,7 +446,7 @@ test('subagent errors when it has no messages', async () => { ]) const runPromise = run( - + You are helpful + Ephemeral instructions Test , @@ -510,7 +522,7 @@ test('Context with cache="ephemeral" creates block with cache_control', async () ]) const runPromise = run( - + Ephemeral context Test , @@ -544,7 +556,7 @@ test('System without cache creates simple string', async () => { ]) const runPromise = run( - + Regular instructions Test , @@ -568,7 +580,7 @@ test('Mixed System and Context parts maintain order and cache flags', async () = ]) const runPromise = run( - + First cached part Ephemeral context Ephemeral instructions @@ -619,7 +631,7 @@ test('Multiple ephemeral parts are all marked correctly', async () => { ]) const runPromise = run( - + First ephemeral Second ephemeral Third ephemeral @@ -653,7 +665,7 @@ test('Multiple System and Context parts use array format', async () => { ]) const runPromise = run( - + First part Second part Third part @@ -687,7 +699,7 @@ test('strict tool enables structured-outputs beta', async () => { ]) const runPromise = run( - + { const runPromise = run( { ]) const runPromise = run( - + Parent system prompt { const runPromise = run( { @@ -158,7 +159,7 @@ test('onComplete callback fires when agent finishes', async () => { ]) const runPromise = run( - + , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(result.content).toContain('42') @@ -952,7 +952,7 @@ describe('NL Condition Evaluation', () => { , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(result.content).toContain('Default') @@ -993,7 +993,7 @@ describe('OpenAI Natural Language Conditions', () => { Math mode active , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(result.content).toContain('42') @@ -1028,7 +1028,7 @@ describe('OpenAI Natural Language Conditions', () => { Special mode , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(result.content).toContain('Default') diff --git a/packages/agentry/tests/create-ai.test.tsx b/packages/agentry/tests/create-ai.test.tsx index a0c9c1a..de29a14 100644 --- a/packages/agentry/tests/create-ai.test.tsx +++ b/packages/agentry/tests/create-ai.test.tsx @@ -8,7 +8,7 @@ test('createAI default clients are used when no per-call override given', async { content: [mockText('Hello from default client')] }, ]) - const ai = createAI({ clients: { anthropic: client } }) + const ai = createAI({ providers: { anthropic: { client } } }) const runPromise = ai.run( @@ -29,13 +29,13 @@ test('createAI per-call clients override default clients', async () => { { content: [mockText('From override client')] }, ]) - const ai = createAI({ clients: { anthropic: defaultClient } }) + const ai = createAI({ providers: { anthropic: { client: defaultClient } } }) const runPromise = ai.run( Hello , - { clients: { anthropic: overrideClient } }, + { providers: { anthropic: { client: overrideClient } } }, ) await controller.nextTurn() @@ -46,13 +46,13 @@ test('createAI per-call clients override default clients', async () => { test('createAI per-call mode overrides default mode', async () => { const { client } = createStepMockClient([]) - const ai = createAI({ clients: { anthropic: client }, mode: 'interactive' }) + const ai = createAI({ providers: { anthropic: { client } }, mode: 'interactive' }) // batch override should return AgentResult, not AgentHandle const { client: batchClient, controller } = createStepMockClient([ { content: [mockText('Batch result')] }, ]) - const ai2 = createAI({ clients: { anthropic: batchClient } }) + const ai2 = createAI({ providers: { anthropic: { client: batchClient } } }) const runPromise = ai2.run( Run in batch @@ -71,7 +71,7 @@ test('createAI interactive mode returns a handle', async () => { ]) const ai = createAI({ - clients: { anthropic: client }, + providers: { anthropic: { client } }, mode: 'interactive', }) @@ -101,7 +101,7 @@ test('createAI createAgent uses merged clients', async () => { { content: [mockText('From createAgent')] }, ]) - const ai = createAI({ clients: { anthropic: client } }) + const ai = createAI({ providers: { anthropic: { client } } }) const agentHandle = ai.createAgent( @@ -119,7 +119,7 @@ test('createAI createAgent uses merged clients', async () => { test('createAI default mode=interactive returns handle without explicit mode override', async () => { const { client } = createStepMockClient([]) - const ai = createAI({ clients: { anthropic: client }, mode: 'interactive' }) + const ai = createAI({ providers: { anthropic: { client } }, mode: 'interactive' }) // TypeScript types the return as AgentResult (no per-call mode override), // but at runtime the merged defaults make it interactive → AgentHandle @@ -149,13 +149,13 @@ test('createAI multi-provider: per-call client overrides default for that provid }, ]) - const ai = createAI({ clients: { anthropic: anthropicDefault } }) + const ai = createAI({ providers: { anthropic: { client: anthropicDefault } } }) const result = await ai.run( Hello , - { clients: { openai: openaiClient } }, + { providers: { openai: { client: openaiClient } } }, ) expect(result.content).toBe('OpenAI response') diff --git a/packages/agentry/tests/dynamic-components.test.tsx b/packages/agentry/tests/dynamic-components.test.tsx index e212d49..12dbfb4 100644 --- a/packages/agentry/tests/dynamic-components.test.tsx +++ b/packages/agentry/tests/dynamic-components.test.tsx @@ -42,7 +42,7 @@ test('state changes trigger reconciler updates', async () => { ]) const runPromise = run(, { - clients: { anthropic: client }, + providers: { anthropic: { client } }, }) await controller.nextTurn() diff --git a/packages/agentry/tests/hooks.test.tsx b/packages/agentry/tests/hooks.test.tsx index 39973f9..a70533f 100644 --- a/packages/agentry/tests/hooks.test.tsx +++ b/packages/agentry/tests/hooks.test.tsx @@ -39,7 +39,7 @@ test('useExecutionState tracks status transitions', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -69,7 +69,7 @@ test('useExecutionState with createStateWatcher helper', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -101,7 +101,7 @@ test('useMessages accumulates conversation history', async () => { Hello , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -125,7 +125,7 @@ test('useMessages with createMessageCollector helper', async () => { Test message , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -160,7 +160,7 @@ test('useAgentState provides full state access', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -205,7 +205,7 @@ test('multiple components can subscribe to same state', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -258,7 +258,7 @@ test('state updates during tool execution', async () => { Use the tool , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() diff --git a/packages/agentry/tests/openai-provider.test.tsx b/packages/agentry/tests/openai-provider.test.tsx index f4138bb..c7d579d 100644 --- a/packages/agentry/tests/openai-provider.test.tsx +++ b/packages/agentry/tests/openai-provider.test.tsx @@ -8,12 +8,24 @@ import { mockToolUse, } from './utils' import { ANTHROPIC_TEST_MODEL, OPENAI_TEST_MODEL } from '../src/constants' -import { toOpenAIInput } from '../src/providers/openai' +import { + toOpenAIInput, + createOpenAIAdapter, + getErrorEventDetails, +} from '../src/providers/openai' +import type { ResponsesWSLike } from '../src/providers/openai' +import type OpenAI from 'openai' import { createAgentStore } from '../src/store' import { ExecutionEngine } from '../src/execution' import { InstanceType, type AgentInstance } from '../src/instances' import { AgentStatus } from '../src/types' import { resetSharedDefaultClients } from '../src/providers/clientResolver' +import type { + NormalizedTurnRequest, + NormalizedTurnResponse, + OpenAIProviderConfigInternal, +} from '../src/providers/types' +import { OPENAI_INTERNAL_WS_FACTORY } from '../src/providers/types' beforeEach(() => { resetSharedDefaultClients() @@ -44,7 +56,7 @@ test('openai provider streams text correctly', async () => { > Hello , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(result.content).toBe('Streamed response') @@ -68,7 +80,7 @@ test('openai provider runs basic response flow', async () => { Hello , { - clients: { openai: client }, + providers: { openai: { client } }, }, ) @@ -136,9 +148,9 @@ test('openai parent can run anthropic AgentTool subagent', async () => { Please research Bun runtime , { - clients: { - openai: openaiClient, - anthropic: anthropicClient, + providers: { + openai: { client: openaiClient }, + anthropic: { client: anthropicClient }, }, }, ) @@ -191,7 +203,7 @@ test('surfaces stream failure from response.failed event', async () => { Hello , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ), ).rejects.toThrow('rate limit exceeded') }) @@ -244,7 +256,12 @@ test('anthropic parent can run openai AgentTool subagent', async () => { Summarize something , - { clients: { anthropic: anthropicClient, openai: openaiClient } }, + { + providers: { + anthropic: { client: anthropicClient }, + openai: { client: openaiClient }, + }, + }, ) await controller.nextTurn() // tool_use turn @@ -277,7 +294,7 @@ test('thinking sends reasoning params to OpenAI API (non-streaming)', async () = > Think about this , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(calls.length).toBe(1) @@ -312,7 +329,7 @@ test('reasoning summary returned in result.thinking (non-streaming)', async () = > Think about this , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) // thinking is available in the final result; onMessage does not fire in non-streaming mode @@ -349,7 +366,7 @@ test('streaming: reasoning delta fires thinking event', async () => { > Stream thoughts , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(result.content).toBe('Streamed answer') @@ -392,7 +409,7 @@ test('tool handler error is returned as [ERROR] function_call_output', async () Call the failing tool , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) const secondCallInput = calls[1]!.input as Array> @@ -440,7 +457,7 @@ test('openai multi-turn tool round-trip (function_call → result → response)' What is the weather in London? , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(result.content).toBe('London is sunny') @@ -467,6 +484,7 @@ test('openai multi-turn tool round-trip (function_call → result → response)' test('streaming: function_call item fires tool_use_start event', async () => { const toolUseEvents: Array<{ toolName: string; toolId: string }> = [] + const providerEvents: string[] = [] const { client } = createOpenAIMockClient([ { @@ -498,6 +516,9 @@ test('streaming: function_call item fires tool_use_start event', async () => { if (event.type === 'tool_use_start') { toolUseEvents.push({ toolName: event.toolName, toolId: event.toolId }) } + if (event.type === 'provider_event') { + providerEvents.push(event.itemType) + } }} > @@ -510,13 +531,143 @@ test('streaming: function_call item fires tool_use_start event', async () => { What is the weather in Paris? , - { clients: { openai: client } }, + { providers: { openai: { client } } }, ) expect(result.content).toBe('Paris is cloudy') expect(toolUseEvents).toHaveLength(1) expect(toolUseEvents[0]!.toolName).toBe('get_weather') expect(toolUseEvents[0]!.toolId).toBe('call_stream_1') + expect(providerEvents).toEqual(['function_call', 'message']) +}) + +test('streaming: built-in OpenAI output items fire tool_use_start events and parse cleanly', async () => { + const toolUseEvents: Array<{ toolName: string; toolId: string }> = [] + const providerEvents: string[] = [] + + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'mcp_list_tools', + id: 'mcp_list_1', + server_label: 'cloudflare-demo', + tools: [], + }, + { + type: 'web_search_call', + id: 'ws_1', + status: 'completed', + }, + { + type: 'code_interpreter_call', + id: 'ci_1', + status: 'completed', + code: null, + container_id: 'container_1', + outputs: null, + }, + { + type: 'mcp_call', + id: 'mcp_call_1', + name: 'demo_tool', + arguments: '{}', + server_label: 'cloudflare-demo', + }, + { + type: 'file_search_call', + id: 'fs_1', + status: 'completed', + }, + { + type: 'image_generation_call', + id: 'img_1', + status: 'completed', + result: null, + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'Built-ins ok' }], + }, + ], + }, + ]) + + const result = await run( + { + if (event.type === 'tool_use_start') { + toolUseEvents.push({ toolName: event.toolName, toolId: event.toolId }) + } + if (event.type === 'provider_event') { + providerEvents.push(event.itemType) + } + }} + > + Use built-ins. + , + { providers: { openai: { client } } }, + ) + + expect(result.content).toBe('Built-ins ok') + expect(toolUseEvents).toEqual([ + { toolName: 'web_search', toolId: 'ws_1' }, + { toolName: 'code_interpreter', toolId: 'ci_1' }, + { toolName: 'mcp:demo_tool', toolId: 'mcp_call_1' }, + { toolName: 'file_search', toolId: 'fs_1' }, + { toolName: 'image_generation', toolId: 'img_1' }, + ]) + expect(providerEvents).toEqual([ + 'mcp_list_tools', + 'web_search_call', + 'code_interpreter_call', + 'mcp_call', + 'file_search_call', + 'image_generation_call', + 'message', + ]) +}) + +test('non-streaming: built-in OpenAI output items emit provider_event events', async () => { + const providerEvents: string[] = [] + + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'web_search_call', + id: 'ws_non_stream_1', + status: 'completed', + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'Non-stream built-ins ok' }], + }, + ], + }, + ]) + + const result = await run( + { + if (event.type === 'provider_event') { + providerEvents.push(event.itemType) + } + }} + > + Use built-ins without streaming. + , + { providers: { openai: { client } } }, + ) + + expect(result.content).toBe('Non-stream built-ins ok') + expect(providerEvents).toEqual(['web_search_call', 'message']) }) // toOpenAIInput unit tests @@ -679,3 +830,705 @@ test('checkAndCompact compacts messages for OpenAI provider when threshold excee expect.objectContaining({ type: 'text', text: 'OpenAI summary' }), ]) }) + +test('checkAndCompact resets provider continuation chain after compaction', async () => { + let resetChainCalls = 0 + const store = createAgentStore() + store.setState(() => ({ + executionState: { status: AgentStatus.Idle }, + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Hi' }] }, + ], + })) + + const adapter = { + name: 'openai' as const, + async createTurn( + _client: OpenAI, + _request: NormalizedTurnRequest, + ): Promise { + return { + message: { + content: [{ type: 'text', text: 'Compacted summary' }], + stop_reason: 'end_turn', + usage: { + input_tokens: 1, + output_tokens: 1, + }, + }, + } + }, + resetChain() { + resetChainCalls++ + }, + } + + const anthropicAdapterStub = { + name: 'anthropic' as const, + async createTurn(): Promise { + throw new Error('not used in this test') + }, + } + + const agentInstance: AgentInstance = { + type: InstanceType.Agent, + props: { + provider: 'openai' as const, + model: OPENAI_TEST_MODEL, + maxTokens: 100, + }, + client: {} as OpenAI, + engine: null, + systemParts: [], + tools: [], + builtInTools: [], + mcpServers: [], + children: [], + parent: null, + store, + } + + const engine = new ExecutionEngine({ + provider: 'openai', + client: {} as OpenAI, + model: OPENAI_TEST_MODEL, + maxTokens: 100, + store, + agentInstance, + adapters: { + openai: adapter as never, + anthropic: anthropicAdapterStub as never, + }, + compactionControl: { + enabled: true, + contextTokenThreshold: 10, + model: OPENAI_TEST_MODEL, + }, + }) + + const engineWithInternals = engine as unknown as { + lastMessage: unknown + checkAndCompact: () => Promise + } + + engineWithInternals.lastMessage = { + content: [], + stop_reason: 'end_turn', + usage: { + input_tokens: 100, + output_tokens: 50, + }, + } + + const didCompact = await engineWithInternals.checkAndCompact() + + expect(didCompact).toBe(true) + expect(resetChainCalls).toBe(1) +}) + +// ── WebSocket adapter tests ─────────────────────────────────────────────────── + +/** + * Mock WebSocket emitter for testing createOpenAIAdapter({ websocket: true }). + * Delivers pre-configured response event sequences in response to each send() call. + */ +class MockResponsesWS implements ResponsesWSLike { + private eventHandlers: Array< + (e: OpenAI.Responses.ResponsesServerEvent) => void + > = [] + private errorHandlers: Array<(e: Error) => void> = [] + readonly sentEvents: Array = [] + closed = false + + /** Pre-loaded response batches — consumed one per send() call */ + private responseQueue: Array>> + + constructor(responseQueue: Array>>) { + this.responseQueue = [...responseQueue] + } + + on( + event: 'event', + handler: (e: OpenAI.Responses.ResponsesServerEvent) => void, + ): void + on(event: 'error', handler: (e: Error) => void): void + on( + event: 'event' | 'error', + handler: ((e: OpenAI.Responses.ResponsesServerEvent) => void) & + ((e: Error) => void), + ): void { + if (event === 'event') this.eventHandlers.push(handler) + else if (event === 'error') this.errorHandlers.push(handler) + } + + off( + event: 'event', + handler: (e: OpenAI.Responses.ResponsesServerEvent) => void, + ): void + off(event: 'error', handler: (e: Error) => void): void + off( + event: 'event' | 'error', + handler: ((e: OpenAI.Responses.ResponsesServerEvent) => void) & + ((e: Error) => void), + ): void { + if (event === 'event') { + this.eventHandlers = this.eventHandlers.filter((h) => h !== handler) + } else if (event === 'error') { + this.errorHandlers = this.errorHandlers.filter((h) => h !== handler) + } + } + + send(event: OpenAI.Responses.ResponsesClientEvent): void { + this.sentEvents.push(event) + const batch = this.responseQueue.shift() + if (!batch) throw new Error('MockResponsesWS: no more responses queued') + // Emit all events asynchronously (next microtask) to simulate real WS + Promise.resolve().then(() => { + for (const serverEvent of batch) { + for (const handler of this.eventHandlers) { + handler( + serverEvent as unknown as OpenAI.Responses.ResponsesServerEvent, + ) + } + } + }) + } + + close(): void { + this.closed = true + } + + emitError(err: Error): void { + for (const handler of this.errorHandlers) { + handler(err) + } + } +} + +function makeWsResponse( + callIndex: number, + output: Array>, +): Array> { + const events: Array> = [] + for (let i = 0; i < output.length; i++) { + const item = output[i]! + events.push({ type: 'response.output_item.added', output_index: i, item }) + if (item.type === 'message' && Array.isArray(item.content)) { + for (const part of item.content as Array>) { + if (part.type === 'output_text' && typeof part.text === 'string') { + events.push({ + type: 'response.output_text.delta', + output_index: i, + delta: part.text, + }) + } + } + } + events.push({ type: 'response.output_item.done', output_index: i, item }) + } + events.push({ + type: 'response.completed', + response: { + id: `resp_ws_${callIndex}`, + output, + status: 'completed', + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + return events +} + +/** Build a minimal NormalizedTurnRequest for unit testing the adapter directly */ +function makeTurnRequest( + messages: NormalizedTurnRequest['messages'], + overrides: Partial = {}, +): NormalizedTurnRequest { + return { + model: OPENAI_TEST_MODEL, + maxTokens: 4096, + messages, + tools: [], + builtInTools: [], + mcpServers: [], + stream: true, + signal: new AbortController().signal, + onStream: () => {}, + ...overrides, + } +} + +test('createOpenAIAdapter({ websocket: true }) uses WebSocket for streaming', async () => { + let wsMock: MockResponsesWS | null = null + + const { client } = createOpenAIMockClient([]) + + const result = await run( + + Hello via WS + , + { + providers: { + openai: { + client, + websocket: true, + [OPENAI_INTERNAL_WS_FACTORY]: () => { + wsMock = new MockResponsesWS([ + makeWsResponse(1, [ + { + type: 'message', + content: [{ type: 'output_text', text: 'WS response' }], + }, + ]), + ]) + return wsMock + }, + } as OpenAIProviderConfigInternal, + }, + }, + ) + + expect(result.content).toBe('WS response') + expect(wsMock).not.toBeNull() + expect(wsMock!.sentEvents).toHaveLength(1) +}) + +test('createOpenAIAdapter WS: subsequent turns send incremental input + previous_response_id', async () => { + let wsMock: MockResponsesWS | null = null + + const { client } = createOpenAIMockClient([]) + + const result = await run( + + + `Info about ${query}`} + /> + + Use get_info + , + { + providers: { + openai: { + client, + websocket: true, + [OPENAI_INTERNAL_WS_FACTORY]: () => { + wsMock = new MockResponsesWS([ + // Turn 1: function_call + makeWsResponse(1, [ + { + type: 'function_call', + call_id: 'call_ws_1', + name: 'get_info', + arguments: JSON.stringify({ query: 'test' }), + }, + ]), + // Turn 2: final answer + makeWsResponse(2, [ + { + type: 'message', + content: [{ type: 'output_text', text: 'WS final answer' }], + }, + ]), + ]) + return wsMock + }, + } as OpenAIProviderConfigInternal, + }, + }, + ) + + expect(result.content).toBe('WS final answer') + + const mock = wsMock! + expect(mock.sentEvents).toHaveLength(2) + + // Turn 1: first turn sends full context with no previous_response_id + const turn1 = mock.sentEvents[0]! + expect(turn1.previous_response_id).toBeUndefined() + + // Turn 2: has previous_response_id, only incremental input (tool result) + const turn2 = mock.sentEvents[1]! + expect(turn2.previous_response_id).toBe('resp_ws_1') + const turn2Input = turn2.input as unknown as Array> + expect(Array.isArray(turn2Input)).toBe(true) + // Should only contain the tool result, not the original user message or function_call + expect(turn2Input.every((item) => item.type === 'function_call_output')).toBe( + true, + ) +}) + +test('createOpenAIAdapter WS: forwards stopSequences as stop param', async () => { + let wsMock: MockResponsesWS | null = null + + const adapter = createOpenAIAdapter({ + websocket: true, + _responsesWSFactory: () => { + wsMock = new MockResponsesWS([ + makeWsResponse(1, [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Stopped response' }], + }, + ]), + ]) + return wsMock + }, + }) + + const { client } = createOpenAIMockClient([]) + + await adapter.createTurn( + client, + makeTurnRequest( + [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], + { stopSequences: ['STOP'] }, + ), + ) + + const sent = wsMock!.sentEvents[0]! as unknown as Record + expect(sent.stop).toEqual(['STOP']) +}) + +test('createOpenAIAdapter WS: previous_response_not_found retries with HTTP', async () => { + let wsMock: MockResponsesWS | null = null + const httpCalls: Array> = [] + + // HTTP fallback client — used for the full-context retry after the WS error + const fallbackResponses = [ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'HTTP fallback result' }], + }, + ], + }, + ] + const { client } = createOpenAIMockClient(fallbackResponses) + const originalCreate = ( + client as unknown as { responses: { create: (...a: unknown[]) => unknown } } + ).responses.create.bind( + ( + client as unknown as { + responses: { create: (...a: unknown[]) => unknown } + } + ).responses, + ) + ;( + client as unknown as { responses: { create: (...a: unknown[]) => unknown } } + ).responses.create = async (...args: unknown[]) => { + httpCalls.push(args[0] as Record) + return originalCreate(...args) + } + + const result = await run( + + + 'done'} + /> + + Do something + , + { + providers: { + openai: { + client, + websocket: true, + [OPENAI_INTERNAL_WS_FACTORY]: () => { + wsMock = new MockResponsesWS([ + // Turn 1 via WS: function_call to trigger a tool use + makeWsResponse(1, [ + { + type: 'function_call', + call_id: 'call_ws_1', + name: 'noop', + arguments: '{}', + }, + ]), + // Turn 2 via WS (after tool result): previous_response_not_found error + [ + { + type: 'error', + code: 'previous_response_not_found', + message: 'Previous response not found', + param: null, + sequence_number: 0, + }, + ], + ]) + return wsMock + }, + } as OpenAIProviderConfigInternal, + }, + }, + ) + + expect(result.content).toBe('HTTP fallback result') + expect(httpCalls.length).toBeGreaterThan(0) +}) + +test('createOpenAIAdapter WS: adapter.close() closes the WebSocket', async () => { + let wsMock: MockResponsesWS | null = null + + const adapter = createOpenAIAdapter({ + websocket: true, + _responsesWSFactory: () => { + wsMock = new MockResponsesWS([ + makeWsResponse(1, [ + { + type: 'message', + content: [{ type: 'output_text', text: 'OK' }], + }, + ]), + ]) + return wsMock + }, + }) + + const { client } = createOpenAIMockClient([]) + const request = makeTurnRequest([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + ]) + + await adapter.createTurn(client, request) + + expect(wsMock!.closed).toBe(false) + adapter.close() + expect(wsMock!.closed).toBe(true) +}) + +test('createOpenAIAdapter WS: adapter.close() resets continuation state', async () => { + const wsMocks: MockResponsesWS[] = [] + + const adapter = createOpenAIAdapter({ + websocket: true, + _responsesWSFactory: () => { + const ws = new MockResponsesWS([ + makeWsResponse(wsMocks.length + 1, [ + { + type: 'message', + content: [{ type: 'output_text', text: 'OK' }], + }, + ]), + ]) + wsMocks.push(ws) + return ws + }, + }) + + const { client } = createOpenAIMockClient([]) + + await adapter.createTurn( + client, + makeTurnRequest([ + { role: 'user', content: [{ type: 'text', text: 'First turn' }] }, + ]), + ) + + adapter.close() + + await adapter.createTurn( + client, + makeTurnRequest([ + { role: 'user', content: [{ type: 'text', text: 'Second turn' }] }, + ]), + ) + + expect(wsMocks).toHaveLength(2) + expect(wsMocks[1]!.sentEvents).toHaveLength(1) + + // After close(), second turn should send full context with no previous_response_id + const secondTurnEvent = wsMocks[1]!.sentEvents[0]! + expect(secondTurnEvent.previous_response_id).toBeUndefined() + + const secondTurnInput = secondTurnEvent.input as unknown as Array< + Record + > + expect(secondTurnInput.length).toBeGreaterThan(0) +}) + +test('createOpenAIAdapter WS: resetChain() starts a new chain without closing websocket', async () => { + let wsMock: MockResponsesWS | null = null + + const adapter = createOpenAIAdapter({ + websocket: true, + _responsesWSFactory: () => { + wsMock = new MockResponsesWS([ + makeWsResponse(1, [ + { + type: 'message', + content: [{ type: 'output_text', text: 'First response' }], + }, + ]), + makeWsResponse(2, [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Second response' }], + }, + ]), + ]) + return wsMock + }, + }) + + const { client } = createOpenAIMockClient([]) + + await adapter.createTurn( + client, + makeTurnRequest([ + { role: 'user', content: [{ type: 'text', text: 'First turn' }] }, + ]), + ) + + adapter.resetChain?.() + + await adapter.createTurn( + client, + makeTurnRequest([ + { role: 'user', content: [{ type: 'text', text: 'Second turn' }] }, + ]), + ) + + expect(wsMock!.closed).toBe(false) + expect(wsMock!.sentEvents).toHaveLength(2) + + // After resetChain(), second turn sends full context with no previous_response_id + const secondEvent = wsMock!.sentEvents[1]! as unknown as Record< + string, + unknown + > + expect(secondEvent.previous_response_id).toBeUndefined() + const secondInput = secondEvent.input as Array> + expect(secondInput).toEqual([ + expect.objectContaining({ role: 'user', content: 'Second turn' }), + ]) +}) + +test('createOpenAIAdapter WS: adapter.close() called automatically on handle.close()', async () => { + let wsMock: MockResponsesWS | null = null + + const { client } = createOpenAIMockClient([]) + + await run( + + Hello + , + { + providers: { + openai: { + client, + websocket: true, + [OPENAI_INTERNAL_WS_FACTORY]: () => { + wsMock = new MockResponsesWS([ + makeWsResponse(1, [ + { + type: 'message', + content: [{ type: 'output_text', text: 'OK' }], + }, + ]), + ]) + return wsMock + }, + } as OpenAIProviderConfigInternal, + }, + }, + ) + + // In batch mode, run() calls handle.close() in its finally block, + // which calls cleanup() → adapter.close() + expect(wsMock!.closed).toBe(true) +}) + +test('createOpenAIAdapter WS: websocket_connection_limit_reached retries with HTTP', async () => { + let wsMock: MockResponsesWS | null = null + const httpCalls: Array> = [] + + const fallbackResponses = [ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'HTTP fallback after limit' }], + }, + ], + }, + ] + const { client } = createOpenAIMockClient(fallbackResponses) + const originalCreate = ( + client as unknown as { responses: { create: (...a: unknown[]) => unknown } } + ).responses.create.bind( + ( + client as unknown as { + responses: { create: (...a: unknown[]) => unknown } + } + ).responses, + ) + ;( + client as unknown as { responses: { create: (...a: unknown[]) => unknown } } + ).responses.create = async (...args: unknown[]) => { + httpCalls.push(args[0] as Record) + return originalCreate(...args) + } + + const adapter = createOpenAIAdapter({ + websocket: true, + _responsesWSFactory: () => { + wsMock = new MockResponsesWS([ + // First real turn: connection limit error + [ + { + type: 'error', + code: 'websocket_connection_limit_reached', + message: 'Connection limit reached', + param: null, + sequence_number: 0, + }, + ], + ]) + return wsMock + }, + }) + + const request = makeTurnRequest([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + ]) + + const result = await adapter.createTurn(client, request) + + expect(result.message.content).toEqual([ + { type: 'text', text: 'HTTP fallback after limit' }, + ]) + expect(wsMock!.closed).toBe(true) + expect(httpCalls.length).toBeGreaterThan(0) +}) + +test('getErrorEventDetails extracts nested error fields', () => { + // Nested error: no top-level message/code, only in error sub-object + const result = getErrorEventDetails({ + error: { message: 'nested error message', code: 'nested_code' }, + }) + expect(result.message).toBe('nested error message') + expect(result.code).toBe('nested_code') + + // Top-level fields take precedence + const result2 = getErrorEventDetails({ + message: 'top level', + code: 'top_code', + error: { message: 'nested', code: 'nested_code' }, + }) + expect(result2.message).toBe('top level') + expect(result2.code).toBe('top_code') + + // Fallback to JSON.stringify when no message available + const result3 = getErrorEventDetails({ error: { code: 'only_code' } }) + expect(result3.message).toContain('only_code') + expect(result3.code).toBe('only_code') +}) diff --git a/packages/agentry/tests/run-agent.test.tsx b/packages/agentry/tests/run-agent.test.tsx index 12d31f5..d953910 100644 --- a/packages/agentry/tests/run-agent.test.tsx +++ b/packages/agentry/tests/run-agent.test.tsx @@ -47,7 +47,7 @@ test('runAgent executes subagent and returns result', async () => { Research AI technologies , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() // Parent tool use @@ -110,7 +110,7 @@ test('runAgent supports parallel spawning', async () => { Analyze this content , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() // Parent tool use @@ -191,7 +191,7 @@ test('runAgent respects custom model option', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -263,7 +263,7 @@ test('runAgent respects custom maxTokens option', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -326,7 +326,7 @@ test('runAgent handles errors gracefully', async () => { Test error handling , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() // Parent tool use @@ -381,7 +381,7 @@ test('runAgent returns full AgentResult', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -447,7 +447,7 @@ test('runAgent with conditional agent selection', async () => { Analyze with high complexity , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -509,7 +509,7 @@ test('context.runAgent with provider and clients override', async () => { Run cross-provider task , - { clients: { anthropic: anthropicClient } }, + { providers: { anthropic: { client: anthropicClient } } }, ) await controller.nextTurn() diff --git a/packages/agentry/tests/runtime.test.tsx b/packages/agentry/tests/runtime.test.tsx index 4c27787..d35f294 100644 --- a/packages/agentry/tests/runtime.test.tsx +++ b/packages/agentry/tests/runtime.test.tsx @@ -30,7 +30,7 @@ test('root agent sees pre-loaded JSX messages', async () => { 2+2 equals 4. And what is 3+3? , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) // wait for API call to be queued @@ -68,7 +68,7 @@ test('run creates an agent and executes in batch mode', async () => { You are a test assistant. Be very brief. Say hello in 3 words , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -117,7 +117,7 @@ test('run handles tools correctly', async () => { Use the tool to get info about testing , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -145,7 +145,7 @@ test('interactive mode allows multiple turns', async () => { > You are a test assistant. Be very concise. , - { mode: 'interactive', clients: { anthropic: client } }, + { mode: 'interactive', providers: { anthropic: { client } } }, ) const agent = await agentPromise @@ -185,7 +185,7 @@ test('stream() accepts message parameter for first turn', async () => { > You are a test assistant. Be very concise. , - { mode: 'interactive', clients: { anthropic: client } }, + { mode: 'interactive', providers: { anthropic: { client } } }, ) const agent = await agentPromise @@ -221,7 +221,7 @@ test('stream() works with message for subsequent turns', async () => { > You are a test assistant. Be very concise. , - { mode: 'interactive', clients: { anthropic: client } }, + { mode: 'interactive', providers: { anthropic: { client } } }, ) const agent = await agentPromise @@ -290,7 +290,7 @@ test('handles multiple tool calls in sequence', async () => { Increment twice , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -331,7 +331,7 @@ test('respects maxIterations limit', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -405,7 +405,7 @@ test('tool schemas in API requests are complete', async () => { Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -449,7 +449,7 @@ test('batch mode errors when agent has no messages', async () => { You are helpful {/* No components */} , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ), ).rejects.toThrow('Agent has no messages. In batch mode') }) @@ -461,7 +461,7 @@ test('interactive mode does NOT error when agent has no messages', async () => { You are helpful , - { clients: { anthropic: client }, mode: 'interactive' }, + { providers: { anthropic: { client } }, mode: 'interactive' }, ) // should not throw - agent is created successfully @@ -509,7 +509,7 @@ test('subagent errors when it has no messages', async () => { Use the subagent , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -547,7 +547,7 @@ test('System with cache="ephemeral" creates block with cache_control', async () Ephemeral instructions Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -587,7 +587,7 @@ test('Context with cache="ephemeral" creates block with cache_control', async () Ephemeral context Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -626,7 +626,7 @@ test('System without cache creates simple string', async () => { Regular instructions Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -658,7 +658,7 @@ test('Mixed System and Context parts maintain order and cache flags', async () = Second cached part Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -713,7 +713,7 @@ test('Multiple ephemeral parts are all marked correctly', async () => { Third ephemeral Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -752,7 +752,7 @@ test('Multiple System and Context parts use array format', async () => { Third part Test , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -797,7 +797,7 @@ test('strict tool enables structured-outputs beta', async () => { Extract name , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -828,7 +828,7 @@ test('thinking enables interleaved-thinking beta by default', async () => { > Analyze this , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -863,7 +863,7 @@ test('thinking with interleaved: false disables interleaved-thinking beta', asyn > Analyze this , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -890,7 +890,7 @@ test('provider prop omission throws correct error', async () => { Hello , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ), ).rejects.toThrow('Provider is required on the rendered agent.') }) @@ -906,7 +906,7 @@ test('missing Anthropic client throws descriptive error when no env var set', as Hello , - { clients: {} }, + { providers: {} }, ), ).rejects.toThrow('No Anthropic client configured') } finally { @@ -1001,7 +1001,7 @@ test('toAnthropicMessage: thinking blocks stripped when replaying assistant turn Use the echo tool , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() // first turn: thinking + tool_use diff --git a/packages/agentry/tests/subagents.test.tsx b/packages/agentry/tests/subagents.test.tsx index 12b29cf..aea318a 100644 --- a/packages/agentry/tests/subagents.test.tsx +++ b/packages/agentry/tests/subagents.test.tsx @@ -57,7 +57,7 @@ test('subagent has isolated message context', async () => { Call the isolated agent , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -112,7 +112,7 @@ test('onStepFinish callback fires for subagent calls', async () => { Call subagent , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -170,7 +170,7 @@ test('onComplete callback fires when agent finishes', async () => { Run the completable agent , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -219,7 +219,7 @@ test('conditionally hidden subagents are not available as tools', async () => { ]) const runPromise = run(, { - clients: { anthropic: client }, + providers: { anthropic: { client } }, }) await controller.nextTurn() @@ -303,7 +303,7 @@ test('tools can be mounted during execution via state change', async () => { ]) const agent = createAgent(, { - clients: { anthropic: client }, + providers: { anthropic: { client } }, }) try { @@ -416,7 +416,7 @@ test('subagents can be mounted during execution via state change', async () => { ]) const agent = createAgent(, { - clients: { anthropic: client }, + providers: { anthropic: { client } }, }) try { @@ -495,7 +495,7 @@ test('subagent sees pre-loaded JSX messages', async () => { Call preloaded agent , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) // parent turn - calls preloaded @@ -577,7 +577,7 @@ test('useMessages works inside subagent children', async () => { Call the hooked agent , - { clients: { anthropic: client } }, + { providers: { anthropic: { client } } }, ) // parent turn @@ -660,7 +660,7 @@ test('tools can be unmounted during execution via state change', async () => { ]) const agent = createAgent(, { - clients: { anthropic: client }, + providers: { anthropic: { client } }, }) try { diff --git a/packages/agentry/tests/utils/mockClient.ts b/packages/agentry/tests/utils/mockClient.ts index 165e1d4..3215b38 100644 --- a/packages/agentry/tests/utils/mockClient.ts +++ b/packages/agentry/tests/utils/mockClient.ts @@ -128,7 +128,7 @@ export function mockText(text: string): BetaContentBlock { * { content: [mockText('Results found')] }, * ]); * - * const agent = createAgent(..., { clients: { anthropic: client } }); + * const agent = createAgent(..., { providers: { anthropic: { client } } }); * const runPromise = agent.run(); * * // Wait for first turn to be ready diff --git a/packages/examples/src/chatbot.tsx b/packages/examples/src/chatbot.tsx index c44e71c..e88173a 100644 --- a/packages/examples/src/chatbot.tsx +++ b/packages/examples/src/chatbot.tsx @@ -22,9 +22,9 @@ const WebSearch = const EXAMPLE_MODEL = EXAMPLE_PROVIDER === 'openai' ? OPENAI_MODEL : MODEL const ai = EXAMPLE_PROVIDER === 'openai' - ? createAI({ clients: { openai: new OpenAI() } }) + ? createAI({ providers: { openai: { client: new OpenAI() } } }) : createAI({ - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) const ChatbotAgent = () => { diff --git a/packages/examples/src/compaction.tsx b/packages/examples/src/compaction.tsx index 55113a7..7580640 100644 --- a/packages/examples/src/compaction.tsx +++ b/packages/examples/src/compaction.tsx @@ -22,8 +22,8 @@ const EXAMPLE_PROVIDER = const EXAMPLE_MODEL = EXAMPLE_PROVIDER === 'openai' ? OPENAI_MODEL : MODEL const ai = EXAMPLE_PROVIDER === 'openai' - ? createAI({ clients: { openai: new OpenAI() } }) - : createAI({ clients: { anthropic: new Anthropic() } }) + ? createAI({ providers: { openai: { client: new OpenAI() } } }) + : createAI({ providers: { anthropic: { client: new Anthropic() } } }) console.log(`Provider: ${EXAMPLE_PROVIDER}\n`) diff --git a/packages/examples/src/cross-provider/subagents.tsx b/packages/examples/src/cross-provider/subagents.tsx index 35845e0..cfabe74 100644 --- a/packages/examples/src/cross-provider/subagents.tsx +++ b/packages/examples/src/cross-provider/subagents.tsx @@ -18,9 +18,9 @@ import { const OPENAI_MODEL = process.env.OPENAI_MODEL ?? EXAMPLE_OPENAI_MODEL const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? EXAMPLE_ANTHROPIC_MODEL const ai = createAI({ - clients: { - openai: new OpenAI(), - anthropic: new Anthropic(), + providers: { + openai: { client: new OpenAI() }, + anthropic: { client: new Anthropic() }, }, }) diff --git a/packages/examples/src/demo.tsx b/packages/examples/src/demo.tsx index 095f1ef..61316ba 100644 --- a/packages/examples/src/demo.tsx +++ b/packages/examples/src/demo.tsx @@ -24,9 +24,9 @@ const WebSearch = const EXAMPLE_MODEL = EXAMPLE_PROVIDER === 'openai' ? OPENAI_MODEL : MODEL const ai = EXAMPLE_PROVIDER === 'openai' - ? createAI({ clients: { openai: new OpenAI() } }) + ? createAI({ providers: { openai: { client: new OpenAI() } } }) : createAI({ - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) function CompanyResearcherAgent({ diff --git a/packages/examples/src/interactive.tsx b/packages/examples/src/interactive.tsx index 422a7d1..f07117e 100644 --- a/packages/examples/src/interactive.tsx +++ b/packages/examples/src/interactive.tsx @@ -13,9 +13,9 @@ const WebSearch = const EXAMPLE_MODEL = EXAMPLE_PROVIDER === 'openai' ? OPENAI_MODEL : MODEL const ai = EXAMPLE_PROVIDER === 'openai' - ? createAI({ clients: { openai: new OpenAI() } }) + ? createAI({ providers: { openai: { client: new OpenAI() } } }) : createAI({ - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) const InteractiveAgent = () => { diff --git a/packages/examples/src/mcp.tsx b/packages/examples/src/mcp.tsx index 56be47e..8ba0279 100644 --- a/packages/examples/src/mcp.tsx +++ b/packages/examples/src/mcp.tsx @@ -21,9 +21,9 @@ const MCP = EXAMPLE_PROVIDER === 'openai' ? OpenAIMCP : AnthropicMCP const EXAMPLE_MODEL = EXAMPLE_PROVIDER === 'openai' ? OPENAI_MODEL : MODEL const ai = EXAMPLE_PROVIDER === 'openai' - ? createAI({ clients: { openai: new OpenAI() } }) + ? createAI({ providers: { openai: { client: new OpenAI() } } }) : createAI({ - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) const MCP_SERVER_URL = 'https://demo-day.mcp.cloudflare.com/sse' diff --git a/packages/examples/src/openai/basic.tsx b/packages/examples/src/openai/basic.tsx index f7b1a0f..39e0836 100644 --- a/packages/examples/src/openai/basic.tsx +++ b/packages/examples/src/openai/basic.tsx @@ -25,8 +25,8 @@ const result = await run( What is 12 + 30? , { - clients: { - openai: new OpenAI(), + providers: { + openai: { client: new OpenAI(), websocket: true }, }, }, ) diff --git a/packages/examples/src/openai/built-ins.tsx b/packages/examples/src/openai/built-ins.tsx new file mode 100644 index 0000000..e02e705 --- /dev/null +++ b/packages/examples/src/openai/built-ins.tsx @@ -0,0 +1,44 @@ +import { createAI, Agent, Message, System, Tools } from 'agentry' +import OpenAI from 'openai' +import { CodeExecution, MCP, WebSearch } from 'agentry/openai' +import { OPENAI_MODEL as EXAMPLE_OPENAI_MODEL } from '../constants' + +const OPENAI_MODEL = process.env.OPENAI_MODEL ?? EXAMPLE_OPENAI_MODEL +const MCP_SERVER_URL = 'https://demo-day.mcp.cloudflare.com/sse' + +const ai = createAI({ + providers: { + openai: { client: new OpenAI(), websocket: true }, + }, +}) + +const result = await ai.run( + + + You can use web_search, code execution, and MCP tools. First, find one + recent data point about renewable energy adoption in 2025, then use code + execution to compute a simple projection for 2030, and finally use an MCP + tool if relevant to validate or enrich your answer. Return a concise + summary with the calculation steps. + + + + + + + + + + + Research renewable energy adoption, do a projection to 2030, and include + what tools you used. + + , +) + +console.log('Result:', result.content) +console.log('Usage:', result.usage) diff --git a/packages/examples/src/openai/codex-subagent.tsx b/packages/examples/src/openai/codex-subagent.tsx index f4f01ef..cad593c 100644 --- a/packages/examples/src/openai/codex-subagent.tsx +++ b/packages/examples/src/openai/codex-subagent.tsx @@ -3,7 +3,7 @@ import { run, Agent, AgentTool, Message, System, Tools } from 'agentry' import OpenAI from 'openai' const ORCHESTRATOR_MODEL = 'gpt-5.2' -const CODEX_MODEL = 'gpt-5.2-codex' +const CODEX_MODEL = 'gpt-5.3-codex' const result = await run( @@ -56,8 +56,8 @@ const result = await run( , { - clients: { - openai: new OpenAI(), + providers: { + openai: { client: new OpenAI(), websocket: true }, }, }, ) diff --git a/packages/examples/src/openai/subtools-smoke.tsx b/packages/examples/src/openai/subtools-smoke.tsx index 334eafe..759cf0c 100644 --- a/packages/examples/src/openai/subtools-smoke.tsx +++ b/packages/examples/src/openai/subtools-smoke.tsx @@ -42,8 +42,8 @@ const result = await run( , { - clients: { - openai: new OpenAI(), + providers: { + openai: { client: new OpenAI(), websocket: true }, }, }, ) diff --git a/packages/examples/src/openai/websocket.tsx b/packages/examples/src/openai/websocket.tsx new file mode 100644 index 0000000..b2cca36 --- /dev/null +++ b/packages/examples/src/openai/websocket.tsx @@ -0,0 +1,120 @@ +/** + * WebSocket mode example + * + * OpenAI's Responses API supports a persistent WebSocket connection that sends + * only incremental input on continuation turns via `previous_response_id`, + * cutting per-turn latency ~40% in multi-tool-call loops. + * + * Enable it with a single flag: + * providers: { openai: { client, websocket: true } } + * + * Run: + * bun run example:openai:websocket + */ + +import { z } from 'zod' +import { run, Agent, System, Tools, Tool, Message } from 'agentry' +import OpenAI from 'openai' +import { OPENAI_MODEL as EXAMPLE_OPENAI_MODEL } from '../constants' + +const MODEL = process.env.OPENAI_MODEL ?? EXAMPLE_OPENAI_MODEL +const client = new OpenAI() + +// ── Tools ───────────────────────────────────────────────────────────────────── + +const productDb: Record< + string, + { name: string; price: number; stock: number } +> = { + P001: { name: 'Widget Pro', price: 29.99, stock: 142 }, + P002: { name: 'Gadget Plus', price: 49.99, stock: 7 }, + P003: { name: 'Doohickey Max', price: 9.99, stock: 0 }, +} + +// ── Run ─────────────────────────────────────────────────────────────────────── + +console.log(`WebSocket mode — model: ${MODEL}`) +console.log('─'.repeat(50)) + +const t0 = performance.now() +let toolCallCount = 0 + +const result = await run( + { + if (event.type === 'tool_use_start') { + toolCallCount++ + console.log(`\n[tool] ${event.toolName}(${event.toolId})`) + } else if (event.type === 'text') { + process.stdout.write(event.text) + } + }} + > + + You are a shopping assistant. Use the provided tools to look up product + information and estimate shipping costs. Be concise. + + + { + const product = productDb[id] + const result = product + ? JSON.stringify(product) + : `Product ${id} not found` + console.log(` → ${result}`) + return result + }} + /> + { + const base = destination === 'US' ? 5.99 : 14.99 + const total = (base + 0.5 * quantity).toFixed(2) + const days = destination === 'US' ? '2-3' : '7-10' + const result = JSON.stringify({ + product_id, + quantity, + destination, + shipping: total, + days, + }) + console.log(` → ${result}`) + return result + }} + /> + + + I want to order 3x Widget Pro (P001) and 2x Gadget Plus (P002) shipped to + the US. Look up both products, estimate shipping for each, then give me a + total cost breakdown including shipping. + + , + { + providers: { openai: { client, websocket: true } }, + }, +) + +const ms = Math.round(performance.now() - t0) + +console.log('\n') +console.log('─'.repeat(50)) +console.log(`Tool calls: ${toolCallCount}`) +console.log(`Time: ${ms}ms`) +console.log( + `Tokens: ${result.usage.inputTokens} in / ${result.usage.outputTokens} out`, +) diff --git a/packages/examples/src/tweet.tsx b/packages/examples/src/tweet.tsx index 129c795..59a5905 100644 --- a/packages/examples/src/tweet.tsx +++ b/packages/examples/src/tweet.tsx @@ -22,9 +22,9 @@ const CodeExecution = const EXAMPLE_MODEL = EXAMPLE_PROVIDER === 'openai' ? OPENAI_MODEL : MODEL const ai = EXAMPLE_PROVIDER === 'openai' - ? createAI({ clients: { openai: new OpenAI() } }) + ? createAI({ providers: { openai: { client: new OpenAI() } } }) : createAI({ - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) function Calculator() { diff --git a/packages/examples/src/web-search.tsx b/packages/examples/src/web-search.tsx index d13da1d..90acc63 100644 --- a/packages/examples/src/web-search.tsx +++ b/packages/examples/src/web-search.tsx @@ -35,9 +35,9 @@ const WebSearch = const EXAMPLE_MODEL = EXAMPLE_PROVIDER === 'openai' ? OPENAI_MODEL : MODEL const ai = EXAMPLE_PROVIDER === 'openai' - ? createAI({ clients: { openai: new OpenAI() } }) + ? createAI({ providers: { openai: { client: new OpenAI() } } }) : createAI({ - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) /** From df6518789849a53f9f24e4fd7e9031e223339ed5 Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:04:24 -0800 Subject: [PATCH 31/39] chore: improved error handling --- .../agentry/src/execution/ExecutionEngine.ts | 12 +++++- packages/agentry/src/execution/conditions.ts | 37 ++++++++++--------- .../src/execution/createEngineConfig.ts | 2 +- packages/agentry/src/handles/AgentHandle.ts | 2 +- .../agentry/src/instances/createInstance.ts | 5 ++- packages/agentry/src/providers/anthropic.ts | 17 +++------ packages/agentry/src/providers/openai.ts | 20 ++++++---- packages/agentry/src/run/createAI.ts | 26 ++++++------- packages/agentry/src/types/agent.ts | 2 +- packages/agentry/src/types/messages.ts | 6 +++ packages/agentry/src/types/tools.ts | 6 +++ 11 files changed, 78 insertions(+), 57 deletions(-) diff --git a/packages/agentry/src/execution/ExecutionEngine.ts b/packages/agentry/src/execution/ExecutionEngine.ts index b947b81..1587868 100644 --- a/packages/agentry/src/execution/ExecutionEngine.ts +++ b/packages/agentry/src/execution/ExecutionEngine.ts @@ -211,11 +211,11 @@ export class ExecutionEngine extends EventEmitter { if (err.name === 'AbortError') throw err debug( 'conditions', - `NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}), conditions unchanged`, + `NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}), conditions may be stale due to evaluation failure`, { error: err.message }, ) console.error( - `[agentry] NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}), conditions unchanged:`, + `[agentry] NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}), conditions may be stale due to evaluation failure:`, err, ) this.emit('error', err) @@ -641,6 +641,14 @@ export class ExecutionEngine extends EventEmitter { if (status === 401 || status === 403 || status === 404) { throw err } + // Re-throw programming errors — these indicate bugs, not transient failures + if ( + err instanceof TypeError || + err instanceof ReferenceError || + err instanceof SyntaxError + ) { + throw err + } const compactionModel = compactionControl.model ?? this.config.model const tokenCount = (this.lastMessage?.usage.input_tokens ?? 0) + diff --git a/packages/agentry/src/execution/conditions.ts b/packages/agentry/src/execution/conditions.ts index b2a88c6..6c254e4 100644 --- a/packages/agentry/src/execution/conditions.ts +++ b/packages/agentry/src/execution/conditions.ts @@ -141,6 +141,23 @@ function mapConditionResults({ return Array.from({ length: conditionCount }, (_, i) => trueIndices.has(i)) } +/** + * Validate and extract the `trueConditionIndices` array from a provider tool call result. + * Shared by both Anthropic and OpenAI evaluation paths. + */ +function parseConditionIndices( + input: Record, + provider: ProviderName, +): number[] { + const indices = input.trueConditionIndices + if (!Array.isArray(indices) || !indices.every((i) => typeof i === 'number')) { + throw new Error( + `[agentry] NL condition evaluation: unexpected tool input shape from ${provider}: ${JSON.stringify(input)}`, + ) + } + return indices +} + /** * Dispatch NL condition evaluation to the appropriate provider implementation. */ @@ -253,15 +270,7 @@ async function evaluateNLWithAnthropic({ const toolUse = response.content.find((block) => block.type === 'tool_use') if (toolUse && toolUse.type === 'tool_use') { const input = toolUse.input as Record - const indices = input.trueConditionIndices - if ( - !Array.isArray(indices) || - !indices.every((i) => typeof i === 'number') - ) { - throw new Error( - `[agentry] NL condition evaluation: unexpected tool input shape from Anthropic: ${JSON.stringify(input)}`, - ) - } + const indices = parseConditionIndices(input, 'anthropic') return mapConditionResults({ trueConditionIndices: indices, conditionCount: conditions.length, @@ -351,15 +360,7 @@ async function evaluateNLWithOpenAI({ { cause: e }, ) } - const indices = parsed.trueConditionIndices - if ( - !Array.isArray(indices) || - !indices.every((i) => typeof i === 'number') - ) { - throw new Error( - `[agentry] NL condition evaluation: unexpected tool input shape from OpenAI: ${JSON.stringify(parsed)}`, - ) - } + const indices = parseConditionIndices(parsed, 'openai') return mapConditionResults({ trueConditionIndices: indices, conditionCount: conditions.length, diff --git a/packages/agentry/src/execution/createEngineConfig.ts b/packages/agentry/src/execution/createEngineConfig.ts index 43a6868..9bb1481 100644 --- a/packages/agentry/src/execution/createEngineConfig.ts +++ b/packages/agentry/src/execution/createEngineConfig.ts @@ -90,7 +90,7 @@ export function createEngineConfig( temperature: agent.props.temperature, agentName: agent.props.name, thinking: agent.props.thinking, - betas: agent.props.betas, + betas: (agent.props as { betas?: string[] }).betas, agentInstance: agent, store, } diff --git a/packages/agentry/src/handles/AgentHandle.ts b/packages/agentry/src/handles/AgentHandle.ts index fe05d70..229bae5 100644 --- a/packages/agentry/src/handles/AgentHandle.ts +++ b/packages/agentry/src/handles/AgentHandle.ts @@ -50,7 +50,7 @@ export class AgentHandle extends AbstractAgentHandle { } if (openaiConfig?.websocket || responsesWSFactory) { adapters.openai = createOpenAIAdapter({ - websocket: openaiConfig.websocket, + websocket: openaiConfig?.websocket, _responsesWSFactory: responsesWSFactory, }) } diff --git a/packages/agentry/src/instances/createInstance.ts b/packages/agentry/src/instances/createInstance.ts index a535ecc..775d4e9 100644 --- a/packages/agentry/src/instances/createInstance.ts +++ b/packages/agentry/src/instances/createInstance.ts @@ -38,6 +38,7 @@ type AgentPropsAllKeys = { provider: AgentProps['provider'] model: AgentProps['model'] thinking: AgentProps['thinking'] + betas?: string[] } interface SubagentCreationProps extends Omit< @@ -137,7 +138,7 @@ function createAgentInstance( stream: props.stream ?? true, compactionControl: props.compactionControl, thinking: props.thinking, - betas: props.betas, + betas: (props as { betas?: string[] }).betas, onMessage: props.onMessage, onComplete: props.onComplete, onError: props.onError, @@ -318,7 +319,7 @@ export function createSubagentInstance( stream: props.stream ?? inherited.stream ?? true, compactionControl: props.compactionControl ?? inherited.compactionControl, thinking: props.thinking ?? inherited.thinking, - betas: props.betas ?? inherited.betas, + betas: (props as { betas?: string[] }).betas ?? inherited.betas, // callbacks never inherited onMessage: props.onMessage, onComplete: props.onComplete, diff --git a/packages/agentry/src/providers/anthropic.ts b/packages/agentry/src/providers/anthropic.ts index 9066d6c..82c06f5 100644 --- a/packages/agentry/src/providers/anthropic.ts +++ b/packages/agentry/src/providers/anthropic.ts @@ -25,7 +25,6 @@ import { import { toApiTool } from '../tools' import type { BuiltInTool } from '../types/tools' import type { JsonObject } from '../types/json' -import { debug } from '../debug' export function toAnthropicMessage( message: AgentMessageParam, @@ -60,9 +59,8 @@ export function toAnthropicMessage( }) break default: - debug( - 'anthropic', - `Dropping unrecognized message block type: ${(block as { type: string }).type}`, + console.warn( + `[agentry] Dropping unrecognized message block type "${(block as { type: string }).type}" during Anthropic message conversion`, ) } } @@ -89,10 +87,8 @@ function toAgentBlocks(content: BetaContentBlock[]): AgentContentBlock[] { if (typeof block.input === 'object' && block.input !== null) { input = block.input as JsonObject } else { - debug( - 'anthropic', - `Tool "${block.name}": non-object input coerced to {}`, - { input: block.input }, + console.warn( + `[agentry] Anthropic tool "${block.name}": non-object input coerced to {} (received ${typeof block.input})`, ) input = {} } @@ -105,9 +101,8 @@ function toAgentBlocks(content: BetaContentBlock[]): AgentContentBlock[] { break } default: - debug( - 'anthropic', - `Unrecognized content block type: "${(block as { type: string }).type}" — block dropped`, + console.warn( + `[agentry] Unrecognized content block type "${(block as { type: string }).type}" — block dropped`, ) } } diff --git a/packages/agentry/src/providers/openai.ts b/packages/agentry/src/providers/openai.ts index ddd9af9..d7f7e7b 100644 --- a/packages/agentry/src/providers/openai.ts +++ b/packages/agentry/src/providers/openai.ts @@ -103,7 +103,7 @@ export function getErrorEventDetails(event: { return { message, - ...(codeValue ? { code: codeValue } : {}), + code: codeValue, } } @@ -137,9 +137,8 @@ export function toOpenAIInput( } else if (block.type === 'thinking') { // Thinking blocks have no OpenAI equivalent — intentionally skipped } else { - debug( - 'openai', - `Unrecognized message block type "${(block as { type: string }).type}" skipped during input conversion`, + console.warn( + `[agentry] Unrecognized message block type "${(block as { type: string }).type}" skipped during input conversion`, ) } } @@ -241,9 +240,8 @@ function parseOpenAIResponse( } default: { const unhandled: never = item - debug( - 'openai', - `Unrecognized output item type: ${(unhandled as { type: string }).type}`, + console.warn( + `[agentry] Unrecognized output item type "${(unhandled as { type: string }).type}"`, ) } } @@ -783,6 +781,9 @@ export function createOpenAIAdapter(options?: { await waitForOpen(ws) } catch (err) { // WS failed to open — fall back to HTTP for this turn + console.warn( + `[agentry] WebSocket connection failed, falling back to HTTP: ${err instanceof Error ? err.message : String(err)}`, + ) debug( 'openai', `WebSocket connection failed, falling back to HTTP: ${err instanceof Error ? err.message : String(err)}`, @@ -824,7 +825,12 @@ export function createOpenAIAdapter(options?: { previousResponseId = responseId return result } catch (err) { + // Abort errors should propagate immediately — not be retried + if (err instanceof Error && err.name === 'AbortError') throw err if (isRetryableWSError(err)) { + console.warn( + `[agentry] Retryable WebSocket error (${err instanceof OpenAITurnError ? err.code : 'unknown'}), falling back to HTTP`, + ) debug( 'openai', `Retryable WebSocket error (${err instanceof OpenAITurnError ? err.code : 'unknown'}), falling back to HTTP`, diff --git a/packages/agentry/src/run/createAI.ts b/packages/agentry/src/run/createAI.ts index 4fbe84d..9361ca0 100644 --- a/packages/agentry/src/run/createAI.ts +++ b/packages/agentry/src/run/createAI.ts @@ -34,18 +34,22 @@ export interface AI { ): AgentHandle } +function mergeProviders( + base?: ProvidersConfig, + override?: ProvidersConfig, +): ProvidersConfig { + return { + openai: { ...base?.openai, ...override?.openai }, + anthropic: { ...base?.anthropic, ...override?.anthropic }, + } +} + function mergeRunOptions( defaults: AIDefaults, options?: AIBoundRunOptions, ): RunOptions { return { - providers: { - openai: { ...defaults.providers?.openai, ...options?.providers?.openai }, - anthropic: { - ...defaults.providers?.anthropic, - ...options?.providers?.anthropic, - }, - }, + providers: mergeProviders(defaults.providers, options?.providers), mode: options?.mode ?? defaults.mode, } } @@ -55,13 +59,7 @@ function mergeCreateAgentOptions( options?: AIBoundCreateAgentOptions, ): CreateAgentOptions { return { - providers: { - openai: { ...defaults.providers?.openai, ...options?.providers?.openai }, - anthropic: { - ...defaults.providers?.anthropic, - ...options?.providers?.anthropic, - }, - }, + providers: mergeProviders(defaults.providers, options?.providers), } } diff --git a/packages/agentry/src/types/agent.ts b/packages/agentry/src/types/agent.ts index 8737d7a..c35e99c 100644 --- a/packages/agentry/src/types/agent.ts +++ b/packages/agentry/src/types/agent.ts @@ -36,7 +36,6 @@ export interface BaseAgentProps { stopSequences?: string[] temperature?: number stream?: boolean - betas?: string[] onMessage?: (message: AgentStreamEvent) => void onComplete?: (result: AgentResult) => void onError?: (error: Error) => void @@ -49,6 +48,7 @@ export type ProviderVariant = provider: 'anthropic' model?: AnthropicModel thinking?: AnthropicThinkingEnabled | { type: 'disabled' } + betas?: string[] } | { provider: 'openai' diff --git a/packages/agentry/src/types/messages.ts b/packages/agentry/src/types/messages.ts index bc8dc5d..505c8e6 100644 --- a/packages/agentry/src/types/messages.ts +++ b/packages/agentry/src/types/messages.ts @@ -73,6 +73,12 @@ export function isThinkingBlock( return block.type === 'thinking' } +export function isToolResultBlock( + block: AgentContentBlock, +): block is ToolResultContentBlock { + return block.type === 'tool_result' +} + export function extractText(message: AgentMessage): string { return message.content .filter(isTextBlock) diff --git a/packages/agentry/src/types/tools.ts b/packages/agentry/src/types/tools.ts index 376570e..cccb6ed 100644 --- a/packages/agentry/src/types/tools.ts +++ b/packages/agentry/src/types/tools.ts @@ -125,6 +125,12 @@ type ProviderContextFields = type BaseToolContext = { agentName: string + /** + * Map of all available provider clients. Unlike the narrowed `client` field + * from `ProviderContextFields` (which is typed to the current provider), + * `clients` gives access to every configured provider client — useful for + * cross-provider subagent spawning via `context.runAgent()`. + */ clients?: Partial model?: Model signal?: AbortSignal From 3ab52f41346615cec2abdd7bf814223abaff2962 Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:59:29 -0800 Subject: [PATCH 32/39] refactor: move websocket to the Agent component --- packages/agentry/src/components/Agent.tsx | 1 + packages/agentry/src/handles/AgentHandle.ts | 62 ++++++++++++--- .../agentry/src/handles/SubagentHandle.ts | 8 ++ .../agentry/src/instances/createInstance.ts | 7 +- packages/agentry/src/providers/types.ts | 2 - packages/agentry/src/types/agent.ts | 1 + packages/agentry/tests/create-ai.test.tsx | 79 +++++++++++++++++-- .../agentry/tests/openai-provider.test.tsx | 32 ++++++-- packages/examples/src/openai/basic.tsx | 9 ++- packages/examples/src/openai/built-ins.tsx | 9 ++- .../examples/src/openai/codex-subagent.tsx | 9 ++- .../examples/src/openai/subtools-smoke.tsx | 4 +- packages/examples/src/openai/websocket.tsx | 7 +- 13 files changed, 193 insertions(+), 37 deletions(-) diff --git a/packages/agentry/src/components/Agent.tsx b/packages/agentry/src/components/Agent.tsx index 6ac4790..14d19cb 100644 --- a/packages/agentry/src/components/Agent.tsx +++ b/packages/agentry/src/components/Agent.tsx @@ -23,6 +23,7 @@ type PublicProviderVariant = provider: 'openai' model: OpenAIModel thinking?: OpenAIThinkingEnabled | { type: 'disabled' } + websocket?: boolean } | { provider?: undefined; model?: undefined; thinking?: ThinkingConfig } diff --git a/packages/agentry/src/handles/AgentHandle.ts b/packages/agentry/src/handles/AgentHandle.ts index 229bae5..0fffeb4 100644 --- a/packages/agentry/src/handles/AgentHandle.ts +++ b/packages/agentry/src/handles/AgentHandle.ts @@ -16,8 +16,9 @@ import type { import { OPENAI_INTERNAL_WS_FACTORY } from '../providers/types' import type { ProviderName } from '../types/provider' import { createDefaultAdapters } from '../providers' -import { createOpenAIAdapter } from '../providers/openai' +import { createOpenAIAdapter, type ResponsesWSLike } from '../providers/openai' import { ensureProviderClient } from '../providers/clientResolver' +import type OpenAI from 'openai' /** * Handle for controlling a regular agent at runtime @@ -27,6 +28,14 @@ import { ensureProviderClient } from '../providers/clientResolver' export class AgentHandle extends AbstractAgentHandle { private element: ReactNode private mode: 'batch' | 'interactive' + private wsFactory: ((client: OpenAI) => ResponsesWSLike) | undefined + private wsReady = false + private initialProps: { + provider: string + model: string + websocket: boolean + } | null = null + constructor( element: ReactNode, options: { providers?: ProvidersConfig } = {}, @@ -44,16 +53,9 @@ export class AgentHandle extends AbstractAgentHandle { if (options.providers?.anthropic?.client) clients.anthropic = options.providers.anthropic.client - // Build adapters — WS adapter if websocket option enabled const adapters: Record> = { ...createDefaultAdapters(), } - if (openaiConfig?.websocket || responsesWSFactory) { - adapters.openai = createOpenAIAdapter({ - websocket: openaiConfig?.websocket, - _responsesWSFactory: responsesWSFactory, - }) - } const store = createAgentStore() @@ -86,6 +88,7 @@ export class AgentHandle extends AbstractAgentHandle { }) this.element = element this.mode = mode + this.wsFactory = responsesWSFactory } update(element: ReactNode): void { @@ -135,11 +138,52 @@ export class AgentHandle extends AbstractAgentHandle { if (!provider) { throw new Error('Provider is required on the rendered agent.') } - if (!agent.props.model) { + const model = agent.props.model + if (!model) { throw new Error('Model is required on the rendered agent.') } + const websocket = + agent.props.provider === 'openai' + ? (agent.props.websocket ?? false) + : false + + if (this.initialProps === null) { + this.initialProps = { provider, model, websocket } + } else { + if (this.initialProps.provider !== provider) { + throw new Error( + `Agent provider cannot change between runs (was "${this.initialProps.provider}", got "${provider}"). ` + + `Create a new agent handle instead.`, + ) + } + if (this.initialProps.model !== model) { + throw new Error( + `Agent model cannot change between runs (was "${this.initialProps.model}", got "${model}"). ` + + `Create a new agent handle instead.`, + ) + } + if (this.initialProps.websocket !== websocket) { + throw new Error( + `Agent websocket mode cannot change between runs (was ${this.initialProps.websocket}, got ${websocket}). ` + + `Create a new agent handle instead.`, + ) + } + } + agent.client = await ensureProviderClient(this.clients, provider) + if ( + !this.wsReady && + agent.props.provider === 'openai' && + agent.props.websocket + ) { + this.adapters.openai = createOpenAIAdapter({ + websocket: true, + _responsesWSFactory: this.wsFactory, + }) + this.wsReady = true + } + return agent } } diff --git a/packages/agentry/src/handles/SubagentHandle.ts b/packages/agentry/src/handles/SubagentHandle.ts index af4c428..e916d92 100644 --- a/packages/agentry/src/handles/SubagentHandle.ts +++ b/packages/agentry/src/handles/SubagentHandle.ts @@ -12,6 +12,7 @@ import type { ExecutionEngineConfig } from '../execution/ExecutionEngine' import type { ProviderClientMap } from '../providers/types' import type { ProviderName } from '../types/provider' import { createDefaultAdapters } from '../providers' +import { createOpenAIAdapter } from '../providers/openai' import { ensureProviderClient } from '../providers/clientResolver' export class SubagentHandle extends AbstractAgentHandle { @@ -127,6 +128,13 @@ export class SubagentHandle extends AbstractAgentHandle { } agentInstance.client = await ensureProviderClient(this.clients, provider) + if ( + agentInstance.props.provider === 'openai' && + agentInstance.props.websocket + ) { + this.adapters.openai = createOpenAIAdapter({ websocket: true }) + } + return agentInstance } diff --git a/packages/agentry/src/instances/createInstance.ts b/packages/agentry/src/instances/createInstance.ts index 775d4e9..fb26823 100644 --- a/packages/agentry/src/instances/createInstance.ts +++ b/packages/agentry/src/instances/createInstance.ts @@ -39,14 +39,16 @@ type AgentPropsAllKeys = { model: AgentProps['model'] thinking: AgentProps['thinking'] betas?: string[] + websocket?: boolean } interface SubagentCreationProps extends Omit< AgentComponentProps, - 'children' | 'model' + 'children' | 'model' | 'websocket' > { model?: AgentComponentProps['model'] agentNode?: React.ReactNode + websocket?: boolean } interface PropagatedSettings { @@ -60,6 +62,7 @@ interface PropagatedSettings { model?: Model thinking?: AgentProps['thinking'] betas?: string[] + websocket?: boolean } export type ElementProps = @@ -139,6 +142,7 @@ function createAgentInstance( compactionControl: props.compactionControl, thinking: props.thinking, betas: (props as { betas?: string[] }).betas, + websocket: props.provider === 'openai' ? props.websocket : undefined, onMessage: props.onMessage, onComplete: props.onComplete, onError: props.onError, @@ -320,6 +324,7 @@ export function createSubagentInstance( compactionControl: props.compactionControl ?? inherited.compactionControl, thinking: props.thinking ?? inherited.thinking, betas: (props as { betas?: string[] }).betas ?? inherited.betas, + websocket: props.websocket ?? inherited.websocket, // callbacks never inherited onMessage: props.onMessage, onComplete: props.onComplete, diff --git a/packages/agentry/src/providers/types.ts b/packages/agentry/src/providers/types.ts index 57f9db6..c9e930b 100644 --- a/packages/agentry/src/providers/types.ts +++ b/packages/agentry/src/providers/types.ts @@ -13,8 +13,6 @@ export interface ProviderClientMap { export interface OpenAIProviderConfig { client?: OpenAI - /** Use persistent WebSocket for ~40% lower per-turn latency */ - websocket?: boolean } /** @internal Test-only key for injecting a mock ResponsesWS factory through provider config. */ diff --git a/packages/agentry/src/types/agent.ts b/packages/agentry/src/types/agent.ts index c35e99c..2d9c2c3 100644 --- a/packages/agentry/src/types/agent.ts +++ b/packages/agentry/src/types/agent.ts @@ -54,6 +54,7 @@ export type ProviderVariant = provider: 'openai' model?: OpenAIModel thinking?: OpenAIThinkingEnabled | { type: 'disabled' } + websocket?: boolean } | { provider?: undefined; model?: Model; thinking?: ThinkingConfig } diff --git a/packages/agentry/tests/create-ai.test.tsx b/packages/agentry/tests/create-ai.test.tsx index de29a14..b0071be 100644 --- a/packages/agentry/tests/create-ai.test.tsx +++ b/packages/agentry/tests/create-ai.test.tsx @@ -44,10 +44,6 @@ test('createAI per-call clients override default clients', async () => { }) test('createAI per-call mode overrides default mode', async () => { - const { client } = createStepMockClient([]) - - const ai = createAI({ providers: { anthropic: { client } }, mode: 'interactive' }) - // batch override should return AgentResult, not AgentHandle const { client: batchClient, controller } = createStepMockClient([ { content: [mockText('Batch result')] }, @@ -119,7 +115,10 @@ test('createAI createAgent uses merged clients', async () => { test('createAI default mode=interactive returns handle without explicit mode override', async () => { const { client } = createStepMockClient([]) - const ai = createAI({ providers: { anthropic: { client } }, mode: 'interactive' }) + const ai = createAI({ + providers: { anthropic: { client } }, + mode: 'interactive', + }) // TypeScript types the return as AgentResult (no per-call mode override), // but at runtime the merged defaults make it interactive → AgentHandle @@ -136,6 +135,72 @@ test('createAI default mode=interactive returns handle without explicit mode ove result.close() }) +test('AgentHandle throws on provider change between sendMessage calls', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('First response')] }, + ]) + + const ai = createAI({ + providers: { anthropic: { client } }, + mode: 'interactive', + }) + + const handle = (await ai.run( + , + { mode: 'interactive' }, + )) as AgentHandle + + // First sendMessage — sets initialProps + const firstPromise = handle.sendMessage('Hello') + await controller.nextTurn() + await firstPromise + + // Mutate to a different provider + handle.update( + , + ) + + // Second sendMessage — should throw at the call site + expect(handle.sendMessage('Hi again')).rejects.toThrow( + /Agent provider cannot change between runs/, + ) + + handle.close() +}) + +test('AgentHandle throws on model change between sendMessage calls', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('First response')] }, + ]) + + const ai = createAI({ + providers: { anthropic: { client } }, + mode: 'interactive', + }) + + const handle = (await ai.run( + , + { mode: 'interactive' }, + )) as AgentHandle + + // First sendMessage — sets initialProps + const firstPromise = handle.sendMessage('Hello') + await controller.nextTurn() + await firstPromise + + // Mutate to a different model + handle.update( + , + ) + + // Second sendMessage — should throw at the call site + expect(handle.sendMessage('Hi again')).rejects.toThrow( + /Agent model cannot change between runs/, + ) + + handle.close() +}) + test('createAI multi-provider: per-call client overrides default for that provider', async () => { const { client: anthropicDefault } = createStepMockClient([]) const { client: openaiClient } = createOpenAIMockClient([ @@ -149,7 +214,9 @@ test('createAI multi-provider: per-call client overrides default for that provid }, ]) - const ai = createAI({ providers: { anthropic: { client: anthropicDefault } } }) + const ai = createAI({ + providers: { anthropic: { client: anthropicDefault } }, + }) const result = await ai.run( diff --git a/packages/agentry/tests/openai-provider.test.tsx b/packages/agentry/tests/openai-provider.test.tsx index c7d579d..9bc68bb 100644 --- a/packages/agentry/tests/openai-provider.test.tsx +++ b/packages/agentry/tests/openai-provider.test.tsx @@ -1064,14 +1064,18 @@ test('createOpenAIAdapter({ websocket: true }) uses WebSocket for streaming', as const { client } = createOpenAIMockClient([]) const result = await run( - + Hello via WS , { providers: { openai: { client, - websocket: true, [OPENAI_INTERNAL_WS_FACTORY]: () => { wsMock = new MockResponsesWS([ makeWsResponse(1, [ @@ -1099,7 +1103,12 @@ test('createOpenAIAdapter WS: subsequent turns send incremental input + previous const { client } = createOpenAIMockClient([]) const result = await run( - + { wsMock = new MockResponsesWS([ // Turn 1: function_call @@ -1226,7 +1234,12 @@ test('createOpenAIAdapter WS: previous_response_not_found retries with HTTP', as } const result = await run( - + { wsMock = new MockResponsesWS([ // Turn 1 via WS: function_call to trigger a tool use @@ -1418,14 +1430,18 @@ test('createOpenAIAdapter WS: adapter.close() called automatically on handle.clo const { client } = createOpenAIMockClient([]) await run( - + Hello , { providers: { openai: { client, - websocket: true, [OPENAI_INTERNAL_WS_FACTORY]: () => { wsMock = new MockResponsesWS([ makeWsResponse(1, [ diff --git a/packages/examples/src/openai/basic.tsx b/packages/examples/src/openai/basic.tsx index 39e0836..ad4b888 100644 --- a/packages/examples/src/openai/basic.tsx +++ b/packages/examples/src/openai/basic.tsx @@ -17,7 +17,12 @@ const calculatorTool = defineTool({ }) const result = await run( - + You are a helpful math assistant. @@ -26,7 +31,7 @@ const result = await run( , { providers: { - openai: { client: new OpenAI(), websocket: true }, + openai: { client: new OpenAI() }, }, }, ) diff --git a/packages/examples/src/openai/built-ins.tsx b/packages/examples/src/openai/built-ins.tsx index e02e705..89ff6d6 100644 --- a/packages/examples/src/openai/built-ins.tsx +++ b/packages/examples/src/openai/built-ins.tsx @@ -8,12 +8,17 @@ const MCP_SERVER_URL = 'https://demo-day.mcp.cloudflare.com/sse' const ai = createAI({ providers: { - openai: { client: new OpenAI(), websocket: true }, + openai: { client: new OpenAI() }, }, }) const result = await ai.run( - + You can use web_search, code execution, and MCP tools. First, find one recent data point about renewable energy adoption in 2025, then use code diff --git a/packages/examples/src/openai/codex-subagent.tsx b/packages/examples/src/openai/codex-subagent.tsx index cad593c..175b0ee 100644 --- a/packages/examples/src/openai/codex-subagent.tsx +++ b/packages/examples/src/openai/codex-subagent.tsx @@ -6,7 +6,12 @@ const ORCHESTRATOR_MODEL = 'gpt-5.2' const CODEX_MODEL = 'gpt-5.3-codex' const result = await run( - + You are a senior engineering lead. When you need code written or reviewed, delegate to the codex subagent. Synthesize its output into a final answer. @@ -57,7 +62,7 @@ const result = await run( , { providers: { - openai: { client: new OpenAI(), websocket: true }, + openai: { client: new OpenAI() }, }, }, ) diff --git a/packages/examples/src/openai/subtools-smoke.tsx b/packages/examples/src/openai/subtools-smoke.tsx index 759cf0c..597e4f6 100644 --- a/packages/examples/src/openai/subtools-smoke.tsx +++ b/packages/examples/src/openai/subtools-smoke.tsx @@ -6,7 +6,7 @@ import { OPENAI_MODEL as EXAMPLE_OPENAI_MODEL } from '../constants' const MODEL = process.env.OPENAI_MODEL ?? EXAMPLE_OPENAI_MODEL const result = await run( - + You are a coordinator. Use subagents when you need focused help. @@ -43,7 +43,7 @@ const result = await run( , { providers: { - openai: { client: new OpenAI(), websocket: true }, + openai: { client: new OpenAI() }, }, }, ) diff --git a/packages/examples/src/openai/websocket.tsx b/packages/examples/src/openai/websocket.tsx index b2cca36..e8b5995 100644 --- a/packages/examples/src/openai/websocket.tsx +++ b/packages/examples/src/openai/websocket.tsx @@ -5,8 +5,8 @@ * only incremental input on continuation turns via `previous_response_id`, * cutting per-turn latency ~40% in multi-tool-call loops. * - * Enable it with a single flag: - * providers: { openai: { client, websocket: true } } + * Enable it per agent with the `websocket` prop: + * * * Run: * bun run example:openai:websocket @@ -45,6 +45,7 @@ const result = await run( model={MODEL} maxTokens={1024} stream={true} + websocket={true} onMessage={(event) => { if (event.type === 'tool_use_start') { toolCallCount++ @@ -105,7 +106,7 @@ const result = await run( , { - providers: { openai: { client, websocket: true } }, + providers: { openai: { client } }, }, ) From c7b5e3802edb67e01d695e9e67861626729a8ce3 Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:04:54 -0800 Subject: [PATCH 33/39] refactor: optimize updates --- packages/agentry/src/handles/AgentHandle.ts | 84 ++++++++++----------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/packages/agentry/src/handles/AgentHandle.ts b/packages/agentry/src/handles/AgentHandle.ts index 0fffeb4..f55ecf0 100644 --- a/packages/agentry/src/handles/AgentHandle.ts +++ b/packages/agentry/src/handles/AgentHandle.ts @@ -1,10 +1,9 @@ -import { createElement, type ReactNode } from 'react' +import { type ReactNode } from 'react' import { InstanceType, type AgentInstance } from '../instances/types' import type { AgentMessageParam } from '../types/messages' -import { createContainer, updateContainer } from '../reconciler/renderer' +import { createContainer } from '../reconciler/renderer' import { createAgentStore } from '../store' import { isAgentInstance } from '../instances' -import { AgentProvider } from '../context' import { AbstractAgentHandle } from './AbstractAgentHandle' import type { ExecutionEngineConfig } from '../execution/ExecutionEngine' import type { @@ -29,7 +28,7 @@ export class AgentHandle extends AbstractAgentHandle { private element: ReactNode private mode: 'batch' | 'interactive' private wsFactory: ((client: OpenAI) => ResponsesWSLike) | undefined - private wsReady = false + private isDirty = true private initialProps: { provider: string model: string @@ -93,15 +92,7 @@ export class AgentHandle extends AbstractAgentHandle { update(element: ReactNode): void { this.element = element - const wrappedElement = this.wrapWithProvider(element) - updateContainer(wrappedElement, this.containerInfo) - } - - private wrapWithProvider(element: ReactNode): ReactNode { - return createElement(AgentProvider, { - store: this.store, - children: element, - }) + this.isDirty = true } protected shouldEmitEvents(): boolean { @@ -122,8 +113,43 @@ export class AgentHandle extends AbstractAgentHandle { } protected async prepareAgent(): Promise { - await this.renderWithProvider(this.element) + if (this.isDirty) { + await this.renderWithProvider(this.element) + this.isDirty = false + } + + if (this.instance !== null) { + const agent = this.instance + const provider = agent.props.provider + const model = agent.props.model + const websocket = + agent.props.provider === 'openai' + ? (agent.props.websocket ?? false) + : false + if (this.initialProps!.provider !== provider) { + throw new Error( + `Agent provider cannot change between runs (was "${this.initialProps!.provider}", got "${provider}"). ` + + `Create a new agent handle instead.`, + ) + } + if (this.initialProps!.model !== model) { + throw new Error( + `Agent model cannot change between runs (was "${this.initialProps!.model}", got "${model}"). ` + + `Create a new agent handle instead.`, + ) + } + if (this.initialProps!.websocket !== websocket) { + throw new Error( + `Agent websocket mode cannot change between runs (was ${this.initialProps!.websocket}, got ${websocket}). ` + + `Create a new agent handle instead.`, + ) + } + + return agent + } + + // First run only: full setup const container = this.containerInfo.container if (!isAgentInstance(container)) { throw new Error('Root container is not an agent instance') @@ -147,41 +173,15 @@ export class AgentHandle extends AbstractAgentHandle { ? (agent.props.websocket ?? false) : false - if (this.initialProps === null) { - this.initialProps = { provider, model, websocket } - } else { - if (this.initialProps.provider !== provider) { - throw new Error( - `Agent provider cannot change between runs (was "${this.initialProps.provider}", got "${provider}"). ` + - `Create a new agent handle instead.`, - ) - } - if (this.initialProps.model !== model) { - throw new Error( - `Agent model cannot change between runs (was "${this.initialProps.model}", got "${model}"). ` + - `Create a new agent handle instead.`, - ) - } - if (this.initialProps.websocket !== websocket) { - throw new Error( - `Agent websocket mode cannot change between runs (was ${this.initialProps.websocket}, got ${websocket}). ` + - `Create a new agent handle instead.`, - ) - } - } + this.initialProps = { provider, model, websocket } agent.client = await ensureProviderClient(this.clients, provider) - if ( - !this.wsReady && - agent.props.provider === 'openai' && - agent.props.websocket - ) { + if (agent.props.provider === 'openai' && agent.props.websocket) { this.adapters.openai = createOpenAIAdapter({ websocket: true, _responsesWSFactory: this.wsFactory, }) - this.wsReady = true } return agent From 6ced04a0964b04de95e2a6fb344bbef4bb9e306d Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:41:25 -0800 Subject: [PATCH 34/39] refactor: update engine configuration and improve type handling --- .../agentry/src/execution/createEngineConfig.ts | 4 ++-- packages/agentry/src/handles/SubagentHandle.ts | 1 + .../agentry/src/instances/createInstance.ts | 5 +++-- packages/agentry/src/providers/openai.ts | 12 ++++++++---- packages/agentry/src/providers/types.ts | 17 +++++++++++++++++ packages/agentry/src/run/createAI.ts | 2 +- packages/agentry/src/types/agent.ts | 3 ++- 7 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/agentry/src/execution/createEngineConfig.ts b/packages/agentry/src/execution/createEngineConfig.ts index 9bb1481..d722980 100644 --- a/packages/agentry/src/execution/createEngineConfig.ts +++ b/packages/agentry/src/execution/createEngineConfig.ts @@ -48,7 +48,7 @@ export function buildSystemPrompt( * Shared factory for ExecutionEngine configuration * Used by both root agents (AgentHandle) and subagents (renderSubagent) * - * Unified defaults: maxTokens=4096, stream=true + * Unified defaults: maxTokens=4096 * These apply when agent.props doesn't specify a value */ export function createEngineConfig( @@ -90,7 +90,7 @@ export function createEngineConfig( temperature: agent.props.temperature, agentName: agent.props.name, thinking: agent.props.thinking, - betas: (agent.props as { betas?: string[] }).betas, + betas: agent.props.provider === 'anthropic' ? agent.props.betas : undefined, agentInstance: agent, store, } diff --git a/packages/agentry/src/handles/SubagentHandle.ts b/packages/agentry/src/handles/SubagentHandle.ts index e916d92..38bb94d 100644 --- a/packages/agentry/src/handles/SubagentHandle.ts +++ b/packages/agentry/src/handles/SubagentHandle.ts @@ -139,6 +139,7 @@ export class SubagentHandle extends AbstractAgentHandle { } protected override cleanup(): void { + super.cleanup() if (this.abortSignal && this.abortHandler) { this.abortSignal.removeEventListener('abort', this.abortHandler) } diff --git a/packages/agentry/src/instances/createInstance.ts b/packages/agentry/src/instances/createInstance.ts index fb26823..0c11dad 100644 --- a/packages/agentry/src/instances/createInstance.ts +++ b/packages/agentry/src/instances/createInstance.ts @@ -48,6 +48,7 @@ interface SubagentCreationProps extends Omit< > { model?: AgentComponentProps['model'] agentNode?: React.ReactNode + betas?: string[] websocket?: boolean } @@ -141,7 +142,7 @@ function createAgentInstance( stream: props.stream ?? true, compactionControl: props.compactionControl, thinking: props.thinking, - betas: (props as { betas?: string[] }).betas, + betas: props.provider === 'anthropic' ? props.betas : undefined, websocket: props.provider === 'openai' ? props.websocket : undefined, onMessage: props.onMessage, onComplete: props.onComplete, @@ -323,7 +324,7 @@ export function createSubagentInstance( stream: props.stream ?? inherited.stream ?? true, compactionControl: props.compactionControl ?? inherited.compactionControl, thinking: props.thinking ?? inherited.thinking, - betas: (props as { betas?: string[] }).betas ?? inherited.betas, + betas: props.betas ?? inherited.betas, websocket: props.websocket ?? inherited.websocket, // callbacks never inherited onMessage: props.onMessage, diff --git a/packages/agentry/src/providers/openai.ts b/packages/agentry/src/providers/openai.ts index d7f7e7b..43c7903 100644 --- a/packages/agentry/src/providers/openai.ts +++ b/packages/agentry/src/providers/openai.ts @@ -5,7 +5,7 @@ import type { NormalizedTurnResponse, } from './types' import type { AgentContentBlock, AgentMessageParam } from '../types/messages' -import type { JsonObject } from '../types/json' +import type { JsonObject, JsonValue } from '../types/json' import { isCodeExecutionTool, isWebSearchTool } from '../types/tools' import { debug } from '../debug' @@ -70,10 +70,10 @@ function stringifyContent( function toProviderOutputEventItem( item: OpenAIResponseResult['output'][number], -): { itemType: string; item: Record } { +): { itemType: string; item: Record } { return { itemType: item.type, - item: item as unknown as Record, + item: item as unknown as Record, } } @@ -333,7 +333,7 @@ function toOpenAITools( type: 'mcp', server_label: server.name, server_url: server.url, - authorization: server.authorization_token, + authorization: server.authorization_token ?? undefined, allowed_tools: server.tool_configuration?.allowed_tools, require_approval: 'never', } satisfies OpenAI.Responses.Tool.Mcp) @@ -585,6 +585,10 @@ async function* createWSEventStream( ws.on('error', onError) signal.addEventListener('abort', onAbort) + if (signal.aborted) { + onAbort() + } + try { ws.send(wsEvent) diff --git a/packages/agentry/src/providers/types.ts b/packages/agentry/src/providers/types.ts index c9e930b..ed891e3 100644 --- a/packages/agentry/src/providers/types.ts +++ b/packages/agentry/src/providers/types.ts @@ -5,6 +5,7 @@ import type { AgentMessage, AgentMessageParam } from '../types/messages' import type { InternalTool, BuiltInTool } from '../types/tools' import type { ProviderName } from '../types/provider' import type { ResponsesWSLike } from '../providers/openai' +import type { Beta } from '@anthropic-ai/sdk/resources/beta' export interface ProviderClientMap { anthropic: Anthropic @@ -62,6 +63,22 @@ export interface MCPServerConfig { } } +// Compile-time check: MCPServerConfig must stay compatible with the Anthropic SDK +const _assertMCPAnthropicCompat: Beta.Messages.BetaRequestMCPServerURLDefinition = + {} as MCPServerConfig +void _assertMCPAnthropicCompat + +// Compile-time check: MCPServerConfig fields must stay compatible with the OpenAI SDK +const _assertMCPOpenAICompat: Pick< + OpenAI.Responses.Tool.Mcp, + 'server_label' | 'server_url' | 'authorization' +> = {} as { + server_label: MCPServerConfig['name'] + server_url: MCPServerConfig['url'] + authorization: MCPServerConfig['authorization_token'] +} +void _assertMCPOpenAICompat + export interface NormalizedTurnRequest { model: Model maxTokens: number diff --git a/packages/agentry/src/run/createAI.ts b/packages/agentry/src/run/createAI.ts index 9361ca0..502f4b4 100644 --- a/packages/agentry/src/run/createAI.ts +++ b/packages/agentry/src/run/createAI.ts @@ -86,7 +86,7 @@ function mergeCreateAgentOptions( * ```ts * const ai = createAI({ * providers: { - * openai: { client: new OpenAI(), websocket: true }, + * openai: { client: new OpenAI() }, * anthropic: { client: new Anthropic() }, * }, * }) diff --git a/packages/agentry/src/types/agent.ts b/packages/agentry/src/types/agent.ts index 2d9c2c3..f33925d 100644 --- a/packages/agentry/src/types/agent.ts +++ b/packages/agentry/src/types/agent.ts @@ -1,3 +1,4 @@ +import type { JsonValue } from './json' import type { OnStepFinishResult } from './lifecycle' import type { AgentMessageParam } from './messages' import type { Model as _AnthropicModel } from '@anthropic-ai/sdk/resources/messages' @@ -75,7 +76,7 @@ export type AgentStreamEvent = | { type: 'provider_event' itemType: string - item: Record + item: Record } | { type: 'thinking'; text: string } | { type: 'message_complete'; stopReason: string | null } From ca4e681714d19c3d82ee35053ad30d85a54bead5 Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:53:00 -0800 Subject: [PATCH 35/39] feat: implement async-aware step finish callback and emit synthetic events for non-streaming responses --- .../agentry/src/execution/ExecutionEngine.ts | 10 + .../src/handles/AbstractAgentHandle.ts | 7 +- packages/agentry/src/providers/anthropic.ts | 25 +- packages/agentry/src/providers/openai.ts | 12 +- .../agentry/src/providers/syntheticEvents.ts | 40 +++ packages/agentry/src/types/agent.ts | 1 - .../agentry/tests/openai-provider.test.tsx | 143 ++++++++++- packages/agentry/tests/runtime.test.tsx | 236 ++++++++++++++++++ .../agentry/tests/synthetic-events.test.ts | 123 +++++++++ 9 files changed, 591 insertions(+), 6 deletions(-) create mode 100644 packages/agentry/src/providers/syntheticEvents.ts create mode 100644 packages/agentry/tests/synthetic-events.test.ts diff --git a/packages/agentry/src/execution/ExecutionEngine.ts b/packages/agentry/src/execution/ExecutionEngine.ts index 1587868..7c4e93c 100644 --- a/packages/agentry/src/execution/ExecutionEngine.ts +++ b/packages/agentry/src/execution/ExecutionEngine.ts @@ -146,6 +146,14 @@ export class ExecutionEngine extends EventEmitter { private agentInstance: AgentInstance private toolExecutionTimes = new Map() + /** + * Async-aware step finish callback. Unlike the EventEmitter-based 'stepFinish' + * event, this callback is awaited before the next iteration begins, allowing + * consumers to perform async work (e.g. logging, persistence) that must complete + * before execution continues. + */ + onStepFinish?: (result: OnStepFinishResult) => void | Promise + constructor(config: ExecutionEngineConfig) { super() this.client = config.client @@ -313,9 +321,11 @@ export class ExecutionEngine extends EventEmitter { toolResults, ) this.emit('stepFinish', stepResult) + await this.onStepFinish?.(stepResult) } else { const stepResult = this.buildStepFinishResult(message, [], []) this.emit('stepFinish', stepResult) + await this.onStepFinish?.(stepResult) break } } diff --git a/packages/agentry/src/handles/AbstractAgentHandle.ts b/packages/agentry/src/handles/AbstractAgentHandle.ts index 406a414..9c2984b 100644 --- a/packages/agentry/src/handles/AbstractAgentHandle.ts +++ b/packages/agentry/src/handles/AbstractAgentHandle.ts @@ -110,6 +110,12 @@ export abstract class AbstractAgentHandle extends EventEmitter { + await agent.props.onStepFinish?.(result) + } + let onStateChange: ((state: AgentState) => void) | undefined const onStream = (event: AgentStreamEvent) => { if (emitEvents) this.emit('stream', event) @@ -121,7 +127,6 @@ export abstract class AbstractAgentHandle extends EventEmitter { if (emitEvents) this.emit('stepFinish', result) - agent.props.onStepFinish?.(result) } try { diff --git a/packages/agentry/src/providers/anthropic.ts b/packages/agentry/src/providers/anthropic.ts index 82c06f5..4d097e9 100644 --- a/packages/agentry/src/providers/anthropic.ts +++ b/packages/agentry/src/providers/anthropic.ts @@ -17,6 +17,7 @@ import type { NormalizedTurnRequest, NormalizedTurnResponse, } from './types' +import { emitSyntheticEvents } from './syntheticEvents' import { isCodeExecutionTool, isMemoryTool, @@ -190,11 +191,13 @@ export const anthropicAdapter: ProviderAdapter<'anthropic'> = { let response: BetaMessage if (request.stream) { + let accumulatedText = '' const stream = client.beta.messages.stream(params, { signal: request.signal, }) - stream.on('text', (text, snapshot) => { - request.onStream({ type: 'text', text, accumulated: snapshot }) + stream.on('text', (text) => { + accumulatedText += text + request.onStream({ type: 'text', text, accumulated: accumulatedText }) }) stream.on('thinking', (thinking) => { request.onStream({ type: 'thinking', text: thinking }) @@ -218,6 +221,24 @@ export const anthropicAdapter: ProviderAdapter<'anthropic'> = { { ...params, stream: false }, { signal: request.signal }, ) + + const blocks = toAgentBlocks(response.content) + emitSyntheticEvents(blocks, response.stop_reason, request.onStream) + + return { + message: { + content: blocks, + stop_reason: response.stop_reason, + usage: { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + cache_creation_input_tokens: + response.usage.cache_creation_input_tokens ?? null, + cache_read_input_tokens: + response.usage.cache_read_input_tokens ?? null, + }, + }, + } } return { diff --git a/packages/agentry/src/providers/openai.ts b/packages/agentry/src/providers/openai.ts index 43c7903..2be3918 100644 --- a/packages/agentry/src/providers/openai.ts +++ b/packages/agentry/src/providers/openai.ts @@ -7,6 +7,7 @@ import type { import type { AgentContentBlock, AgentMessageParam } from '../types/messages' import type { JsonObject, JsonValue } from '../types/json' import { isCodeExecutionTool, isWebSearchTool } from '../types/tools' +import { emitSyntheticEvents } from './syntheticEvents' import { debug } from '../debug' type OpenAIResponseCreateParams = @@ -508,7 +509,16 @@ export const openaiAdapter: ProviderAdapter<'openai'> = { ...toProviderOutputEventItem(item), }) } - return parseOpenAIResponse(response) + + const result = parseOpenAIResponse(response) + + emitSyntheticEvents( + result.message.content, + result.message.stop_reason, + request.onStream, + ) + + return result }, } diff --git a/packages/agentry/src/providers/syntheticEvents.ts b/packages/agentry/src/providers/syntheticEvents.ts new file mode 100644 index 0000000..6acea6a --- /dev/null +++ b/packages/agentry/src/providers/syntheticEvents.ts @@ -0,0 +1,40 @@ +import type { AgentContentBlock } from '../types/messages' +import type { AgentStreamEvent } from '../types/agent' + +/** + * Emit synthetic stream events from a complete (non-streaming) response so that + * `onMessage` fires consistently regardless of streaming mode. + * + * Iterates content blocks in order, emitting `text`, `thinking`, and `tool_use_start` + * events. Text blocks accumulate across all blocks so the last `text` event's + * `accumulated` field always equals the full concatenated text content. + * + * Always emits `message_complete` as the final event. + */ +export function emitSyntheticEvents( + content: AgentContentBlock[], + stopReason: string | null, + onStream: (event: AgentStreamEvent) => void, +): void { + let accumulatedText = '' + + for (const block of content) { + if (block.type === 'text') { + accumulatedText += block.text + onStream({ type: 'text', text: block.text, accumulated: accumulatedText }) + } else if (block.type === 'thinking') { + onStream({ type: 'thinking', text: block.thinking }) + } else if (block.type === 'tool_use') { + onStream({ + type: 'tool_use_start', + toolName: block.name, + toolId: block.id, + }) + } + } + + onStream({ + type: 'message_complete', + stopReason: stopReason ?? 'unknown', + }) +} diff --git a/packages/agentry/src/types/agent.ts b/packages/agentry/src/types/agent.ts index f33925d..236a413 100644 --- a/packages/agentry/src/types/agent.ts +++ b/packages/agentry/src/types/agent.ts @@ -71,7 +71,6 @@ export interface CompactionControl { export type AgentStreamEvent = | { type: 'text'; text: string; accumulated: string } | { type: 'tool_use_start'; toolName: string; toolId: string } - | { type: 'tool_use_input'; toolId: string; partialInput: string } | { type: 'tool_result'; toolId: string; result: string; isError: boolean } | { type: 'provider_event' diff --git a/packages/agentry/tests/openai-provider.test.tsx b/packages/agentry/tests/openai-provider.test.tsx index 9bc68bb..8d739ba 100644 --- a/packages/agentry/tests/openai-provider.test.tsx +++ b/packages/agentry/tests/openai-provider.test.tsx @@ -332,7 +332,6 @@ test('reasoning summary returned in result.thinking (non-streaming)', async () = { providers: { openai: { client } } }, ) - // thinking is available in the final result; onMessage does not fire in non-streaming mode expect(result.thinking).toBe('I reasoned...') }) @@ -633,6 +632,7 @@ test('streaming: built-in OpenAI output items fire tool_use_start events and par test('non-streaming: built-in OpenAI output items emit provider_event events', async () => { const providerEvents: string[] = [] + const allEventTypes: string[] = [] const { client } = createOpenAIMockClient([ { @@ -656,6 +656,7 @@ test('non-streaming: built-in OpenAI output items emit provider_event events', a model={OPENAI_TEST_MODEL} stream={false} onMessage={(event) => { + allEventTypes.push(event.type) if (event.type === 'provider_event') { providerEvents.push(event.itemType) } @@ -668,6 +669,10 @@ test('non-streaming: built-in OpenAI output items emit provider_event events', a expect(result.content).toBe('Non-stream built-ins ok') expect(providerEvents).toEqual(['web_search_call', 'message']) + // Non-streaming mode now emits synthetic text + message_complete events + expect(allEventTypes).toContain('text') + expect(allEventTypes).toContain('message_complete') + expect(allEventTypes[allEventTypes.length - 1]).toBe('message_complete') }) // toOpenAIInput unit tests @@ -1548,3 +1553,139 @@ test('getErrorEventDetails extracts nested error fields', () => { expect(result3.message).toContain('only_code') expect(result3.code).toBe('only_code') }) + +// --- Synthetic event tests --- + +test('non-streaming: text and message_complete events fire', async () => { + const events: Array<{ type: string; text?: string; accumulated?: string }> = + [] + + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Hello from OpenAI' }], + }, + ], + }, + ]) + + await run( + { + if (event.type === 'text') { + events.push({ + type: 'text', + text: event.text, + accumulated: event.accumulated, + }) + } else if (event.type === 'message_complete') { + events.push({ type: 'message_complete' }) + } + }} + > + Hello + , + { providers: { openai: { client } } }, + ) + + expect(events.length).toBeGreaterThanOrEqual(2) + const textEvents = events.filter((e) => e.type === 'text') + expect(textEvents.length).toBe(1) + expect(textEvents[0]!.accumulated).toBe('Hello from OpenAI') + expect(events[events.length - 1]!.type).toBe('message_complete') +}) + +test('non-streaming: multiple output_text blocks accumulate correctly', async () => { + const textEvents: Array<{ text: string; accumulated: string }> = [] + + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [ + { type: 'output_text', text: 'Hello ' }, + { type: 'output_text', text: 'world' }, + ], + }, + ], + }, + ]) + + await run( + { + if (event.type === 'text') { + textEvents.push({ text: event.text, accumulated: event.accumulated }) + } + }} + > + Hello + , + { providers: { openai: { client } } }, + ) + + expect(textEvents).toHaveLength(2) + expect(textEvents[0]!.accumulated).toBe('Hello ') + expect(textEvents[1]!.accumulated).toBe('Hello world') +}) + +test('non-streaming: thinking events fire with reasoning block', async () => { + const events: Array<{ type: string; text?: string }> = [] + + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: 'Thinking deeply...' }], + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'The answer is 42' }], + }, + ], + }, + ]) + + await run( + { + if ( + event.type === 'thinking' || + event.type === 'text' || + event.type === 'message_complete' + ) { + events.push({ + type: event.type, + text: 'text' in event ? event.text : undefined, + }) + } + }} + > + Think hard + , + { providers: { openai: { client } } }, + ) + + const types = events.map((e) => e.type) + expect(types).toContain('thinking') + expect(types).toContain('text') + expect(types).toContain('message_complete') + expect(types[types.length - 1]).toBe('message_complete') + + const thinkingEvent = events.find((e) => e.type === 'thinking')! + expect(thinkingEvent.text).toBe('Thinking deeply...') +}) diff --git a/packages/agentry/tests/runtime.test.tsx b/packages/agentry/tests/runtime.test.tsx index d35f294..b59ee22 100644 --- a/packages/agentry/tests/runtime.test.tsx +++ b/packages/agentry/tests/runtime.test.tsx @@ -1014,3 +1014,239 @@ test('toAnthropicMessage: thinking blocks stripped when replaying assistant turn const allBlocks = capturedBlocks.flat() expect(allBlocks.some((b) => b.type === 'thinking')).toBe(false) }) + +test('non-streaming: onMessage fires text and message_complete events', async () => { + const eventTypes: string[] = [] + + const { client, controller } = createStepMockClient([ + { content: [mockText('Hello from Anthropic')] }, + ]) + + const runPromise = run( + { + eventTypes.push(event.type) + }} + > + Hello + , + { providers: { anthropic: { client } } }, + ) + + await controller.nextTurn() + const result = await runPromise + + expect(result.content).toBe('Hello from Anthropic') + expect(eventTypes).toContain('text') + expect(eventTypes).toContain('message_complete') + expect(eventTypes[eventTypes.length - 1]).toBe('message_complete') +}) + +test('non-streaming: multiple text blocks accumulate correctly', async () => { + const events: Array<{ type: string; text?: string; accumulated?: string }> = + [] + + const { client, controller } = createStepMockClient([ + { content: [mockText('Hello '), mockText('world')] }, + ]) + + const runPromise = run( + { + if (event.type === 'text') { + events.push({ + type: 'text', + text: event.text, + accumulated: event.accumulated, + }) + } + }} + > + Hello + , + { providers: { anthropic: { client } } }, + ) + + await controller.nextTurn() + await runPromise + + expect(events).toHaveLength(2) + expect(events[0]!.accumulated).toBe('Hello ') + expect(events[1]!.accumulated).toBe('Hello world') +}) + +test('non-streaming: thinking events fire', async () => { + const events: Array<{ type: string; text?: string }> = [] + + const { client, controller } = createStepMockClient([ + { + content: [ + { + type: 'thinking', + thinking: 'Let me think...', + citations: null, + } as never, + mockText('Answer'), + ], + }, + ]) + + const runPromise = run( + { + events.push({ + type: event.type, + text: 'text' in event ? event.text : undefined, + }) + }} + > + Think about this + , + { providers: { anthropic: { client } } }, + ) + + await controller.nextTurn() + await runPromise + + const types = events.map((e) => e.type) + expect(types).toEqual(['thinking', 'text', 'message_complete']) + expect(events[0]!.text).toBe('Let me think...') +}) + +test('non-streaming: event order is content-blocks then message_complete', async () => { + const eventTypes: string[] = [] + + const { client, controller } = createStepMockClient([ + { + content: [mockText('Hi'), mockToolUse('search', { q: 'test' })], + stop_reason: 'tool_use', + }, + { content: [mockText('Done')] }, + ]) + + const runPromise = run( + { + eventTypes.push(event.type) + }} + > + + 'result'} + /> + + Search + , + { providers: { anthropic: { client } } }, + ) + + await controller.nextTurn() + await controller.waitForNextCall() + await controller.nextTurn() + await runPromise + + // First turn: text, tool_use_start, message_complete + // Then tool_result event from engine + // Second turn: text, message_complete + expect(eventTypes[0]).toBe('text') + expect(eventTypes[1]).toBe('tool_use_start') + expect(eventTypes[2]).toBe('message_complete') +}) + +test('streaming: text and message_complete events fire', async () => { + const eventTypes: string[] = [] + let lastAccumulated = '' + + const { client, controller } = createStepMockClient([ + { content: [mockText('Streamed text')] }, + ]) + + const runPromise = run( + { + eventTypes.push(event.type) + if (event.type === 'text') lastAccumulated = event.accumulated + }} + > + Hello + , + { providers: { anthropic: { client } } }, + ) + + await controller.nextTurn() + await runPromise + + expect(eventTypes).toContain('text') + expect(eventTypes).toContain('message_complete') + expect(eventTypes[eventTypes.length - 1]).toBe('message_complete') + expect(lastAccumulated).toBe('Streamed text') +}) + +test('async onStepFinish is awaited before next iteration', async () => { + const order: string[] = [] + + const { client, controller } = createStepMockClient([ + { + content: [mockToolUse('myTool', { x: 1 })], + stop_reason: 'tool_use', + }, + { content: [mockText('Done')] }, + ]) + + const runPromise = run( + { + order.push('stepFinish:start') + await new Promise((r) => setTimeout(r, 50)) + order.push('stepFinish:end') + }} + > + + { + order.push('tool:executed') + return 'ok' + }} + /> + + Go + , + { providers: { anthropic: { client } } }, + ) + + await controller.nextTurn() + await controller.waitForNextCall() + await controller.nextTurn() + await runPromise + + // The async onStepFinish should complete before the next API call + const toolIdx = order.indexOf('tool:executed') + const stepStartIdx = order.indexOf('stepFinish:start') + const stepEndIdx = order.indexOf('stepFinish:end') + expect(stepStartIdx).toBeGreaterThan(toolIdx) + expect(stepEndIdx).toBeGreaterThan(stepStartIdx) +}) diff --git a/packages/agentry/tests/synthetic-events.test.ts b/packages/agentry/tests/synthetic-events.test.ts new file mode 100644 index 0000000..b052570 --- /dev/null +++ b/packages/agentry/tests/synthetic-events.test.ts @@ -0,0 +1,123 @@ +import { test, expect } from 'bun:test' +import { emitSyntheticEvents } from '../src/providers/syntheticEvents' +import type { AgentStreamEvent } from '../src/types/agent' +import type { AgentContentBlock } from '../src/types/messages' + +function collectEvents( + content: AgentContentBlock[], + stopReason: string | null = 'end_turn', +): AgentStreamEvent[] { + const events: AgentStreamEvent[] = [] + emitSyntheticEvents(content, stopReason, (e) => events.push(e)) + return events +} + +test('single text block — accumulated equals block text', () => { + const events = collectEvents([{ type: 'text', text: 'Hello world' }]) + + expect(events).toHaveLength(2) + expect(events[0]).toEqual({ + type: 'text', + text: 'Hello world', + accumulated: 'Hello world', + }) + expect(events[1]).toEqual({ + type: 'message_complete', + stopReason: 'end_turn', + }) +}) + +test('multiple text blocks — accumulated grows across blocks', () => { + const events = collectEvents([ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'world' }, + ]) + + expect(events).toHaveLength(3) + expect(events[0]).toEqual({ + type: 'text', + text: 'Hello ', + accumulated: 'Hello ', + }) + expect(events[1]).toEqual({ + type: 'text', + text: 'world', + accumulated: 'Hello world', + }) + expect(events[2]).toEqual({ + type: 'message_complete', + stopReason: 'end_turn', + }) +}) + +test('mixed content — correct event types in order', () => { + const events = collectEvents([ + { type: 'thinking', thinking: 'Let me think...' }, + { type: 'text', text: 'Answer: ' }, + { type: 'tool_use', id: 'tool_1', name: 'search', input: { q: 'test' } }, + { type: 'text', text: '42' }, + ]) + + expect(events.map((e) => e.type)).toEqual([ + 'thinking', + 'text', + 'tool_use_start', + 'text', + 'message_complete', + ]) + + // Verify thinking event + expect(events[0]).toEqual({ type: 'thinking', text: 'Let me think...' }) + + // Verify accumulated across text blocks (skipping thinking and tool_use) + const textEvents = events.filter( + (e): e is Extract => e.type === 'text', + ) + expect(textEvents[0]!.accumulated).toBe('Answer: ') + expect(textEvents[1]!.accumulated).toBe('Answer: 42') + + // Verify tool_use_start + expect(events[2]).toEqual({ + type: 'tool_use_start', + toolName: 'search', + toolId: 'tool_1', + }) +}) + +test('empty content — only message_complete', () => { + const events = collectEvents([]) + + expect(events).toHaveLength(1) + expect(events[0]).toEqual({ + type: 'message_complete', + stopReason: 'end_turn', + }) +}) + +test('message_complete always last with correct stopReason', () => { + const events = collectEvents([{ type: 'text', text: 'done' }], 'tool_use') + + const last = events[events.length - 1]! + expect(last).toEqual({ type: 'message_complete', stopReason: 'tool_use' }) +}) + +test('null stop_reason becomes unknown', () => { + const events = collectEvents([{ type: 'text', text: 'hi' }], null) + + const last = events[events.length - 1]! + expect(last).toEqual({ type: 'message_complete', stopReason: 'unknown' }) +}) + +test('tool_result blocks are ignored', () => { + const events = collectEvents([ + { type: 'text', text: 'result' }, + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: 'output', + is_error: false, + }, + ]) + + expect(events.map((e) => e.type)).toEqual(['text', 'message_complete']) +}) From e9fe4b6f0aa481073a8d2e6c99efb8c42200cadf Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:02:25 -0800 Subject: [PATCH 36/39] chore: update readme and examples --- README.md | 66 ++++++----- .../examples/src/openai/codex-subagent.tsx | 2 +- packages/examples/src/tweet.tsx | 104 +++++++++--------- 3 files changed, 90 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 1bcd561..d81b654 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ const result = await run( What is 42 + 17? , { - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }, ) @@ -99,6 +99,7 @@ bun run agent.tsx - **Declarative subagents** - Use `` to create subagents with type-safe parameters - **Type-safe tools** - Handler params inferred from Zod schemas - **Streaming support** - Stream responses +- **WebSocket mode (OpenAI)** - Persistent connection via `websocket` prop reduces per-turn latency in multi-tool loops - **Programmatic agent spawning** - Spawn and execute agents on-demand from tool handlers using `context.runAgent()` - **Cross-provider subagents** - Mix providers across parent/subagent boundaries - **Compaction control** - Automatic message compaction for long conversations to manage context window usage @@ -124,7 +125,7 @@ import { createAI, Agent, Message, Tools } from 'agentry' import { WebSearch } from 'agentry/anthropic' const ai = createAI({ - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) const result = await ai.run( @@ -141,31 +142,34 @@ const result = await ai.run( Want to see code? See [examples/](/packages/examples/src) -| Example | Description | -| -------------------------------------------------------------------------------------- | ------------------------------------------ | -| [`demo.tsx`](packages/examples/src/demo.tsx) | Company research with web search | -| [`basic.tsx`](packages/examples/src/basic.tsx) | Simple calculator tool | -| [`interactive.tsx`](packages/examples/src/interactive.tsx) | Multi-turn conversations with streaming | -| [`subagents.tsx`](packages/examples/src/subagents.tsx) | Manager delegating to specialists | -| [`hooks.tsx`](packages/examples/src/hooks.tsx) | Hooks, composition, and dynamic tools | -| [`web-search.tsx`](packages/examples/src/web-search.tsx) | Web search workflows | -| [`mcp.tsx`](packages/examples/src/mcp.tsx) | MCP server integration | -| [`chatbot.tsx`](packages/examples/src/chatbot.tsx) | Terminal-based chatbot | -| [`create-subagent.tsx`](packages/examples/src/create-subagent.tsx) | Dynamic subagent creation | -| [`anthropic/cache-ephemeral.tsx`](packages/examples/src/anthropic/cache-ephemeral.tsx) | Prompt caching with ephemeral content | -| [`conditions.tsx`](packages/examples/src/conditions.tsx) | State-based and NL condition rendering | -| [`anthropic/thinking.tsx`](packages/examples/src/anthropic/thinking.tsx) | Extended thinking with interleaved support | -| [`workflow.tsx`](packages/examples/src/workflow.tsx) | Interactive authentication workflow | -| [`conversation-persistence.tsx`](packages/examples/src/conversation-persistence.tsx) | Conversation save/load | -| [`openai/basic.tsx`](packages/examples/src/openai/basic.tsx) | OpenAI Responses API basic usage | -| [`cross-provider/subagents.tsx`](packages/examples/src/cross-provider/subagents.tsx) | OpenAI parent + Anthropic subagents | -| [`openai/codex-subagent.tsx`](packages/examples/src/openai/codex-subagent.tsx) | OpenAI Codex subagent | +| Example | Description | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| [`demo.tsx`](packages/examples/src/demo.tsx) | Company research with web search | +| [`basic.tsx`](packages/examples/src/basic.tsx) | Simple calculator tool | +| [`interactive.tsx`](packages/examples/src/interactive.tsx) | Multi-turn conversations with streaming | +| [`subagents.tsx`](packages/examples/src/subagents.tsx) | Manager delegating to specialists | +| [`hooks.tsx`](packages/examples/src/hooks.tsx) | Hooks, composition, and dynamic tools | +| [`web-search.tsx`](packages/examples/src/web-search.tsx) | Web search workflows | +| [`mcp.tsx`](packages/examples/src/mcp.tsx) | MCP server integration | +| [`chatbot.tsx`](packages/examples/src/chatbot.tsx) | Terminal-based chatbot | +| [`create-subagent.tsx`](packages/examples/src/create-subagent.tsx) | Dynamic subagent creation | +| [`anthropic/cache-ephemeral.tsx`](packages/examples/src/anthropic/cache-ephemeral.tsx) | Prompt caching with ephemeral content | +| [`conditions.tsx`](packages/examples/src/conditions.tsx) | State-based and NL condition rendering | +| [`anthropic/thinking.tsx`](packages/examples/src/anthropic/thinking.tsx) | Extended thinking with interleaved support | +| [`workflow.tsx`](packages/examples/src/workflow.tsx) | Interactive authentication workflow | +| [`conversation-persistence.tsx`](packages/examples/src/conversation-persistence.tsx) | Conversation save/load | +| [`openai/basic.tsx`](packages/examples/src/openai/basic.tsx) | OpenAI Responses API basic usage | +| [`cross-provider/subagents.tsx`](packages/examples/src/cross-provider/subagents.tsx) | OpenAI parent + Anthropic subagents | +| [`openai/codex-subagent.tsx`](packages/examples/src/openai/codex-subagent.tsx) | OpenAI Codex subagent | +| [`openai/built-ins.tsx`](packages/examples/src/openai/built-ins.tsx) | OpenAI built-ins: WebSearch, CodeExecution, and MCP | +| [`openai/websocket.tsx`](packages/examples/src/openai/websocket.tsx) | OpenAI WebSocket mode for lower-latency multi-tool loops | +| [`compaction.tsx`](packages/examples/src/compaction.tsx) | Context compaction demo (works with Anthropic and OpenAI) | Run an example: ```bash echo "ANTHROPIC_API_KEY=sk-ant-***" > .env -echo "OPENAI_API_KEY=sk-***" >> .env +# echo "OPENAI_API_KEY=sk-***" >> .env bun run example:basic # OpenAI examples: bun run example:openai:basic @@ -183,7 +187,7 @@ bun run example:anthropic:thinking ```tsx const result = await run(..., { - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) ``` @@ -192,7 +196,7 @@ const result = await run(..., { ```tsx const agent = await run(..., { mode: 'interactive', - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) await agent.sendMessage('Hello') for await (const event of agent.stream('Tell me more')) { @@ -286,7 +290,10 @@ import { createAI, Agent, AgentTool, Message, Tools } from 'agentry' import { z } from 'zod' const ai = createAI({ - clients: { openai: new OpenAI(), anthropic: new Anthropic() }, + providers: { + openai: { client: new OpenAI() }, + anthropic: { client: new Anthropic() }, + }, }) await ai.run( @@ -451,20 +458,20 @@ Runs an agent and returns a result or handle. ```tsx // Batch mode const result: AgentResult = await run(..., { - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) // Interactive mode const handle: AgentHandle = await run(..., { mode: 'interactive', - clients: { anthropic: new Anthropic() }, + providers: { anthropic: { client: new Anthropic() } }, }) ``` **Options:** - `mode?: 'batch' | 'interactive'` - Execution mode (default: `'batch'`) -- `clients?: { anthropic?: Anthropic; openai?: OpenAI }` - Provider client map +- `providers?: { anthropic?: { client?: Anthropic }; openai?: { client?: OpenAI } }` - Provider client map - provider is chosen from `` - if omitted, provider clients are created from environment variables by default @@ -477,7 +484,7 @@ import OpenAI from 'openai' import { createAI, Agent, Message } from 'agentry' const ai = createAI({ - clients: { openai: new OpenAI() }, + providers: { openai: { client: new OpenAI() } }, }) const result = await ai.run( @@ -509,6 +516,7 @@ Built-ins are provider-owned exports: | `stream?` | `boolean` | Enable streaming (default: `true`) | | `betas?` | `string[]` | Additional Anthropic beta features to enable | | `thinking?` | `ThinkingConfig` | Extended thinking config (provider-dependent). Anthropic: `{ type: 'enabled', budget_tokens: number, interleaved?: boolean }`. OpenAI: `{ type: 'enabled', effort: 'low' \| 'medium' \| 'high', summary: 'auto' \| 'concise' \| 'detailed' }`. Disable: `{ type: 'disabled' }`. | +| `websocket?` | `boolean` | Enable WebSocket mode for OpenAI (reduces per-turn latency ~40% in multi-tool loops via persistent WebSocket connection | | `compactionControl?` | `CompactionControl` | Context compaction settings (see below) | | `onMessage?` | `(event: AgentStreamEvent) => void` | Stream event callback | | `onComplete?` | `(result: AgentResult) => void` | Completion callback | diff --git a/packages/examples/src/openai/codex-subagent.tsx b/packages/examples/src/openai/codex-subagent.tsx index 175b0ee..ce8c929 100644 --- a/packages/examples/src/openai/codex-subagent.tsx +++ b/packages/examples/src/openai/codex-subagent.tsx @@ -35,7 +35,7 @@ const result = await run( provider="openai" model={CODEX_MODEL} name="codex" - maxTokens={8192} + websocket={true} onComplete={(result) => { console.log( `[codex] subagent complete — ${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens (model=${CODEX_MODEL})`, diff --git a/packages/examples/src/tweet.tsx b/packages/examples/src/tweet.tsx index 59a5905..880817a 100644 --- a/packages/examples/src/tweet.tsx +++ b/packages/examples/src/tweet.tsx @@ -1,62 +1,56 @@ -import { useState } from 'react' import { z } from 'zod' -import { - createAI, - Agent, - System, - Tools, - Tool, - AgentTool, - Message, -} from 'agentry' +import { run, Agent, System, Tools, AgentTool, Message } from 'agentry' +import { WebSearch, CodeExecution } from 'agentry/openai' import Anthropic from '@anthropic-ai/sdk' import OpenAI from 'openai' -import { CodeExecution as AnthropicCodeExecution } from 'agentry/anthropic' -import { CodeExecution as OpenAICodeExecution } from 'agentry/openai' -import { MODEL, OPENAI_MODEL } from './constants' - -const EXAMPLE_PROVIDER = - process.env.EXAMPLE_PROVIDER === 'openai' ? 'openai' : 'anthropic' -const CodeExecution = - EXAMPLE_PROVIDER === 'openai' ? OpenAICodeExecution : AnthropicCodeExecution -const EXAMPLE_MODEL = EXAMPLE_PROVIDER === 'openai' ? OPENAI_MODEL : MODEL -const ai = - EXAMPLE_PROVIDER === 'openai' - ? createAI({ providers: { openai: { client: new OpenAI() } } }) - : createAI({ - providers: { anthropic: { client: new Anthropic() } }, - }) - -function Calculator() { - const [value, setValue] = useState(0) +function Researcher() { return ( - - Value: {value} + { + console.log('top-level', event) + }} + > + You are a research assistant. - { - setValue(value + n) - return `Value: ${value + n}` - }} + ( + { + console.log('sub-agent search', JSON.stringify(event, null, 2)) + }} + > + + + + {query} + + )} /> ( - - - Use the code execution sandbox to evaluate the expression with - Python. - + name="compute" + description="Run Python to analyze data" + parameters={z.object({ task: z.string() })} + agent={({ task }) => ( + { + console.log('sub-agent compute', JSON.stringify(event, null, 2)) + }} + > - Evaluate: {expression} + {task} )} /> @@ -65,11 +59,17 @@ function Calculator() { ) } -const agent = await ai.run(, { mode: 'interactive' }) +const agent = await run(, { + mode: 'interactive', + providers: { + anthropic: { client: new Anthropic() }, + openai: { client: new OpenAI() }, + }, +}) -await agent.sendMessage('Add 10') -await agent.sendMessage('Add 5') const res = await agent.sendMessage( - 'Find the Nth prime number (where N is the current value)', + 'Look up the populations of the 10 largest US cities, ' + + 'then compute what percentage of the total US population they represent.', ) console.log(res.content) +agent.close() From 74cca57abfcf2cbdd053f03a54ce8c77b9a92528 Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:07:08 -0800 Subject: [PATCH 37/39] fix: reduce delay in async onStepFinish callback to fix tests not passing --- packages/agentry/tests/runtime.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agentry/tests/runtime.test.tsx b/packages/agentry/tests/runtime.test.tsx index b59ee22..2a84e61 100644 --- a/packages/agentry/tests/runtime.test.tsx +++ b/packages/agentry/tests/runtime.test.tsx @@ -1218,7 +1218,7 @@ test('async onStepFinish is awaited before next iteration', async () => { stream={false} onStepFinish={async () => { order.push('stepFinish:start') - await new Promise((r) => setTimeout(r, 50)) + await new Promise((r) => setTimeout(r, 10)) order.push('stepFinish:end') }} > From ad3319c412ca5e3edd2a89519d58ca4b31e11cf8 Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:32:48 -0800 Subject: [PATCH 38/39] refactor: enhance type handling and error management --- .../agentry/src/execution/ExecutionEngine.ts | 4 +- packages/agentry/src/execution/conditions.ts | 9 +- packages/agentry/src/handles/AgentHandle.ts | 29 ++-- packages/agentry/src/providers/openai.ts | 17 ++- packages/agentry/src/types/agent.ts | 3 +- packages/agentry/tests/conditions.test.tsx | 68 ++++++--- .../agentry/tests/openai-provider.test.tsx | 141 ++++++++++++++++++ packages/agentry/tests/utils/index.ts | 1 + packages/agentry/tests/utils/openaiMock.ts | 42 +++++- 9 files changed, 272 insertions(+), 42 deletions(-) diff --git a/packages/agentry/src/execution/ExecutionEngine.ts b/packages/agentry/src/execution/ExecutionEngine.ts index 7c4e93c..c8f3230 100644 --- a/packages/agentry/src/execution/ExecutionEngine.ts +++ b/packages/agentry/src/execution/ExecutionEngine.ts @@ -216,7 +216,7 @@ export class ExecutionEngine extends EventEmitter { }) } catch (e) { const err = e instanceof Error ? e : new Error(String(e)) - if (err.name === 'AbortError') throw err + if (err.name === 'AbortError' || signal?.aborted) throw err debug( 'conditions', `NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}), conditions may be stale due to evaluation failure`, @@ -646,6 +646,8 @@ export class ExecutionEngine extends EventEmitter { return true } catch (e) { const err = e instanceof Error ? e : new Error(String(e)) + // Re-throw abort errors — the agent is being cancelled + if (err.name === 'AbortError') throw err // Re-throw fatal errors (auth failures, invalid model) — these won't resolve on retry const status = (err as { status?: number }).status if (status === 401 || status === 403 || status === 404) { diff --git a/packages/agentry/src/execution/conditions.ts b/packages/agentry/src/execution/conditions.ts index 6c254e4..db4bcb3 100644 --- a/packages/agentry/src/execution/conditions.ts +++ b/packages/agentry/src/execution/conditions.ts @@ -6,6 +6,7 @@ import { isConditionInstance } from '../instances/types' import { debug } from '../debug' import type { ProviderClientMap } from '../providers/types' import type { ProviderName } from '../types/provider' +import type { JsonObject } from '../types/json' import { toOpenAIInput } from '../providers/openai' import { toAnthropicMessage } from '../providers/anthropic' @@ -146,7 +147,7 @@ function mapConditionResults({ * Shared by both Anthropic and OpenAI evaluation paths. */ function parseConditionIndices( - input: Record, + input: JsonObject, provider: ProviderName, ): number[] { const indices = input.trueConditionIndices @@ -269,7 +270,7 @@ async function evaluateNLWithAnthropic({ const toolUse = response.content.find((block) => block.type === 'tool_use') if (toolUse && toolUse.type === 'tool_use') { - const input = toolUse.input as Record + const input = toolUse.input as JsonObject const indices = parseConditionIndices(input, 'anthropic') return mapConditionResults({ trueConditionIndices: indices, @@ -351,9 +352,9 @@ async function evaluateNLWithOpenAI({ item.type === 'function_call' && item.name === 'evaluate_conditions', ) if (functionCall && functionCall.type === 'function_call') { - let parsed: Record + let parsed: JsonObject try { - parsed = JSON.parse(functionCall.arguments) as Record + parsed = JSON.parse(functionCall.arguments) as JsonObject } catch (e) { throw new Error( `[agentry] NL condition evaluation: failed to parse OpenAI function call arguments: "${functionCall.arguments}"`, diff --git a/packages/agentry/src/handles/AgentHandle.ts b/packages/agentry/src/handles/AgentHandle.ts index f55ecf0..c0a87f8 100644 --- a/packages/agentry/src/handles/AgentHandle.ts +++ b/packages/agentry/src/handles/AgentHandle.ts @@ -29,11 +29,13 @@ export class AgentHandle extends AbstractAgentHandle { private mode: 'batch' | 'interactive' private wsFactory: ((client: OpenAI) => ResponsesWSLike) | undefined private isDirty = true - private initialProps: { - provider: string - model: string - websocket: boolean - } | null = null + private initialProps: + | { + provider: string + model: string + websocket: boolean + } + | undefined constructor( element: ReactNode, @@ -119,6 +121,11 @@ export class AgentHandle extends AbstractAgentHandle { } if (this.instance !== null) { + if (this.initialProps === undefined) { + throw new Error( + 'Internal error: initialProps not set but instance exists', + ) + } const agent = this.instance const provider = agent.props.provider const model = agent.props.model @@ -127,21 +134,21 @@ export class AgentHandle extends AbstractAgentHandle { ? (agent.props.websocket ?? false) : false - if (this.initialProps!.provider !== provider) { + if (this.initialProps.provider !== provider) { throw new Error( - `Agent provider cannot change between runs (was "${this.initialProps!.provider}", got "${provider}"). ` + + `Agent provider cannot change between runs (was "${this.initialProps.provider}", got "${provider}"). ` + `Create a new agent handle instead.`, ) } - if (this.initialProps!.model !== model) { + if (this.initialProps.model !== model) { throw new Error( - `Agent model cannot change between runs (was "${this.initialProps!.model}", got "${model}"). ` + + `Agent model cannot change between runs (was "${this.initialProps.model}", got "${model}"). ` + `Create a new agent handle instead.`, ) } - if (this.initialProps!.websocket !== websocket) { + if (this.initialProps.websocket !== websocket) { throw new Error( - `Agent websocket mode cannot change between runs (was ${this.initialProps!.websocket}, got ${websocket}). ` + + `Agent websocket mode cannot change between runs (was ${this.initialProps.websocket}, got ${websocket}). ` + `Create a new agent handle instead.`, ) } diff --git a/packages/agentry/src/providers/openai.ts b/packages/agentry/src/providers/openai.ts index 2be3918..2883d38 100644 --- a/packages/agentry/src/providers/openai.ts +++ b/packages/agentry/src/providers/openai.ts @@ -5,7 +5,7 @@ import type { NormalizedTurnResponse, } from './types' import type { AgentContentBlock, AgentMessageParam } from '../types/messages' -import type { JsonObject, JsonValue } from '../types/json' +import type { JsonObject } from '../types/json' import { isCodeExecutionTool, isWebSearchTool } from '../types/tools' import { emitSyntheticEvents } from './syntheticEvents' import { debug } from '../debug' @@ -71,10 +71,10 @@ function stringifyContent( function toProviderOutputEventItem( item: OpenAIResponseResult['output'][number], -): { itemType: string; item: Record } { +): { itemType: string; item: Record } { return { itemType: item.type, - item: item as unknown as Record, + item: item as unknown as Record, } } @@ -586,7 +586,9 @@ async function* createWSEventStream( } const onAbort = () => { - error = new OpenAITurnError('Request aborted') + const e = new Error('Request aborted') + e.name = 'AbortError' + error = e as OpenAITurnError done = true wake() } @@ -794,6 +796,13 @@ export function createOpenAIAdapter(options?: { ws = await ensureWS(client) await waitForOpen(ws) } catch (err) { + if ( + err instanceof TypeError || + err instanceof ReferenceError || + err instanceof SyntaxError + ) { + throw err + } // WS failed to open — fall back to HTTP for this turn console.warn( `[agentry] WebSocket connection failed, falling back to HTTP: ${err instanceof Error ? err.message : String(err)}`, diff --git a/packages/agentry/src/types/agent.ts b/packages/agentry/src/types/agent.ts index 236a413..7d89a72 100644 --- a/packages/agentry/src/types/agent.ts +++ b/packages/agentry/src/types/agent.ts @@ -1,4 +1,3 @@ -import type { JsonValue } from './json' import type { OnStepFinishResult } from './lifecycle' import type { AgentMessageParam } from './messages' import type { Model as _AnthropicModel } from '@anthropic-ai/sdk/resources/messages' @@ -75,7 +74,7 @@ export type AgentStreamEvent = | { type: 'provider_event' itemType: string - item: Record + item: Record } | { type: 'thinking'; text: string } | { type: 'message_complete'; stopReason: string | null } diff --git a/packages/agentry/tests/conditions.test.tsx b/packages/agentry/tests/conditions.test.tsx index f86d429..09c589b 100644 --- a/packages/agentry/tests/conditions.test.tsx +++ b/packages/agentry/tests/conditions.test.tsx @@ -44,7 +44,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('Hello') @@ -73,7 +75,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('Hello') @@ -103,7 +107,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('Both') @@ -152,7 +158,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -207,7 +215,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -237,7 +247,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('Hello') @@ -278,7 +290,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -385,7 +399,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() // Route evaluation await controller.nextTurn() // Tool use await controller.nextTurn() // Final response @@ -454,7 +470,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() await controller.nextTurn() @@ -501,7 +519,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -551,7 +571,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -584,7 +606,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('authenticate') @@ -630,7 +654,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -689,7 +715,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() await controller.nextTurn() @@ -748,7 +776,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() await controller.nextTurn() @@ -803,7 +833,9 @@ describe('NL Condition Evaluation', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() // NL eval await controller.nextTurn() // agent calls calculate await controller.nextTurn() // final response @@ -844,7 +876,9 @@ describe('NL Condition Evaluation', () => { }, ]) - const runPromise = run(, { providers: { anthropic: { client } } }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() // NL eval (no tool_use — fallback) await controller.nextTurn() // agent response const result = await runPromise diff --git a/packages/agentry/tests/openai-provider.test.tsx b/packages/agentry/tests/openai-provider.test.tsx index 8d739ba..cc41bc8 100644 --- a/packages/agentry/tests/openai-provider.test.tsx +++ b/packages/agentry/tests/openai-provider.test.tsx @@ -1689,3 +1689,144 @@ test('non-streaming: thinking events fire with reasoning block', async () => { const thinkingEvent = events.find((e) => e.type === 'thinking')! expect(thinkingEvent.text).toBe('Thinking deeply...') }) + +// --- Test A: malformed function_call items in parseOpenAIResponse --- + +test('non-streaming: function_call with no call_id or id throws', async () => { + const { client } = createOpenAIMockClient([ + { + output: [{ type: 'function_call', name: 'my_tool', arguments: '{}' }], + }, + ]) + + await expect( + run( + + Hello + , + { providers: { openai: { client } } }, + ), + ).rejects.toThrow(/function_call with no call_id or id/) +}) + +test('non-streaming: function_call with no name throws', async () => { + const { client } = createOpenAIMockClient([ + { + output: [{ type: 'function_call', call_id: 'call_1', arguments: '{}' }], + }, + ]) + + await expect( + run( + + Hello + , + { providers: { openai: { client } } }, + ), + ).rejects.toThrow(/function_call with no name/) +}) + +test('non-streaming: function_call with non-object arguments throws', async () => { + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'function_call', + call_id: 'call_1', + name: 'my_tool', + arguments: 'null', + }, + ], + }, + ]) + + await expect( + run( + + Hello + , + { providers: { openai: { client } } }, + ), + ).rejects.toThrow(/expected object arguments/) +}) + +// --- Test B: response.status === 'incomplete' stop reason --- + +test('non-streaming: incomplete response with reason maps to stopReason length', async () => { + const { client } = createOpenAIMockClient([ + { + type: 'incomplete', + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Partial answer' }], + }, + ], + incomplete_details: { reason: 'length' }, + }, + ]) + + const result = await run( + + Tell me a lot + , + { providers: { openai: { client } } }, + ) + + expect(result.stopReason).toBe('length') + expect(result.content).toBe('Partial answer') +}) + +test('non-streaming: incomplete response without incomplete_details falls back to length', async () => { + const { client } = createOpenAIMockClient([ + { + type: 'incomplete', + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Cut off' }], + }, + ], + }, + ]) + + const result = await run( + + Tell me a lot + , + { providers: { openai: { client } } }, + ) + + expect(result.stopReason).toBe('length') +}) + +// --- Test C: WS error event handler via emitError --- + +test('WS: emitError propagates WebSocket-level error from a turn', async () => { + let wsMock: MockResponsesWS | null = null + const { client } = createOpenAIMockClient([]) + + const adapter = createOpenAIAdapter({ + websocket: true, + _responsesWSFactory: () => { + // Provide an empty batch so send() doesn't throw; the error comes via emitError + wsMock = new MockResponsesWS([[]]) + return wsMock + }, + }) + + const request = makeTurnRequest([ + { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, + ]) + + const wsError = new Error('WebSocket connection reset') + + // Start the turn, then emit the WS error after send() has been called + const turnPromise = adapter.createTurn(client, request) + // Yield twice: once for send() scheduling, once for the empty batch emission + await Promise.resolve() + await Promise.resolve() + wsMock!.emitError(wsError) + + await expect(turnPromise).rejects.toThrow('WebSocket connection reset') +}) diff --git a/packages/agentry/tests/utils/index.ts b/packages/agentry/tests/utils/index.ts index 4d1f832..96eef8a 100644 --- a/packages/agentry/tests/utils/index.ts +++ b/packages/agentry/tests/utils/index.ts @@ -10,4 +10,5 @@ export { createOpenAIMockClient, type OpenAIMockResponse, type OpenAIMockFailedResponse, + type OpenAIMockIncompleteResponse, } from './openaiMock' diff --git a/packages/agentry/tests/utils/openaiMock.ts b/packages/agentry/tests/utils/openaiMock.ts index f8650bb..c9706c6 100644 --- a/packages/agentry/tests/utils/openaiMock.ts +++ b/packages/agentry/tests/utils/openaiMock.ts @@ -5,6 +5,13 @@ export interface OpenAIMockFailedResponse { message: string } +export interface OpenAIMockIncompleteResponse { + type: 'incomplete' + output: Array> + incomplete_details?: { reason: string } | null + usage?: { input_tokens?: number; output_tokens?: number } +} + interface OpenAIMockSuccessResponse { output: Array> usage?: { input_tokens?: number; output_tokens?: number } @@ -13,6 +20,7 @@ interface OpenAIMockSuccessResponse { export type OpenAIMockResponse = | OpenAIMockSuccessResponse | OpenAIMockFailedResponse + | OpenAIMockIncompleteResponse function isFailedResponse( resp: OpenAIMockResponse, @@ -20,6 +28,15 @@ function isFailedResponse( return 'type' in resp && (resp as OpenAIMockFailedResponse).type === 'failed' } +function isIncompleteResponse( + resp: OpenAIMockResponse, +): resp is OpenAIMockIncompleteResponse { + return ( + 'type' in resp && + (resp as OpenAIMockIncompleteResponse).type === 'incomplete' + ) +} + export function createOpenAIMockClient(responses: OpenAIMockResponse[]): { client: OpenAI calls: Array> @@ -80,14 +97,18 @@ export function createOpenAIMockClient(responses: OpenAIMockResponse[]): { yield { type: 'response.output_item.done', output_index: i, item } } - // Emit the final response.completed event + // Emit the final response.completed or response.incomplete event + const isIncomplete = isIncompleteResponse(resp) yield { - type: 'response.completed', + type: isIncomplete ? 'response.incomplete' : 'response.completed', sequence_number: resp.output.length, response: { id: `resp_${callIndex}`, output: resp.output, - status: 'completed', + status: isIncomplete ? 'incomplete' : 'completed', + ...(isIncomplete && resp.incomplete_details !== undefined + ? { incomplete_details: resp.incomplete_details } + : {}), usage: { input_tokens: resp.usage?.input_tokens ?? 100, output_tokens: resp.usage?.output_tokens ?? 50, @@ -117,6 +138,21 @@ export function createOpenAIMockClient(responses: OpenAIMockResponse[]): { throw new Error(`OpenAI response failed: ${next.message}`) } + if (isIncompleteResponse(next)) { + return { + id: `resp_${callIndex}`, + output: next.output, + status: 'incomplete', + ...(next.incomplete_details !== undefined + ? { incomplete_details: next.incomplete_details } + : {}), + usage: { + input_tokens: next.usage?.input_tokens ?? 100, + output_tokens: next.usage?.output_tokens ?? 50, + }, + } + } + return { id: `resp_${callIndex}`, output: next.output, From 9f91c4ae0464e12f8fecd24352b86f52cd5a9ba3 Mon Sep 17 00:00:00 2001 From: colinds <90475914+colinds@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:41:35 -0800 Subject: [PATCH 39/39] refactor: enhance Condition and Agent components with provider/model overrides and improve type handling --- README.md | 10 +++-- packages/agentry/src/components/Agent.tsx | 18 ++++----- packages/agentry/src/components/Condition.tsx | 37 ++++++++++++++--- .../agentry/src/components/jsx-elements.d.ts | 7 +++- .../agentry/src/execution/ExecutionEngine.ts | 40 +++++++++++++------ packages/agentry/src/execution/conditions.ts | 27 ++++++++++--- .../src/handles/AbstractAgentHandle.ts | 6 ++- .../agentry/src/instances/createInstance.ts | 24 +++++++---- packages/agentry/src/instances/types.ts | 30 +++++++------- packages/agentry/src/providers/anthropic.ts | 25 +++--------- packages/agentry/src/providers/openai.ts | 4 +- packages/agentry/src/reconciler/collectors.ts | 6 +-- packages/agentry/src/reconciler/reconciler.ts | 17 +++++++- packages/agentry/src/types/agent.ts | 29 +++++++++----- packages/agentry/src/types/index.ts | 1 + packages/agentry/src/types/messages.ts | 2 +- .../agentry/tests/openai-provider.test.tsx | 1 + packages/examples/src/tweet.tsx | 25 ++---------- 18 files changed, 189 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index d81b654..4ed7666 100644 --- a/README.md +++ b/README.md @@ -573,10 +573,12 @@ Built-ins are provider-owned exports: #### `` -| Prop | Type | Description | -| ---------- | ------------------- | ------------------------------------------------------ | -| `when` | `boolean \| string` | Condition (boolean or NL description evaluated by LLM) | -| `children` | `ReactNode` | Content to render when condition is true | +| Prop | Type | Description | +| ----------- | ------------------------------- | --------------------------------------------------------------------------------------------- | +| `when` | `boolean \| string` | Condition (boolean or NL description evaluated by LLM) | +| `provider?` | `'anthropic' \| 'openai'` | Override provider for NL evaluation (first NL condition's override applies to the batch) | +| `model?` | `AnthropicModel \| OpenAIModel` | Override model for NL evaluation (defaults to `claude-haiku-4-5` / `gpt-4.1-mini` if not set) | +| `children` | `ReactNode` | Content to render when condition is true | #### `` diff --git a/packages/agentry/src/components/Agent.tsx b/packages/agentry/src/components/Agent.tsx index 14d19cb..bd3db07 100644 --- a/packages/agentry/src/components/Agent.tsx +++ b/packages/agentry/src/components/Agent.tsx @@ -1,8 +1,7 @@ import type { ReactNode } from 'react' import type { BaseAgentProps, - AnthropicModel, - OpenAIModel, + ProviderModelOverride, ThinkingConfig, AnthropicThinkingEnabled, OpenAIThinkingEnabled, @@ -12,19 +11,18 @@ import type { * `provider` and `model` must be specified together or not at all. * - Root agents: specify both (`provider="anthropic" model="claude-haiku-4-5"`) * - Subagents inside AgentTool: can omit both (inherit from parent) + * + * Extends `ProviderModelOverride` but makes `model` required when `provider` + * is specified (root agents must provide a model). */ type PublicProviderVariant = - | { - provider: 'anthropic' - model: AnthropicModel + | (Required> & { thinking?: AnthropicThinkingEnabled | { type: 'disabled' } - } - | { - provider: 'openai' - model: OpenAIModel + }) + | (Required> & { thinking?: OpenAIThinkingEnabled | { type: 'disabled' } websocket?: boolean - } + }) | { provider?: undefined; model?: undefined; thinking?: ThinkingConfig } export type AgentComponentPublicProps = BaseAgentProps & diff --git a/packages/agentry/src/components/Condition.tsx b/packages/agentry/src/components/Condition.tsx index f497cf0..a58f9f9 100644 --- a/packages/agentry/src/components/Condition.tsx +++ b/packages/agentry/src/components/Condition.tsx @@ -1,9 +1,20 @@ import type { ReactNode } from 'react' +import type { ProviderModelOverride } from '../types/agent' -export interface ConditionProps { - when: boolean | string - children?: ReactNode -} +/** + * Props for the Condition component. + * + * Boolean conditions (`when={boolean}`) don't accept model/provider overrides. + * NL conditions (`when="..."`) extend `ProviderModelOverride` for typed provider/model + * overrides used during LLM evaluation. The first NL condition's override applies to + * the entire batch. + */ +export type ConditionProps = + | ({ when: boolean; children?: ReactNode } & { + provider?: undefined + model?: undefined + }) + | ({ when: string; children?: ReactNode } & ProviderModelOverride) /** * Condition component - conditionally renders children based on a boolean or natural language condition @@ -33,6 +44,13 @@ export interface ConditionProps { * * ``` * + * @example NL condition with cheap model override + * ```tsx + * + * + * + * ``` + * * @example Nested conditions * ```tsx * @@ -42,6 +60,13 @@ export interface ConditionProps { * * ``` */ -export function Condition({ when, children }: ConditionProps): ReactNode { - return {children} +export function Condition(props: ConditionProps): ReactNode { + if (typeof props.when === 'boolean') { + return {props.children} + } + return ( + + {props.children} + + ) } diff --git a/packages/agentry/src/components/jsx-elements.d.ts b/packages/agentry/src/components/jsx-elements.d.ts index 7500afb..95ff235 100644 --- a/packages/agentry/src/components/jsx-elements.d.ts +++ b/packages/agentry/src/components/jsx-elements.d.ts @@ -5,7 +5,7 @@ import type { AgentComponentPublicProps } from './Agent.tsx' import type { MCPServerConfig } from '../instances/types' import type { AgentContentBlock } from '../types/messages' import type { ProviderName } from '../types/provider' -import type { Model } from '../types/agent' +import type { Model, ProviderModelOverride } from '../types/agent' export interface AgentryElements { // Internal reconciler element — both fields are optional so the reconciler can @@ -44,7 +44,10 @@ export interface AgentryElements { key?: string } - condition: { when: boolean | string; children?: ReactNode } + condition: { + when: boolean | string + children?: ReactNode + } & ProviderModelOverride } declare module 'react' { diff --git a/packages/agentry/src/execution/ExecutionEngine.ts b/packages/agentry/src/execution/ExecutionEngine.ts index c8f3230..9eb15fc 100644 --- a/packages/agentry/src/execution/ExecutionEngine.ts +++ b/packages/agentry/src/execution/ExecutionEngine.ts @@ -202,32 +202,42 @@ export class ExecutionEngine extends EventEmitter { private async evaluateAllConditions( signal?: AbortSignal, - options?: { evaluateNL?: boolean }, - ): Promise { + options?: { evaluateNL?: boolean; consecutiveFailures?: number }, + ): Promise<{ changed: boolean; consecutiveFailures: number }> { try { - return await evaluateConditions({ + const changed = await evaluateConditions({ root: this.agentInstance, messages: this.messages as AgentMessageParam[], clients: this.config.clients ?? {}, provider: this.config.provider, - model: this.config.model, signal, evaluateNL: options?.evaluateNL, }) + return { changed, consecutiveFailures: 0 } } catch (e) { const err = e instanceof Error ? e : new Error(String(e)) if (err.name === 'AbortError' || signal?.aborted) throw err + + const failures = (options?.consecutiveFailures ?? 0) + 1 + + if (failures >= 3) { + throw new Error( + `[agentry] NL condition evaluation failed ${failures} consecutive times (agent: ${this.config.agentName ?? 'unknown'}). Aborting to prevent silently stale conditions.`, + { cause: err }, + ) + } + debug( 'conditions', - `NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}), conditions may be stale due to evaluation failure`, + `NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}, consecutive failures: ${failures}), conditions may be stale due to evaluation failure`, { error: err.message }, ) console.error( - `[agentry] NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}), conditions may be stale due to evaluation failure:`, + `[agentry] NL condition evaluation failed (agent: ${this.config.agentName ?? 'unknown'}, iteration: ${this.iterationCount}, consecutive failures: ${failures}), conditions may be stale due to evaluation failure:`, err, ) this.emit('error', err) - return false + return { changed: false, consecutiveFailures: failures } } } @@ -253,6 +263,8 @@ export class ExecutionEngine extends EventEmitter { this.iterationCount = 0 try { + let conditionEvalFailures = 0 + while (!this.aborted) { if ( this.config.maxIterations !== undefined && @@ -269,11 +281,15 @@ export class ExecutionEngine extends EventEmitter { }) const isFirstIteration = this.iterationCount === 1 - const conditionsChanged = await this.evaluateAllConditions( + const conditionResult = await this.evaluateAllConditions( abortController.signal, - { evaluateNL: isFirstIteration }, // only evaluate NL conditions from the user's history + { + evaluateNL: isFirstIteration, + consecutiveFailures: conditionEvalFailures, + }, ) - if (conditionsChanged) { + conditionEvalFailures = conditionResult.consecutiveFailures + if (conditionResult.changed) { this.recollectAll() } @@ -498,7 +514,7 @@ export class ExecutionEngine extends EventEmitter { type: 'tool_result', tool_use_id: toolCall.id, content: result, - is_error: isError || undefined, + is_error: isError, } } @@ -522,7 +538,7 @@ export class ExecutionEngine extends EventEmitter { toolCallId: tr.tool_use_id, toolName: toolUse?.name ?? 'unknown', result: tr.content, - isError: tr.is_error ?? false, + isError: tr.is_error, executionTime: this.toolExecutionTimes.get(tr.tool_use_id), } }) diff --git a/packages/agentry/src/execution/conditions.ts b/packages/agentry/src/execution/conditions.ts index db4bcb3..4268a42 100644 --- a/packages/agentry/src/execution/conditions.ts +++ b/packages/agentry/src/execution/conditions.ts @@ -10,6 +10,11 @@ import type { JsonObject } from '../types/json' import { toOpenAIInput } from '../providers/openai' import { toAnthropicMessage } from '../providers/anthropic' +const CONDITION_DEFAULT_MODELS: Record = { + anthropic: 'claude-haiku-4-5', + openai: 'gpt-4.1-mini', +} + function buildNLConditionSystemPrompt(conditionDescriptions: string): string { return `You are a condition evaluation assistant. Given a conversation, determine which conditions are true. Multiple conditions can be true simultaneously. @@ -49,7 +54,6 @@ export async function evaluateConditions({ messages, clients, provider, - model, signal, evaluateNL, }: { @@ -57,7 +61,6 @@ export async function evaluateConditions({ messages: AgentMessageParam[] clients: Partial provider: ProviderName - model: string signal?: AbortSignal evaluateNL?: boolean }): Promise { @@ -89,12 +92,18 @@ export async function evaluateConditions({ const nlConditions = conditions.filter((c) => typeof c.when === 'string') if (nlConditions.length > 0 && evaluateNL !== false) { + // Resolve provider/model for NL evaluation: first condition's override → cheap default + const firstNL = nlConditions[0]! + const resolvedProvider = firstNL.provider ?? provider + const resolvedModel = + firstNL.model ?? CONDITION_DEFAULT_MODELS[resolvedProvider] + const nlResults = await evaluateNaturalLanguageConditions({ conditions: nlConditions, messages, clients, - provider, - model, + provider: resolvedProvider, + model: resolvedModel, signal, }) @@ -223,7 +232,11 @@ async function evaluateNLWithAnthropic({ const validIndices = conditions.map((_, index) => index) - const evalMessages = ensureValidMessageStart(messages).map(toAnthropicMessage) + const trimmedMessages = ensureValidMessageStart(messages) + if (trimmedMessages.length === 0) { + return Array.from({ length: conditions.length }, () => false) + } + const evalMessages = trimmedMessages.map(toAnthropicMessage) const startTime = performance.now() const response = await client.beta.messages.create( @@ -308,6 +321,10 @@ async function evaluateNLWithOpenAI({ const validIndices = conditions.map((_, index) => index) const evalMessages = ensureValidMessageStart(messages) + if (evalMessages.length === 0) { + return Array.from({ length: conditions.length }, () => false) + } + const input = [ ...toOpenAIInput(evalMessages), { role: 'user' as const, content: 'Which conditions are true?' }, diff --git a/packages/agentry/src/handles/AbstractAgentHandle.ts b/packages/agentry/src/handles/AbstractAgentHandle.ts index 9c2984b..23fbad6 100644 --- a/packages/agentry/src/handles/AbstractAgentHandle.ts +++ b/packages/agentry/src/handles/AbstractAgentHandle.ts @@ -113,7 +113,11 @@ export abstract class AbstractAgentHandle extends EventEmitter { - await agent.props.onStepFinish?.(result) + try { + await agent.props.onStepFinish?.(result) + } catch (err) { + throw new Error('[agentry] onStepFinish callback threw', { cause: err }) + } } let onStateChange: ((state: AgentState) => void) | undefined diff --git a/packages/agentry/src/instances/createInstance.ts b/packages/agentry/src/instances/createInstance.ts index 0c11dad..72b0257 100644 --- a/packages/agentry/src/instances/createInstance.ts +++ b/packages/agentry/src/instances/createInstance.ts @@ -5,7 +5,7 @@ import type { SubagentInstance, AgentToolInstance, ToolInstance, - SdkToolInstance, + BuiltInToolInstance, SystemInstance, ContextInstance, MessageInstance, @@ -15,7 +15,7 @@ import type { AgentComponentProps, AgentToolComponentProps, ToolComponentProps, - SdkToolComponentProps, + BuiltInToolComponentProps, SystemComponentProps, ContextComponentProps, MessageComponentProps, @@ -70,7 +70,7 @@ export type ElementProps = | AgentComponentProps | AgentToolComponentProps | ToolComponentProps - | SdkToolComponentProps + | BuiltInToolComponentProps | SystemComponentProps | ContextComponentProps | MessageComponentProps @@ -93,7 +93,7 @@ export function createInstance( case InstanceType.Tool: return createToolInstance(props as ToolComponentProps) case InstanceType.BuiltInTool: - return createSdkToolInstance(props as SdkToolComponentProps) + return createBuiltInToolInstance(props as BuiltInToolComponentProps) case InstanceType.System: return createSystemInstance(props as SystemComponentProps) case InstanceType.Context: @@ -190,7 +190,9 @@ function createAgentToolInstance( } } -function createSdkToolInstance(props: SdkToolComponentProps): SdkToolInstance { +function createBuiltInToolInstance( + props: BuiltInToolComponentProps, +): BuiltInToolInstance { return { type: InstanceType.BuiltInTool, tool: props.tool, @@ -280,6 +282,8 @@ function createConditionInstance( return { type: InstanceType.Condition, when: props.when, + model: props.model, + provider: props.provider, isActive: typeof props.when === 'boolean' ? props.when : false, children: [], parent: null, @@ -324,8 +328,14 @@ export function createSubagentInstance( stream: props.stream ?? inherited.stream ?? true, compactionControl: props.compactionControl ?? inherited.compactionControl, thinking: props.thinking ?? inherited.thinking, - betas: props.betas ?? inherited.betas, - websocket: props.websocket ?? inherited.websocket, + betas: + (props.provider ?? inherited.provider) === 'anthropic' + ? (props.betas ?? inherited.betas) + : undefined, + websocket: + (props.provider ?? inherited.provider) === 'openai' + ? (props.websocket ?? inherited.websocket) + : undefined, // callbacks never inherited onMessage: props.onMessage, onComplete: props.onComplete, diff --git a/packages/agentry/src/instances/types.ts b/packages/agentry/src/instances/types.ts index 6a04027..8bc0551 100644 --- a/packages/agentry/src/instances/types.ts +++ b/packages/agentry/src/instances/types.ts @@ -11,6 +11,7 @@ import type { ExecutionEngine } from '../execution' import type { AgentStore } from '../store' import type { z } from 'zod' import type { ProviderClientMap, MCPServerConfig } from '../providers/types' +import type { ProviderModelOverride } from '../types/agent' export type { MCPServerConfig } @@ -51,7 +52,7 @@ export interface ToolInstance extends BaseInstance { tool: InternalTool } -export interface SdkToolInstance extends BaseInstance { +export interface BuiltInToolInstance extends BaseInstance { type: InstanceType.BuiltInTool tool: BuiltInTool } @@ -105,13 +106,14 @@ export interface AgentToolInstance extends BaseInstance { agent: AgentToolFunction } -export interface ConditionInstance extends BaseInstance { - type: InstanceType.Condition - parent: Instance | null - when: boolean | string - isActive: boolean - children: Instance[] -} +export type ConditionInstance = BaseInstance & + ProviderModelOverride & { + type: InstanceType.Condition + parent: Instance | null + when: boolean | string + isActive: boolean + children: Instance[] + } export type AgentLike = AgentInstance | SubagentInstance @@ -120,7 +122,7 @@ export type Instance = | SubagentInstance | AgentToolInstance | ToolInstance - | SdkToolInstance + | BuiltInToolInstance | SystemInstance | ContextInstance | MessageInstance @@ -141,7 +143,7 @@ export interface AgentToolComponentProps { agentTool: InternalAgentTool } -export interface SdkToolComponentProps { +export interface BuiltInToolComponentProps { tool: BuiltInTool } @@ -172,10 +174,10 @@ export interface ToolsContainerProps { children?: React.ReactNode } -export interface ConditionComponentProps { +export type ConditionComponentProps = { when: boolean | string children?: React.ReactNode -} +} & ProviderModelOverride export function isAgentInstance(instance: Instance): instance is AgentInstance { return instance.type === InstanceType.Agent @@ -185,9 +187,9 @@ export function isToolInstance(instance: Instance): instance is ToolInstance { return instance.type === InstanceType.Tool } -export function isSdkToolInstance( +export function isBuiltInToolInstance( instance: Instance, -): instance is SdkToolInstance { +): instance is BuiltInToolInstance { return instance.type === InstanceType.BuiltInTool } diff --git a/packages/agentry/src/providers/anthropic.ts b/packages/agentry/src/providers/anthropic.ts index 4d097e9..7682358 100644 --- a/packages/agentry/src/providers/anthropic.ts +++ b/packages/agentry/src/providers/anthropic.ts @@ -110,7 +110,7 @@ function toAgentBlocks(content: BetaContentBlock[]): AgentContentBlock[] { return blocks } -function toApiSdkTool(tool: BuiltInTool): BetaToolUnion { +function toApiBuiltInTool(tool: BuiltInTool): BetaToolUnion { if (isCodeExecutionTool(tool)) { return { type: 'code_execution_20250825', name: 'code_execution' } } @@ -147,7 +147,7 @@ export const anthropicAdapter: ProviderAdapter<'anthropic'> = { ): Promise { const tools: BetaToolUnion[] = [ ...request.tools.map(toApiTool), - ...request.builtInTools.map(toApiSdkTool), + ...request.builtInTools.map(toApiBuiltInTool), ...request.mcpServers.map((server) => ({ type: 'mcp_toolset' as const, mcp_server_name: server.name, @@ -221,29 +221,16 @@ export const anthropicAdapter: ProviderAdapter<'anthropic'> = { { ...params, stream: false }, { signal: request.signal }, ) + } - const blocks = toAgentBlocks(response.content) + const blocks = toAgentBlocks(response.content) + if (!request.stream) { emitSyntheticEvents(blocks, response.stop_reason, request.onStream) - - return { - message: { - content: blocks, - stop_reason: response.stop_reason, - usage: { - input_tokens: response.usage.input_tokens, - output_tokens: response.usage.output_tokens, - cache_creation_input_tokens: - response.usage.cache_creation_input_tokens ?? null, - cache_read_input_tokens: - response.usage.cache_read_input_tokens ?? null, - }, - }, - } } return { message: { - content: toAgentBlocks(response.content), + content: blocks, stop_reason: response.stop_reason, usage: { input_tokens: response.usage.input_tokens, diff --git a/packages/agentry/src/providers/openai.ts b/packages/agentry/src/providers/openai.ts index 2883d38..2cb98a2 100644 --- a/packages/agentry/src/providers/openai.ts +++ b/packages/agentry/src/providers/openai.ts @@ -71,10 +71,10 @@ function stringifyContent( function toProviderOutputEventItem( item: OpenAIResponseResult['output'][number], -): { itemType: string; item: Record } { +): { itemType: string; item: JsonObject } { return { itemType: item.type, - item: item as unknown as Record, + item: item as unknown as JsonObject, } } diff --git a/packages/agentry/src/reconciler/collectors.ts b/packages/agentry/src/reconciler/collectors.ts index 8672bea..c1b0ea6 100644 --- a/packages/agentry/src/reconciler/collectors.ts +++ b/packages/agentry/src/reconciler/collectors.ts @@ -1,7 +1,7 @@ import type { Instance, AgentInstance } from '../instances' import { isToolInstance, - isSdkToolInstance, + isBuiltInToolInstance, isSystemInstance, isContextInstance, isMessageInstance, @@ -41,7 +41,7 @@ export function collectChild(agent: AgentInstance, child: Instance): void { } else if (isMessageInstance(child)) { // Write directly to store instead of agent.messages agent.store.getState().actions.pushMessage(child.message) - } else if (isSdkToolInstance(child)) { + } else if (isBuiltInToolInstance(child)) { agent.builtInTools.push(child.tool) debug('reconciler', `SDK tool added: ${child.tool.name}`) } else if (isMCPServerInstance(child)) { @@ -98,7 +98,7 @@ export function uncollectChild(agent: AgentInstance, child: Instance): void { } else if (isMessageInstance(child)) { // Remove from store agent.store.getState().actions.removeMessage(child.message) - } else if (isSdkToolInstance(child)) { + } else if (isBuiltInToolInstance(child)) { const index = agent.builtInTools.findIndex((t) => t === child.tool) if (index >= 0) { agent.builtInTools.splice(index, 1) diff --git a/packages/agentry/src/reconciler/reconciler.ts b/packages/agentry/src/reconciler/reconciler.ts index 8470045..8600aa1 100644 --- a/packages/agentry/src/reconciler/reconciler.ts +++ b/packages/agentry/src/reconciler/reconciler.ts @@ -14,7 +14,12 @@ import { isConditionInstance, } from '../instances' import { createInstance, InstanceType, type ElementProps } from '../instances' -import type { AgentProps, CompactionControl, Model } from '../types' +import type { + AgentProps, + CompactionControl, + Model, + ProviderModelOverride, +} from '../types' import { debug } from '../debug' import { diffProps, disposeOnIdle } from './utils' import { collectChild, uncollectChild } from './collectors' @@ -422,9 +427,17 @@ function applyUpdate( } } } else if (isConditionInstance(instance)) { - const payload = updatePayload as { when?: boolean | string } + const payload = updatePayload as Partial< + { when: boolean | string } & ProviderModelOverride + > if (payload.when !== undefined) { instance.when = payload.when } + if (payload.model !== undefined) { + instance.model = payload.model + } + if (payload.provider !== undefined) { + instance.provider = payload.provider + } } } diff --git a/packages/agentry/src/types/agent.ts b/packages/agentry/src/types/agent.ts index 7d89a72..85a6ce6 100644 --- a/packages/agentry/src/types/agent.ts +++ b/packages/agentry/src/types/agent.ts @@ -1,5 +1,6 @@ import type { OnStepFinishResult } from './lifecycle' import type { AgentMessageParam } from './messages' +import type { JsonObject } from './json' import type { Model as _AnthropicModel } from '@anthropic-ai/sdk/resources/messages' import type { Reasoning, @@ -43,20 +44,28 @@ export interface BaseAgentProps { compactionControl?: CompactionControl } +/** + * Base discriminated union for provider + model selection. + * Narrows `model` to the correct SDK type based on `provider`. + * Extended by `ProviderVariant` (Agent) and used directly by `Condition`. + */ +export type ProviderModelOverride = + | { provider: 'anthropic'; model?: AnthropicModel } + | { provider: 'openai'; model?: OpenAIModel } + | { provider?: undefined; model?: Model } + export type ProviderVariant = - | { - provider: 'anthropic' - model?: AnthropicModel + | (Extract & { thinking?: AnthropicThinkingEnabled | { type: 'disabled' } betas?: string[] - } - | { - provider: 'openai' - model?: OpenAIModel + }) + | (Extract & { thinking?: OpenAIThinkingEnabled | { type: 'disabled' } websocket?: boolean - } - | { provider?: undefined; model?: Model; thinking?: ThinkingConfig } + }) + | (Extract & { + thinking?: ThinkingConfig + }) export type AgentProps = BaseAgentProps & ProviderVariant @@ -74,7 +83,7 @@ export type AgentStreamEvent = | { type: 'provider_event' itemType: string - item: Record + item: JsonObject } | { type: 'thinking'; text: string } | { type: 'message_complete'; stopReason: string | null } diff --git a/packages/agentry/src/types/index.ts b/packages/agentry/src/types/index.ts index e1d75c3..914fe51 100644 --- a/packages/agentry/src/types/index.ts +++ b/packages/agentry/src/types/index.ts @@ -47,6 +47,7 @@ export { type AnthropicThinkingEnabled, type OpenAIThinkingEnabled, type CompactionControl, + type ProviderModelOverride, type AgentStreamEvent, type AgentResult, } from './agent' diff --git a/packages/agentry/src/types/messages.ts b/packages/agentry/src/types/messages.ts index 505c8e6..52c6ad6 100644 --- a/packages/agentry/src/types/messages.ts +++ b/packages/agentry/src/types/messages.ts @@ -23,7 +23,7 @@ export interface ToolResultContentBlock { type: 'tool_result' tool_use_id: string content: string | TextContentArray - is_error?: boolean + is_error: boolean } export type AgentContentBlock = diff --git a/packages/agentry/tests/openai-provider.test.tsx b/packages/agentry/tests/openai-provider.test.tsx index cc41bc8..d275679 100644 --- a/packages/agentry/tests/openai-provider.test.tsx +++ b/packages/agentry/tests/openai-provider.test.tsx @@ -707,6 +707,7 @@ test('toOpenAIInput: tool_result without is_error has no [ERROR] prefix', () => type: 'tool_result', tool_use_id: 'call_y', content: 'success result', + is_error: false, }, ], }, diff --git a/packages/examples/src/tweet.tsx b/packages/examples/src/tweet.tsx index 880817a..bfc17dc 100644 --- a/packages/examples/src/tweet.tsx +++ b/packages/examples/src/tweet.tsx @@ -6,13 +6,7 @@ import OpenAI from 'openai' function Researcher() { return ( - { - console.log('top-level', event) - }} - > + You are a research assistant. ( - { - console.log('sub-agent search', JSON.stringify(event, null, 2)) - }} - > + @@ -40,13 +27,7 @@ function Researcher() { description="Run Python to analyze data" parameters={z.object({ task: z.string() })} agent={({ task }) => ( - { - console.log('sub-agent compute', JSON.stringify(event, null, 2)) - }} - > +