diff --git a/.claude/hooks/format.sh b/.claude/hooks/format.sh new file mode 100755 index 0000000..073ddfb --- /dev/null +++ b/.claude/hooks/format.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -e + +input=$(cat) +file_path=$(echo "$input" | jq -r '.tool_input.file_path') + +if [[ -z "$file_path" || ! -f "$file_path" ]]; then + exit 0 +fi + +if [[ "$file_path" =~ \.(js|jsx|ts|tsx)$ ]]; then + bunx oxlint --fix "$file_path" 2>/dev/null || true + bunx oxfmt --write "$file_path" 2>/dev/null || true +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..82f1c2b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "bash .claude/hooks/format.sh", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 0000000..65bba1e --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [{ "command": "./.cursor/hooks/format.sh" }] + } +} diff --git a/.cursor/hooks/format.sh b/.cursor/hooks/format.sh new file mode 100755 index 0000000..748a9ef --- /dev/null +++ b/.cursor/hooks/format.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -e + +input=$(cat) +file_path=$(echo "$input" | jq -r '.file_path') + +if [[ -z "$file_path" || ! -f "$file_path" ]]; then + exit 0 +fi + +if [[ "$file_path" =~ \.(js|jsx|ts|tsx)$ ]]; then + bunx oxlint --fix "$file_path" 2>/dev/null || true + bunx oxfmt --write "$file_path" 2>/dev/null || true +fi + +exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6bcc3d..8b8449c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,8 +57,13 @@ jobs: run: bun run test - name: Update npm run: npm install -g npm@latest + - name: Pack + run: bun pm pack + working-directory: packages/agentry - name: Publish to npm - run: npm publish --provenance --access public + run: | + VERSION=$(jq -r '.version' package.json) + npm publish "agentry-${VERSION}.tgz" --provenance --access public working-directory: packages/agentry - name: Create GitHub release uses: softprops/action-gh-release@v2 diff --git a/.oxlintrc.json b/.oxlintrc.json index b99210f..7a78f10 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -2,7 +2,11 @@ "$schema": "./node_modules/oxlint/configuration_schema.json", "plugins": null, "categories": {}, - "rules": {}, + "rules": { + "no-unused-vars": "error", + "unicorn/no-nested-ternary": "error", + "max-params": "error" + }, "settings": { "jsx-a11y": { "polymorphicPropName": null, diff --git a/CLAUDE.md b/CLAUDE.md index 29679de..6dd43fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,235 +2,85 @@ ## 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 +- `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 -## TypeScript Configuration +## Current Architecture Notes -- 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) +### Providers and Exports -## Important Implementation Notes +- 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. -**Tool Definition** +### SDK Type Reuse -- 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) +- 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. -**Instance Tree Mutations** +### Subagents -- 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 +- `` supports declarative subagents. +- `context.runAgent(...)` supports programmatic subagent spawning. +- Cross-provider subagents are supported (for example, OpenAI parent + Anthropic child, and vice versa). -**Agent State Management** +### Conditions -- AgentStore (Zustand) manages global agent state -- ExecutionEngine emits state change events -- Hooks subscribe to store updates for reactive behavior +- 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. -**Error Handling** +## Workflow -- 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 +- After finishing a set of changes, always run `bun test`, `bun run typecheck`, `bun run lint`, and `bun run format` before considering the work done. -**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..4ed7666 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`: @@ -51,11 +49,12 @@ Next, in your `tsconfig.json`: In `agent.tsx`: ```tsx +import Anthropic from '@anthropic-ai/sdk' import { run, Agent, System, Tools, Tool, Message } from 'agentry' import { z } from 'zod' const result = await run( - + You are a helpful math assistant What is 42 + 17? , + { + providers: { anthropic: { client: new Anthropic() } }, + }, ) console.log(result.content) @@ -97,39 +99,84 @@ 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 - **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** - ``, ``, ``, `` + +## 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). + +### Reusable instance + +```tsx +import Anthropic from '@anthropic-ai/sdk' +import { createAI, Agent, Message, Tools } from 'agentry' +import { WebSearch } from 'agentry/anthropic' + +const ai = createAI({ + providers: { anthropic: { client: new Anthropic() } }, +}) + +const result = await ai.run( + + + + + Find the latest React release notes + , +) +``` ## Examples 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 | -| [`cache-ephemeral.tsx`](packages/examples/src/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 | -| [`workflow.tsx`](packages/examples/src/workflow.tsx) | Interactive authentication workflow | -| [`conversation-persistence.tsx`](packages/examples/src/conversation-persistence.tsx) | Conversation save/load | +| 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 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 +186,18 @@ bun run example:basic **Batch mode** (default) - Runs to completion: ```tsx -const result = await run(...) +const result = await run(..., { + providers: { anthropic: { client: new Anthropic() } }, +}) ``` **Interactive mode** - Returns a handle for ongoing interaction: ```tsx -const agent = await run(..., { mode: 'interactive' }) +const agent = await run(..., { + mode: 'interactive', + providers: { anthropic: { client: new Anthropic() } }, +}) await agent.sendMessage('Hello') for await (const event of agent.stream('Tell me more')) { if (event.type === 'text') process.stdout.write(event.text) @@ -158,7 +210,7 @@ agent.close() Create subagents using `` with type-safe parameters: ```tsx - + + { }} ``` +### Cross-provider subagents + +`` and `context.runAgent(...)` can run subagents on a different provider than the parent: + +```tsx +import Anthropic from '@anthropic-ai/sdk' +import OpenAI from 'openai' +import { createAI, Agent, AgentTool, Message, Tools } from 'agentry' +import { z } from 'zod' + +const ai = createAI({ + providers: { + openai: { client: new OpenAI() }, + anthropic: { client: new Anthropic() }, + }, +}) + +await ai.run( + + + ( + + Research: {topic} + + )} + /> + + Use claude_researcher for React 19 updates. + , +) +``` + ### State-Driven Tools Tools can be added/removed during execution using React state: @@ -235,7 +323,7 @@ Tools can be added/removed during execution using React state: function DynamicAgent() { const [hasAdvanced, setHasAdvanced] = useState(false) return ( - + You are a helpful assistant that can analyze technical and business content. You can unlock advanced analysis tools by calling the unlock_advanced tool. @@ -269,7 +357,7 @@ function AuthAgent() { const [isPremium, setIsPremium] = useState(false) return ( - + {/* Boolean condition */} Please authenticate first @@ -320,7 +408,7 @@ Conditions are evaluated before each API call: Use `cache="ephemeral"` on `` or `` components to mark dynamic content that shouldn't be cached. ```tsx - + {/* Stable instructions - will be cached */} You are a helpful assistant. Always be concise and accurate. @@ -340,6 +428,7 @@ For long-running conversations, you can enable automatic message compaction to m ```tsx ...) +const result: AgentResult = await run(..., { + providers: { anthropic: { client: new Anthropic() } }, +}) // Interactive mode -const handle: AgentHandle = await run(..., { +const handle: AgentHandle = await run(..., { mode: 'interactive', + providers: { anthropic: { client: new Anthropic() } }, }) ``` **Options:** - `mode?: 'batch' | 'interactive'` - Execution mode (default: `'batch'`) -- `client?: Anthropic` - Custom Anthropic client +- `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 + +### `createAI(defaults)` + +Create a defaults-bound runner so you can use `ai.run(...)` and `ai.createAgent(...)`. + +```tsx +import OpenAI from 'openai' +import { createAI, Agent, Message } from 'agentry' + +const ai = createAI({ + providers: { openai: { client: new OpenAI() } }, +}) + +const result = await ai.run( + + Hello + , +) +``` ### Components +Built-ins are provider-owned exports: + +- `WebSearch`, `CodeExecution`, `MCP` from `agentry/anthropic` or `agentry/openai` +- `Memory` from `agentry/anthropic` + #### `` -| Prop | Type | Description | -| -------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `model` | `string` | Claude model (e.g. `claude-sonnet-4-5`) | -| `name?` | `string` | Agent identifier | -| `description?` | `string` | Agent description | -| `maxTokens?` | `number` | Max output tokens (default: `4096`) | -| `maxIterations?` | `number` | Max tool call iterations (default: `20`) | -| `stopSequences?` | `string[]` | Stop sequences | -| `temperature?` | `number` | Sampling temperature (0-1) | -| `stream?` | `boolean` | Enable streaming (default: `true`) | -| `betas?` | `string[]` | Additional Anthropic beta features to enable | -| `thinking?` | `ThinkingConfig` | Extended thinking config: `{ type: 'enabled', budget_tokens: number, interleaved?: boolean }`. When enabled, `interleaved` defaults to `true`. Set `interleaved: false` to disable interleaved thinking. | -| `compactionControl?` | `CompactionControl` | Context compaction settings (see below) | -| `onMessage?` | `(event: AgentStreamEvent) => void` | Stream event callback | -| `onComplete?` | `(result: AgentResult) => void` | Completion callback | -| `onError?` | `(error: Error) => void` | Error callback | -| `onStepFinish?` | `(result: OnStepFinishResult) => void` | Step completion callback | +| Prop | Type | Description | +| -------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provider?` | `'anthropic' \| 'openai'` | AI provider for this agent | +| `model` | `string` | Provider model id (e.g. `claude-sonnet-4-5`, `gpt-5-mini`) | +| `name?` | `string` | Agent identifier | +| `description?` | `string` | Agent description | +| `maxTokens?` | `number` | Max output tokens (default: `4096`) | +| `maxIterations?` | `number` | Max tool call iterations (default: `20`) | +| `stopSequences?` | `string[]` | Stop sequences | +| `temperature?` | `number` | Sampling temperature (0-1) | +| `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 | +| `onError?` | `(error: Error) => void` | Error callback | +| `onStepFinish?` | `(result: OnStepFinishResult) => void` | Step completion callback | **CompactionControl:** @@ -453,10 +573,12 @@ const handle: AgentHandle = await run(..., { #### `` -| 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 | #### `` @@ -493,11 +615,11 @@ No props. Enables sandboxed code execution. ### Hooks -| Hook | Returns | Description | -| --------------------- | -------------------- | ----------------------- | -| `useExecutionState()` | `AgentState` | Current execution state | -| `useMessages()` | `BetaMessageParam[]` | Conversation messages | -| `useAgentState()` | `AgentStoreState` | Full agent state | +| Hook | Returns | Description | +| --------------------- | --------------------- | ----------------------- | +| `useExecutionState()` | `AgentState` | Current execution state | +| `useMessages()` | `AgentMessageParam[]` | Conversation messages | +| `useAgentState()` | `AgentStoreState` | Full agent state | ### AgentHandle (Interactive Mode) @@ -509,7 +631,7 @@ No props. Enables sandboxed code execution. | `abort()` | `() => void` | Abort current execution | | `close()` | `() => void` | Clean up resources | | `state` | `AgentState` | Current execution state | -| `messages` | `BetaMessageParam[]` | Conversation history | +| `messages` | `AgentMessageParam[]` | Conversation history | | `isRunning` | `boolean` | Whether agent is processing | ### Utilities @@ -527,27 +649,31 @@ 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 | -| -------------- | ------------- | ----------------------- | -| `model?` | `string` | Override parent's model | -| `maxTokens?` | `number` | Override max tokens | -| `temperature?` | `number` | Override temperature | -| `signal?` | `AbortSignal` | Custom abort signal | +| 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 | +| `signal?` | `AbortSignal` | Custom abort signal | ## Requirements - 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..0bc5971 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,10 @@ "name": "agentry", "devDependencies": { "@types/bun": "1.3.9", - "@types/react": "^19.2.14", - "@typescript/native-preview": "^7.0.0-dev.20260214.1", - "oxfmt": "^0.32.0", - "oxlint": "^1.47.0", + "@types/react": "catalog:", + "@typescript/native-preview": "^7.0.0-dev.20260224.1", + "oxfmt": "^0.35.0", + "oxlint": "^1.50.0", "typescript": "^5", }, }, @@ -17,35 +17,48 @@ "name": "agentry", "version": "0.0.2", "dependencies": { - "@anthropic-ai/sdk": "^0.71.1", "eventemitter3": "^5.0.1", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "zustand": "^5.0.3", }, "devDependencies": { - "@types/react": "^19.0.0", + "@types/react": "catalog:", "@types/react-reconciler": "^0.32.3", "@types/scheduler": "^0.26.0", }, "peerDependencies": { + "@anthropic-ai/sdk": "^0.71.1", + "openai": "^6.23.0", "react": "^19.0.0", "zod": "^4.0.0", }, + "optionalPeers": [ + "@anthropic-ai/sdk", + "openai", + ], }, "packages/examples": { "name": "@agentry/examples", "version": "0.0.1", "dependencies": { - "@anthropic-ai/sdk": "^0.71.2", + "@anthropic-ai/sdk": "catalog:", "agentry": "workspace:*", - "zod": "^4.1.13", + "openai": "catalog:", + "zod": "catalog:", }, "peerDependencies": { "react": "^19.0.0", }, }, }, + "catalog": { + "@anthropic-ai/sdk": "^0.71.2", + "@types/react": "^19.2.14", + "openai": "^6.23.0", + "react": "^19.0.0", + "zod": "^4.1.13", + }, "packages": { "@agentry/examples": ["@agentry/examples@workspace:packages/examples"], @@ -53,81 +66,81 @@ "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.32.0", "", { "os": "android", "cpu": "arm" }, "sha512-DpVyuVzgLH6/MvuB/YD3vXO9CN/o9EdRpA0zXwe/tagP6yfVSFkFWkPqTROdqp0mlzLH5Yl+/m+hOrcM601EbA=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-w1cmNXf9zs0vKLuNgyUF3hZ9VUAS1hBmQGndYJv1OmcVqStBtRTRNxSWkWM0TMkrA9UbvIvM9gfN+ib4Wy6lkQ=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.35.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-m6wQojz/hn94XdZugFPtdFbOvXbOSYEqPsR2gyLyID3BvcrC2QsJyT1o3gb4BZEGtZrG1NiKVGwDRLM0dHd2mg=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.35.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hN966Uh6r3Erkg2MvRcrJWaB6QpBzP15rxWK/QtkUyD47eItJLsAQ2Hrm88zMIpFZ3COXZLuN3hqgSlUtvB0Xw=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.35.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-g5UZPGt8tJj263OfSiDGdS54HPa0KgFfspLVAUivVSdoOgsk6DkwVS9nO16xQTDztzBPGxTvrby8WuufF0g86Q=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.35.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-F4ZY83/PVQo9ZJhtzoMqbmjqEyTVEZjbaw4x1RhzdfUhddB41ZB2Vrt4eZi7b4a4TP85gjPRHgQBeO0c1jbtaw=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.35.0", "", { "os": "linux", "cpu": "arm" }, "sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-olR37eG16Lzdj9OBSvuoT5RxzgM5xfQEHm1OEjB3M7Wm4KWa5TDWIT13Aiy74GvAN77Hq1+kUKcGVJ/0ynf75g=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.35.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eZhk6AIjRCDeLoXYBhMW7qq/R1YyVi+tGnGfc3kp7AZQrMsFaWtP/bgdCJCTNXMpbMwymtVz0qhSQvR5w2sKcg=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.35.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UYiqO9MlipntFbdbUKOIo84vuyzrK4TVIs7Etat91WNMFSW54F6OnHq08xa5ZM+K9+cyYMgQPXvYCopuP+LyKw=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.35.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.32.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-IDH/fxMv+HmKsMtsjEbXqhScCKDIYp38sgGEcn0QKeXMxrda67PPZA7HMfoUwEtFUG+jsO1XJxTrQsL+kQ90xQ=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.35.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.32.0", "", { "os": "linux", "cpu": "none" }, "sha512-bQFGPDa0buYWJFeK2I7ah8wRZjrAgamaG2OAGv+Ua5UMYEnHxmHcv+r8lWUUrwP2oqQGvp1SB8JIVtBbYuAueQ=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.35.0", "", { "os": "linux", "cpu": "none" }, "sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.32.0", "", { "os": "linux", "cpu": "none" }, "sha512-3vFp9DW1ItEKWltADzCFqG5N7rYFToT4ztlhg8wALoo2E2VhveLD88uAF4FF9AxD9NhgHDGmPCV+WZl/Qlj8cQ=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.35.0", "", { "os": "linux", "cpu": "none" }, "sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.32.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Fub2y8S9ImuPzAzpbgkoz/EVTWFFBolxFZYCMRhRZc8cJZI2gl/NlZswqhvJd/U0Jopnwgm/OJ2x128vVzFFWA=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.35.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XufwsnV3BF81zO2ofZvhT4FFaMmLTzZEZnC9HpFz/quPeg9C948+kbLlZnsfjmp+1dUxKMCpfmRMqOfF4AOLsA=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.35.0", "", { "os": "linux", "cpu": "x64" }, "sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-u2f9tC2qYfikKmA2uGpnEJgManwmk0ZXWs5BB4ga4KDu2JNLdA3i634DGHeMLK9wY9+iRf3t7IYpgN3OVFrvDw=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.35.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.32.0", "", { "os": "none", "cpu": "arm64" }, "sha512-5ZXb1wrdbZ1YFXuNXNUCePLlmLDy4sUt4evvzD4Cgumbup5wJgS9PIe5BOaLywUg9f1wTH6lwltj3oT7dFpIGA=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.35.0", "", { "os": "none", "cpu": "arm64" }, "sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-IGSMm/Agq+IA0++aeAV/AGPfjcBdjrsajB5YpM3j7cMcwoYgUTi/k2YwAmsHH3ueZUE98pSM/Ise2J7HtyRjOA=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.35.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.32.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-H/9gsuqXmceWMsVoCPZhtJG2jLbnBeKr7xAXm2zuKpxLVF7/2n0eh7ocOLB6t+L1ARE76iORuUsRMnuGjj8FjQ=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.35.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-fF8VIOeligq+mA6KfKvWtFRXbf0EFy73TdR6ZnNejdJRM8VWN1e3QFhYgIwD7O8jBrQsd7EJbUpkAr/YlUOokg=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.35.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.47.0", "", { "os": "android", "cpu": "arm" }, "sha512-UHqo3te9K/fh29brCuQdHjN+kfpIi9cnTPABuD5S9wb9ykXYRGTOOMVuSV/CK43sOhU4wwb2nT1RVjcbrrQjFw=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.50.0", "", { "os": "android", "cpu": "arm" }, "sha512-G7MRGk/6NCe+L8ntonRdZP7IkBfEpiZ/he3buLK6JkLgMHgJShXZ+BeOwADmspXez7U7F7L1Anf4xLSkLHiGTg=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.47.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xh02lsTF1TAkR+SZrRMYHR/xCx8Wg2MAHxJNdHVpAKELh9/yE9h4LJeqAOBbIb3YYn8o/D97U9VmkvkfJfrHfw=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.50.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GeSuMoJWCVpovJi/e3xDSNgjeR8WEZ6MCXL6EtPiCIM2NTzv7LbflARINTXTJy2oFBYyvdf/l2PwHzYo6EdXvg=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.47.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OSOfNJqabOYbkyQDGT5pdoL+05qgyrmlQrvtCO58M4iKGEQ/xf3XkkKj7ws+hO+k8Y4VF4zGlBsJlwqy7qBcHA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.50.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w3SY5YtxGnxCHPJ8Twl3KmS9oja1gERYk3AMoZ7Hv8P43ZtB6HVfs02TxvarxfL214Tm3uzvc2vn+DhtUNeKnw=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.47.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hP2bOI4IWNS+F6pVXWtRshSTuJ1qCRZgDgVUg6EBUqsRy+ExkEPJkx+YmIuxgdCduYK1LKptLNFuQLJP8voPbQ=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.50.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hNfogDqy7tvmllXKBSlHo6k5x7dhTUVOHbMSE15CCAcXzmqf5883aPvBYPOq9AE7DpDUQUZ1kVE22YbiGW+tuw=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.47.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-F55jIEH5xmGu7S661Uho8vGiLFk0bY3A/g4J8CTKiLJnYu/PSMZ2WxFoy5Hji6qvFuujrrM9Q8XXbMO0fKOYPg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.50.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ykZevOWEyu0nsxolA911ucxpEv0ahw8jfEeGWOwwb/VPoE4xoexuTOAiPNlWZNJqANlJl7yp8OyzCtXTUAxotw=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wxmOn/wns/WKPXUC1fo5mu9pMZPVOu8hsynaVDrgmmXMdHKS7on6bA5cPauFFN9tJXNdsjW26AK9lpfu3IfHBQ=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hif3iDk7vo5GGJ4OLCCZAf2vjnU9FztGw4L0MbQL0M2iY9LKFtDMMiQAHmkF0PQGQMVbTYtPdXCLKVgdkiqWXQ=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KJTmVIA/GqRlM2K+ZROH30VMdydEU7bDTY35fNg3tOPzQRIs2deLZlY/9JWwdWo1F/9mIYmpbdCmPqtKhWNOPg=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.50.0", "", { "os": "linux", "cpu": "arm" }, "sha512-dVp9iSssiGAnTNey2Ruf6xUaQhdnvcFOJyRWd/mu5o2jVbFK15E5fbWGeFRfmuobu5QXuROtFga44+7DOS3PLg=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PF7ELcFg1GVlS0X0ZB6aWiXobjLrAKer3T8YEkwIoO8RwWiAMkL3n3gbleg895BuZkHVlJ2kPRUwfrhHrVkD1A=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1cT7yz2HA910CKA9NkH1ZJo50vTtmND2fkoW1oyiSb0j6WvNtJ0Wx2zoySfXWc/c+7HFoqRK5AbEoL41LOn9oA=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4BezLRO5cu0asf0Jp1gkrnn2OHiXrPPPEfBTxq1k5/yJ2zdGGTmZxHD2KF2voR23wb8Elyu3iQawXo7wvIZq0Q=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.50.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-++B3k/HEPFVlj89cOz8kWfQccMZB/aWL9AhsW7jPIkG++63Mpwb2cE9XOEsd0PATbIan78k2Gky+09uWM1d/gQ=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.47.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aI5ds9jq2CPDOvjeapiIj48T/vlWp+f4prkxs+FVzrmVN9BWIj0eqeJ/hV8WgXg79HVMIz9PU6deI2ki09bR1w=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.50.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z9b/KpFMkx66w3gVBqjIC1AJBTZAGoI9+U+K5L4QM0CB/G0JSNC1es9b3Y0Vcrlvtdn8A+IQTkYjd/Q0uCSaZw=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-mO7ycp9Elvgt5EdGkQHCwJA6878xvo9tk+vlMfT1qg++UjvOMB8INsOCQIOH2IKErF/8/P21LULkdIrocMw9xA=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-jvmuIw8wRSohsQlFNIST5uUwkEtEJmOQYr33bf/K2FrFPXHhM4KqGekI3ShYJemFS/gARVacQFgBzzJKCAyJjg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-24D0wsYT/7hDFn3Ow32m3/+QT/1ZwrUhShx4/wRDAmz11GQHOZ1k+/HBuK/MflebdnalmXWITcPEy4BWTi7TCA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.50.0", "", { "os": "linux", "cpu": "none" }, "sha512-x+UrN47oYNh90nmAAyql8eQaaRpHbDPu5guasDg10+OpszUQ3/1+1J6zFMmV4xfIEgTcUXG/oI5fxJhF4eWCNA=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.47.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8tPzPne882mtML/uy3mApvdCyuVOpthJ7xUv3b67gVfz63hOOM/bwO0cysSkPyYYFDFRn6/FnUb7Jhmsesntvg=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.50.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-i/JLi2ljLUIVfekMj4ISmdt+Hn11wzYUdRRrkVUYsCWw7zAy5xV7X9iA+KMyM156LTFympa7s3oKBjuCLoTAUQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-q58pIyGIzeffEBhEgbRxLFHmHfV9m7g1RnkLiahQuEvyjKNiJcvdHOwKH2BdgZxdzc99Cs6hF5xTa86X40WzPw=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/C7brhn6c6UUPccgSPCcpLQXcp+xKIW/3sji/5VZ8/OItL3tQ2U7KalHz887UxxSQeEOmd1kY6lrpuwFnmNqOA=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-e7DiLZtETZUCwTa4EEHg9G+7g3pY+afCWXvSeMG7m0TQ29UHHxMARPaEQUE4mfKgSqIWnJaUk2iZzRPMRdga5g=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.50.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oDR1f+bGOYU8LfgtEW8XtotWGB63ghtcxk5Jm6IDTCk++rTA/IRMsjOid2iMd+1bW+nP9Mdsmcdc7VbPD3+iyQ=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.47.0", "", { "os": "none", "cpu": "arm64" }, "sha512-3AFPfQ0WKMleT/bKd7zsks3xoawtZA6E/wKf0DjwysH7wUiMMJkNKXOzYq1R/00G98JFgSU1AkrlOQrSdNNhlg=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.50.0", "", { "os": "none", "cpu": "arm64" }, "sha512-4CmRGPp5UpvXyu4jjP9Tey/SrXDQLRvZXm4pb4vdZBxAzbFZkCyh0KyRy4txld/kZKTJlW4TO8N1JKrNEk+mWw=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.47.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-cLMVVM6TBxp+N7FldQJ2GQnkcLYEPGgiuEaXdvhgvSgODBk9ov3jed+khIXSAWtnFOW0wOnG3RjwqPh0rCuheA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.50.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fq0M6vsGcFsSfeuWAACDhd5KJrO85ckbEfe1EGuBj+KPyJz7KeWte2fSFrFGmNKNXyhEMyx4tbgxiWRujBM2KQ=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.47.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-VpFOSzvTnld77/Edje3ZdHgZWnlTb5nVWXyTgjD3/DKF/6t5bRRbwn3z77zOdnGy44xAMvbyAwDNOSeOdVUmRA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.50.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-qTdWR9KwY/vxJGhHVIZG2eBOhidOQvOwzDxnX+jhW/zIVacal1nAhR8GLkiywW8BIFDkQKXo/zOfT+/DY+ns/w=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.47.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+q8IWptxXx2HMTM6JluR67284t0h8X/oHJgqpxH1siowxPMqZeIpAcWCUq+tY+Rv2iQK8TUugjZnSBQAVV5CmA=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.50.0", "", { "os": "win32", "cpu": "x64" }, "sha512-682t7npLC4G2Ca+iNlI9fhAKTcFPYYXJjwoa88H4q+u5HHHlsnL/gHULapX3iqp+A8FIJbgdylL5KMYo2LaluQ=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], @@ -139,21 +152,21 @@ "@types/scheduler": ["@types/scheduler@0.26.0", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260214.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260214.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260214.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260214.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260214.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260214.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260214.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260214.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-BDM0ZLf2v6ilR0tDi8OMEr4X08lFCToPk3/p1SSE4GhagzmlU/5b+9slR0kKtaKMrds01FhvaKx6U9+NmAWgbQ=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260224.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260224.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260224.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260224.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260224.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260224.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260224.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260224.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-PU0zBXLvz6RKxbIubT66RCnJXgScdDIhfmNMkvRhOnX/C4SZom5TFSn7BEHC3w8JPj7OSz5OYoubtV1Haty2GA=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260214.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jb2WcLGpTOC6x58e8QPYC/14xmDbnbFIuKqUvYoI77hVtojVyxZi8L5Y4CgYqXYx8vRWmIFk35c1OGdtPip6Sg=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260224.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9VHXRhB7sM5DFqdlKaeDww8vuklgfzhYCjBazLCEnuFvb4J+rJ1DodLykc2bL+6kE8k6sdhYi3x8ipfbjtO44g=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260214.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-O9l2gVuQFZsb8NIQtu0HN5Tn/Hw2fwylPOPS/0Y4oW+FUMhkqtvetUkb3zZ0qj7capilZ4YnmyGYg3TDqkP4Nw=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260224.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-uCHipPRcIhHnvb7lAM29MQ1QT9pZ+uirqtH630aOMFm8VG3j8mkxVM9iGRLx829n38DMSDLjc3joCrQO3+sDcQ=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260214.1", "", { "os": "linux", "cpu": "arm" }, "sha512-TaFrVnx3iXtl/oH1hzwvFyqWj9tzkjW8Ufl2m0Vx2/7GXnzZadm2KA6tFpGbzzWbZJznmXxKHL4O3AZRQYyZqQ=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260224.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cEWSRQ8b+CXdMJvoG18IjNTvBo+qT22B5imqm6nAssMpyHHQb62PvZGnrA8mPRQNPzLpa5F956j8GwAjyP8hBQ=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260214.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Hl4e3yxJqzIGgFI8aH/rLGW+a7kSLHJCpAd5JOLG7hHKnamZF4SjlunnoHLV4IcMri+G6UE3W/84i0QvQP5wLA=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260224.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yFEEq6hD2R70+lTogb211sPdCwz3H5hpYh0+YuKVMPsKo0oM8/jMvgjj2pyutmj/uCKLdbcJ9HP2vJ/13Szbcg=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260214.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a/JypIXTc/tdodhYdQm24WH6aTfnJJjDbwxce4BS2g6IzYSc2GFcZBvlq1CJYS2FAVLpiSxj0OFAZmgjpCDAKg=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260224.1", "", { "os": "linux", "cpu": "x64" }, "sha512-zGz5kVcCeBRheQwA4jVTAxtbLsBsTkp9AEvWK5AlyCs1rQCUQobBhtx37X4VEmxn4ekIDMxYgaZdlZb7/PGp8w=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260214.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-MJGPEDvdXj8olcWH0P+cWYcaN4r/0J4aSbcaISlen3MZ/2hrrgNl46PV4eGJKKCDniY2pH2fJzrMyJWZOcdb0w=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260224.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-A0f9ZDQqKvGk/an59HuAJuzoI/wMyrgTd69oX9gFCx7+5E/ajSdgv0Eg1Fco+nyLfT/UVM0CV3ERyWrKzx277w=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260214.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BtF48TRUyiCKznlOcQ7r7EXhonGSanm9X2eu7d8Yq1vaWO5SDgB0e+ISQXSoIfs3a1S3d5S5QV/vTE4+vocPxA=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260224.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Se9JrcMdVLeDYMLn+CKEV3qy1yiildb5N23USGvnC9siNFalz8tVgd589dhRP+ywDhXnbIsZiFKDrZF/7B4wSQ=="], "agentry": ["agentry@workspace:packages/agentry"], @@ -165,9 +178,11 @@ "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=="], - "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=="], + "openai": ["openai@6.24.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-WLR+qki3S4c9ZHilR42GDTxEpTrMFeWfcFNg1RMELW0ZCCBJMDDlqVV+QQEM5KN2hjUyZu45QpgJmln/0ykFRg=="], + + "oxfmt": ["oxfmt@0.35.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.35.0", "@oxfmt/binding-android-arm64": "0.35.0", "@oxfmt/binding-darwin-arm64": "0.35.0", "@oxfmt/binding-darwin-x64": "0.35.0", "@oxfmt/binding-freebsd-x64": "0.35.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.35.0", "@oxfmt/binding-linux-arm-musleabihf": "0.35.0", "@oxfmt/binding-linux-arm64-gnu": "0.35.0", "@oxfmt/binding-linux-arm64-musl": "0.35.0", "@oxfmt/binding-linux-ppc64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-gnu": "0.35.0", "@oxfmt/binding-linux-riscv64-musl": "0.35.0", "@oxfmt/binding-linux-s390x-gnu": "0.35.0", "@oxfmt/binding-linux-x64-gnu": "0.35.0", "@oxfmt/binding-linux-x64-musl": "0.35.0", "@oxfmt/binding-openharmony-arm64": "0.35.0", "@oxfmt/binding-win32-arm64-msvc": "0.35.0", "@oxfmt/binding-win32-ia32-msvc": "0.35.0", "@oxfmt/binding-win32-x64-msvc": "0.35.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q=="], - "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=="], + "oxlint": ["oxlint@1.50.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.50.0", "@oxlint/binding-android-arm64": "1.50.0", "@oxlint/binding-darwin-arm64": "1.50.0", "@oxlint/binding-darwin-x64": "1.50.0", "@oxlint/binding-freebsd-x64": "1.50.0", "@oxlint/binding-linux-arm-gnueabihf": "1.50.0", "@oxlint/binding-linux-arm-musleabihf": "1.50.0", "@oxlint/binding-linux-arm64-gnu": "1.50.0", "@oxlint/binding-linux-arm64-musl": "1.50.0", "@oxlint/binding-linux-ppc64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-gnu": "1.50.0", "@oxlint/binding-linux-riscv64-musl": "1.50.0", "@oxlint/binding-linux-s390x-gnu": "1.50.0", "@oxlint/binding-linux-x64-gnu": "1.50.0", "@oxlint/binding-linux-x64-musl": "1.50.0", "@oxlint/binding-openharmony-arm64": "1.50.0", "@oxlint/binding-win32-arm64-msvc": "1.50.0", "@oxlint/binding-win32-ia32-msvc": "1.50.0", "@oxlint/binding-win32-x64-msvc": "1.50.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.14.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-iSJ4IZEICBma8cZX7kxIIz9PzsYLF2FaLAYN6RKu7VwRVKdu7RIgpP99bTZaGl//Yao7fsaGZLSEo5xBrI5ReQ=="], "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], diff --git a/package.json b/package.json index ccc4832..3ecc596 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,10 @@ "author": "Colin D'Souza", "devDependencies": { "@types/bun": "1.3.9", - "@types/react": "^19.2.14", - "@typescript/native-preview": "^7.0.0-dev.20260214.1", - "oxfmt": "^0.32.0", - "oxlint": "^1.47.0", + "@types/react": "catalog:", + "@typescript/native-preview": "^7.0.0-dev.20260224.1", + "oxfmt": "^0.35.0", + "oxlint": "^1.50.0", "typescript": "^5" }, "license": "MIT", @@ -26,24 +26,40 @@ "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:openai:codex-subagent": "bun run packages/examples/src/openai/codex-subagent.tsx", + "example:openai:websocket": "bun run packages/examples/src/openai/websocket.tsx", + "example:openai:built-ins": "bun run packages/examples/src/openai/built-ins.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:compaction": "bun run packages/examples/src/compaction.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:router": "bun run packages/examples/src/router-demo.tsx" + "example:tweet": "bun run packages/examples/src/tweet.tsx" }, "type": "module", - "workspaces": [ - "packages/*" - ] + "workspaces": { + "packages": [ + "packages/*" + ], + "catalog": { + "@anthropic-ai/sdk": "^0.71.2", + "@types/react": "^19.2.14", + "openai": "^6.23.0", + "react": "^19.0.0", + "zod": "^4.1.13" + } + } } 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..5d6fd85 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": [ @@ -44,7 +54,6 @@ "postpack": "rm README.md LICENSE.md" }, "dependencies": { - "@anthropic-ai/sdk": "^0.71.1", "eventemitter3": "^5.0.1", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", @@ -52,10 +61,20 @@ }, "peerDependencies": { "react": "^19.0.0", - "zod": "^4.0.0" + "zod": "^4.0.0", + "@anthropic-ai/sdk": "^0.71.1", + "openai": "^6.23.0" + }, + "peerDependenciesMeta": { + "@anthropic-ai/sdk": { + "optional": true + }, + "openai": { + "optional": true + } }, "devDependencies": { - "@types/react": "^19.0.0", + "@types/react": "catalog:", "@types/react-reconciler": "^0.32.3", "@types/scheduler": "^0.26.0" } diff --git a/packages/agentry/src/anthropic.ts b/packages/agentry/src/anthropic.ts new file mode 100644 index 0000000..1e92b13 --- /dev/null +++ b/packages/agentry/src/anthropic.ts @@ -0,0 +1,36 @@ +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 { + 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/Agent.tsx b/packages/agentry/src/components/Agent.tsx index a75fed4..bd3db07 100644 --- a/packages/agentry/src/components/Agent.tsx +++ b/packages/agentry/src/components/Agent.tsx @@ -1,13 +1,32 @@ import type { ReactNode } from 'react' -import type { AgentComponentProps } from '../instances/types' +import type { + BaseAgentProps, + ProviderModelOverride, + ThinkingConfig, + AnthropicThinkingEnabled, + OpenAIThinkingEnabled, +} from '../types/agent' -export interface AgentComponentPublicProps extends Omit< - AgentComponentProps, - 'client' | 'model' -> { - // model is optional - required for root agents, inherited for subagents - model?: AgentComponentProps['model'] -} +/** + * `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 = + | (Required> & { + thinking?: AnthropicThinkingEnabled | { type: 'disabled' } + }) + | (Required> & { + thinking?: OpenAIThinkingEnabled | { type: 'disabled' } + websocket?: boolean + }) + | { provider?: undefined; model?: undefined; thinking?: ThinkingConfig } + +export type AgentComponentPublicProps = BaseAgentProps & + PublicProviderVariant & { children?: React.ReactNode } /** * Agent component - the root container for an AI agent diff --git a/packages/agentry/src/components/AgentTool.tsx b/packages/agentry/src/components/AgentTool.tsx index e9ab56b..697cd93 100644 --- a/packages/agentry/src/components/AgentTool.tsx +++ b/packages/agentry/src/components/AgentTool.tsx @@ -51,10 +51,7 @@ export function AgentTool( ): ReactNode { if ('parameters' in props && 'jsonSchema' in props) { return ( - } - key={props.name} - /> + ) } @@ -62,7 +59,7 @@ export function AgentTool( return ( } + agentTool={agentTool as InternalAgentTool} key={agentTool.name} /> ) 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/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/built-ins/CodeExecution.tsx b/packages/agentry/src/components/built-ins/CodeExecution.tsx index d91f166..bf417fa 100644 --- a/packages/agentry/src/components/built-ins/CodeExecution.tsx +++ b/packages/agentry/src/components/built-ins/CodeExecution.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { CodeExecutionTool } from '../../types/tools' +import { type CodeExecutionTool, BuiltInToolType } from '../../types/tools' /** * CodeExecution built-in tool - enables code execution capability @@ -21,9 +21,9 @@ import type { CodeExecutionTool } from '../../types/tools' */ export function CodeExecution(): ReactNode { const tool: CodeExecutionTool = { - type: 'code_execution_20250825', + type: BuiltInToolType.CodeExecution, name: 'code_execution', } - return + return } diff --git a/packages/agentry/src/components/built-ins/Memory.tsx b/packages/agentry/src/components/built-ins/Memory.tsx index c96cb50..e121bd4 100644 --- a/packages/agentry/src/components/built-ins/Memory.tsx +++ b/packages/agentry/src/components/built-ins/Memory.tsx @@ -1,5 +1,9 @@ import type { ReactNode } from 'react' -import type { MemoryHandlers, MemoryTool } from '../../types' +import { + type MemoryHandlers, + type MemoryTool, + BuiltInToolType, +} from '../../types' // Re-export MemoryHandlers for convenience export type { MemoryHandlers } @@ -43,20 +47,11 @@ type MemoryProps = MemoryHandlers * ``` */ export function Memory(props: MemoryProps): ReactNode { - const handlers: MemoryHandlers = { - onView: props.onView, - onCreate: props.onCreate, - onStrReplace: props.onStrReplace, - onInsert: props.onInsert, - onDelete: props.onDelete, - onRename: props.onRename, - } - const tool: MemoryTool = { - type: 'memory_20250818', + type: BuiltInToolType.Memory, name: 'memory', - memoryHandlers: handlers, + memoryHandlers: props, } - return + return } diff --git a/packages/agentry/src/components/built-ins/WebSearch.tsx b/packages/agentry/src/components/built-ins/WebSearch.tsx index 1cb873d..f893142 100644 --- a/packages/agentry/src/components/built-ins/WebSearch.tsx +++ b/packages/agentry/src/components/built-ins/WebSearch.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import type { WebSearchTool } from '../../types' +import { type WebSearchTool, BuiltInToolType } from '../../types' export interface WebSearchProps { /** maximum number of searches allowed */ @@ -44,7 +44,7 @@ export function WebSearch(props: WebSearchProps): ReactNode { : undefined const tool: WebSearchTool = { - type: 'web_search_20250305', + type: BuiltInToolType.WebSearch, name: 'web_search', max_uses: props.maxUses, allowed_domains: props.allowedDomains, @@ -52,5 +52,5 @@ export function WebSearch(props: WebSearchProps): ReactNode { user_location: userLocation, } - return + return } diff --git a/packages/agentry/src/components/hooks.ts b/packages/agentry/src/components/hooks.ts index adff61a..be38991 100644 --- a/packages/agentry/src/components/hooks.ts +++ b/packages/agentry/src/components/hooks.ts @@ -1,20 +1,9 @@ import { useContext } from 'react' import { useStore } from 'zustand' -import { - createAgentStore, - type AgentStore, - type AgentStoreState, -} from '../store' -import { AgentContext, AgentProvider } from '../context' -import type { AgentState, BetaMessageParam } from '../types' - -export { - createAgentStore, - AgentContext, - AgentProvider, - type AgentStore, - type AgentStoreState, -} +import type { AgentStore, AgentStoreState } from '../store' +import { AgentContext } from '../context' +import type { AgentState } from '../types' +import type { AgentMessageParam } from '../types/messages' /** * Get the agent store from context (throws if not found) @@ -60,7 +49,7 @@ export function useExecutionState(): AgentState { * } * ``` */ -export function useMessages(): BetaMessageParam[] { +export function useMessages(): AgentMessageParam[] { const store = useAgentStore() return useStore(store, ({ messages }) => messages) } @@ -81,8 +70,3 @@ export function useAgentState(): AgentStoreState { const store = useAgentStore() return useStore(store) } - -/** - * Hook to access the raw store (for transient updates or subscriptions) - */ -export { useAgentStore } 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..95ff235 100644 --- a/packages/agentry/src/components/jsx-elements.d.ts +++ b/packages/agentry/src/components/jsx-elements.d.ts @@ -1,25 +1,28 @@ import type { ReactNode } from 'react' import type { InternalTool, InternalAgentTool } from '../types' -import type { - BetaToolUnion, - BetaRequestMCPServerToolConfiguration, - BetaContentBlockParam, -} from '@anthropic-ai/sdk/resources/beta' +import type { BuiltInTool } from '../types/tools' 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, ProviderModelOverride } from '../types/agent' export interface AgentryElements { - agent: Omit & { - model?: AgentComponentPublicProps['model'] + // Internal reconciler element — both fields are optional so the reconciler can + // accept any combination (subagents inherit provider/model at runtime). + agent: Omit & { + provider?: ProviderName + model?: Model children?: ReactNode /** Deferred children - stored but not reconciled during parent render */ 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 } + built_in_tool: { tool: BuiltInTool; key?: string } system: { children: ReactNode; cache?: 'ephemeral' } @@ -28,7 +31,7 @@ export interface AgentryElements { message: { role: 'user' | 'assistant' children?: ReactNode - rawContent?: string | BetaContentBlockParam[] + rawContent?: string | AgentContentBlock[] } tools: { children?: ReactNode } @@ -37,30 +40,30 @@ export interface AgentryElements { name: string url: string authorization_token?: string - tool_configuration?: BetaRequestMCPServerToolConfiguration + tool_configuration?: MCPServerConfig['tool_configuration'] key?: string } - condition: { when: boolean | string; children?: ReactNode } + condition: { + when: boolean | string + children?: ReactNode + } & ProviderModelOverride } declare module 'react' { namespace JSX { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface IntrinsicElements extends AgentryElements {} } } declare module 'react/jsx-runtime' { namespace JSX { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface IntrinsicElements extends AgentryElements {} } } declare module 'react/jsx-dev-runtime' { namespace JSX { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface IntrinsicElements extends AgentryElements {} } } diff --git a/packages/agentry/src/constants.ts b/packages/agentry/src/constants.ts index d16811a..4f95a6f 100644 --- a/packages/agentry/src/constants.ts +++ b/packages/agentry/src/constants.ts @@ -1,5 +1,5 @@ -export const MODEL = 'claude-haiku-4-5' -export const TEST_MODEL = 'claude-3-5-haiku-20241022' +export const ANTHROPIC_TEST_MODEL = 'claude-3-5-haiku-20241022' +export const OPENAI_TEST_MODEL = 'gpt-5-mini' /** * Anthropic API beta feature flags @@ -19,6 +19,3 @@ export const ANTHROPIC_BETAS = { /** Interleaved thinking for thinking blocks interleaved with tool calls */ INTERLEAVED_THINKING: 'interleaved-thinking-2025-05-14', } as const - -export type AnthropicBeta = - (typeof ANTHROPIC_BETAS)[keyof typeof ANTHROPIC_BETAS] 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..9eb15fc 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,95 +6,85 @@ 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' import { transition, + TransitionType, + AgentStatus, 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, isMemoryToolInput } 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 -} - -export interface ExecutionEngineEvents { +import { + isThinkingBlock, + type AgentMessage, + type AgentMessageParam, + type ToolResultContentBlock, +} from '../types/messages' +import type { ProviderName } from '../types/provider' +import type { + ProviderAdapter, + ProviderClientMap, + SystemBlock, +} from '../providers/types' +import { createDefaultAdapters } from '../providers' +import { isTextBlock } from '../types/messages' +import type { JsonObject } from '../types/json' + +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 } -export type SystemPrompt = - | string - | Array<{ - type: 'text' - text: string - cache_control?: { type: 'ephemeral' } - }> +export type SystemPrompt = string | SystemBlock[] + +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,55 +94,86 @@ export interface ExecutionEngineConfig { betas?: string[] } -const DEFAULT_TOKEN_THRESHOLD = 100_000 - -function hasName(t: unknown): 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 +function buildToolContext( + config: ExecutionEngineConfig, + client: ProviderClientMap[ProviderName], + signal: AbortSignal, +): ToolContext { + const base = { + agentName: config.agentName ?? 'agent', + clients: config.clients, + model: config.model, + signal, + runAgent: createRunAgent({ + provider: config.provider, + clients: config.clients, + model: config.model, + signal, + }), + } + if (config.provider === 'anthropic') { + return { + ...base, + provider: 'anthropic', + client: client as ProviderClientMap['anthropic'], + } } - // CodeExecutionTool and WebSearchTool are already in the correct format - return sdkTool as BetaToolUnion + if (config.provider === 'openai') { + return { + ...base, + provider: 'openai', + client: client as ProviderClientMap['openai'], + } + } + const _exhaustive: never = config.provider + throw new Error( + `[agentry] buildToolContext: unhandled provider "${String(_exhaustive)}". Add a branch for this provider.`, + ) } -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 + * Handles the conversation loop with the configured AI provider via a ProviderAdapter. + * Manages state transitions, tool execution, condition evaluation, and compaction. */ 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() + /** + * 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 - 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() + this.adapter = adapters[provider] + if (!this.adapter) { + throw new Error( + `[agentry] No adapter registered for provider "${provider}". Available: ${Object.keys(adapters).join(', ')}.`, + ) + } + this.config = { + ...config, + provider, + adapters, + clients: config.clients ?? {}, + } this.store = config.store this.agentInstance = config.agentInstance } @@ -175,7 +182,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 +190,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,86 +200,45 @@ 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, - }) + private async evaluateAllConditions( + signal?: AbortSignal, + options?: { evaluateNL?: boolean; consecutiveFailures?: number }, + ): Promise<{ changed: boolean; consecutiveFailures: number }> { + try { + const changed = await evaluateConditions({ + root: this.agentInstance, + messages: this.messages as AgentMessageParam[], + clients: this.config.clients ?? {}, + provider: this.config.provider, + 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 }, + ) } - } - - 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, + debug( + 'conditions', + `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}, consecutive failures: ${failures}), conditions may be stale due to evaluation failure:`, + err, + ) + this.emit('error', err) + return { changed: false, consecutiveFailures: failures } } - - return params - } - - private async evaluateAllConditions(options?: { - evaluateNL?: boolean - }): Promise { - return evaluateConditions( - this.agentInstance, - this.messages as BetaMessageParam[], - this.client, - this.config.model, - undefined, // signal - options, - ) } // todo(colin): not a huge fan on recollecting everything @@ -281,7 +247,7 @@ export class ExecutionEngine extends EventEmitter { private recollectAll(): void { this.agentInstance.tools = [] this.agentInstance.systemParts = [] - this.agentInstance.sdkTools = [] + this.agentInstance.builtInTools = [] this.agentInstance.mcpServers = [] for (const child of this.agentInstance.children) { @@ -297,6 +263,8 @@ export class ExecutionEngine extends EventEmitter { this.iterationCount = 0 try { + let conditionEvalFailures = 0 + while (!this.aborted) { if ( this.config.maxIterations !== undefined && @@ -307,13 +275,21 @@ export class ExecutionEngine extends EventEmitter { this.iterationCount++ const abortController = new AbortController() - this.transition({ type: 'start_streaming', abortController }) + this.transition({ + type: TransitionType.StartStreaming, + abortController, + }) const isFirstIteration = this.iterationCount === 1 - const conditionsChanged = await this.evaluateAllConditions({ - evaluateNL: isFirstIteration, // only evaluate NL conditions from the user's history - }) - if (conditionsChanged) { + const conditionResult = await this.evaluateAllConditions( + abortController.signal, + { + evaluateNL: isFirstIteration, + consecutiveFailures: conditionEvalFailures, + }, + ) + conditionEvalFailures = conditionResult.consecutiveFailures + if (conditionResult.changed) { this.recollectAll() } @@ -321,9 +297,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) @@ -335,23 +311,25 @@ export class ExecutionEngine extends EventEmitter { input: tu.input, })) - this.transition({ type: 'tools_requested', pendingTools }) + this.transition({ type: TransitionType.ToolsRequested, pendingTools }) - const toolResults = await this.executeTools(pendingTools) + const toolResults = await this.executeTools( + pendingTools, + abortController, + ) - const toolResultMessage: BetaMessageParam = { + const toolResultMessage: AgentMessageParam = { role: 'user', content: toolResults, } this.pushMessage(toolResultMessage) - this.transition({ type: 'tools_completed', results: [] }) + this.transition({ type: TransitionType.ToolsCompleted, results: [] }) // force React to commit any pending state updates from tool handlers flushSync(() => {}) await yieldToSchedulerImmediate() - - await this.checkAndCompact() + await this.checkAndCompact(abortController.signal) const stepResult = this.buildStepFinishResult( message, @@ -359,9 +337,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 } } @@ -371,12 +351,15 @@ export class ExecutionEngine extends EventEmitter { } const result = this.buildResult() - this.transition({ type: 'completed', finalMessage: this.lastMessage }) + this.transition({ + type: TransitionType.Completed, + finalMessage: this.lastMessage, + }) this.emit('complete', result) return result } catch (error) { const err = error instanceof Error ? error : new Error(String(error)) - this.transition({ type: 'error', error: err }) + this.transition({ type: TransitionType.Error, error: err }) this.emit('error', err) throw err } @@ -384,110 +367,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) } - : {}), - }) - + ): 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, + builtInTools: this.agentInstance.builtInTools, + 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, - }) - - 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', + stopReason: response.message.stop_reason, + toolUses: extractToolUses(response.message).map((t) => t.name), + textLength: extractText(response.message).length, }) - - return finalMessage + return response.message } private async executeTools( pendingTools: PendingToolCall[], - ): Promise { - this.transition({ type: 'tools_executing', pendingTools }) + abortController: AbortController, + ): Promise { + this.transition({ type: TransitionType.ToolsExecuting, pendingTools }) - const currentState = this.executionState - const context: ToolContext = { - agentName: this.config.agentName ?? 'agent', - client: this.client, - model: this.config.model, - signal: - currentState.status === 'streaming' - ? currentState.abortController.signal - : undefined, - runAgent: createRunAgent({ - client: this.client, - model: this.config.model, - signal: - currentState.status === 'streaming' - ? currentState.abortController.signal - : undefined, - }), - } + const signal = abortController.signal + const context = buildToolContext(this.config, this.client, signal) - const { tools: internalTools = [], sdkTools = [] } = this.agentInstance + const { tools: internalTools = [], builtInTools = [] } = this.agentInstance const results = await Promise.all( pendingTools.map(async (toolCall) => { @@ -512,112 +430,101 @@ export class ExecutionEngine extends EventEmitter { typeof result === 'string' ? result.substring(0, 100) : result, }) - const executionTime = performance.now() - startTime - this.toolExecutionTimes.set(toolCall.id, executionTime) - - this.emit('stream', { - type: 'tool_result', - toolId: toolCall.id, - result: - typeof result === 'string' ? result : JSON.stringify(result), + return this.buildToolResult({ + toolCall, + startTime, + result, isError, }) - - return { - type: 'tool_result' as const, - tool_use_id: toolCall.id, - content: result, - is_error: isError ? true : undefined, - } } - // check if it's an SDK tool - const sdkTool = sdkTools.find( - (t) => hasName(t) && t.name === toolCall.name, - ) + // check if it's a built-in tool + const sdkTool = builtInTools.find((t) => t.name === toolCall.name) if (sdkTool) { // Memory tool requires client-side handlers if (isMemoryTool(sdkTool) && sdkTool.memoryHandlers) { + if (!isMemoryToolInput(toolCall.input)) { + return this.buildToolResult({ + toolCall, + startTime, + result: 'Error: Invalid memory tool input payload', + isError: true, + }) + } const { result, isError } = await executeMemoryTool( sdkTool, - toolCall.input as Parameters[1], + toolCall.input, ) - const executionTime = performance.now() - startTime - this.toolExecutionTimes.set(toolCall.id, executionTime) - - this.emit('stream', { - type: 'tool_result', - toolId: toolCall.id, - result: - typeof result === 'string' ? result : JSON.stringify(result), + return this.buildToolResult({ + toolCall, + startTime, + result, isError, }) - - return { - type: 'tool_result' as const, - tool_use_id: toolCall.id, - content: result, - is_error: isError ? true : undefined, - } } - // Other SDK tools are handled by Anthropic server-side - const errorMessage = `Tool '${toolCall.name}' is a server-side tool and cannot be executed locally` - const executionTime = performance.now() - startTime - this.toolExecutionTimes.set(toolCall.id, executionTime) - - this.emit('stream', { - type: 'tool_result', - toolId: toolCall.id, - result: errorMessage, + // Non-memory SDK tools (web_search, code_interpreter) are dispatched to the provider and handled server-side + debug( + 'tool', + `Server-side tool "${toolCall.name}" cannot be executed locally`, + ) + return this.buildToolResult({ + toolCall, + startTime, + result: `Tool '${toolCall.name}' is a server-side tool and cannot be executed locally`, isError: true, }) - - return { - type: 'tool_result' as const, - tool_use_id: toolCall.id, - content: errorMessage, - is_error: true, - } } - const errorMessage = `Error: Tool '${toolCall.name}' not found` - const executionTime = performance.now() - startTime - this.toolExecutionTimes.set(toolCall.id, executionTime) - - this.emit('stream', { - type: 'tool_result', - toolId: toolCall.id, - result: errorMessage, + debug('tool', `Tool "${toolCall.name}" not found`, { + available: internalTools.map((t) => t.name), + }) + return this.buildToolResult({ + toolCall, + startTime, + result: `Error: Tool '${toolCall.name}' not found`, isError: true, }) - - return { - type: 'tool_result' as const, - tool_use_id: toolCall.id, - content: errorMessage, - is_error: true, - } }), ) return results } + private buildToolResult(opts: { + toolCall: PendingToolCall + startTime: number + result: ToolResultContentBlock['content'] + isError: boolean + }): ToolResultContentBlock { + const { toolCall, startTime, result, isError } = opts + const executionTime = performance.now() - startTime + this.toolExecutionTimes.set(toolCall.id, executionTime) + + this.emit('stream', { + type: 'tool_result', + toolId: toolCall.id, + result: typeof result === 'string' ? result : JSON.stringify(result), + isError, + }) + + return { + type: 'tool_result', + tool_use_id: toolCall.id, + content: result, + is_error: isError, + } + } + private buildStepFinishResult( - message: BetaMessage, - toolUses: Array<{ id: string; name: string; input: unknown }>, - toolResults: BetaToolResultBlockParam[], + message: AgentMessage, + toolUses: Array<{ id: string; name: string; input: JsonObject }>, + toolResults: ToolResultContentBlock[], ): OnStepFinishResult { const text = extractText(message) - - const thinkingBlock = message.content.find( - (b): b is Extract => - b.type === 'thinking', - ) - const thinking = thinkingBlock?.thinking + const thinking = message.content.find(isThinkingBlock)?.thinking const toolCalls: StepToolCall[] = toolUses.map((tu) => ({ id: tu.id, @@ -631,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), } }) @@ -657,96 +564,12 @@ export class ExecutionEngine extends EventEmitter { } } - private async checkAndCompact(): Promise { - const compactionControl = this.config.compactionControl - if (!compactionControl?.enabled) { - return false - } - - if (!this.lastMessage) { - return false - } - - const totalTokens = - this.lastMessage.usage.input_tokens + - this.lastMessage.usage.output_tokens + - (this.lastMessage.usage.cache_creation_input_tokens ?? 0) + - (this.lastMessage.usage.cache_read_input_tokens ?? 0) - - const threshold = - compactionControl.contextTokenThreshold ?? DEFAULT_TOKEN_THRESHOLD - - if (totalTokens < threshold) { - return false - } - - const model = compactionControl.model ?? this.config.model - const summaryPrompt = - compactionControl.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT - - 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({ - model, - messages: [ - ...currentMessages, - { - role: 'user', - content: [{ type: 'text', text: summaryPrompt }], - }, - ], - 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 - } - - this.store.getState().actions.setMessages([ - { - role: 'user', - 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 + const thinking = this.lastMessage.content.find(isThinkingBlock)?.thinking return { content: extractText(this.lastMessage), @@ -767,11 +590,104 @@ export class ExecutionEngine extends EventEmitter { abort(): void { this.aborted = true const currentState = this.executionState - if (currentState.status === 'streaming') { + if (currentState.status === AgentStatus.Streaming) { currentState.abortController.abort() } const error = new Error('Execution aborted') - this.transition({ type: 'error', error }) + error.name = 'AbortError' + this.transition({ type: TransitionType.Error, error }) this.emit('error', error) } + + private async checkAndCompact(signal?: AbortSignal): Promise { + const compactionControl = this.config.compactionControl + if (!compactionControl?.enabled || !this.lastMessage) { + return false + } + + const totalTokens = + this.lastMessage.usage.input_tokens + + this.lastMessage.usage.output_tokens + + (this.lastMessage.usage.cache_creation_input_tokens ?? 0) + + (this.lastMessage.usage.cache_read_input_tokens ?? 0) + + const threshold = + compactionControl.contextTokenThreshold ?? DEFAULT_TOKEN_THRESHOLD + + if (totalTokens < threshold) { + return false + } + + const summaryPrompt = + compactionControl.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT + const currentMessages = [...this.messages] as AgentMessageParam[] + + try { + const response = await this.adapter.createTurn(this.client, { + model: compactionControl.model ?? this.config.model, + maxTokens: this.config.maxTokens, + messages: [ + ...currentMessages, + { role: 'user', content: summaryPrompt }, + ], + tools: [], + builtInTools: [], + mcpServers: [], + betas: this.config.betas, + stream: false, + signal: signal ?? AbortSignal.timeout(60_000), + onStream: () => {}, + }) + + const summaryText = response.message.content + .filter(isTextBlock) + .map((block) => block.text) + .join('') + + if (!summaryText) { + const err = new Error( + `[agentry] Compaction: model returned no text summary (agent: ${this.config.agentName ?? 'unknown'})`, + ) + console.warn(err.message) + this.emit('error', err) + return false + } + + this.store + .getState() + .actions.setMessages([ + { role: 'user', content: [{ type: 'text', text: summaryText }] }, + ]) + this.adapter.resetChain?.() + 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) { + 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) + + (this.lastMessage?.usage.output_tokens ?? 0) + console.warn( + `[agentry] Compaction failed for agent "${this.config.agentName ?? 'unknown'}" ` + + `(model: ${compactionModel}, tokens: ${tokenCount}): ${err.message}. ` + + `Continuing without compaction.`, + ) + this.emit('error', err) + return false + } + } } diff --git a/packages/agentry/src/execution/conditions.ts b/packages/agentry/src/execution/conditions.ts index 0d69bd1..4268a42 100644 --- a/packages/agentry/src/execution/conditions.ts +++ b/packages/agentry/src/execution/conditions.ts @@ -1,21 +1,43 @@ import type Anthropic from '@anthropic-ai/sdk' -import type { BetaMessageParam } from '../types/messages' +import type OpenAI from 'openai' +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' +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. + +Conditions: +${conditionDescriptions} + +Return ALL indices of conditions that are TRUE based on the current conversation state.` +} /** * Find all condition instances in the tree */ -export function findAllConditions(root: Instance): ConditionInstance[] { +function findAllConditions(root: Instance): ConditionInstance[] { const conditions: ConditionInstance[] = [] - const traverse = (inst: Instance) => { + function traverse(inst: Instance): void { if (isConditionInstance(inst)) { conditions.push(inst) } if ('children' in inst && Array.isArray(inst.children)) { - inst.children.forEach(traverse) + for (const child of inst.children) { + traverse(child) + } } } @@ -27,14 +49,21 @@ export function findAllConditions(root: Instance): ConditionInstance[] { * Evaluate all conditions and update isActive state * Returns true if any condition changed state */ -export async function evaluateConditions( - root: Instance, - messages: BetaMessageParam[], - client: Anthropic, - model: string, - signal?: AbortSignal, - options?: { evaluateNL?: boolean }, -): Promise { +export async function evaluateConditions({ + root, + messages, + clients, + provider, + signal, + evaluateNL, +}: { + root: Instance + messages: AgentMessageParam[] + clients: Partial + provider: ProviderName + signal?: AbortSignal + evaluateNL?: boolean +}): Promise { const conditions = findAllConditions(root) if (conditions.length === 0) { @@ -62,14 +91,21 @@ export async function evaluateConditions( // step 2: batch evaluate all natural language conditions via LLM const nlConditions = conditions.filter((c) => typeof c.when === 'string') - if (nlConditions.length > 0 && options?.evaluateNL !== false) { - const nlResults = await evaluateNaturalLanguageConditions( - nlConditions, + 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, - client, - model, + clients, + provider: resolvedProvider, + model: resolvedModel, signal, - ) + }) for (let i = 0; i < nlConditions.length; i++) { const condition = nlConditions[i]! @@ -94,38 +130,124 @@ export async function evaluateConditions( } /** - * Batch evaluate multiple natural language conditions via single LLM call + * Map provider-returned true-condition indices into a boolean[] aligned with the input conditions. + */ +function mapConditionResults({ + trueConditionIndices, + conditionCount, + provider, + durationMs, +}: { + trueConditionIndices: number[] + conditionCount: number + provider: ProviderName + durationMs: number +}): boolean[] { + const trueIndices = new Set(trueConditionIndices) + debug( + 'reconciler:conditions', + `NL evaluation via ${provider} (${durationMs}ms): ${trueIndices.size}/${conditionCount} conditions true [${Array.from(trueIndices).join(', ')}]`, + ) + 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: JsonObject, + 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. */ -async function evaluateNaturalLanguageConditions( - conditions: ConditionInstance[], - messages: BetaMessageParam[], - client: Anthropic, - model: string, - signal?: AbortSignal, -): Promise { +async function evaluateNaturalLanguageConditions({ + conditions, + messages, + clients, + provider, + model, + signal, +}: { + conditions: ConditionInstance[] + messages: AgentMessageParam[] + clients: Partial + provider: ProviderName + model: string + signal?: AbortSignal +}): Promise { + if (provider === 'anthropic' && clients.anthropic) { + return evaluateNLWithAnthropic({ + conditions, + messages, + client: clients.anthropic, + model, + signal, + }) + } + if (provider === 'openai' && clients.openai) { + return evaluateNLWithOpenAI({ + conditions, + messages, + client: clients.openai, + model, + signal, + }) + } + throw new Error( + `[agentry] Cannot evaluate natural-language conditions: no ${provider} client available. ` + + `Provide a client via createAI({ clients: { ${provider}: ... } }) or set the appropriate API key.`, + ) +} + +/** + * Evaluate NL conditions using Anthropic's structured outputs beta. + */ +async function evaluateNLWithAnthropic({ + conditions, + messages, + client, + model, + signal, +}: { + conditions: ConditionInstance[] + messages: AgentMessageParam[] + client: Anthropic + model: string + signal?: AbortSignal +}): Promise { const conditionDescriptions = conditions .map((c, index) => `${index}. ${c.when}`) .join('\n') const validIndices = conditions.map((_, index) => index) - const evalMessages = ensureValidMessageStart(messages) + 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( { - model: 'claude-haiku-4-5', + model, max_tokens: 512, - system: `You are a condition evaluation assistant. Given a conversation, determine which conditions are true. Multiple conditions can be true simultaneously. - -Conditions: -${conditionDescriptions} - -Return ALL indices of conditions that are TRUE based on the current conversation state.`, + system: buildNLConditionSystemPrompt(conditionDescriptions), messages: [ ...evalMessages, { - role: 'user', + role: 'user' as const, content: 'Which conditions are true?', }, ], @@ -161,29 +283,119 @@ Return ALL indices of conditions that are TRUE based on the current conversation const toolUse = response.content.find((block) => block.type === 'tool_use') if (toolUse && toolUse.type === 'tool_use') { - const input = toolUse.input as { trueConditionIndices: number[] } - const trueIndices = new Set(input.trueConditionIndices || []) + const input = toolUse.input as JsonObject + const indices = parseConditionIndices(input, 'anthropic') + return mapConditionResults({ + trueConditionIndices: indices, + conditionCount: conditions.length, + provider: 'anthropic', + durationMs, + }) + } - debug( - 'reconciler:conditions', - `NL evaluation (${durationMs}ms): ${trueIndices.size}/${conditions.length} conditions true [${Array.from(trueIndices).join(', ')}]`, - ) + throw new Error( + '[agentry] NL condition evaluation: model did not invoke the evaluate_conditions tool. Conditions cannot be reliably evaluated.', + ) +} + +/** + * Evaluate NL conditions using OpenAI's function calling via the Responses API. + */ +async function evaluateNLWithOpenAI({ + conditions, + messages, + client, + model, + signal, +}: { + conditions: ConditionInstance[] + messages: AgentMessageParam[] + client: OpenAI + model: string + signal?: AbortSignal +}): Promise { + const conditionDescriptions = conditions + .map((c, index) => `${index}. ${c.when}`) + .join('\n') + + const validIndices = conditions.map((_, index) => index) + const evalMessages = ensureValidMessageStart(messages) - return conditions.map((_, index) => trueIndices.has(index)) + if (evalMessages.length === 0) { + return Array.from({ length: conditions.length }, () => false) } - // fallback: all false if evaluation fails - debug( - 'reconciler:conditions', - 'NL evaluation failed, defaulting all to false', + const input = [ + ...toOpenAIInput(evalMessages), + { role: 'user' as const, content: 'Which conditions are true?' }, + ] + + const startTime = performance.now() + const response = await client.responses.create( + { + model, + instructions: buildNLConditionSystemPrompt(conditionDescriptions), + input, + tools: [ + { + type: 'function', + name: 'evaluate_conditions', + description: 'Select all condition indices that evaluate to true', + parameters: { + type: 'object', + properties: { + trueConditionIndices: { + type: 'array', + items: { type: 'number', enum: validIndices }, + description: + 'Indices of conditions that are true (empty array if none)', + }, + }, + required: ['trueConditionIndices'], + additionalProperties: false, + }, + strict: true, + }, + ], + tool_choice: { type: 'function', name: 'evaluate_conditions' }, + stream: false, + }, + { signal }, + ) + const durationMs = Math.round(performance.now() - startTime) + + const functionCall = response.output.find( + (item) => + item.type === 'function_call' && item.name === 'evaluate_conditions', + ) + if (functionCall && functionCall.type === 'function_call') { + let parsed: JsonObject + try { + 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}"`, + { cause: e }, + ) + } + const indices = parseConditionIndices(parsed, 'openai') + return mapConditionResults({ + trueConditionIndices: indices, + conditionCount: conditions.length, + provider: 'openai', + durationMs, + }) + } + + throw new Error( + '[agentry] NL condition evaluation: model did not invoke the evaluate_conditions tool. Conditions cannot be reliably evaluated.', ) - return conditions.map(() => false) } // ensure messages don't start with a tool_result (which requires a preceding tool_use) function ensureValidMessageStart( - messages: BetaMessageParam[], -): BetaMessageParam[] { + messages: AgentMessageParam[], +): AgentMessageParam[] { if (messages.length === 0) return messages // find the first user message that doesn't contain tool_results diff --git a/packages/agentry/src/execution/createEngineConfig.ts b/packages/agentry/src/execution/createEngineConfig.ts index 4ea39c5..d722980 100644 --- a/packages/agentry/src/execution/createEngineConfig.ts +++ b/packages/agentry/src/execution/createEngineConfig.ts @@ -1,26 +1,26 @@ -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, + SystemBlock, +} from '../providers/types' +import type { ProviderName } from '../types/provider' -export interface EngineConfigOptions { +interface EngineConfigOptions { agent: AgentInstance - client: Anthropic + clients: Partial + adapters: Record> store?: AgentStore } -export interface EngineConfigResult { +interface EngineConfigResult { config: ExecutionEngineConfig store: AgentStore } -export interface SystemBlock { - type: 'text' - text: string - cache_control?: { type: 'ephemeral' } -} - export function buildSystemPrompt( agent: AgentInstance, ): string | SystemBlock[] | undefined { @@ -48,21 +48,39 @@ 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( 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 model = agent.props.model + if (!model) { + throw new Error( + 'model is required on the agent. Set it via the model prop on .', + ) + } + 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, + model, maxTokens: agent.props.maxTokens ?? 4096, system, stream: agent.props.stream ?? false, @@ -72,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.provider === 'anthropic' ? agent.props.betas : undefined, agentInstance: agent, store, } diff --git a/packages/agentry/src/execution/index.ts b/packages/agentry/src/execution/index.ts index b7da1e8..f80ed26 100644 --- a/packages/agentry/src/execution/index.ts +++ b/packages/agentry/src/execution/index.ts @@ -1,10 +1,2 @@ -export { - ExecutionEngine, - type ExecutionEngineEvents, - type ExecutionEngineConfig, -} from './ExecutionEngine' -export { - createEngineConfig, - type EngineConfigOptions, - type EngineConfigResult, -} from './createEngineConfig' +export { ExecutionEngine, type ExecutionEngineConfig } from './ExecutionEngine' +export { createEngineConfig } from './createEngineConfig' diff --git a/packages/agentry/src/handles/AbstractAgentHandle.ts b/packages/agentry/src/handles/AbstractAgentHandle.ts index 58aecac..23fbad6 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,8 +19,10 @@ import { import { isProcessing, type AgentState } from '../types/state' import { yieldToScheduler } from '../scheduler' import { AgentProvider } from '../context' +import type { ProviderAdapter, ProviderClientMap } from '../providers/types' +import type { ProviderName } from '../types/provider' -export interface AgentHandleEvents { +interface AgentHandleEvents { stateChange: (state: AgentState) => void stream: (event: AgentStreamEvent) => void complete: (result: AgentResult) => void @@ -36,18 +37,26 @@ 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, - containerInfo: ContainerInfo, - store: AgentStore, - ) { + constructor({ + clients, + adapters, + containerInfo, + store, + }: { + 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 +65,7 @@ export abstract class AbstractAgentHandle extends EventEmitter { + try { + await agent.props.onStepFinish?.(result) + } catch (err) { + throw new Error('[agentry] onStepFinish callback threw', { cause: err }) + } + } + let onStateChange: ((state: AgentState) => void) | undefined - let onStream: ((event: AgentStreamEvent) => void) | undefined - let onError: ((error: Error) => void) | undefined - let onStepFinish: ((result: OnStepFinishResult) => void) | undefined + const onStream = (event: AgentStreamEvent) => { + if (emitEvents) this.emit('stream', event) + agent.props.onMessage?.(event) + } + const onError = (error: Error) => { + if (emitEvents) this.emit('error', error) + agent.props.onError?.(error) + } + const onStepFinish = (result: OnStepFinishResult) => { + if (emitEvents) this.emit('stepFinish', result) + } try { if (emitEvents) { - onStateChange = (state: AgentState) => { - // State is already updated in store by engine - this.emit('stateChange', state) - } - onStream = (event: AgentStreamEvent) => { - this.emit('stream', event) - agent.props.onMessage?.(event) - } - onError = (error: Error) => { - this.emit('error', error) - agent.props.onError?.(error) - } - onStepFinish = (result: OnStepFinishResult) => { - this.emit('stepFinish', result) - agent.props.onStepFinish?.(result) - } - + onStateChange = (state: AgentState) => this.emit('stateChange', state) this.engine.on('stateChange', onStateChange) - this.engine.on('stream', onStream) - this.engine.on('error', onError) - this.engine.on('stepFinish', onStepFinish) - } else { - // Still wire up agent props callbacks even if not emitting events - onStream = (event: AgentStreamEvent) => { - agent.props.onMessage?.(event) - } - onError = (error: Error) => { - agent.props.onError?.(error) - } - onStepFinish = (result: OnStepFinishResult) => { - agent.props.onStepFinish?.(result) - } - - this.engine.on('stream', onStream) - this.engine.on('error', onError) - this.engine.on('stepFinish', onStepFinish) } + this.engine.on('stream', onStream) + this.engine.on('error', onError) + this.engine.on('stepFinish', onStepFinish) const result = await this.engine.run() @@ -277,9 +274,13 @@ export abstract class AbstractAgentHandle extends EventEmitter { - error = e + const runPromise = this.run(message).catch((e: unknown) => { + error = e instanceof Error ? e : new Error(String(e)) done = true + if (resolveNext) { + resolveNext(null) + resolveNext = null + } }) try { @@ -338,9 +339,14 @@ export abstract class AbstractAgentHandle extends EventEmitter ResponsesWSLike) | undefined + private isDirty = true + private initialProps: + | { + provider: string + model: string + websocket: boolean + } + | undefined constructor( element: ReactNode, - client?: Anthropic, + options: { providers?: ProvidersConfig } = {}, mode: 'batch' | 'interactive' = 'batch', ) { - const anthropicClient = client ?? new Anthropic() + const openaiConfig = options.providers?.openai as + | OpenAIProviderConfigInternal + | undefined + const responsesWSFactory = openaiConfig?.[OPENAI_INTERNAL_WS_FACTORY] + + // Extract clients from providers config + const clients: Partial = {} + if (options.providers?.openai?.client) + clients.openai = options.providers.openai.client + if (options.providers?.anthropic?.client) + clients.anthropic = options.providers.anthropic.client + + const adapters: Record> = { + ...createDefaultAdapters(), + } + const store = createAgentStore() const rootAgent: AgentInstance = { - type: 'agent', + type: InstanceType.Agent, props: { - model: MODEL, + provider: undefined, + model: undefined, maxTokens: 4096, stream: true, }, - client: anthropicClient, + client: undefined, engine: null, systemParts: [], tools: [], - sdkTools: [], + builtInTools: [], mcpServers: [], children: [], parent: null, @@ -47,22 +81,20 @@ export class AgentHandle extends AbstractAgentHandle { const containerInfo = createContainer(rootAgent) - super(anthropicClient, containerInfo, store) + super({ + clients, + adapters, + containerInfo, + store, + }) this.element = element this.mode = mode + this.wsFactory = responsesWSFactory } 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 { @@ -72,7 +104,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) { @@ -83,8 +115,48 @@ 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) { + 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 + 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') @@ -95,6 +167,30 @@ 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.') + } + 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 + + this.initialProps = { provider, model, websocket } + + agent.client = await ensureProviderClient(this.clients, provider) + + if (agent.props.provider === 'openai' && agent.props.websocket) { + this.adapters.openai = createOpenAIAdapter({ + websocket: true, + _responsesWSFactory: this.wsFactory, + }) + } + return agent } } diff --git a/packages/agentry/src/handles/SubagentHandle.ts b/packages/agentry/src/handles/SubagentHandle.ts index 7533f7b..38bb94d 100644 --- a/packages/agentry/src/handles/SubagentHandle.ts +++ b/packages/agentry/src/handles/SubagentHandle.ts @@ -1,14 +1,19 @@ -import Anthropic from '@anthropic-ai/sdk' import { createContainer } from '../reconciler' import { createAgentStore } from '../store' import { type AgentInstance, type SubagentInstance, + InstanceType, 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 { createOpenAIAdapter } from '../providers/openai' +import { ensureProviderClient } from '../providers/clientResolver' export class SubagentHandle extends AbstractAgentHandle { private subagent: SubagentInstance @@ -18,22 +23,28 @@ export class SubagentHandle extends AbstractAgentHandle { constructor( subagent: SubagentInstance, options: { - client: Anthropic + 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 } const store = createAgentStore() const container: AgentInstance = { - type: 'agent', + type: InstanceType.Agent, props: { ...subagent.props }, - client, + client: undefined, engine: null, systemParts: [], tools: [], - sdkTools: [], + builtInTools: [], mcpServers: [], children: [], parent: null, @@ -42,7 +53,12 @@ export class SubagentHandle extends AbstractAgentHandle { const containerInfo = createContainer(container) - super(client, containerInfo, store) + super({ + clients, + adapters: createDefaultAdapters(), + containerInfo, + store, + }) this.subagent = subagent @@ -63,7 +79,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) { @@ -92,25 +108,38 @@ export class SubagentHandle extends AbstractAgentHandle { ) } - // Copy props from subagent to the rendered instance - if (!agentInstance.props.model && this.subagent.props.model) { - agentInstance.props.model = this.subagent.props.model + // Inherit subagent props into the rendered instance + const sub = this.subagent.props + agentInstance.props.provider ??= sub.provider + agentInstance.props.model ??= sub.model + if (sub.maxTokens !== undefined) + agentInstance.props.maxTokens = sub.maxTokens + if (sub.temperature !== undefined) + agentInstance.props.temperature = sub.temperature + if (sub.maxIterations !== undefined) + agentInstance.props.maxIterations = sub.maxIterations + if (sub.stopSequences !== undefined) + agentInstance.props.stopSequences = sub.stopSequences + if (sub.stream !== undefined) agentInstance.props.stream = sub.stream + + const provider = agentInstance.props.provider + if (!provider) { + throw new Error('Provider is required on the subagent instance.') } + agentInstance.client = await ensureProviderClient(this.clients, provider) - const { temperature, maxIterations, stopSequences, stream } = - this.subagent.props - Object.assign(agentInstance.props, { - maxTokens: this.subagent.props.maxTokens, - ...(temperature !== undefined && { temperature }), - ...(maxIterations !== undefined && { maxIterations }), - ...(stopSequences !== undefined && { stopSequences }), - ...(stream !== undefined && { stream }), - }) + if ( + agentInstance.props.provider === 'openai' && + agentInstance.props.websocket + ) { + this.adapters.openai = createOpenAIAdapter({ websocket: true }) + } return agentInstance } protected override cleanup(): void { + super.cleanup() if (this.abortSignal && this.abortHandler) { this.abortSignal.removeEventListener('abort', this.abortHandler) } diff --git a/packages/agentry/src/handles/index.ts b/packages/agentry/src/handles/index.ts index 5cc30e5..0d71ab6 100644 --- a/packages/agentry/src/handles/index.ts +++ b/packages/agentry/src/handles/index.ts @@ -1,3 +1,2 @@ -export { AbstractAgentHandle } from './AbstractAgentHandle' export { AgentHandle } from './AgentHandle' export { SubagentHandle } from './SubagentHandle' 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..72b0257 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, @@ -6,7 +5,7 @@ import type { SubagentInstance, AgentToolInstance, ToolInstance, - SdkToolInstance, + BuiltInToolInstance, SystemInstance, ContextInstance, MessageInstance, @@ -16,7 +15,7 @@ import type { AgentComponentProps, AgentToolComponentProps, ToolComponentProps, - SdkToolComponentProps, + BuiltInToolComponentProps, SystemComponentProps, ContextComponentProps, MessageComponentProps, @@ -24,20 +23,37 @@ import type { MCPServerComponentProps, ConditionComponentProps, } from './types' -import { isAgentInstance, isInstance } from './types' -import type { AgentProps, CompactionControl, Model } from '../types' +import { InstanceType, isAgentInstance, isInstance } from './types' +import type { + AgentProps, + BaseAgentProps, + CompactionControl, + Model, +} from '../types' -type RequiredAgentProps = { [K in keyof Required]: AgentProps[K] } +/** Requires all AgentProps keys, with variant fields distributed across the union. */ +type AgentPropsAllKeys = { + [K in keyof Required]: BaseAgentProps[K] +} & { + provider: AgentProps['provider'] + 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 + betas?: string[] + websocket?: boolean } interface PropagatedSettings { + provider?: AgentProps['provider'] stream?: boolean temperature?: number stopSequences?: string[] @@ -47,25 +63,14 @@ interface PropagatedSettings { model?: Model thinking?: AgentProps['thinking'] betas?: string[] + websocket?: boolean } -export type ElementType = - | 'agent' - | 'agent_tool' - | 'tool' - | 'sdk_tool' - | 'system' - | 'context' - | 'message' - | 'tools' - | 'mcp_server' - | 'condition' - export type ElementProps = | AgentComponentProps | AgentToolComponentProps | ToolComponentProps - | SdkToolComponentProps + | BuiltInToolComponentProps | SystemComponentProps | ContextComponentProps | MessageComponentProps @@ -73,33 +78,33 @@ export type ElementProps = | MCPServerComponentProps | ConditionComponentProps +// oxlint-disable-next-line max-params -- called by reconciler host config with fixed arity export function createInstance( - type: ElementType, + type: InstanceType, props: ElementProps, - rootContainer: Instance | unknown, - // eslint-disable-next-line @typescript-eslint/no-unused-vars + rootContainer: Instance | object | null, _hostContext: PropagatedSettings = {}, ): Instance { switch (type) { - case 'agent': + case InstanceType.Agent: return createAgentInstance(props as AgentComponentProps, rootContainer) - case 'agent_tool': + case InstanceType.AgentTool: return createAgentToolInstance(props as AgentToolComponentProps) - case 'tool': + case InstanceType.Tool: return createToolInstance(props as ToolComponentProps) - case 'sdk_tool': - return createSdkToolInstance(props as SdkToolComponentProps) - case 'system': + case InstanceType.BuiltInTool: + return createBuiltInToolInstance(props as BuiltInToolComponentProps) + case InstanceType.System: return createSystemInstance(props as SystemComponentProps) - case 'context': + case InstanceType.Context: return createContextInstance(props as ContextComponentProps) - case 'message': + case InstanceType.Message: return createMessageInstance(props as MessageComponentProps) - case 'tools': + case InstanceType.Tools: return createToolsContainerInstance(props as ToolsContainerProps) - case 'mcp_server': + case InstanceType.McpServer: return createMCPServerInstance(props as MCPServerComponentProps) - case 'condition': + case InstanceType.Condition: return createConditionInstance(props as ConditionComponentProps) default: throw new Error(`Unknown element type: ${type}`) @@ -108,7 +113,7 @@ export function createInstance( function createAgentInstance( props: AgentComponentProps, - rootContainer?: unknown, + rootContainer?: Instance | object | null, ): AgentInstance { if ( !rootContainer || @@ -119,12 +124,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', + type: InstanceType.Agent, props: { + provider, model: props.model, name: props.name, description: props.description, @@ -135,29 +142,25 @@ function createAgentInstance( stream: props.stream ?? true, compactionControl: props.compactionControl, thinking: props.thinking, - betas: props.betas, + betas: props.provider === 'anthropic' ? props.betas : undefined, + websocket: props.provider === 'openai' ? props.websocket : undefined, onMessage: props.onMessage, onComplete: props.onComplete, onError: props.onError, onStepFinish: props.onStepFinish, - } satisfies RequiredAgentProps, + } satisfies AgentPropsAllKeys as AgentProps, client, engine: null, systemParts: [], tools: [], - sdkTools: [], + builtInTools: [], mcpServers: [], children: [], parent: null, store, } - if ( - rootContainer && - isInstance(rootContainer) && - isAgentInstance(rootContainer) && - props.model - ) { + if (props.model) { rootContainer.props.model = props.model } @@ -166,7 +169,7 @@ function createAgentInstance( function createToolInstance(props: ToolComponentProps): ToolInstance { return { - type: 'tool', + type: InstanceType.Tool, tool: props.tool, parent: null, } @@ -177,7 +180,7 @@ function createAgentToolInstance( ): AgentToolInstance { const { agentTool } = props return { - type: 'agent_tool', + type: InstanceType.AgentTool, name: agentTool.name, description: agentTool.description, parameters: agentTool.parameters, @@ -187,9 +190,11 @@ function createAgentToolInstance( } } -function createSdkToolInstance(props: SdkToolComponentProps): SdkToolInstance { +function createBuiltInToolInstance( + props: BuiltInToolComponentProps, +): BuiltInToolInstance { return { - type: 'sdk_tool', + type: InstanceType.BuiltInTool, tool: props.tool, parent: null, } @@ -197,7 +202,7 @@ function createSdkToolInstance(props: SdkToolComponentProps): SdkToolInstance { function createSystemInstance(props: SystemComponentProps): SystemInstance { return { - type: 'system', + type: InstanceType.System, content: reactNodeToString(props.children), cache: props.cache, parent: null, @@ -206,7 +211,7 @@ function createSystemInstance(props: SystemComponentProps): SystemInstance { function createContextInstance(props: ContextComponentProps): ContextInstance { return { - type: 'context', + type: InstanceType.Context, content: reactNodeToString(props.children), cache: props.cache, parent: null, @@ -236,20 +241,20 @@ function createMessageInstance(props: MessageComponentProps): MessageInstance { const content = props.rawContent ?? reactNodeToString(props.children) return { - type: 'message', + type: InstanceType.Message, message: { role: props.role, - content: content, + content, }, parent: null, } } function createToolsContainerInstance( - _props: ToolsContainerProps, // eslint-disable-line @typescript-eslint/no-unused-vars + _props: ToolsContainerProps, ): ToolsContainerInstance { return { - type: 'tools_container', + type: InstanceType.Tools, children: [], parent: null, } @@ -259,7 +264,7 @@ function createMCPServerInstance( props: MCPServerComponentProps, ): MCPServerInstance { return { - type: 'mcp_server', + type: InstanceType.McpServer, config: { type: 'url', name: props.name, @@ -275,9 +280,11 @@ function createConditionInstance( props: ConditionComponentProps, ): ConditionInstance { return { - type: 'condition', + type: InstanceType.Condition, when: props.when, - isActive: false, + model: props.model, + provider: props.provider, + isActive: typeof props.when === 'boolean' ? props.when : false, children: [], parent: null, } @@ -288,21 +295,22 @@ 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.`, ) } return { - type: 'subagent', + type: InstanceType.Subagent, name: props.name, description: props.description, props: { + provider: props.provider ?? inherited.provider, model, name: props.name, description: props.description, @@ -320,16 +328,23 @@ 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.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, onError: props.onError, onStepFinish: props.onStepFinish, - } satisfies RequiredAgentProps, + } satisfies AgentPropsAllKeys as AgentProps, systemParts: [], tools: [], - sdkTools: [], + builtInTools: [], mcpServers: [], children: [], parent: null, diff --git a/packages/agentry/src/instances/index.ts b/packages/agentry/src/instances/index.ts index 40a57cb..3e5b838 100644 --- a/packages/agentry/src/instances/index.ts +++ b/packages/agentry/src/instances/index.ts @@ -1,12 +1,2 @@ export * from './types' -export { - createInstance, - type ElementType, - type ElementProps, -} from './createInstance' -export { - isSubagentInstance, - isMCPServerInstance, - isAgentLike, - type AgentLike, -} from './types' +export { createInstance, type ElementProps } from './createInstance' diff --git a/packages/agentry/src/instances/types.ts b/packages/agentry/src/instances/types.ts index 0b4e94e..8bc0551 100644 --- a/packages/agentry/src/instances/types.ts +++ b/packages/agentry/src/instances/types.ts @@ -1,104 +1,119 @@ -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, - SdkTool, + BuiltInTool, AgentToolFunction, InternalAgentTool, } from '../types' +import type { JsonValue } from '../types/json' 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 } + +export enum InstanceType { + Agent = 'agent', + Tool = 'tool', + BuiltInTool = 'built_in_tool', + System = 'system', + Context = 'context', + Message = 'message', + McpServer = 'mcp_server', + Subagent = 'subagent', + AgentTool = 'agent_tool', + Tools = 'tools', + Condition = 'condition', +} export interface BaseInstance { - type: string + type: InstanceType parent: Instance | null } export interface AgentInstance extends BaseInstance { - type: 'agent' + type: InstanceType.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[] + builtInTools: BuiltInTool[] + mcpServers: MCPServerConfig[] children: Instance[] store: AgentStore } export interface ToolInstance extends BaseInstance { - type: 'tool' + type: InstanceType.Tool tool: InternalTool } -export interface SdkToolInstance extends BaseInstance { - type: 'sdk_tool' - tool: SdkTool +export interface BuiltInToolInstance extends BaseInstance { + type: InstanceType.BuiltInTool + tool: BuiltInTool } export interface SystemInstance extends BaseInstance { - type: 'system' + type: InstanceType.System content: string cache?: 'ephemeral' } export interface ContextInstance extends BaseInstance { - type: 'context' + type: InstanceType.Context content: string cache?: 'ephemeral' } export interface MessageInstance extends BaseInstance { - type: 'message' - message: BetaMessageParam + type: InstanceType.Message + message: AgentMessageParam } export interface MCPServerInstance extends BaseInstance { - type: 'mcp_server' - config: BetaRequestMCPServerURLDefinition + type: InstanceType.McpServer + config: MCPServerConfig } export interface ToolsContainerInstance extends BaseInstance { - type: 'tools_container' + type: InstanceType.Tools children: Instance[] } export interface SubagentInstance extends BaseInstance { - type: 'subagent' + type: InstanceType.Subagent name: string description?: string props: AgentProps children: Instance[] systemParts: Array<{ content: string; cache?: 'ephemeral' }> tools: InternalTool[] - sdkTools: SdkTool[] - mcpServers: BetaRequestMCPServerURLDefinition[] + builtInTools: BuiltInTool[] + mcpServers: MCPServerConfig[] agentNode: React.ReactNode | null } export interface AgentToolInstance extends BaseInstance { - type: 'agent_tool' + type: InstanceType.AgentTool name: string description: string parameters: z.ZodType - jsonSchema: Record + jsonSchema: Record agent: AgentToolFunction } -export interface ConditionInstance extends BaseInstance { - type: '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 @@ -107,7 +122,7 @@ export type Instance = | SubagentInstance | AgentToolInstance | ToolInstance - | SdkToolInstance + | BuiltInToolInstance | SystemInstance | ContextInstance | MessageInstance @@ -115,8 +130,8 @@ export type Instance = | MCPServerInstance | ConditionInstance -export interface AgentComponentProps extends AgentProps { - client?: Anthropic +export type AgentComponentProps = AgentProps & { + client?: ProviderClientMap[keyof ProviderClientMap] children?: React.ReactNode } @@ -128,8 +143,8 @@ export interface AgentToolComponentProps { agentTool: InternalAgentTool } -export interface SdkToolComponentProps { - tool: SdkTool +export interface BuiltInToolComponentProps { + tool: BuiltInTool } export interface SystemComponentProps { @@ -145,92 +160,95 @@ 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 { 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 === 'agent' + return instance.type === InstanceType.Agent } export function isToolInstance(instance: Instance): instance is ToolInstance { - return instance.type === 'tool' + return instance.type === InstanceType.Tool } -export function isSdkToolInstance( +export function isBuiltInToolInstance( instance: Instance, -): instance is SdkToolInstance { - return instance.type === 'sdk_tool' +): instance is BuiltInToolInstance { + return instance.type === InstanceType.BuiltInTool } export function isSystemInstance( instance: Instance, ): instance is SystemInstance { - return instance.type === 'system' + return instance.type === InstanceType.System } export function isContextInstance( instance: Instance, ): instance is ContextInstance { - return instance.type === 'context' + return instance.type === InstanceType.Context } export function isMessageInstance( instance: Instance, ): instance is MessageInstance { - return instance.type === 'message' + return instance.type === InstanceType.Message } export function isToolsContainerInstance( instance: Instance, ): instance is ToolsContainerInstance { - return instance.type === 'tools_container' + return instance.type === InstanceType.Tools } export function isSubagentInstance( instance: Instance, ): instance is SubagentInstance { - return instance.type === 'subagent' + return instance.type === InstanceType.Subagent } export function isAgentLike(instance: Instance): instance is AgentLike { - return instance.type === 'agent' || instance.type === 'subagent' + return ( + instance.type === InstanceType.Agent || + instance.type === InstanceType.Subagent + ) } export function isMCPServerInstance( instance: Instance, ): instance is MCPServerInstance { - return instance.type === 'mcp_server' + return instance.type === InstanceType.McpServer } export function isAgentToolInstance( instance: Instance, ): instance is AgentToolInstance { - return instance.type === 'agent_tool' + return instance.type === InstanceType.AgentTool } export function isConditionInstance( instance: Instance, ): instance is ConditionInstance { - return instance.type === 'condition' + return instance.type === InstanceType.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..e429836 --- /dev/null +++ b/packages/agentry/src/openai.ts @@ -0,0 +1,35 @@ +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 { + Agent, + Tool, + AgentTool, + System, + Context, + Message, + Tools, + Condition, + WebSearch, + CodeExecution, + MCP, + useExecutionState, + useMessages, + useAgentState, +} +export type { AgentResult } from './types' +export { createOpenAIAdapter } from './providers/openai' diff --git a/packages/agentry/src/providers/anthropic.ts b/packages/agentry/src/providers/anthropic.ts new file mode 100644 index 0000000..7682358 --- /dev/null +++ b/packages/agentry/src/providers/anthropic.ts @@ -0,0 +1,246 @@ +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 { emitSyntheticEvents } from './syntheticEvents' +import { + isCodeExecutionTool, + isMemoryTool, + isWebSearchTool, +} from '../types/tools' +import { toApiTool } from '../tools' +import type { BuiltInTool } from '../types/tools' +import type { JsonObject } from '../types/json' + +export 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) { + switch (block.type) { + case 'text': + content.push({ type: 'text', text: block.text }) + break + case 'thinking': + // Anthropic thinking blocks in request history require provider-generated signatures. + // We keep only user/assistant text and tool blocks when replaying history. + break + case 'tool_use': + content.push({ + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input, + }) + break + case 'tool_result': + content.push({ + type: 'tool_result', + tool_use_id: block.tool_use_id, + content: block.content, + is_error: block.is_error, + }) + break + default: + console.warn( + `[agentry] Dropping unrecognized message block type "${(block as { type: string }).type}" during Anthropic message conversion`, + ) + } + } + // Thinking blocks are dropped (no preserved signatures), so guard against + // an empty content array which the Anthropic API rejects. + if (message.role === 'assistant' && content.length === 0) { + content.push({ type: 'text', text: '' }) + } + return { role: message.role, content } +} + +function toAgentBlocks(content: BetaContentBlock[]): AgentContentBlock[] { + const blocks: AgentContentBlock[] = [] + for (const block of content) { + switch (block.type) { + case 'text': + blocks.push({ type: 'text', text: block.text }) + break + case 'thinking': + blocks.push({ type: 'thinking', thinking: block.thinking }) + break + case 'tool_use': { + let input: JsonObject + if (typeof block.input === 'object' && block.input !== null) { + input = block.input as JsonObject + } else { + console.warn( + `[agentry] Anthropic tool "${block.name}": non-object input coerced to {} (received ${typeof block.input})`, + ) + input = {} + } + blocks.push({ + type: 'tool_use', + id: block.id, + name: block.name, + input, + }) + break + } + default: + console.warn( + `[agentry] Unrecognized content block type "${(block as { type: string }).type}" — block dropped`, + ) + } + } + return blocks +} + +function toApiBuiltInTool(tool: BuiltInTool): BetaToolUnion { + if (isCodeExecutionTool(tool)) { + return { type: 'code_execution_20250825', name: 'code_execution' } + } + if (isWebSearchTool(tool)) { + const { type: _, ...rest } = tool + return { type: 'web_search_20250305', ...rest } + } + if (isMemoryTool(tool)) { + const { type: _, memoryHandlers: _handlers, ...rest } = tool + return { type: 'memory_20250818', ...rest } as BetaMemoryTool20250818 + } + throw new Error( + `[agentry] Unknown built-in tool type: ${JSON.stringify(tool)}`, + ) +} + +function resolveAnthropicThinking( + thinking: NormalizedTurnRequest['thinking'], +): MessageCreateParams['thinking'] { + if (thinking?.type === 'enabled' && 'budget_tokens' in thinking) { + return { type: 'enabled' as const, budget_tokens: thinking.budget_tokens } + } + if (thinking?.type === 'disabled') { + return { type: 'disabled' as const } + } + return undefined +} + +export const anthropicAdapter: ProviderAdapter<'anthropic'> = { + name: 'anthropic', + async createTurn( + client: Anthropic, + request: NormalizedTurnRequest, + ): Promise { + const tools: BetaToolUnion[] = [ + ...request.tools.map(toApiTool), + ...request.builtInTools.map(toApiBuiltInTool), + ...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.builtInTools.some(isCodeExecutionTool)) { + betas.add(ANTHROPIC_BETAS.CODE_EXECUTION) + } + if (request.builtInTools.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' && + 'budget_tokens' in request.thinking && + request.thinking.interleaved + ) { + 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: resolveAnthropicThinking(request.thinking), + } + + let response: BetaMessage + if (request.stream) { + let accumulatedText = '' + const stream = client.beta.messages.stream(params, { + signal: request.signal, + }) + stream.on('text', (text) => { + accumulatedText += text + request.onStream({ type: 'text', text, accumulated: accumulatedText }) + }) + 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 }, + ) + } + + 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, + }, + }, + } + }, +} diff --git a/packages/agentry/src/providers/clientResolver.ts b/packages/agentry/src/providers/clientResolver.ts new file mode 100644 index 0000000..a6743ad --- /dev/null +++ b/packages/agentry/src/providers/clientResolver.ts @@ -0,0 +1,106 @@ +import type Anthropic from '@anthropic-ai/sdk' +import type OpenAI from 'openai' +import { providerDisplayNames, type ProviderName } from '../types/provider' +import type { ProviderClientMap } from './types' + +type ProviderClient = ProviderClientMap[ProviderName] + +const sharedDefaultClients: Partial = {} +// Stores in-flight promises so concurrent callers share one SDK instance instead of creating duplicates. +const pendingClients: Partial>> = + {} + +async function createDefaultAnthropicClient(): Promise< + InstanceType +> { + let AnthropicClass: typeof Anthropic + try { + ;({ default: AnthropicClass } = await import('@anthropic-ai/sdk')) + } catch (err) { + throw new Error( + 'No Anthropic client provided and @anthropic-ai/sdk is not installed. ' + + 'Either pass a client to createAI() or install @anthropic-ai/sdk.', + { cause: err }, + ) + } + return new AnthropicClass() +} + +async function createDefaultOpenAIClient(): Promise< + InstanceType +> { + let OpenAIClass: typeof OpenAI + try { + ;({ default: OpenAIClass } = await import('openai')) + } catch (err) { + throw new Error( + 'No OpenAI client provided and openai is not installed. ' + + 'Either pass a client to createAI() or install openai.', + { cause: err }, + ) + } + return new OpenAIClass() +} + +const envVarNames: Record = { + anthropic: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', +} + +const clientFactories: Record Promise> = { + anthropic: createDefaultAnthropicClient, + openai: createDefaultOpenAIClient, +} + +async function getSharedDefaultClient( + provider: ProviderName, +): Promise { + const envVar = envVarNames[provider] + if (!process.env[envVar]) { + throw new Error( + `No ${providerDisplayNames[provider]} client configured. Provide clients.${provider} or set ${envVar}.`, + ) + } + + if (!sharedDefaultClients[provider]) { + pendingClients[provider] ??= clientFactories[provider]().then( + (client) => { + Object.assign(sharedDefaultClients, { [provider]: client }) + pendingClients[provider] = undefined + return client + }, + (err) => { + pendingClients[provider] = undefined + throw err + }, + ) + return pendingClients[provider]! + } + + return sharedDefaultClients[provider]! +} + +export async function ensureProviderClient( + clients: Partial, + provider: ProviderName, +): Promise { + const configured = clients[provider] + if (configured) { + return configured + } + const client = await getSharedDefaultClient(provider) + // Write the resolved client back into the caller's map so subsequent calls + // hit the fast path (configured check above) instead of going through the + // shared cache again. Object.assign avoids a cast since TypeScript can't + // narrow Partial[provider] through a union-typed key. + Object.assign(clients, { [provider]: client }) + return client +} + +/** For testing only — clears the module-level default client cache. */ +export function resetSharedDefaultClients(): void { + sharedDefaultClients.anthropic = undefined + sharedDefaultClients.openai = undefined + pendingClients.anthropic = undefined + pendingClients.openai = undefined +} diff --git a/packages/agentry/src/providers/index.ts b/packages/agentry/src/providers/index.ts new file mode 100644 index 0000000..177803b --- /dev/null +++ b/packages/agentry/src/providers/index.ts @@ -0,0 +1,17 @@ +import { anthropicAdapter } from './anthropic' +import { openaiAdapter } from './openai' +import type { ProviderAdapter } from './types' +import type { ProviderName } from '../types/provider' + +export type { ProviderAdapter, ProviderClientMap } from './types' +export { createOpenAIAdapter } from './openai' + +export function createDefaultAdapters(): Record< + ProviderName, + ProviderAdapter +> { + 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..2cb98a2 --- /dev/null +++ b/packages/agentry/src/providers/openai.ts @@ -0,0 +1,881 @@ +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' +import { emitSyntheticEvents } from './syntheticEvents' +import { debug } from '../debug' + +type OpenAIResponseCreateParams = + OpenAI.Responses.ResponseCreateParamsNonStreaming +type OpenAIResponseCreateParamsStreaming = + OpenAI.Responses.ResponseCreateParamsStreaming +type OpenAIResponseResult = OpenAI.Responses.Response +type OpenAIResponseStreamEvent = OpenAI.Responses.ResponseStreamEvent +type OpenAIInputItem = OpenAI.Responses.ResponseInputItem +type OpenAIResponsesClientEvent = OpenAI.Responses.ResponsesClientEvent + +/** + * Minimal interface for the WebSocket emitter — compatible with ResponsesWS from the openai SDK, + * but injectable for testing without a real WebSocket connection. + */ +export interface ResponsesWSLike { + on( + event: 'event', + handler: (e: OpenAI.Responses.ResponsesServerEvent) => void, + ): void + on(event: 'error', handler: (e: Error) => void): void + off( + event: 'event', + handler: (e: OpenAI.Responses.ResponsesServerEvent) => void, + ): void + off(event: 'error', handler: (e: Error) => void): void + send(event: OpenAIResponsesClientEvent): void + close(): void + /** Present on real ResponsesWS; absent on mocks */ + socket?: { + readyState: number + once(event: 'open', handler: () => void): void + once(event: 'error', handler: (err: Error) => void): void + once(event: 'close', handler: () => void): void + off(event: 'open', handler: () => void): void + off(event: 'error', handler: (err: Error) => void): void + off(event: 'close', handler: () => void): void + } +} + +/** Typed error that carries the OpenAI error code for retry decisions */ +export class OpenAITurnError extends Error { + readonly code: string | undefined + constructor(message: string, code?: string) { + super(message) + this.name = 'OpenAITurnError' + this.code = code + } +} + +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 toProviderOutputEventItem( + item: OpenAIResponseResult['output'][number], +): { itemType: string; item: JsonObject } { + return { + itemType: item.type, + item: item as unknown as JsonObject, + } +} + +export function getErrorEventDetails(event: { + message?: unknown + code?: unknown + error?: { message?: unknown; code?: unknown } | null +}): { message: string; code?: string } { + let message: string + if (typeof event.message === 'string' && event.message.length > 0) { + message = event.message + } else if ( + typeof event.error?.message === 'string' && + event.error.message.length > 0 + ) { + message = event.error.message + } else { + message = JSON.stringify(event) + } + + let codeValue: string | undefined + if (typeof event.code === 'string') { + codeValue = event.code + } else if (typeof event.error?.code === 'string') { + codeValue = event.error.code + } + + return { + message, + code: codeValue, + } +} + +export 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 itemCountBefore = input.length + for (const block of message.content) { + if (block.type === 'text' && block.text !== undefined) { + input.push({ role: message.role, content: block.text }) + } else if (block.type === 'tool_use') { + input.push({ + type: 'function_call', + call_id: block.id, + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + } as OpenAIInputItem) + } else if (block.type === 'tool_result') { + const output = stringifyContent(block.content) + input.push({ + type: 'function_call_output', + call_id: block.tool_use_id, + output: block.is_error ? `[ERROR] ${output}` : output, + } as OpenAIInputItem) + } else if (block.type === 'thinking') { + // Thinking blocks have no OpenAI equivalent — intentionally skipped + } else { + console.warn( + `[agentry] Unrecognized message block type "${(block as { type: string }).type}" skipped during input conversion`, + ) + } + } + // If we emitted nothing for an assistant message (e.g. thinking-only turn), + // insert an empty placeholder to preserve conversation structure. + if (message.role === 'assistant' && input.length === itemCountBefore) { + input.push({ role: 'assistant', content: '' }) + } + } + return input +} + +function parseOpenAIResponse( + response: OpenAIResponseResult, +): NormalizedTurnResponse { + const content: AgentContentBlock[] = [] + const output = Array.isArray(response.output) ? response.output : [] + for (const item of output) { + switch (item.type) { + case 'message': { + if (!Array.isArray(item.content)) break + for (const part of item.content) { + if (part.type === 'output_text' && typeof part.text === 'string') { + content.push({ type: 'text', text: part.text }) + } + } + break + } + case 'function_call': { + const callId = item.call_id ?? item.id + if (!callId) { + throw new Error( + `[agentry] OpenAI returned a function_call with no call_id or id: ${JSON.stringify(item)}`, + ) + } + if (!item.name) { + throw new Error( + `[agentry] OpenAI returned a function_call with no name: ${JSON.stringify(item)}`, + ) + } + let input: JsonObject = {} + if (typeof item.arguments === 'string') { + let parsed: unknown + try { + parsed = JSON.parse(item.arguments) + } catch (e) { + throw new Error( + `[agentry] OpenAI tool call "${item.name}": failed to parse arguments: ${item.arguments}`, + { cause: e }, + ) + } + if ( + typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) + ) { + input = parsed as JsonObject + } else { + throw new Error( + `[agentry] OpenAI tool call "${item.name}": expected object arguments, got ${typeof parsed}`, + ) + } + } + content.push({ + type: 'tool_use', + id: callId, + name: item.name, + input, + }) + break + } + case 'reasoning': { + if (!Array.isArray(item.summary)) break + const text = (item.summary as Array<{ type: string; text?: string }>) + .filter((s) => s.type === 'summary_text') + .map((s) => s.text ?? '') + .join('\n') + if (text) content.push({ type: 'thinking', thinking: text }) + break + } + case 'web_search_call': + case 'code_interpreter_call': + case 'file_search_call': + case 'image_generation_call': + case 'computer_call': + case 'mcp_call': + case 'mcp_list_tools': + case 'mcp_approval_request': + case 'compaction': + case 'local_shell_call': + case 'shell_call': + case 'shell_call_output': + case 'apply_patch_call': + case 'apply_patch_call_output': + case 'custom_tool_call': { + // Tool metadata and server-side tool-call records are surfaced in + // subsequent assistant message items, so they are intentionally skipped. + break + } + default: { + const unhandled: never = item + console.warn( + `[agentry] Unrecognized output item type "${(unhandled as { type: string }).type}"`, + ) + } + } + } + + let stopReason: string + if (content.some((block) => block.type === 'tool_use')) { + stopReason = 'tool_use' + } else if (response.status === 'incomplete') { + stopReason = response.incomplete_details?.reason ?? 'length' + } else { + stopReason = 'end_turn' + } + + if (!response.usage) { + throw new Error( + '[agentry] OpenAI response missing usage field; cannot track token counts for compaction', + ) + } + + return { + message: { + content, + stop_reason: stopReason, + usage: { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + }, + }, + } +} + +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.builtInTools) { + 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 || sdkTool.blocked_domains + ? { + ...(sdkTool.allowed_domains && { + allowed_domains: sdkTool.allowed_domains, + }), + ...(sdkTool.blocked_domains && { + blocked_domains: sdkTool.blocked_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) + continue + } + + throw new Error( + `[agentry] Built-in tool "${sdkTool.type}" is not supported on OpenAI. ` + + `Remove this component or switch to an Anthropic provider.`, + ) + } + + for (const server of request.mcpServers) { + tools.push({ + type: 'mcp', + server_label: server.name, + server_url: server.url, + authorization: server.authorization_token ?? undefined, + allowed_tools: server.tool_configuration?.allowed_tools, + require_approval: 'never', + } satisfies OpenAI.Responses.Tool.Mcp) + } + + return tools +} + +/** + * Process a stream of OpenAI response events into a NormalizedTurnResponse. + * Shared by both HTTP streaming and WebSocket modes. + * + * Returns the normalized result plus the response ID (for WebSocket state tracking). + */ +async function processResponseStream( + stream: AsyncIterable, + request: NormalizedTurnRequest, +): Promise<{ result: NormalizedTurnResponse; responseId: string | null }> { + let accumulatedText = '' + let finalResponse: OpenAIResponseResult | null = null + + for await (const event of stream) { + if (event.type === 'response.output_text.delta') { + accumulatedText += event.delta + request.onStream({ + type: 'text', + text: event.delta, + accumulated: accumulatedText, + }) + } else if (event.type === 'response.reasoning_summary_text.delta') { + request.onStream({ type: 'thinking', text: event.delta }) + } else if (event.type === 'response.output_item.added') { + const item = event.item + request.onStream({ + type: 'provider_event', + ...toProviderOutputEventItem(item), + }) + if (item.type === 'function_call') { + const callId = 'call_id' in item ? (item.call_id as string) : undefined + const name = 'name' in item ? (item.name as string) : undefined + if (callId && name) { + request.onStream({ + type: 'tool_use_start', + toolName: name, + toolId: callId, + }) + } else { + throw new Error( + `[agentry] OpenAI stream: function_call item missing call_id or name: ${JSON.stringify(item)}`, + ) + } + } else if (item.type === 'code_interpreter_call') { + request.onStream({ + type: 'tool_use_start', + toolName: 'code_interpreter', + toolId: item.id, + }) + } else if (item.type === 'web_search_call') { + request.onStream({ + type: 'tool_use_start', + toolName: 'web_search', + toolId: item.id, + }) + } else if (item.type === 'mcp_call') { + request.onStream({ + type: 'tool_use_start', + toolName: `mcp:${item.name}`, + toolId: item.id, + }) + } else if (item.type === 'file_search_call') { + request.onStream({ + type: 'tool_use_start', + toolName: 'file_search', + toolId: item.id, + }) + } else if (item.type === 'image_generation_call') { + request.onStream({ + type: 'tool_use_start', + toolName: 'image_generation', + toolId: item.id, + }) + } + } else if (event.type === 'response.completed') { + finalResponse = event.response + } else if (event.type === 'response.incomplete') { + finalResponse = event.response + } else if (event.type === 'response.failed') { + const err = event.response.error + throw new OpenAITurnError( + `[agentry] OpenAI response failed: ${err?.message ?? JSON.stringify(err)}`, + err?.code as string | undefined, + ) + } else if (event.type === 'error') { + // Application-level error event from server (e.g. previous_response_not_found) + const { message, code } = getErrorEventDetails( + event as { + message?: unknown + code?: unknown + error?: { message?: unknown; code?: unknown } | null + }, + ) + throw new OpenAITurnError(`[agentry] OpenAI error: ${message}`, code) + } + } + + if (!finalResponse) { + throw new Error( + '[agentry] OpenAI stream ended without a response.completed event', + ) + } + + const result = parseOpenAIResponse(finalResponse) + request.onStream({ + type: 'message_complete', + stopReason: result.message.stop_reason ?? 'unknown', + }) + + return { result, responseId: finalResponse.id ?? null } +} + +export const openaiAdapter: ProviderAdapter<'openai'> = { + 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 oaiThinking = + request.thinking?.type === 'enabled' && 'effort' in request.thinking + ? request.thinking + : undefined + const basePayload = { + model: request.model, + input, + tools: tools.length ? tools : undefined, + max_output_tokens: request.maxTokens, + temperature: request.temperature, + ...(systemText ? { instructions: systemText } : {}), + ...(request.stopSequences?.length ? { stop: request.stopSequences } : {}), + ...(oaiThinking + ? { + reasoning: { + effort: oaiThinking.effort, + summary: oaiThinking.summary, + }, + } + : {}), + } + + if (request.stream) { + return streamOpenAITurn(client, basePayload, request) + } + + const payload: OpenAIResponseCreateParams = { + ...basePayload, + stream: false, + } + const response = await client.responses.create(payload, { + signal: request.signal, + }) + const output = Array.isArray(response.output) ? response.output : [] + for (const item of output) { + request.onStream({ + type: 'provider_event', + ...toProviderOutputEventItem(item), + }) + } + + const result = parseOpenAIResponse(response) + + emitSyntheticEvents( + result.message.content, + result.message.stop_reason, + request.onStream, + ) + + return result + }, +} + +async function streamOpenAITurn( + client: OpenAI, + basePayload: Omit, + request: NormalizedTurnRequest, +): Promise { + const payload: OpenAIResponseCreateParamsStreaming = { + ...basePayload, + stream: true as const, + } + const stream = await client.responses.create(payload, { + signal: request.signal, + }) + + const { result } = await processResponseStream( + stream as AsyncIterable, + request, + ) + return result +} + +/** + * Convert a ResponsesWSLike event emitter into an async iterable of stream events. + * Sends the given client event, then yields server events until a terminal event is received. + */ +async function* createWSEventStream( + ws: ResponsesWSLike, + wsEvent: OpenAIResponsesClientEvent, + signal: AbortSignal, +): AsyncGenerator { + const queue: Array = [] + let resolver: (() => void) | null = null + let error: OpenAITurnError | null = null + let done = false + + const wake = () => { + resolver?.() + resolver = null + } + + const onEvent = (serverEvent: OpenAI.Responses.ResponsesServerEvent) => { + queue.push(serverEvent as OpenAIResponseStreamEvent) + const t = serverEvent.type + if ( + t === 'response.completed' || + t === 'response.incomplete' || + t === 'response.failed' || + t === 'error' + ) { + done = true + } + wake() + } + + const onError = (err: Error) => { + // WebSocketError carries .error.code for specific error codes + const wsErr = err as { error?: { code?: string | null } } + const code = + typeof wsErr.error?.code === 'string' ? wsErr.error.code : undefined + error = new OpenAITurnError(err.message, code) + done = true + wake() + } + + const onAbort = () => { + const e = new Error('Request aborted') + e.name = 'AbortError' + error = e as OpenAITurnError + done = true + wake() + } + + ws.on('event', onEvent) + ws.on('error', onError) + signal.addEventListener('abort', onAbort) + + if (signal.aborted) { + onAbort() + } + + try { + ws.send(wsEvent) + + while (!done || queue.length > 0) { + if (queue.length > 0) { + yield queue.shift()! + } else { + await new Promise((resolve) => { + resolver = resolve + }) + } + } + + if (error) throw error + } finally { + ws.off('event', onEvent) + ws.off('error', onError) + signal.removeEventListener('abort', onAbort) + } +} + +/** Wait for the WebSocket socket to be open before sending */ +async function waitForOpen(ws: ResponsesWSLike): Promise { + if (!ws.socket) return // No socket (mock/test mode) + const socket = ws.socket + if (socket.readyState === 1) return // Already OPEN (WS.OPEN = 1) + if (socket.readyState === 2 || socket.readyState === 3) { + throw new Error('[agentry] WebSocket connection is closed') + } + // readyState === 0 (CONNECTING) — wait for it to open + await new Promise((resolve, reject) => { + const onOpen = () => { + cleanup() + resolve() + } + const onError = (err: unknown) => { + cleanup() + reject(err instanceof Error ? err : new Error(String(err))) + } + const onClose = () => { + cleanup() + reject(new Error('[agentry] WebSocket closed before opening')) + } + const cleanup = () => { + socket.off('open', onOpen) + socket.off('error', onError) + socket.off('close', onClose) + } + socket.once('open', onOpen) + socket.once('error', onError) + socket.once('close', onClose) + }) +} + +function isRetryableWSError(err: unknown): boolean { + if (err instanceof OpenAITurnError) { + return ( + err.code === 'previous_response_not_found' || + err.code === 'websocket_connection_limit_reached' + ) + } + if (err instanceof Error) { + return ( + err.message.includes('previous_response_not_found') || + err.message.includes('websocket_connection_limit_reached') + ) + } + return false +} + +/** + * Create a stateful OpenAI adapter. + * + * When `websocket: true`, uses a persistent WebSocket connection and sends only + * incremental input on continuation turns via `previous_response_id`, significantly + * reducing per-turn latency in long tool-calling loops. + * + * @example + * ```ts + * import { createOpenAIAdapter } from 'agentry/openai' + * import { createAI } from 'agentry' + * import OpenAI from 'openai' + * + * const ai = createAI({ + * providers: { openai: { client: new OpenAI(), websocket: true } }, + * }) + * + * const result = await ai.run(...) + * ``` + * + * @example advanced: manual lifecycle control + * ```ts + * import { createOpenAIAdapter } from 'agentry/openai' + * const wsAdapter = createOpenAIAdapter({ websocket: true }) + * wsAdapter.close() // explicit cleanup when done + * ``` + */ +export function createOpenAIAdapter(options?: { + /** Enable WebSocket transport + incremental input chaining */ + websocket?: boolean + /** + * Test-only: inject a custom WS factory instead of using ResponsesWS. + * The factory receives the OpenAI client and returns a ResponsesWSLike emitter. + * @internal + */ + _responsesWSFactory?: (client: OpenAI) => ResponsesWSLike +}): ProviderAdapter<'openai'> & { close(): void } { + if (!options?.websocket && !options?._responsesWSFactory) { + // Plain HTTP adapter — same behaviour as openaiAdapter, just adds close() + return { + ...openaiAdapter, + close() {}, + } + } + + let responsesWS: ResponsesWSLike | null = null + let previousResponseId: string | null = null + /** messages.length at the start of the last completed turn */ + let previousResponseMessageCount = 0 + + function resetContinuationState(): void { + previousResponseId = null + previousResponseMessageCount = 0 + } + + async function ensureWS(client: OpenAI): Promise { + if (!responsesWS) { + if (options?._responsesWSFactory) { + responsesWS = options._responsesWSFactory(client) + } else { + let ResponsesWSClass: new (client: OpenAI) => ResponsesWSLike + try { + const mod = await import('openai/resources/responses/ws') + ResponsesWSClass = mod.ResponsesWS as unknown as new ( + client: OpenAI, + ) => ResponsesWSLike + } catch (importErr) { + throw new Error( + '[agentry] WebSocket mode requires the "ws" package. Install it: npm install ws', + { cause: importErr }, + ) + } + responsesWS = new ResponsesWSClass(client) + } + } + return responsesWS + } + + return { + 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 oaiThinking = + request.thinking?.type === 'enabled' && 'effort' in request.thinking + ? request.thinking + : undefined + + const baseParams = { + model: request.model as OpenAIResponsesClientEvent['model'], + tools: tools.length ? tools : undefined, + max_output_tokens: request.maxTokens, + temperature: request.temperature, + ...(systemText ? { instructions: systemText } : {}), + ...(request.stopSequences?.length + ? { stop: request.stopSequences } + : {}), + ...(oaiThinking + ? { + reasoning: { + effort: oaiThinking.effort, + summary: oaiThinking.summary, + }, + } + : {}), + stream: true as const, + } + + const currentMessageCount = request.messages.length + const hadPreviousResponse = previousResponseId !== null + + let ws: ResponsesWSLike + try { + 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)}`, + ) + debug( + 'openai', + `WebSocket connection failed, falling back to HTTP: ${err instanceof Error ? err.message : String(err)}`, + ) + responsesWS = null + resetContinuationState() + return openaiAdapter.createTurn(client, request) + } + + try { + let wsClientEvent: OpenAIResponsesClientEvent + if (hadPreviousResponse) { + // Incremental turn: skip the assistant message at previousResponseMessageCount + // (it's stored server-side as previous_response_id) and send only new items + const incrementalMessages = request.messages.slice( + previousResponseMessageCount + 1, + ) + wsClientEvent = { + type: 'response.create', + ...baseParams, + previous_response_id: previousResponseId, + input: toOpenAIInput(incrementalMessages), + } + } else { + wsClientEvent = { + type: 'response.create', + ...baseParams, + input: toOpenAIInput(request.messages), + } + } + + const stream = createWSEventStream(ws, wsClientEvent, request.signal) + const { result, responseId } = await processResponseStream( + stream, + request, + ) + // Record state for the next turn + previousResponseMessageCount = currentMessageCount + 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`, + ) + // Reset state and retry with full context over HTTP + responsesWS?.close() + responsesWS = null + resetContinuationState() + return openaiAdapter.createTurn(client, request) + } + throw err + } + }, + + resetChain() { + resetContinuationState() + }, + + close() { + responsesWS?.close() + responsesWS = null + resetContinuationState() + }, + } +} 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/providers/types.ts b/packages/agentry/src/providers/types.ts new file mode 100644 index 0000000..ed891e3 --- /dev/null +++ b/packages/agentry/src/providers/types.ts @@ -0,0 +1,114 @@ +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, 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 + openai: OpenAI +} + +export interface OpenAIProviderConfig { + client?: OpenAI +} + +/** @internal Test-only key for injecting a mock ResponsesWS factory through provider config. */ +export const OPENAI_INTERNAL_WS_FACTORY: unique symbol = Symbol( + 'agentry.openai.internal_ws_factory', +) + +/** @internal */ +export type OpenAIProviderConfigInternal = OpenAIProviderConfig & { + [OPENAI_INTERNAL_WS_FACTORY]?: (client: OpenAI) => ResponsesWSLike +} + +export interface AnthropicProviderConfig { + client?: Anthropic +} + +/** Per-provider config: client instance + provider-specific options */ +export interface ProvidersConfig { + openai?: OpenAIProviderConfig + anthropic?: AnthropicProviderConfig +} + +// Compile-time check: ProviderClientMap keys must exactly match ProviderName +const _assertProviderClientMapKeys: Record = + {} as ProviderClientMap +void _assertProviderClientMapKeys + +// Compile-time check: ProvidersConfig keys must exactly match ProviderName +const _assertProvidersConfigKeys: Record = + {} as Required +void _assertProvidersConfigKeys + +export interface SystemBlock { + type: 'text' + text: string + cache_control?: { type: 'ephemeral' } +} + +export interface MCPServerConfig { + type: 'url' + name: string + url: string + authorization_token?: string + tool_configuration?: { + enabled?: boolean + allowed_tools?: string[] + } +} + +// 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 + system?: string | SystemBlock[] + messages: AgentMessageParam[] + tools: InternalTool[] + builtInTools: BuiltInTool[] + mcpServers: MCPServerConfig[] + stopSequences?: string[] + temperature?: number + thinking?: ThinkingConfig + /** Anthropic-specific beta features to enable. Ignored by other providers. */ + betas?: string[] + stream?: boolean + signal: AbortSignal + onStream: (event: AgentStreamEvent) => void +} + +export interface NormalizedTurnResponse { + message: AgentMessage +} + +export interface ProviderAdapter { + name: TName + createTurn( + client: ProviderClientMap[TName], + request: NormalizedTurnRequest, + ): Promise + /** Optional lifecycle hook — called when the owning agent handle is closed */ + close?(): void + /** Optional state hook — called when message history is replaced (e.g. compaction) */ + resetChain?(): void +} diff --git a/packages/agentry/src/reconciler/collectors.ts b/packages/agentry/src/reconciler/collectors.ts index 3caecaf..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,8 +41,8 @@ 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)) { - agent.sdkTools.push(child.tool) + } else if (isBuiltInToolInstance(child)) { + agent.builtInTools.push(child.tool) debug('reconciler', `SDK tool added: ${child.tool.name}`) } else if (isMCPServerInstance(child)) { agent.mcpServers.push(child.config) @@ -98,10 +98,10 @@ 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)) { - const index = agent.sdkTools.findIndex((t) => t === child.tool) + } else if (isBuiltInToolInstance(child)) { + const index = agent.builtInTools.findIndex((t) => t === child.tool) if (index >= 0) { - agent.sdkTools.splice(index, 1) + agent.builtInTools.splice(index, 1) debug('reconciler', `SDK tool removed: ${child.tool.name}`) } } else if (isMCPServerInstance(child)) { diff --git a/packages/agentry/src/reconciler/index.ts b/packages/agentry/src/reconciler/index.ts index 04b3f9d..51b9a05 100644 --- a/packages/agentry/src/reconciler/index.ts +++ b/packages/agentry/src/reconciler/index.ts @@ -1,4 +1,3 @@ -export { reconciler } from './reconciler' export { createContainer, updateContainer, @@ -6,4 +5,3 @@ export { unmountContainer, type ContainerInfo, } from './renderer' -export { diffProps, disposeOnIdle } from './utils' diff --git a/packages/agentry/src/reconciler/reconciler.ts b/packages/agentry/src/reconciler/reconciler.ts index e3ec5d4..8600aa1 100644 --- a/packages/agentry/src/reconciler/reconciler.ts +++ b/packages/agentry/src/reconciler/reconciler.ts @@ -13,12 +13,13 @@ import { isToolInstance, isConditionInstance, } from '../instances' -import { - createInstance, - type ElementType, - type ElementProps, -} from '../instances' -import type { AgentProps, CompactionControl, Model } from '../types' +import { createInstance, InstanceType, type ElementProps } from '../instances' +import type { + AgentProps, + CompactionControl, + Model, + ProviderModelOverride, +} from '../types' import { debug } from '../debug' import { diffProps, disposeOnIdle } from './utils' import { collectChild, uncollectChild } from './collectors' @@ -64,13 +65,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[] @@ -82,7 +89,7 @@ interface PropagatedSettings { } interface HostConfig { - type: ElementType + type: InstanceType props: ElementProps container: AgentInstance instance: Instance @@ -127,7 +134,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, @@ -143,6 +150,7 @@ export const reconciler = createReconciler< startSuspendingCommit() {}, suspendInstance() {}, waitForCommitToBeReady: () => null, + // oxlint-disable-next-line max-params -- React reconciler host config callback signature createInstance(type, props, rootContainer, hostContext, _internalHandle) { void _internalHandle return createInstance(type, props, rootContainer, hostContext) @@ -157,6 +165,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, @@ -214,6 +223,7 @@ export const reconciler = createReconciler< resetTextContent() {}, commitTextUpdate() {}, commitMount() {}, + // oxlint-disable-next-line max-params -- React reconciler host config callback signature commitUpdate(instance, _type, prevProps, nextProps, _internalHandle) { void _internalHandle const { changes, hasChanges } = diffProps(prevProps, nextProps) @@ -346,7 +356,7 @@ function removeChild(parent: Instance, child: Instance): void { disposeOnIdle(() => { if (isSubagentInstance(child)) { child.tools = [] - child.sdkTools = [] + child.builtInTools = [] child.systemParts = [] child.mcpServers = [] child.children = [] @@ -417,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/reconciler/renderer.ts b/packages/agentry/src/reconciler/renderer.ts index de310fe..b5b7101 100644 --- a/packages/agentry/src/reconciler/renderer.ts +++ b/packages/agentry/src/reconciler/renderer.ts @@ -4,13 +4,20 @@ 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, 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..03311cb 100644 --- a/packages/agentry/src/run/agent.ts +++ b/packages/agentry/src/run/agent.ts @@ -1,21 +1,27 @@ -import Anthropic from '@anthropic-ai/sdk' import type { ReactNode } from 'react' import { AgentHandle } from '../handles' +import type { AgentResult } from '../types' +import type { ProvidersConfig } from '../providers/types' export interface RunOptions { - /** anthropic client instance */ - client?: Anthropic + /** per-provider client and options */ + providers?: ProvidersConfig /** execution mode */ mode?: 'batch' | 'interactive' } +export interface CreateAgentOptions { + /** per-provider client and options */ + providers?: ProvidersConfig +} + /** * 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 +32,7 @@ export interface RunOptions { * @example interactive mode - returns handle for ongoing interaction * ```ts * const agent = await run( - * + * * You are a helpful assistant * * , @@ -49,7 +55,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 +63,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, + { providers: options.providers }, + mode, + ) if (mode === 'interactive') { return handle @@ -80,7 +90,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, { providers: options?.providers }) } diff --git a/packages/agentry/src/run/createAI.ts b/packages/agentry/src/run/createAI.ts new file mode 100644 index 0000000..502f4b4 --- /dev/null +++ b/packages/agentry/src/run/createAI.ts @@ -0,0 +1,140 @@ +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 { ProvidersConfig } from '../providers/types' +import type { CreateAgentOptions, RunOptions } from './agent' + +export interface AIDefaults { + providers?: ProvidersConfig + mode?: 'batch' | 'interactive' +} + +export interface AIBoundRunOptions { + providers?: ProvidersConfig + mode?: 'batch' | 'interactive' +} + +export interface AIBoundCreateAgentOptions { + providers?: ProvidersConfig +} + +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 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: mergeProviders(defaults.providers, options?.providers), + mode: options?.mode ?? defaults.mode, + } +} + +function mergeCreateAgentOptions( + defaults: AIDefaults, + options?: AIBoundCreateAgentOptions, +): CreateAgentOptions { + return { + providers: mergeProviders(defaults.providers, options?.providers), + } +} + +/** + * Create a pre-configured AI instance with bound default providers and mode. + * All calls to `ai.run()` and `ai.createAgent()` inherit the defaults, with + * per-call options merged on top. + * + * @example basic usage — shared clients across multiple runs + * ```ts + * const ai = createAI({ + * providers: { anthropic: { client: new Anthropic() } }, + * }) + * + * const result = await ai.run( + * + * Hello! + * + * ) + * console.log(result.content) + * ``` + * + * @example multi-provider setup with WebSocket enabled for OpenAI + * ```ts + * const ai = createAI({ + * providers: { + * openai: { client: new OpenAI() }, + * anthropic: { client: new Anthropic() }, + * }, + * }) + * ``` + * + * @example interactive mode default + * ```ts + * const ai = createAI({ + * providers: { anthropic: { client: new Anthropic() } }, + * mode: 'interactive', + * }) + * + * const handle = await ai.run( + * + * You are a helpful assistant + * + * ) + * const result = await handle.sendMessage('Hello!') + * ``` + */ +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..2bc2c04 100644 --- a/packages/agentry/src/run/index.ts +++ b/packages/agentry/src/run/index.ts @@ -1,5 +1,2 @@ -export { run, createAgent, type RunOptions } from './agent' - -export { runSubagent, type RunSubagentOptions } from './subagent' - -export { createRunAgent, type RunAgentOptions } from './runAgentFunction' +export { run, createAgent } from './agent' +export { createAI } from './createAI' diff --git a/packages/agentry/src/run/runAgentFunction.ts b/packages/agentry/src/run/runAgentFunction.ts index 379c1ab..9c64694 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) */ @@ -21,8 +26,9 @@ export interface RunAgentOptions { /** * Context for creating a spawn agent function */ -export interface RunAgentContext { - client: Anthropic +interface RunAgentContext { + clients?: Partial + provider?: ProviderName model?: Model signal?: AbortSignal } @@ -32,13 +38,13 @@ export interface RunAgentContext { * This function is attached to ToolContext and allows tool handlers to * programmatically spawn and execute agents on-demand. * - * @param context - The execution context (client, model, signal) + * @param context - The execution context (clients, model, signal) * @returns A runAgent function that can execute React agent elements * * @example * ```tsx * const runSubagent = createRunAgent({ - * client: anthropicClient, + * clients: { anthropic: anthropicClient }, * model: 'claude-sonnet-4', * signal: abortController.signal, * }) @@ -57,6 +63,12 @@ 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 ?? {} + const elementProps = agentElement.props as { name?: string maxTokens?: number @@ -66,23 +78,25 @@ 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, }) try { - const result = await handle.run() - return result + return await handle.run() } finally { handle.close() } diff --git a/packages/agentry/src/run/subagent.ts b/packages/agentry/src/run/subagent.ts index 58b3589..51efa5b 100644 --- a/packages/agentry/src/run/subagent.ts +++ b/packages/agentry/src/run/subagent.ts @@ -1,11 +1,14 @@ -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 + /** 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..c116587 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 { AgentStatus, type AgentState } from './types/state' +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 } } @@ -17,7 +17,7 @@ export type AgentStore = StoreApi export function createAgentStore(): AgentStore { return createStore((set) => ({ - executionState: { status: 'idle' }, + executionState: { status: AgentStatus.Idle }, messages: [], actions: { setExecutionState: (state) => set({ executionState: state }), @@ -26,7 +26,7 @@ export function createAgentStore(): AgentStore { removeMessage: ({ role, content }) => set((s) => ({ messages: s.messages.filter( - (m) => m.role !== role && m.content !== content, + (m) => m.role !== role || m.content !== content, ), })), setMessages: (messages) => set({ messages }), diff --git a/packages/agentry/src/tools/agentSyntheticTool.ts b/packages/agentry/src/tools/agentSyntheticTool.ts index f75371c..f522fa0 100644 --- a/packages/agentry/src/tools/agentSyntheticTool.ts +++ b/packages/agentry/src/tools/agentSyntheticTool.ts @@ -4,10 +4,11 @@ import type { SubagentInstance } from '../instances/types' import { parseToolInput, formatValidationError } from './defineTool' import { runSubagent } from '../run/subagent' import { createSubagentInstance } from '../instances/createInstance' +import { debug } from '../debug' -export const createAgentSyntheticTool = ( +export function createAgentSyntheticTool( agentTool: AgentToolInstance, -): InternalTool => { +): InternalTool { return { name: agentTool.name, description: agentTool.description, @@ -31,23 +32,45 @@ export const createAgentSyntheticTool = ( const agentElement = agentTool.agent(validatedInput) + // Extract stream from the JSX element's props, defaulting to false for subagents + const agentElementStream = + (agentElement.props as { stream?: boolean }).stream ?? false + const subagent: SubagentInstance = createSubagentInstance( { name: agentTool.name, description: agentTool.description, + provider: toolContext.provider, agentNode: agentElement, + stream: agentElementStream, }, { + provider: toolContext.provider, model: toolContext.model, }, ) - const result = await runSubagent(subagent, { - client: toolContext.client, - signal: toolContext.signal, - }) + let result + try { + result = await runSubagent(subagent, { + provider: subagent.props.provider, + clients: toolContext.clients, + signal: toolContext.signal, + }) + } catch (error) { + debug('agent', `Subagent "${agentTool.name}" failed:`, error as object) + throw error + } - return result.content + const content = result.content + if (!content) { + debug( + 'agent', + `Subagent "${agentTool.name}" produced no text output. Stop reason: ${result.stopReason ?? 'unknown'}`, + ) + return `Subagent "${agentTool.name}" completed but produced no text output. Stop reason: ${result.stopReason ?? 'unknown'}.` + } + return content }, } } diff --git a/packages/agentry/src/tools/defineTool.ts b/packages/agentry/src/tools/defineTool.ts index e2d18ff..413659d 100644 --- a/packages/agentry/src/tools/defineTool.ts +++ b/packages/agentry/src/tools/defineTool.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import type { BetaTool } from '@anthropic-ai/sdk/resources/beta' import type { InternalTool, ToolContext, ToolResult } from '../types' +import { debug } from '../debug' /** * define a type-safe tool with Zod schema validation @@ -33,7 +34,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 +71,7 @@ export function toApiTool(tool: InternalTool): BetaTool { */ export function parseToolInput( tool: InternalTool, - input: unknown, + input: z.output, ): | { success: true; data: TInput } | { @@ -77,7 +81,7 @@ export function parseToolInput( } } { const schema = tool.parameters as { - safeParse: (input: unknown) => { + safeParse: (input: z.output) => { success: boolean data?: TInput error?: { @@ -109,7 +113,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) @@ -125,6 +129,8 @@ export async function executeTool( const result = await tool.handler(parseResult.data, context) return { result, isError: false } } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error + debug('tool', `Tool "${tool.name}" handler threw:`, error as object) const message = error instanceof Error ? error.message : String(error) return { result: `Error: ${message}`, diff --git a/packages/agentry/src/tools/index.ts b/packages/agentry/src/tools/index.ts index 5d02840..5d0d74b 100644 --- a/packages/agentry/src/tools/index.ts +++ b/packages/agentry/src/tools/index.ts @@ -2,7 +2,6 @@ export { defineTool, toApiTool, parseToolInput, - formatValidationError, executeTool, } from './defineTool' diff --git a/packages/agentry/src/tools/memoryTool.ts b/packages/agentry/src/tools/memoryTool.ts index 8c62bbb..6a283de 100644 --- a/packages/agentry/src/tools/memoryTool.ts +++ b/packages/agentry/src/tools/memoryTool.ts @@ -1,7 +1,22 @@ import type { MemoryTool } from '../types' +import type { JsonObject } from '../types/json' +import { debug } from '../debug' + +const MEMORY_COMMANDS = [ + 'view', + 'create', + 'str_replace', + 'insert', + 'delete', + 'rename', +] as const + +type MemoryCommand = (typeof MEMORY_COMMANDS)[number] + +const validCommands = new Set(MEMORY_COMMANDS) export interface MemoryToolInput { - command: string + command: MemoryCommand path?: string view_range?: [number, number] file_text?: string @@ -13,11 +28,20 @@ export interface MemoryToolInput { new_path?: string } -export interface MemoryToolResult { +interface MemoryToolResult { result: string isError: boolean } +export function isMemoryToolInput( + input: JsonObject, +): input is JsonObject & MemoryToolInput { + return ( + typeof input.command === 'string' && + validCommands.has(input.command as MemoryCommand) + ) +} + /** * Execute a memory tool call by routing to the appropriate handler * @@ -43,74 +67,67 @@ export async function executeMemoryTool( try { switch (input.command) { case 'view': { - if (!handlers.onView) { - throw new Error('onView handler not provided') - } + if (!handlers.onView) throw new Error('Missing onView handler.') + if (!input.path) throw new Error('view command requires "path"') result = await Promise.resolve( - handlers.onView({ - path: input.path!, - view_range: input.view_range, - }), + handlers.onView({ path: input.path, view_range: input.view_range }), ) break } case 'create': { - if (!handlers.onCreate) { - throw new Error('onCreate handler not provided') - } + if (!handlers.onCreate) throw new Error('Missing onCreate handler.') + if (!input.path || !input.file_text) + throw new Error('create command requires "path" and "file_text"') result = await Promise.resolve( - handlers.onCreate({ - path: input.path!, - file_text: input.file_text!, - }), + handlers.onCreate({ path: input.path, file_text: input.file_text }), ) break } case 'str_replace': { - if (!handlers.onStrReplace) { - throw new Error('onStrReplace handler not provided') - } + if (!handlers.onStrReplace) + throw new Error('Missing onStrReplace handler.') + if (!input.path || !input.old_str || !input.new_str) + throw new Error( + 'str_replace command requires "path", "old_str", and "new_str"', + ) result = await Promise.resolve( handlers.onStrReplace({ - path: input.path!, - old_str: input.old_str!, - new_str: input.new_str!, + path: input.path, + old_str: input.old_str, + new_str: input.new_str, }), ) break } case 'insert': { - if (!handlers.onInsert) { - throw new Error('onInsert handler not provided') - } + if (!handlers.onInsert) throw new Error('Missing onInsert handler.') + if (!input.path || input.insert_line == null || !input.insert_text) + throw new Error( + 'insert command requires "path", "insert_line", and "insert_text"', + ) result = await Promise.resolve( handlers.onInsert({ - path: input.path!, - insert_line: input.insert_line!, - insert_text: input.insert_text!, + path: input.path, + insert_line: input.insert_line, + insert_text: input.insert_text, }), ) break } case 'delete': { - if (!handlers.onDelete) { - throw new Error('onDelete handler not provided') - } - result = await Promise.resolve( - handlers.onDelete({ - path: input.path!, - }), - ) + if (!handlers.onDelete) throw new Error('Missing onDelete handler.') + if (!input.path) throw new Error('delete command requires "path"') + result = await Promise.resolve(handlers.onDelete({ path: input.path })) break } case 'rename': { - if (!handlers.onRename) { - throw new Error('onRename handler not provided') - } + if (!handlers.onRename) throw new Error('Missing onRename handler.') + if (!input.old_path || !input.new_path) + throw new Error('rename command requires "old_path" and "new_path"') result = await Promise.resolve( handlers.onRename({ - old_path: input.old_path!, - new_path: input.new_path!, + old_path: input.old_path, + new_path: input.new_path, }), ) break @@ -119,6 +136,12 @@ export async function executeMemoryTool( throw new Error(`Unknown memory command: ${input.command}`) } } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error + debug( + 'tool', + `Memory tool command "${input.command}" failed:`, + error instanceof Error ? error : { message: String(error) }, + ) const message = error instanceof Error ? error.message : String(error) result = `Error: ${message}` isError = true diff --git a/packages/agentry/src/types/agent.ts b/packages/agentry/src/types/agent.ts index 0b677dc..85a6ce6 100644 --- a/packages/agentry/src/types/agent.ts +++ b/packages/agentry/src/types/agent.ts @@ -1,19 +1,35 @@ -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 { JsonObject } from './json' +import type { Model as _AnthropicModel } from '@anthropic-ai/sdk/resources/messages' +import type { + Reasoning, + ReasoningEffort, + ResponsesModel, +} from 'openai/resources/shared' -export type { Model } +export type AnthropicModel = _AnthropicModel +export type OpenAIModel = ResponsesModel +export type Model = AnthropicModel | OpenAIModel + +export interface AnthropicThinkingEnabled { + type: 'enabled' + budget_tokens: number + interleaved: boolean +} -export type ThinkingConfig = BetaThinkingConfigParam & { - interleaved?: false +export interface OpenAIThinkingEnabled { + type: 'enabled' + effort: NonNullable + summary: NonNullable } -export interface AgentProps { - model: Model +export type ThinkingConfig = + | { type: 'disabled' } + | AnthropicThinkingEnabled + | OpenAIThinkingEnabled +export interface BaseAgentProps { name?: string description?: string maxTokens?: number @@ -21,8 +37,6 @@ export interface AgentProps { stopSequences?: string[] temperature?: number stream?: boolean - thinking?: ThinkingConfig - betas?: string[] onMessage?: (message: AgentStreamEvent) => void onComplete?: (result: AgentResult) => void onError?: (error: Error) => void @@ -30,6 +44,31 @@ export interface AgentProps { 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 = + | (Extract & { + thinking?: AnthropicThinkingEnabled | { type: 'disabled' } + betas?: string[] + }) + | (Extract & { + thinking?: OpenAIThinkingEnabled | { type: 'disabled' } + websocket?: boolean + }) + | (Extract & { + thinking?: ThinkingConfig + }) + +export type AgentProps = BaseAgentProps & ProviderVariant + export interface CompactionControl { enabled: boolean contextTokenThreshold?: number @@ -40,14 +79,18 @@ 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' + itemType: string + item: JsonObject + } | { type: 'thinking'; text: string } - | { type: 'message_complete'; stopReason: string } + | { type: 'message_complete'; stopReason: string | null } 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..bf5a6b5 100644 --- a/packages/agentry/src/types/agentTool.ts +++ b/packages/agentry/src/types/agentTool.ts @@ -1,5 +1,6 @@ import type { z } from 'zod' import type React from 'react' +import type { JsonValue } from './json' /** * Function type that receives typed input and returns a React element @@ -28,6 +29,6 @@ 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..914fe51 100644 --- a/packages/agentry/src/types/index.ts +++ b/packages/agentry/src/types/index.ts @@ -1,11 +1,6 @@ export { - type BetaMessage, - type BetaMessageParam, - type BetaContentBlock, - type BetaToolUseBlock, - type BetaTextBlock, - isToolUseBlock, - isTextBlock, + type AgentMessage, + type AgentMessageParam, extractText, extractToolUses, } from './messages' @@ -13,19 +8,14 @@ export { export { type ToolResult, type ToolContext, - type RunnableTool, type InternalTool, type DefineToolOptions, - type ToolUnion, type PendingToolCall, - type ToolExecutionResult, - type SdkTool, - type CodeExecutionTool, + type BuiltInTool, type WebSearchTool, type MemoryTool, type MemoryHandlers, - type RunAgentOptions, - isCodeExecutionTool, + BuiltInToolType, isMemoryTool, } from './tools' @@ -37,18 +27,27 @@ export { export { type AgentState, - type StateTransition, + AgentStatus, + TransitionType, initialState, transition, canAcceptMessages, isProcessing, } from './state' +export { type ProviderName } from './provider' + export { type Model, + type AnthropicModel, + type OpenAIModel, type AgentProps, + type BaseAgentProps, type ThinkingConfig, + type AnthropicThinkingEnabled, + type OpenAIThinkingEnabled, type CompactionControl, + type ProviderModelOverride, type AgentStreamEvent, type AgentResult, } from './agent' 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..5e2500a 100644 --- a/packages/agentry/src/types/lifecycle.ts +++ b/packages/agentry/src/types/lifecycle.ts @@ -1,19 +1,20 @@ import type { - BetaMessage, - BetaMessageParam, - BetaToolResultBlockParam, -} from '@anthropic-ai/sdk/resources/beta' + AgentMessage, + AgentMessageParam, + TextContentArray, +} from './messages' +import type { JsonObject } from './json' export interface StepToolCall { id: string name: string - input: unknown + input: JsonObject } export interface StepToolResult { toolCallId: string toolName: string - result: BetaToolResultBlockParam['content'] + result: string | TextContentArray isError: boolean executionTime?: number // milliseconds } @@ -51,11 +52,11 @@ export interface OnStepFinishResult { /** Token usage for this step */ usage: StepUsage - /** Full message from Claude API (advanced use) */ - message: BetaMessage + /** Full message from the provider API (advanced use) */ + 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..52c6ad6 100644 --- a/packages/agentry/src/types/messages.ts +++ b/packages/agentry/src/types/messages.ts @@ -1,36 +1,91 @@ -import type { - BetaMessage, - BetaMessageParam, - BetaContentBlock, - BetaToolUseBlock, - BetaTextBlock, -} from '@anthropic-ai/sdk/resources/beta' - -export type { - BetaMessage, - BetaMessageParam, - BetaContentBlock, - BetaToolUseBlock, - BetaTextBlock, +import type { JsonObject } from './json' + +export type TextContentArray = TextContentBlock[] + +export interface TextContentBlock { + type: 'text' + text: string +} + +export interface ThinkingContentBlock { + type: 'thinking' + thinking: string +} + +export interface ToolUseContentBlock { + type: 'tool_use' + id: string + name: string + input: JsonObject +} + +export interface ToolResultContentBlock { + type: 'tool_result' + tool_use_id: string + content: string | TextContentArray + is_error: boolean +} + +export type AgentContentBlock = + | TextContentBlock + | ThinkingContentBlock + | ToolUseContentBlock + | ToolResultContentBlock + +export interface AgentMessageParam { + role: 'user' | 'assistant' + content: string | AgentContentBlock[] +} + +type StopReason = + | 'end_turn' + | 'tool_use' + | 'max_tokens' + | 'length' + | (string & {}) + +export interface AgentMessage { + content: AgentContentBlock[] + stop_reason: StopReason | null + usage: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens?: number | null + cache_read_input_tokens?: number | null + } } export function isToolUseBlock( - block: BetaContentBlock, -): block is BetaToolUseBlock { + block: AgentContentBlock, +): block is ToolUseContentBlock { return block.type === 'tool_use' } -export function isTextBlock(block: BetaContentBlock): block is BetaTextBlock { +export function isTextBlock( + block: AgentContentBlock, +): block is TextContentBlock { return block.type === 'text' } -export function extractText(message: BetaMessage): string { +export function isThinkingBlock( + block: AgentContentBlock, +): block is ThinkingContentBlock { + 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) .map((block) => block.text) .join('') } -export function extractToolUses(message: BetaMessage): BetaToolUseBlock[] { +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..ef2c68e --- /dev/null +++ b/packages/agentry/src/types/provider.ts @@ -0,0 +1,6 @@ +export type ProviderName = 'anthropic' | 'openai' + +export const providerDisplayNames: Record = { + anthropic: 'Anthropic', + openai: 'OpenAI', +} diff --git a/packages/agentry/src/types/state.ts b/packages/agentry/src/types/state.ts index 52bada8..ecf9af8 100644 --- a/packages/agentry/src/types/state.ts +++ b/packages/agentry/src/types/state.ts @@ -1,24 +1,45 @@ -import type { BetaMessage } from '@anthropic-ai/sdk/resources/beta' +import type { AgentMessage } from './messages' import type { PendingToolCall, ToolExecutionResult } from './tools' +export enum AgentStatus { + Idle = 'idle', + Streaming = 'streaming', + WaitingForTools = 'waiting_for_tools', + ExecutingTools = 'executing_tools', + Completed = 'completed', + Error = 'error', +} + +export enum TransitionType { + StartStreaming = 'start_streaming', + ToolsRequested = 'tools_requested', + ToolsExecuting = 'tools_executing', + ToolsCompleted = 'tools_completed', + Completed = 'completed', + Error = 'error', +} + export type AgentState = - | { status: 'idle' } - | { status: 'streaming'; abortController: AbortController } - | { status: 'waiting_for_tools'; pendingTools: PendingToolCall[] } - | { status: 'executing_tools'; pendingTools: PendingToolCall[] } - | { status: 'completed'; finalMessage: BetaMessage } - | { status: 'error'; error: Error } + | { status: AgentStatus.Idle } + | { status: AgentStatus.Streaming; abortController: AbortController } + | { status: AgentStatus.WaitingForTools; pendingTools: PendingToolCall[] } + | { status: AgentStatus.ExecutingTools; pendingTools: PendingToolCall[] } + | { status: AgentStatus.Completed; finalMessage: AgentMessage } + | { status: AgentStatus.Error; error: Error } export type StateTransition = - | { type: 'start_streaming'; abortController: AbortController } - | { type: 'tools_requested'; pendingTools: PendingToolCall[] } - | { type: 'tools_executing'; pendingTools: PendingToolCall[] } - | { type: 'tools_completed'; results: ToolExecutionResult[] } - | { type: 'completed'; finalMessage: BetaMessage } - | { type: 'error'; error: Error } + | { + type: TransitionType.StartStreaming + abortController: AbortController + } + | { type: TransitionType.ToolsRequested; pendingTools: PendingToolCall[] } + | { type: TransitionType.ToolsExecuting; pendingTools: PendingToolCall[] } + | { type: TransitionType.ToolsCompleted; results: ToolExecutionResult[] } + | { type: TransitionType.Completed; finalMessage: AgentMessage } + | { type: TransitionType.Error; error: Error } export function initialState(): AgentState { - return { status: 'idle' } + return { status: AgentStatus.Idle } } export function transition( @@ -26,38 +47,54 @@ export function transition( event: StateTransition, ): AgentState { switch (event.type) { - case 'start_streaming': - return { status: 'streaming', abortController: event.abortController } + case TransitionType.StartStreaming: + return { + status: AgentStatus.Streaming, + abortController: event.abortController, + } - case 'tools_requested': - return { status: 'waiting_for_tools', pendingTools: event.pendingTools } + case TransitionType.ToolsRequested: + return { + status: AgentStatus.WaitingForTools, + pendingTools: event.pendingTools, + } - case 'tools_executing': - return { status: 'executing_tools', pendingTools: event.pendingTools } + case TransitionType.ToolsExecuting: + return { + status: AgentStatus.ExecutingTools, + pendingTools: event.pendingTools, + } - case 'tools_completed': + case TransitionType.ToolsCompleted: // after tools complete, go back to idle for next iteration - return { status: 'idle' } + return { status: AgentStatus.Idle } - case 'completed': - return { status: 'completed', finalMessage: event.finalMessage } + case TransitionType.Completed: + return { + status: AgentStatus.Completed, + finalMessage: event.finalMessage, + } - case 'error': - return { status: 'error', error: event.error } + case TransitionType.Error: + return { status: AgentStatus.Error, error: event.error } - default: + default: { + const _exhaustive: never = event return state + } } } export function canAcceptMessages(state: AgentState): boolean { - return state.status === 'idle' || state.status === 'completed' + return ( + state.status === AgentStatus.Idle || state.status === AgentStatus.Completed + ) } export function isProcessing(state: AgentState): boolean { return ( - state.status === 'streaming' || - state.status === 'waiting_for_tools' || - state.status === 'executing_tools' + state.status === AgentStatus.Streaming || + state.status === AgentStatus.WaitingForTools || + state.status === AgentStatus.ExecutingTools ) } diff --git a/packages/agentry/src/types/tools.ts b/packages/agentry/src/types/tools.ts index ddc6f43..cccb6ed 100644 --- a/packages/agentry/src/types/tools.ts +++ b/packages/agentry/src/types/tools.ts @@ -1,12 +1,10 @@ import type { ReactElement } from 'react' import type { z } from 'zod' -import type Anthropic from '@anthropic-ai/sdk' -import type { - BetaToolResultBlockParam, - BetaWebSearchTool20250305, - BetaMemoryTool20250818, -} 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, JsonValue } from './json' +import type { TextContentArray } from './messages' export interface MemoryHandlers { /** Handler for viewing directory contents or file contents */ @@ -40,58 +38,76 @@ export interface MemoryHandlers { }) => Promise | string } -export type ToolResult = string | BetaToolResultBlockParam['content'] +export type ToolResult = string | TextContentArray + +export enum BuiltInToolType { + CodeExecution = 'code_execution', + WebSearch = 'web_search', + Memory = 'memory', +} -/** - * Code execution tool - enables code execution capability - */ export interface CodeExecutionTool { - type: 'code_execution_20250825' + type: BuiltInToolType.CodeExecution name: 'code_execution' } -/** - * Web search tool - enables web search capability - */ -export type WebSearchTool = BetaWebSearchTool20250305 +export interface WebSearchTool { + type: BuiltInToolType.WebSearch + name: 'web_search' + max_uses?: number + allowed_domains?: string[] + blocked_domains?: string[] + user_location?: { + type: 'approximate' + city?: string + region?: string + country?: string + timezone?: string + } +} -/** - * Memory tool - enables memory capability with client-side handlers - */ -export type MemoryTool = BetaMemoryTool20250818 & { +export interface MemoryTool { + type: BuiltInToolType.Memory + name: 'memory' memoryHandlers?: MemoryHandlers } /** - * Union of all supported SDK tools + * Union of all supported built-in tools */ -export type SdkTool = CodeExecutionTool | WebSearchTool | MemoryTool +export type BuiltInTool = CodeExecutionTool | WebSearchTool | MemoryTool /** * Type guard for code execution tool */ -export function isCodeExecutionTool(tool: SdkTool): tool is CodeExecutionTool { - return 'type' in tool && tool.type === 'code_execution_20250825' +export function isCodeExecutionTool( + tool: BuiltInTool, +): tool is CodeExecutionTool { + return tool.type === BuiltInToolType.CodeExecution } /** * Type guard for web search tool */ -export function isWebSearchTool(tool: SdkTool): tool is WebSearchTool { - return 'type' in tool && tool.type === 'web_search_20250305' +export function isWebSearchTool(tool: BuiltInTool): tool is WebSearchTool { + return tool.type === BuiltInToolType.WebSearch } /** * Type guard for memory tool */ -export function isMemoryTool(tool: SdkTool): tool is MemoryTool { - return 'type' in tool && tool.type === 'memory_20250818' +export function isMemoryTool(tool: BuiltInTool): tool is MemoryTool { + return tool.type === BuiltInToolType.Memory } /** * 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) */ @@ -102,13 +118,23 @@ export interface RunAgentOptions { signal?: AbortSignal } -export interface ToolContext { +type ProviderContextFields = + | { provider: 'anthropic'; client: ProviderClientMap['anthropic'] } + | { provider: 'openai'; client: ProviderClientMap['openai'] } + | { provider?: undefined; client?: undefined } + +type BaseToolContext = { agentName: string - client: Anthropic + /** + * 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 - // 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,6 +163,8 @@ export interface ToolContext { ) => Promise } +export type ToolContext = BaseToolContext & ProviderContextFields + export interface RunnableTool { name: string description: string @@ -148,7 +176,7 @@ export interface RunnableTool { } export interface InternalTool extends RunnableTool { - jsonSchema: Record + jsonSchema: Record strict?: boolean } @@ -160,16 +188,12 @@ export type DefineToolOptions = Omit< strict?: boolean } -export type ToolUnion = InternalTool | SdkTool - -export function isRunnableTool(tool: ToolUnion): tool is InternalTool { - return 'handler' in tool && typeof tool.handler === 'function' -} +export type ToolUnion = InternalTool | BuiltInTool export interface PendingToolCall { id: string name: string - input: unknown + input: JsonObject } export interface ToolExecutionResult { diff --git a/packages/agentry/tests/agent-props-types.test.ts b/packages/agentry/tests/agent-props-types.test.ts new file mode 100644 index 0000000..13244b3 --- /dev/null +++ b/packages/agentry/tests/agent-props-types.test.ts @@ -0,0 +1,23 @@ +import type { AgentComponentPublicProps } from '../src/components/Agent.tsx' + +// Compile-time checks — no test runner needed. +// If any of the @ts-expect-error comments are "unused", the constraint is broken. + +// ✅ Root agent: both provider and model +const _root: AgentComponentPublicProps = { + provider: 'anthropic', + model: 'claude-haiku-4-5', +} + +// ✅ Subagent: inherit everything +const _sub: AgentComponentPublicProps = {} + +// ❌ model without provider — must not compile +// @ts-expect-error model requires provider +const _modelOnly: AgentComponentPublicProps = { model: 'claude-haiku-4-5' } + +// ❌ provider without model — must not compile +// @ts-expect-error provider requires model +const _providerOnly: AgentComponentPublicProps = { provider: 'anthropic' } + +export { _root, _sub, _modelOnly, _providerOnly } diff --git a/packages/agentry/tests/built-ins.test.tsx b/packages/agentry/tests/built-ins.test.tsx new file mode 100644 index 0000000..349a733 --- /dev/null +++ b/packages/agentry/tests/built-ins.test.tsx @@ -0,0 +1,266 @@ +import { test, expect, beforeEach, describe } from 'bun:test' +import { run, Agent, Message, Tools } from '../src' +import { CodeExecution, WebSearch, Memory, MCP } from '../src/anthropic' +import { MCP as OpenAIMCP } from '../src/openai' +import { createStepMockClient, mockText } from './utils' +import { createOpenAIMockClient } from './utils' +import { + ANTHROPIC_TEST_MODEL, + OPENAI_TEST_MODEL, + ANTHROPIC_BETAS, +} from '../src/constants' +import { resetSharedDefaultClients } from '../src/providers/clientResolver' + +beforeEach(() => { + resetSharedDefaultClients() +}) + +describe('Anthropic', () => { + test('CodeExecution is sent with versioned wire type', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('done')] }, + ]) + + const runPromise = run( + + + + + run some code + , + { providers: { anthropic: { client } } }, + ) + + await controller.waitForNextCall() + const call = controller.peekNextCall() + const tools = call!.params.tools as Array<{ type: string }> + expect(tools.some((t) => t.type === 'code_execution_20250825')).toBe(true) + + await controller.nextTurn() + await runPromise + }) + + test('WebSearch is sent with versioned wire type', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('done')] }, + ]) + + const runPromise = run( + + + + + search something + , + { providers: { anthropic: { client } } }, + ) + + await controller.waitForNextCall() + const call = controller.peekNextCall() + const tools = call!.params.tools as Array<{ + type: string + max_uses?: number + allowed_domains?: string[] + }> + const wsTool = tools.find((t) => t.type === 'web_search_20250305') + expect(wsTool).toBeDefined() + expect(wsTool?.max_uses).toBe(5) + expect(wsTool?.allowed_domains).toEqual(['example.com']) + + await controller.nextTurn() + await runPromise + }) + + test('Memory is sent with versioned wire type (memoryHandlers stripped)', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('done')] }, + ]) + + const runPromise = run( + + + 'contents'} /> + + remember something + , + { providers: { anthropic: { client } } }, + ) + + await controller.waitForNextCall() + const call = controller.peekNextCall() + const tools = call!.params.tools as Array> + const memTool = tools.find((t) => t['type'] === 'memory_20250818') + expect(memTool).toBeDefined() + expect(memTool!['memoryHandlers']).toBeUndefined() + + await controller.nextTurn() + await runPromise + }) +}) + +describe('Anthropic beta headers', () => { + test('CodeExecution sends CODE_EXECUTION beta', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('done')] }, + ]) + + const runPromise = run( + + + + + run code + , + { providers: { anthropic: { client } } }, + ) + + await controller.waitForNextCall() + const call = controller.peekNextCall() + const betas = call!.params.betas as string[] | undefined + expect(betas).toContain(ANTHROPIC_BETAS.CODE_EXECUTION) + + await controller.nextTurn() + await runPromise + }) + + test('Memory sends CONTEXT_MANAGEMENT beta', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('done')] }, + ]) + + const runPromise = run( + + + 'contents'} /> + + remember something + , + { providers: { anthropic: { client } } }, + ) + + await controller.waitForNextCall() + const call = controller.peekNextCall() + const betas = call!.params.betas as string[] | undefined + expect(betas).toContain(ANTHROPIC_BETAS.CONTEXT_MANAGEMENT) + + await controller.nextTurn() + await runPromise + }) + + test('MCP sends MCP_CLIENT beta and mcp_servers param', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('done')] }, + ]) + + const runPromise = run( + + + use mcp + , + { providers: { anthropic: { client } } }, + ) + + await controller.waitForNextCall() + const call = controller.peekNextCall() + const betas = call!.params.betas as string[] | undefined + const mcpServers = call!.params.mcp_servers as + | Array> + | undefined + expect(betas).toContain(ANTHROPIC_BETAS.MCP_CLIENT) + expect(mcpServers).toBeDefined() + expect(mcpServers!.some((s) => s['name'] === 'filesystem')).toBe(true) + + await controller.nextTurn() + await runPromise + }) +}) + +describe('OpenAI', () => { + test('MCP maps to mcp tool type', async () => { + const { client, calls } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'done' }], + }, + ], + }, + ]) + + await run( + + + use mcp + , + { providers: { openai: { client } } }, + ) + + const tools = calls[0]!['tools'] as Array<{ + type: string + server_label?: string + server_url?: string + }> + const mcpTool = tools.find((t) => t.type === 'mcp') + expect(mcpTool).toBeDefined() + expect(mcpTool!.server_label).toBe('filesystem') + expect(mcpTool!.server_url).toBe('https://mcp.example.com/sse') + }) + + test('CodeExecution maps to code_interpreter', async () => { + const { client, calls } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'done' }], + }, + ], + }, + ]) + + await run( + + + + + run some code + , + { providers: { openai: { client } } }, + ) + + const tools = calls[0]!['tools'] as Array<{ type: string }> + expect(tools.some((t) => t.type === 'code_interpreter')).toBe(true) + }) + + test('WebSearch maps to web_search', async () => { + const { client, calls } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'done' }], + }, + ], + }, + ]) + + await run( + + + + + search something + , + { providers: { openai: { client } } }, + ) + + const tools = calls[0]!['tools'] as Array<{ + type: string + filters?: { allowed_domains?: string[] } + }> + const wsTool = tools.find((t) => t.type === 'web_search') + expect(wsTool).toBeDefined() + expect(wsTool?.filters?.allowed_domains).toEqual(['example.com']) + }) +}) diff --git a/packages/agentry/tests/compaction.test.ts b/packages/agentry/tests/compaction.test.ts index 19a7463..39b364f 100644 --- a/packages/agentry/tests/compaction.test.ts +++ b/packages/agentry/tests/compaction.test.ts @@ -2,9 +2,9 @@ import { test, expect } from 'bun:test' import { ExecutionEngine } from '../src/execution' import { createAgentStore } from '../src/store' import { createStepMockClient, mockText } from './utils' -import { TEST_MODEL } from '../src/constants' -import type { BetaMessageParam } from '@anthropic-ai/sdk/resources/beta' -import type { AgentInstance } from '../src/instances' +import { ANTHROPIC_TEST_MODEL } from '../src/constants' +import { AgentStatus, type AgentMessageParam } from '../src/types' +import { InstanceType, type AgentInstance } from '../src/instances' test('compactionControl compacts messages when threshold is exceeded', async () => { const { client, controller } = createStepMockClient([ @@ -12,30 +12,34 @@ test('compactionControl compacts messages when threshold is exceeded', async () ]) const store = createAgentStore() - const originalMessages: BetaMessageParam[] = [ + const originalMessages: AgentMessageParam[] = [ { role: 'user', - content: [{ type: 'text', text: 'First message', citations: null }], + content: [{ type: 'text', text: 'First message' }], }, { role: 'assistant', - content: [{ type: 'text', text: 'Second message', citations: null }], + content: [{ type: 'text', text: 'Second message' }], }, ] store.setState(() => ({ - executionState: { status: 'idle' }, + executionState: { status: AgentStatus.Idle }, messages: originalMessages, })) const agentInstance: AgentInstance = { - type: 'agent', - props: { model: TEST_MODEL, maxTokens: 100 }, + type: InstanceType.Agent, + props: { + provider: 'anthropic', + model: ANTHROPIC_TEST_MODEL, + maxTokens: 100, + }, client, engine: null, systemParts: [], tools: [], - sdkTools: [], + builtInTools: [], mcpServers: [], children: [], parent: null, @@ -43,15 +47,16 @@ test('compactionControl compacts messages when threshold is exceeded', async () } const engine = new ExecutionEngine({ + provider: 'anthropic', client, - model: TEST_MODEL, + model: ANTHROPIC_TEST_MODEL, maxTokens: 100, store, agentInstance, compactionControl: { enabled: true, contextTokenThreshold: 10, - model: TEST_MODEL, + model: ANTHROPIC_TEST_MODEL, summaryPrompt: 'Please summarize the conversation so far', }, }) @@ -66,7 +71,7 @@ test('compactionControl compacts messages when threshold is exceeded', async () type: 'message', role: 'assistant', content: [], - model: TEST_MODEL, + model: ANTHROPIC_TEST_MODEL, stop_reason: 'end_turn', stop_sequence: null, container: null, @@ -92,9 +97,7 @@ test('compactionControl compacts messages when threshold is exceeded', async () const lastMessage = params.messages[params.messages.length - 1] expect(lastMessage).toMatchObject({ role: 'user', - content: [ - { type: 'text', text: 'Please summarize the conversation so far' }, - ], + content: 'Please summarize the conversation so far', }) await controller.nextTurn() @@ -117,30 +120,34 @@ test('compactionControl does nothing when under threshold', async () => { ]) const store = createAgentStore() - const originalMessages: BetaMessageParam[] = [ + const originalMessages: AgentMessageParam[] = [ { role: 'user', - content: [{ type: 'text', text: 'Short exchange', citations: null }], + content: [{ type: 'text', text: 'Short exchange' }], }, { role: 'assistant', - content: [{ type: 'text', text: 'Still short', citations: null }], + content: [{ type: 'text', text: 'Still short' }], }, ] store.setState(() => ({ - executionState: { status: 'idle' }, + executionState: { status: AgentStatus.Idle }, messages: originalMessages, })) const agentInstance: AgentInstance = { - type: 'agent', - props: { model: TEST_MODEL, maxTokens: 100 }, + type: InstanceType.Agent, + props: { + provider: 'anthropic', + model: ANTHROPIC_TEST_MODEL, + maxTokens: 100, + }, client, engine: null, systemParts: [], tools: [], - sdkTools: [], + builtInTools: [], mcpServers: [], children: [], parent: null, @@ -148,15 +155,16 @@ test('compactionControl does nothing when under threshold', async () => { } const engine = new ExecutionEngine({ + provider: 'anthropic', client, - model: TEST_MODEL, + model: ANTHROPIC_TEST_MODEL, maxTokens: 100, store, agentInstance, compactionControl: { enabled: true, contextTokenThreshold: 1_000_000, - model: TEST_MODEL, + model: ANTHROPIC_TEST_MODEL, summaryPrompt: 'Please summarize the conversation so far', }, }) @@ -171,7 +179,7 @@ test('compactionControl does nothing when under threshold', async () => { type: 'message', role: 'assistant', content: [], - model: TEST_MODEL, + model: ANTHROPIC_TEST_MODEL, stop_reason: 'end_turn', stop_sequence: null, container: null, diff --git a/packages/agentry/tests/conditions.test.tsx b/packages/agentry/tests/conditions.test.tsx index 4397cc2..09c589b 100644 --- a/packages/agentry/tests/conditions.test.tsx +++ b/packages/agentry/tests/conditions.test.tsx @@ -10,7 +10,13 @@ import { Tool, Message, } from '../src' -import { createStepMockClient, mockText, mockToolUse } from './utils' +import { + createStepMockClient, + createOpenAIMockClient, + mockText, + mockToolUse, +} from './utils' +import { OPENAI_TEST_MODEL } from '../src/constants' import { z } from 'zod' describe('Condition', () => { @@ -19,7 +25,7 @@ describe('Condition', () => { const TestAgent = () => { const [isActive] = useState(true) return ( - + Hello Active mode @@ -38,7 +44,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('Hello') @@ -48,7 +56,7 @@ describe('Condition', () => { const TestAgent = () => { const [isActive] = useState(false) return ( - + Hello Active mode @@ -67,7 +75,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('Hello') @@ -78,7 +88,7 @@ describe('Condition', () => { const [condition1] = useState(true) const [condition2] = useState(true) return ( - + Test Route 1 active @@ -97,7 +107,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('Both') @@ -109,7 +121,7 @@ describe('Condition', () => { const TestAgent = () => { const [isAuthenticated] = useState(false) return ( - + Help me @@ -146,7 +158,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -157,7 +171,7 @@ describe('Condition', () => { const TestAgent = () => { const [isAuthenticated, setIsAuthenticated] = useState(false) return ( - + Authenticate me @@ -201,7 +215,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -214,7 +230,7 @@ describe('Condition', () => { const TestAgent = () => { const [condition] = useState(false) return ( - + Base system prompt Hello @@ -231,7 +247,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('Hello') @@ -243,7 +261,7 @@ describe('Condition', () => { const TestAgent = () => { const [isActive] = useState(true) return ( - + Test Context 1 @@ -272,7 +290,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -285,7 +305,7 @@ describe('Condition', () => { const TestAgent = () => { const [isActive] = useState(true) return ( - + Hello Route 1 @@ -308,7 +328,10 @@ describe('Condition', () => { ]) // Create agent in interactive mode - const handle = await run(, { client, mode: 'interactive' }) + const handle = await run(, { + providers: { anthropic: { client } }, + mode: 'interactive', + }) // Trigger reconciler by starting execution (but abort immediately after first API call) const runPromise = handle.run() @@ -326,7 +349,7 @@ describe('Condition', () => { const TestAgent = () => { const [isAuthenticated] = useState(true) return ( - + Can you calculate 5 times 8 for me? User is authenticated @@ -341,7 +364,6 @@ describe('Condition', () => { expression: z.string(), })} handler={async ({ expression }) => { - // eslint-disable-next-line react-hooks/unsupported-syntax const result = eval(expression) return `Result: ${result}` }} @@ -359,11 +381,11 @@ describe('Condition', () => { { type: 'tool_use', id: 'route_1', - name: 'select_routes', - input: { matchingRouteIndices: [1] }, // Route index 1 (math route) + name: 'evaluate_conditions', + input: { trueConditionIndices: [0] }, // NL condition index 0 (math route) }, ], - stop_reason: 'tool_use', + stop_reason: 'end_turn', }, // Second call: Agent's response with math tool { @@ -377,7 +399,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() // Route evaluation await controller.nextTurn() // Tool use await controller.nextTurn() // Final response @@ -389,7 +413,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 @@ -399,7 +423,6 @@ describe('Condition', () => { description="Calculate" parameters={z.object({ expr: z.string() })} handler={async ({ expr }) => { - // eslint-disable-next-line react-hooks/unsupported-syntax return `Result: ${eval(expr)}` }} /> @@ -427,11 +450,11 @@ describe('Condition', () => { { type: 'tool_use', id: 'route_1', - name: 'select_routes', - input: { matchingRouteIndices: [0, 1] }, // Both routes active + name: 'evaluate_conditions', + input: { trueConditionIndices: [0, 1] }, // Both routes active }, ], - stop_reason: 'tool_use', + stop_reason: 'end_turn', }, // Agent uses both tools { @@ -447,7 +470,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() await controller.nextTurn() @@ -463,7 +488,7 @@ describe('Condition', () => { const [isAuthenticated] = useState(true) const [isPremium] = useState(true) return ( - + Hello User is authenticated @@ -494,7 +519,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -506,7 +533,7 @@ describe('Condition', () => { const [isAuthenticated] = useState(true) const [isPremium] = useState(false) return ( - + Hello User is authenticated @@ -544,7 +571,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -556,7 +585,7 @@ describe('Condition', () => { const [isAuthenticated] = useState(false) const [isPremium] = useState(true) return ( - + Hello @@ -577,7 +606,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() const result = await runPromise expect(result.content).toContain('authenticate') @@ -589,7 +620,7 @@ describe('Condition', () => { const [isPremium] = useState(true) const [isAdmin] = useState(true) return ( - + Hello Authenticated @@ -623,7 +654,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() const result = await runPromise @@ -634,7 +667,7 @@ describe('Condition', () => { const TestAgent = () => { const [isAuthenticated] = useState(true) return ( - + I want to calculate 10 + 5 User is authenticated @@ -646,7 +679,6 @@ describe('Condition', () => { description="Perform calculation" parameters={z.object({ expression: z.string() })} handler={async ({ expression }) => { - // eslint-disable-next-line react-hooks/unsupported-syntax const result = eval(expression) return `Result: ${result}` }} @@ -665,11 +697,11 @@ describe('Condition', () => { { type: 'tool_use', id: 'route_1', - name: 'select_routes', - input: { matchingRouteIndices: [0] }, // Math route matches + name: 'evaluate_conditions', + input: { trueConditionIndices: [0] }, // NL condition index 0 (math route) }, ], - stop_reason: 'tool_use', + stop_reason: 'end_turn', }, // Agent uses calculate tool { @@ -683,7 +715,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() await controller.nextTurn() @@ -696,7 +730,7 @@ describe('Condition', () => { const [isAuthenticated, setIsAuthenticated] = useState(false) const [hasPermission] = useState(true) return ( - + Authenticate and perform action @@ -742,7 +776,9 @@ describe('Condition', () => { }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.nextTurn() await controller.nextTurn() @@ -751,3 +787,284 @@ describe('Condition', () => { }) }) }) + +describe('NL Condition Evaluation', () => { + describe('Anthropic', () => { + it('should activate condition and make tool available when model returns trueConditionIndices', async () => { + const TestAgent = () => ( + + Calculate 6 times 7 + + + { + return `Result: ${eval(expression)}` + }} + /> + + + + ) + + const { client, controller } = createStepMockClient([ + // Turn 1: NL eval — condition 0 is true + { + content: [ + mockToolUse( + 'evaluate_conditions', + { trueConditionIndices: [0] }, + 'eval_1', + ), + ], + stop_reason: 'end_turn', + }, + // Turn 2: agent calls calculate (tool is available because condition activated) + { + content: [mockToolUse('calculate', { expression: '6 * 7' })], + stop_reason: 'tool_use', + }, + // Turn 3: final response + { + content: [mockText('6 times 7 is 42.')], + stop_reason: 'end_turn', + }, + ]) + + const runPromise = run(, { + providers: { anthropic: { client } }, + }) + await controller.nextTurn() // NL eval + await controller.nextTurn() // agent calls calculate + await controller.nextTurn() // final response + const result = await runPromise + + expect(result.content).toContain('42') + }) + + it('should keep condition false and skip tool when model returns no tool_use', async () => { + const TestAgent = () => ( + + Hello + + + { + return `Result: ${eval(expression)}` + }} + /> + + + + ) + + const { client, controller } = createStepMockClient([ + // Turn 1: NL eval returns plain text (no tool_use) — conditions stay false + { + content: [mockText('I cannot evaluate that')], + stop_reason: 'end_turn', + }, + // Turn 2: agent responds without tools (condition is false, calculate not available) + { + content: [mockText('Default response without tools.')], + stop_reason: 'end_turn', + }, + ]) + + const runPromise = run(, { + providers: { anthropic: { client } }, + }) + await controller.nextTurn() // NL eval (no tool_use — fallback) + await controller.nextTurn() // agent response + const result = await runPromise + + expect(result.content).toContain('Default') + expect(result.content).not.toContain('Result:') + }) + }) + + describe('OpenAI', () => { + it('should activate condition and make tool available when model returns trueConditionIndices', async () => { + const { client } = createOpenAIMockClient([ + // Turn 1: NL eval (stream: false) — condition 0 is true + { + output: [ + { + type: 'function_call', + call_id: 'cond_1', + name: 'evaluate_conditions', + arguments: JSON.stringify({ trueConditionIndices: [0] }), + }, + ], + }, + // Turn 2: agent calls calculate (tool available because condition activated) + { + output: [ + { + type: 'function_call', + call_id: 'calc_1', + name: 'calculate', + arguments: JSON.stringify({ expression: '6 * 7' }), + }, + ], + }, + // Turn 3: final response + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: '6 times 7 is 42.' }], + }, + ], + }, + ]) + + const result = await run( + + Calculate 6 times 7 + + + { + return `Result: ${eval(expression)}` + }} + /> + + + , + { providers: { openai: { client } } }, + ) + + expect(result.content).toContain('42') + }) + + it('should keep condition false and skip tool when model returns no function_call', async () => { + const { client } = createOpenAIMockClient([ + // Turn 1: NL eval returns a plain message (no function_call) — conditions stay false + { + output: [ + { + type: 'message', + content: [ + { type: 'output_text', text: 'I cannot evaluate that' }, + ], + }, + ], + }, + // Turn 2: agent responds without tools (condition is false) + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Default response.' }], + }, + ], + }, + ]) + + const result = await run( + + Hello + + + { + return `Result: ${eval(expression)}` + }} + /> + + + , + { providers: { openai: { client } } }, + ) + + expect(result.content).toContain('Default') + expect(result.content).not.toContain('Result:') + }) + }) +}) + +describe('OpenAI Natural Language Conditions', () => { + it('should evaluate NL conditions via OpenAI function calling', async () => { + const { client } = createOpenAIMockClient([ + // First call: NL condition evaluation — model returns evaluate_conditions tool call + { + output: [ + { + type: 'function_call', + call_id: 'cond_1', + name: 'evaluate_conditions', + arguments: JSON.stringify({ trueConditionIndices: [0] }), + }, + ], + }, + // Second call: agent response + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Math mode active: 42' }], + }, + ], + }, + ]) + + const result = await run( + + Calculate 6 times 7 + + Math mode active + + , + { providers: { openai: { client } } }, + ) + + expect(result.content).toContain('42') + }) + + it('should default all NL conditions to false when model returns no function call', async () => { + const { client } = createOpenAIMockClient([ + // NL condition evaluation — model returns a plain message instead of function_call + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'I cannot evaluate that' }], + }, + ], + }, + // Agent response (conditions stayed false) + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Default response' }], + }, + ], + }, + ]) + + const result = await run( + + Hello + + Special mode + + , + { 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 new file mode 100644 index 0000000..b0071be --- /dev/null +++ b/packages/agentry/tests/create-ai.test.tsx @@ -0,0 +1,229 @@ +import { test, expect } from 'bun:test' +import { createAI, Agent, Message, AgentHandle } from '../src' +import { createStepMockClient, mockText, createOpenAIMockClient } from './utils' +import { ANTHROPIC_TEST_MODEL, OPENAI_TEST_MODEL } from '../src/constants' + +test('createAI default clients are used when no per-call override given', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('Hello from default client')] }, + ]) + + const ai = createAI({ providers: { anthropic: { client } } }) + + const runPromise = ai.run( + + Hello + , + ) + + await controller.nextTurn() + const result = await runPromise + expect(result.content).toBe('Hello from default client') +}) + +test('createAI per-call clients override default clients', async () => { + const { client: defaultClient } = createStepMockClient([ + { content: [mockText('Should not appear')] }, + ]) + const { client: overrideClient, controller } = createStepMockClient([ + { content: [mockText('From override client')] }, + ]) + + const ai = createAI({ providers: { anthropic: { client: defaultClient } } }) + + const runPromise = ai.run( + + Hello + , + { providers: { anthropic: { client: overrideClient } } }, + ) + + await controller.nextTurn() + const result = await runPromise + expect(result.content).toBe('From override client') +}) + +test('createAI per-call mode overrides default mode', async () => { + // batch override should return AgentResult, not AgentHandle + const { client: batchClient, controller } = createStepMockClient([ + { content: [mockText('Batch result')] }, + ]) + const ai2 = createAI({ providers: { anthropic: { client: batchClient } } }) + const runPromise = ai2.run( + + Run in batch + , + { mode: 'batch' }, + ) + await controller.nextTurn() + const result = await runPromise + expect(result.content).toBe('Batch result') + expect(typeof result.content).toBe('string') +}) + +test('createAI interactive mode returns a handle', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('Interactive response')] }, + ]) + + const ai = createAI({ + providers: { anthropic: { client } }, + mode: 'interactive', + }) + + const handlePromise = ai.run( + , + { mode: 'interactive' }, + ) + + const handle = await handlePromise + expect(handle).toBeDefined() + expect(typeof handle.sendMessage).toBe('function') + + const sendPromise = handle.sendMessage('Hello') + await controller.nextTurn() + const result = await sendPromise + expect(result.content).toBe('Interactive response') + + handle.close() +}) + +test('createAI createAgent uses merged clients', async () => { + const { client, controller } = createStepMockClient([ + { content: [mockText('From createAgent')] }, + ]) + + const ai = createAI({ providers: { anthropic: { client } } }) + + const agentHandle = ai.createAgent( + + Hello + , + ) + + const runPromise = agentHandle.run() + await controller.nextTurn() + const result = await runPromise + expect(result.content).toBe('From createAgent') + agentHandle.close() +}) + +test('createAI default mode=interactive returns handle without explicit mode override', async () => { + const { client } = createStepMockClient([]) + + 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 + const result = (await ai.run( + , + )) as unknown as AgentHandle + + expect(result).toBeInstanceOf(AgentHandle) + expect(typeof result.sendMessage).toBe('function') + 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([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'OpenAI response' }], + }, + ], + }, + ]) + + const ai = createAI({ + providers: { anthropic: { client: anthropicDefault } }, + }) + + const result = await ai.run( + + Hello + , + { 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 c81de59..12dbfb4 100644 --- a/packages/agentry/tests/dynamic-components.test.tsx +++ b/packages/agentry/tests/dynamic-components.test.tsx @@ -5,7 +5,7 @@ import { run } from '../src' import { defineTool } from '../src/tools' import { Agent, System, Tools, Tool, Message } from '../src' import { createStepMockClient, mockText, mockToolUse } from './utils' -import { TEST_MODEL } from '../src/constants' +import { ANTHROPIC_TEST_MODEL } from '../src/constants' test('state changes trigger reconciler updates', async () => { let updateCount = 0 @@ -25,7 +25,7 @@ test('state changes trigger reconciler updates', async () => { }) return ( - + Counter is at {count} @@ -41,7 +41,9 @@ test('state changes trigger reconciler updates', async () => { { content: [mockText('Done incrementing')] }, ]) - const runPromise = run(, { client }) + const runPromise = run(, { + providers: { anthropic: { client } }, + }) await controller.nextTurn() await controller.waitForNextCall() diff --git a/packages/agentry/tests/hooks.test.tsx b/packages/agentry/tests/hooks.test.tsx index 526cef1..a70533f 100644 --- a/packages/agentry/tests/hooks.test.tsx +++ b/packages/agentry/tests/hooks.test.tsx @@ -12,7 +12,8 @@ import { useAgentState, } from '../src' import { createStepMockClient, mockText, mockToolUse } from './utils' -import { TEST_MODEL } from '../src/constants' +import { AgentStatus } from '../src/types' +import { ANTHROPIC_TEST_MODEL } from '../src/constants' import { createStateWatcher, createMessageCollector } from './utils/testHelpers' test('useExecutionState tracks status transitions', async () => { @@ -29,11 +30,16 @@ test('useExecutionState tracks status transitions', async () => { ]) const runPromise = run( - + Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -54,19 +60,26 @@ test('useExecutionState with createStateWatcher helper', async () => { ]) const runPromise = run( - + Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() const result = await runPromise expect(watcher.states.length).toBeGreaterThan(0) - expect(watcher.states[0]?.status).toBe('idle') - expect(watcher.states[watcher.states.length - 1]?.status).toBe('completed') + expect(watcher.states[0]?.status).toBe(AgentStatus.Idle) + expect(watcher.states[watcher.states.length - 1]?.status).toBe( + AgentStatus.Completed, + ) expect(result.stopReason).toBe('end_turn') }) @@ -84,11 +97,11 @@ test('useMessages accumulates conversation history', async () => { ]) const runPromise = run( - + Hello , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -108,11 +121,11 @@ test('useMessages with createMessageCollector helper', async () => { ]) const runPromise = run( - + Test message , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -134,7 +147,6 @@ test('useAgentState provides full state access', async () => { function FullStateTracker() { const state = useAgentState() - // eslint-disable-next-line react-hooks/immutability capturedStateRef.current = state return null } @@ -144,11 +156,11 @@ test('useAgentState provides full state access', async () => { ]) const runPromise = run( - + Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -164,7 +176,6 @@ test('hooks throw error when used outside AgentProvider', () => { function InvalidComponent() { try { useExecutionState() - // eslint-disable-next-line react-hooks/error-boundaries return
Should not reach here
} catch (error: unknown) { const err = error as Error @@ -189,12 +200,12 @@ test('multiple components can subscribe to same state', async () => { ]) const runPromise = run( - + Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -235,14 +246,19 @@ test('state updates during tool execution', async () => { ]) const runPromise = run( - + Use the tool , - { 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 new file mode 100644 index 0000000..d275679 --- /dev/null +++ b/packages/agentry/tests/openai-provider.test.tsx @@ -0,0 +1,1833 @@ +import { test, expect, beforeEach } from 'bun:test' +import { z } from 'zod' +import { run, Agent, Message, Tools, AgentTool, Tool } from '../src' +import { + createOpenAIMockClient, + createStepMockClient, + mockText, + mockToolUse, +} from './utils' +import { ANTHROPIC_TEST_MODEL, OPENAI_TEST_MODEL } from '../src/constants' +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() +}) + +test('openai provider streams text correctly', async () => { + const streamedChunks: string[] = [] + + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Streamed response' }], + }, + ], + }, + ]) + + const result = await run( + { + if (event.type === 'text') streamedChunks.push(event.text) + }} + > + Hello + , + { providers: { openai: { client } } }, + ) + + expect(result.content).toBe('Streamed response') + expect(streamedChunks).toContain('Streamed response') +}) + +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 + , + { + providers: { 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, calls: openaiCalls } = 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 + , + { + providers: { + openai: { client: openaiClient }, + anthropic: { client: anthropicClient }, + }, + }, + ) + + const result = await runPromise + expect(result.content).toBe('Final combined answer') + + // assert the OpenAI client received two calls: function_call turn + final turn + expect(openaiCalls.length).toBe(2) + // second call should contain a function_call_output referencing call_1 + const secondCallInput = openaiCalls[1]!.input as Array< + Record + > + const functionCallOutput = secondCallInput.find( + (item) => item.type === 'function_call_output', + ) + expect(functionCallOutput).toBeDefined() + expect(functionCallOutput!.call_id).toBe('call_1') + expect(functionCallOutput!.output).toContain('Anthropic subagent result') +}) + +test('missing OpenAI client throws descriptive error when no env var set', async () => { + const originalKey = process.env.OPENAI_API_KEY + delete process.env.OPENAI_API_KEY + resetSharedDefaultClients() + + try { + await expect( + run( + + Hello + , + ), + ).rejects.toThrow('No OpenAI client configured') + } finally { + if (originalKey !== undefined) { + process.env.OPENAI_API_KEY = originalKey + } + resetSharedDefaultClients() + } +}) + +test('surfaces stream failure from response.failed event', async () => { + const { client } = createOpenAIMockClient([ + { type: 'failed', message: 'rate limit exceeded' }, + ]) + + await expect( + run( + + Hello + , + { providers: { openai: { client } } }, + ), + ).rejects.toThrow('rate limit exceeded') +}) + +test('anthropic parent can run openai AgentTool subagent', async () => { + const { client: anthropicClient, controller } = createStepMockClient([ + { + content: [mockToolUse('summarizer', { text: 'Hello world' })], + stop_reason: 'tool_use', + }, + { + content: [mockText('Summary complete')], + stop_reason: 'end_turn', + }, + ]) + + let openaiCallCount = 0 + const { client: openaiClient } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'OpenAI summary result' }], + }, + ], + }, + ]) + + const originalCreate = (openaiClient as any).responses.create.bind( + (openaiClient as any).responses, + ) + ;(openaiClient as any).responses.create = async (...args: unknown[]) => { + openaiCallCount++ + return originalCreate(...args) + } + + const runPromise = run( + + + ( + + Summarize: {text} + + )} + /> + + Summarize something + , + { + providers: { + anthropic: { client: anthropicClient }, + openai: { client: openaiClient }, + }, + }, + ) + + await controller.nextTurn() // tool_use turn + await controller.waitForNextCall() + await controller.nextTurn() // final anthropic turn + + const result = await runPromise + expect(result.content).toBe('Summary complete') + expect(openaiCallCount).toBe(1) +}) + +test('thinking sends reasoning params to OpenAI API (non-streaming)', async () => { + const { client, calls } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Reasoned response' }], + }, + ], + }, + ]) + + await run( + + Think about this + , + { providers: { openai: { client } } }, + ) + + expect(calls.length).toBe(1) + const reasoning = calls[0]!.reasoning as Record + expect(reasoning).toBeDefined() + expect(reasoning.effort).toBe('medium') + expect(reasoning.summary).toBe('auto') +}) + +test('reasoning summary returned in result.thinking (non-streaming)', async () => { + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'I reasoned...' }], + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'Final answer' }], + }, + ], + }, + ]) + + const result = await run( + + Think about this + , + { providers: { openai: { client } } }, + ) + + expect(result.thinking).toBe('I reasoned...') +}) + +test('streaming: reasoning delta fires thinking event', async () => { + const thinkingEvents: string[] = [] + + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'Streaming thought' }], + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'Streamed answer' }], + }, + ], + }, + ]) + + const result = await run( + { + if (event.type === 'thinking') thinkingEvents.push(event.text) + }} + > + Stream thoughts + , + { providers: { openai: { client } } }, + ) + + expect(result.content).toBe('Streamed answer') + expect(thinkingEvents).toContain('Streaming thought') +}) + +test('tool handler error is returned as [ERROR] function_call_output', async () => { + const { client, calls } = createOpenAIMockClient([ + { + output: [ + { + type: 'function_call', + call_id: 'call_err', + name: 'failing_tool', + arguments: JSON.stringify({}), + }, + ], + }, + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Got error from tool' }], + }, + ], + }, + ]) + + await run( + + + { + throw new Error('boom') + }} + /> + + Call the failing tool + , + { providers: { openai: { client } } }, + ) + + const secondCallInput = calls[1]!.input as Array> + const toolOutput = secondCallInput.find( + (item) => item.type === 'function_call_output', + ) + expect(toolOutput).toBeDefined() + expect(toolOutput!.call_id).toBe('call_err') + expect(String(toolOutput!.output)).toMatch(/boom/i) +}) + +test('openai multi-turn tool round-trip (function_call → result → response)', async () => { + let capturedSecondInput: Array> | null = null + + const { client, calls } = createOpenAIMockClient([ + { + output: [ + { + type: 'function_call', + call_id: 'call_abc', + name: 'get_weather', + arguments: JSON.stringify({ city: 'London' }), + }, + ], + }, + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'London is sunny' }], + }, + ], + }, + ]) + + const result = await run( + + + `Weather in ${city}: Sunny`} + /> + + What is the weather in London? + , + { providers: { openai: { client } } }, + ) + + expect(result.content).toBe('London is sunny') + expect(calls.length).toBe(2) + + capturedSecondInput = calls[1]!.input as Array> + + // verify function_call was echoed back as function_call output with matching call_id + const functionCallOutput = capturedSecondInput.find( + (item) => item.type === 'function_call_output', + ) + expect(functionCallOutput).toBeDefined() + expect(functionCallOutput!.call_id).toBe('call_abc') + expect(functionCallOutput!.output).toContain('London') + + // verify the original function_call is replayed correctly + const functionCall = capturedSecondInput.find( + (item) => item.type === 'function_call', + ) + expect(functionCall).toBeDefined() + expect(functionCall!.call_id).toBe('call_abc') + expect(functionCall!.name).toBe('get_weather') +}) + +test('streaming: function_call item fires tool_use_start event', async () => { + const toolUseEvents: Array<{ toolName: string; toolId: string }> = [] + const providerEvents: string[] = [] + + const { client } = createOpenAIMockClient([ + { + output: [ + { + type: 'function_call', + call_id: 'call_stream_1', + name: 'get_weather', + arguments: JSON.stringify({ city: 'Paris' }), + }, + ], + }, + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'Paris is cloudy' }], + }, + ], + }, + ]) + + 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) + } + }} + > + + `Weather in ${city}: Cloudy`} + /> + + What is the weather in Paris? + , + { 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 allEventTypes: 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( + { + allEventTypes.push(event.type) + 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']) + // 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 + +test('toOpenAIInput: tool_result with is_error:true is prefixed with [ERROR]', () => { + const input = toOpenAIInput([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_x', + content: 'something went wrong', + is_error: true, + }, + ], + }, + ]) + expect(input).toHaveLength(1) + const item = input[0] as unknown as Record + expect(item.type).toBe('function_call_output') + expect(item.call_id).toBe('call_x') + expect(item.output).toBe('[ERROR] something went wrong') +}) + +test('toOpenAIInput: tool_result without is_error has no [ERROR] prefix', () => { + const input = toOpenAIInput([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_y', + content: 'success result', + is_error: false, + }, + ], + }, + ]) + const item = input[0] as unknown as Record + expect(item.output).toBe('success result') + expect(String(item.output)).not.toMatch(/^\[ERROR\]/) +}) + +test('toOpenAIInput: assistant message with only thinking block emits empty placeholder', () => { + const input = toOpenAIInput([ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'some internal thought', + }, + ], + }, + ]) + expect(input).toHaveLength(1) + const item = input[0] as unknown as Record + expect(item.role).toBe('assistant') + expect(item.content).toBe('') +}) + +test('toOpenAIInput: assistant message with text and tool_use preserves order', () => { + const input = toOpenAIInput([ + { + role: 'assistant', + content: [ + { type: 'text', text: 'I will call a tool' }, + { type: 'tool_use', id: 'call_z', name: 'my_tool', input: { x: 1 } }, + ], + }, + ]) + expect(input).toHaveLength(2) + const first = input[0] as unknown as Record + const second = input[1] as unknown as Record + expect(first.content).toBe('I will call a tool') + expect(second.type).toBe('function_call') + expect(second.call_id).toBe('call_z') + expect(second.name).toBe('my_tool') +}) + +// OpenAI compaction test + +test('checkAndCompact compacts messages for OpenAI provider when threshold exceeded', async () => { + const { client, calls } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'OpenAI summary' }], + }, + ], + }, + ]) + + 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 agentInstance: AgentInstance = { + type: InstanceType.Agent, + props: { + provider: 'openai' as const, + model: OPENAI_TEST_MODEL, + maxTokens: 100, + }, + client, + engine: null, + systemParts: [], + tools: [], + builtInTools: [], + mcpServers: [], + children: [], + parent: null, + store, + } + + const engine = new ExecutionEngine({ + provider: 'openai', + client, + model: OPENAI_TEST_MODEL, + maxTokens: 100, + store, + agentInstance, + 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(calls.length).toBe(1) + + const state = store.getState() + expect(state.messages).toHaveLength(1) + const onlyMessage = state.messages[0]! + expect(onlyMessage.role).toBe('user') + expect(onlyMessage.content as unknown[]).toEqual([ + 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, + [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, + [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, + [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, + [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') +}) + +// --- 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...') +}) + +// --- 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/run-agent.test.tsx b/packages/agentry/tests/run-agent.test.tsx index 8a7e108..d953910 100644 --- a/packages/agentry/tests/run-agent.test.tsx +++ b/packages/agentry/tests/run-agent.test.tsx @@ -1,9 +1,14 @@ import { test, expect } from 'bun:test' import { run, type AgentResult } from '../src' import { Agent, System, Tools, Tool, Message } from '../src' -import { createStepMockClient, mockText, mockToolUse } from './utils' +import { + createStepMockClient, + mockText, + mockToolUse, + createOpenAIMockClient, +} from './utils' import { z } from 'zod' -import { TEST_MODEL } from '../src/constants' +import { ANTHROPIC_TEST_MODEL, OPENAI_TEST_MODEL } from '../src/constants' test('runAgent executes subagent and returns result', async () => { const { client, controller } = createStepMockClient([ @@ -22,7 +27,7 @@ test('runAgent executes subagent and returns result', async () => { ]) const runPromise = run( - + Test agent with spawn capability { Research AI technologies , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() // Parent tool use @@ -76,7 +81,7 @@ test('runAgent supports parallel spawning', async () => { ]) const runPromise = run( - + Parallel analysis coordinator { Analyze this content , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() // Parent tool use @@ -144,7 +149,6 @@ test('runAgent respects custom model option', async () => { const originalCreate = client.beta.messages.create.bind(client.beta.messages) const originalStream = client.beta.messages.stream.bind(client.beta.messages) - // eslint-disable-next-line @typescript-eslint/no-explicit-any client.beta.messages.create = (async (params: any, options?: any) => { callCount++ // Second call should be the spawned agent @@ -154,7 +158,6 @@ test('runAgent respects custom model option', async () => { return originalCreate(params, options) }) as typeof client.beta.messages.create - // eslint-disable-next-line @typescript-eslint/no-explicit-any client.beta.messages.stream = ((params: any, options?: any) => { callCount++ // Second call should be the spawned agent @@ -165,7 +168,7 @@ test('runAgent respects custom model option', async () => { }) as typeof client.beta.messages.stream const runPromise = run( - + Test { Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -224,7 +227,6 @@ test('runAgent respects custom maxTokens option', async () => { // So we capture the first create() call that has system === 'Test' and max_tokens !== 4096 const originalCreate = client.beta.messages.create.bind(client.beta.messages) - // eslint-disable-next-line @typescript-eslint/no-explicit-any client.beta.messages.create = (async (params: any, options?: any) => { // Capture if it's the spawned agent (has Test system and not the parent's maxTokens) if ( @@ -238,7 +240,7 @@ test('runAgent respects custom maxTokens option', async () => { }) as typeof client.beta.messages.create const runPromise = run( - + Test { Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -290,7 +292,6 @@ test('runAgent handles errors gracefully', async () => { // The spawned agent uses create() (streaming disabled), identify it by system prompt const originalCreate = client.beta.messages.create.bind(client.beta.messages) - // eslint-disable-next-line @typescript-eslint/no-explicit-any client.beta.messages.create = (async (params: any, options?: any) => { // The spawned agent has system === 'Test' and uses create() (not stream) if (params.system === 'Test') { @@ -301,7 +302,7 @@ test('runAgent handles errors gracefully', async () => { }) as typeof client.beta.messages.create const runPromise = run( - + Test error handling { Test error handling , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() // Parent tool use @@ -359,7 +360,7 @@ test('runAgent returns full AgentResult', async () => { ]) const runPromise = run( - + Test { Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -416,7 +417,7 @@ test('runAgent with conditional agent selection', async () => { ]) const runPromise = run( - + Conditional spawner { Analyze with high complexity , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -458,3 +459,64 @@ test('runAgent with conditional agent selection', async () => { const result = await runPromise expect(result.content).toBe('Analysis complete') }) + +test('context.runAgent with provider and clients override', async () => { + const { client: anthropicClient, controller } = createStepMockClient([ + { + content: [mockToolUse('cross_provider_task', { query: 'test' })], + stop_reason: 'tool_use', + }, + { + content: [mockText('Combined result')], + stop_reason: 'end_turn', + }, + ]) + + const { client: openaiClient } = createOpenAIMockClient([ + { + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: 'OpenAI sub-result' }], + }, + ], + }, + ]) + + let capturedSubResult = '' + + const runPromise = run( + + + { + const subResult = await context.runAgent( + + Process: {input.query} + , + { + provider: 'openai', + clients: { openai: openaiClient }, + }, + ) + capturedSubResult = subResult.content ?? '' + return `OpenAI processed: ${subResult.content}` + }} + /> + + Run cross-provider task + , + { providers: { anthropic: { client: anthropicClient } } }, + ) + + await controller.nextTurn() + await controller.waitForNextCall() + await controller.nextTurn() + + const result = await runPromise + expect(result.content).toBe('Combined result') + expect(capturedSubResult).toBe('OpenAI sub-result') +}) diff --git a/packages/agentry/tests/runtime.test.tsx b/packages/agentry/tests/runtime.test.tsx index ae9aefb..2a84e61 100644 --- a/packages/agentry/tests/runtime.test.tsx +++ b/packages/agentry/tests/runtime.test.tsx @@ -1,10 +1,16 @@ -import { test, expect } from 'bun:test' +import { test, expect, beforeEach } from 'bun:test' import { z } from 'zod' import { run } from '../src' import { defineTool } from '../src/tools' import { Agent, System, Context, Tools, Tool, Message, AgentTool } from '../src' import { createStepMockClient, mockText, mockToolUse } from './utils' -import { TEST_MODEL } from '../src/constants' +import { ANTHROPIC_TEST_MODEL } from '../src/constants' +import { resetSharedDefaultClients } from '../src/providers/clientResolver' +import { toAnthropicMessage } from '../src/providers/anthropic' + +beforeEach(() => { + resetSharedDefaultClients() +}) test('root agent sees pre-loaded JSX messages', async () => { const { client, controller } = createStepMockClient([ @@ -12,14 +18,19 @@ test('root agent sees pre-loaded JSX messages', async () => { ]) const runPromise = run( - + You continue conversations {/* pre-loaded conversation history */} What is 2+2? 2+2 equals 4. And what is 3+3? , - { client }, + { providers: { anthropic: { client } } }, ) // wait for API call to be queued @@ -47,11 +58,17 @@ 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 , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -88,14 +105,19 @@ test('run handles tools correctly', async () => { ]) const runPromise = run( - + You are a test assistant. Use the get_info tool. Use the tool to get info about testing , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -115,10 +137,15 @@ test('interactive mode allows multiple turns', async () => { ]) const agentPromise = run( - + You are a test assistant. Be very concise. , - { mode: 'interactive', client }, + { mode: 'interactive', providers: { anthropic: { client } } }, ) const agent = await agentPromise @@ -150,10 +177,15 @@ test('stream() accepts message parameter for first turn', async () => { ]) const agentPromise = run( - + You are a test assistant. Be very concise. , - { mode: 'interactive', client }, + { mode: 'interactive', providers: { anthropic: { client } } }, ) const agent = await agentPromise @@ -181,10 +213,15 @@ test('stream() works with message for subsequent turns', async () => { ]) const agentPromise = run( - + You are a test assistant. Be very concise. , - { mode: 'interactive', client }, + { mode: 'interactive', providers: { anthropic: { client } } }, ) const agent = await agentPromise @@ -242,13 +279,18 @@ test('handles multiple tool calls in sequence', async () => { ]) const runPromise = run( - + Increment twice , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -277,13 +319,19 @@ test('respects maxIterations limit', async () => { }) const runPromise = run( - + Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -345,14 +393,19 @@ test('tool schemas in API requests are complete', async () => { ]) const runPromise = run( - + You are a test assistant. Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -392,11 +445,11 @@ test('batch mode errors when agent has no messages', async () => { await expect( run( - + You are helpful {/* No components */} , - { client }, + { providers: { anthropic: { client } } }, ), ).rejects.toThrow('Agent has no messages. In batch mode') }) @@ -405,10 +458,10 @@ 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' }, + { providers: { anthropic: { client } }, mode: 'interactive' }, ) // should not throw - agent is created successfully @@ -418,7 +471,12 @@ 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 +492,12 @@ test('subagent errors when it has no messages', async () => { ]) const runPromise = run( - + You are helpful { Use the subagent , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -475,11 +538,16 @@ test('System with cache="ephemeral" creates block with cache_control', async () ]) const runPromise = run( - + Ephemeral instructions Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -510,11 +578,16 @@ test('Context with cache="ephemeral" creates block with cache_control', async () ]) const runPromise = run( - + Ephemeral context Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -544,11 +617,16 @@ test('System without cache creates simple string', async () => { ]) const runPromise = run( - + Regular instructions Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -568,14 +646,19 @@ test('Mixed System and Context parts maintain order and cache flags', async () = ]) const runPromise = run( - + First cached part Ephemeral context Ephemeral instructions Second cached part Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -619,13 +702,18 @@ test('Multiple ephemeral parts are all marked correctly', async () => { ]) const runPromise = run( - + First ephemeral Second ephemeral Third ephemeral Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -653,13 +741,18 @@ test('Multiple System and Context parts use array format', async () => { ]) const runPromise = run( - + First part Second part Third part Test , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -687,7 +780,12 @@ test('strict tool enables structured-outputs beta', async () => { ]) const runPromise = run( - + { Extract name , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -718,17 +816,19 @@ test('thinking enables interleaved-thinking beta by default', async () => { const runPromise = run( Analyze this , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -751,7 +851,8 @@ test('thinking with interleaved: false disables interleaved-thinking beta', asyn const runPromise = run( Analyze this , - { client }, + { providers: { anthropic: { client } } }, ) await controller.waitForNextCall() @@ -779,3 +880,373 @@ test('thinking with interleaved: false disables interleaved-thinking beta', asyn await controller.nextTurn() await runPromise }) + +test('provider prop omission throws correct error', async () => { + const { client } = createStepMockClient([]) + + await expect( + run( + // @ts-expect-error intentionally missing provider to test runtime error + + Hello + , + { providers: { anthropic: { client } } }, + ), + ).rejects.toThrow('Provider is required on the rendered agent.') +}) + +test('missing Anthropic client throws descriptive error when no env var set', async () => { + const prev = process.env.ANTHROPIC_API_KEY + delete process.env.ANTHROPIC_API_KEY + resetSharedDefaultClients() + + try { + await expect( + run( + + Hello + , + { providers: {} }, + ), + ).rejects.toThrow('No Anthropic client configured') + } finally { + if (prev !== undefined) process.env.ANTHROPIC_API_KEY = prev + resetSharedDefaultClients() + } +}) + +// toAnthropicMessage unit tests + +test('toAnthropicMessage: thinking blocks are dropped from replayed history', () => { + const converted = toAnthropicMessage({ + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'internal monologue' }, + { type: 'text', text: 'visible response' }, + ], + }) + + expect(Array.isArray(converted.content)).toBe(true) + const blocks = converted.content as Array<{ type: string }> + expect(blocks.some((b) => b.type === 'thinking')).toBe(false) + expect(blocks.some((b) => b.type === 'text')).toBe(true) +}) + +test('toAnthropicMessage: thinking-only assistant message gets empty text placeholder', () => { + const converted = toAnthropicMessage({ + role: 'assistant', + content: [{ type: 'thinking', thinking: 'internal monologue' }], + }) + + expect(Array.isArray(converted.content)).toBe(true) + const blocks = converted.content as Array<{ type: string; text?: string }> + expect(blocks).toHaveLength(1) + expect(blocks[0]!.type).toBe('text') + expect(blocks[0]!.text).toBe('') +}) + +test('toAnthropicMessage: thinking blocks stripped when replaying assistant turn in multi-turn conversation', async () => { + // First turn: thinking + tool_use forces a second API call with the first turn in history + const { client, controller } = createStepMockClient([ + { + content: [ + { + type: 'thinking', + thinking: 'I should use a tool', + citations: null, + } as never, + mockToolUse('echo', { msg: 'hello' }), + ], + stop_reason: 'tool_use', + }, + { + content: [mockText('Done')], + stop_reason: 'end_turn', + }, + ]) + + const capturedBlocks: Array<{ type: string }>[] = [] + const rawClient = client as unknown as { + beta: { + messages: { + create: (p: Record, opts?: unknown) => Promise + } + } + } + const originalCreate = rawClient.beta.messages.create.bind( + rawClient.beta.messages, + ) + rawClient.beta.messages.create = async ( + params: Record, + opts?: unknown, + ) => { + const msgs = params.messages as Array<{ role: string; content: unknown }> + for (const msg of msgs) { + if (Array.isArray(msg.content)) { + capturedBlocks.push(msg.content as Array<{ type: string }>) + } + } + return originalCreate(params, opts) + } + + const runPromise = run( + + + msg} + /> + + Use the echo tool + , + { providers: { anthropic: { client } } }, + ) + + await controller.nextTurn() // first turn: thinking + tool_use + await controller.waitForNextCall() + await controller.nextTurn() // second turn: end_turn + + await runPromise + + // All blocks seen across both API calls must not include thinking blocks + 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, 10)) + 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/subagent.test.ts b/packages/agentry/tests/subagent.test.ts index a95ead7..3cb38d1 100644 --- a/packages/agentry/tests/subagent.test.ts +++ b/packages/agentry/tests/subagent.test.ts @@ -1,5 +1,6 @@ import { test, expect } from 'bun:test' import { createSubagentInstance } from '../src/instances/createInstance' +import { InstanceType } from '../src/instances/types' test('createSubagentInstance requires name', () => { expect(() => { @@ -17,7 +18,7 @@ test('createSubagentInstance creates correct structure', () => { maxTokens: 1000, } as Parameters[0]) - expect(subagent.type).toBe('subagent') + expect(subagent.type).toBe(InstanceType.Subagent) expect(subagent.name).toBe('researcher') expect(subagent.description).toBe('A research agent') expect(subagent.props.model).toBe('claude-haiku-4-5') diff --git a/packages/agentry/tests/subagents.test.tsx b/packages/agentry/tests/subagents.test.tsx index 1f3f3fa..aea318a 100644 --- a/packages/agentry/tests/subagents.test.tsx +++ b/packages/agentry/tests/subagents.test.tsx @@ -13,7 +13,7 @@ import { useMessages, } from '../src' import { createStepMockClient, mockText, mockToolUse } from './utils' -import { TEST_MODEL } from '../src/constants' +import { ANTHROPIC_TEST_MODEL } from '../src/constants' import { z } from 'zod' import { getRegisteredTools } from './utils/testHelpers' @@ -22,15 +22,15 @@ test('subagent has isolated message context', async () => { function IsolatedAgent() { const messages = useMessages() - // eslint-disable-next-line react-hooks/immutability subagentMessageCountRef.current = messages.length return ( You are isolated Perform isolated task @@ -45,7 +45,7 @@ test('subagent has isolated message context', async () => { ]) const runPromise = run( - + Parent system prompt { Call the isolated agent , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -94,7 +94,8 @@ test('onStepFinish callback fires for subagent calls', async () => { const runPromise = run( { stepFinishCalled = true @@ -111,7 +112,7 @@ test('onStepFinish callback fires for subagent calls', async () => {
Call subagent
, - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -158,7 +159,7 @@ test('onComplete callback fires when agent finishes', async () => { ]) const runPromise = run( - + { Run the completable agent , - { client }, + { providers: { anthropic: { client } } }, ) await controller.nextTurn() @@ -197,7 +198,7 @@ test('conditionally hidden subagents are not available as tools', async () => { function OptionalSubagentTest() { const showHidden = false return ( - + {showHidden && (