# Adapter Adapter modules connect external models, tools, and protocol ecosystems to Rejelly. Adapters fall into two categories by responsibility: - **Model Adapters**: Wrap provider models into Rejelly's `ModelAdapter`, see [Model Adapter (OpenAI / Gemini)](/en/api/adapter/model). - **Tool / Source Adapters**: Convert external tool ecosystems into Rejelly `ToolDefinition`, see [MCP](/en/api/adapter/mcp) and [LangChain](/en/api/adapter/langchain). Adapters have been extracted from `@rejelly/core` and published as separate packages. Install the corresponding package as needed: | Package | Purpose | |---------|---------| | `@rejelly/adapter-openai` | OpenAI model adapter, provides `createOpenAIAdapter` | | `@rejelly/adapter-gemini` | Gemini model adapter, provides `createGeminiAdapter` | | `@rejelly/adapter-mcp` | MCP tool / resource / prompt integration, provides `equipMCP` / `fromMCPTool` | | `@rejelly/adapter-langchain` | LangChain tool adapter, provides `fromLangChainTool` | ## Multimodal Tool Results Tools can return model-visible multimodal content (e.g., images) by wrapping the result with `toolContent(parts: ContentPart[])` from `@rejelly/core`. Regular JSON / string results are still stringified as-is; only objects marked by `toolContent` enter the conversation as `MessageContent`. The two adapter categories each handle this on their respective side: **Model adapter (OpenAI / Gemini) — send-side auto-splitting.** Most providers only allow **text** in tool / function result messages. When a tool message contains media (e.g., an image returned via `toolContent`), the message converter **automatically splits it into two messages**: a plain-text tool result + an immediately following `user` message carrying the media, so the model can actually "see" the image. Plain-text tool results are unaffected and remain as a single message. - **OpenAI** (`toOpenAIMessages`): `tool` (text, placeholder sentence when purely media) → `user` (`image_url`). - **Gemini** (`toGeminiMessages`): `functionResponse` (text) → `user` Content (`inlineData`). Note this produces two consecutive client-side turns (`function` directly followed by `user`). > This is normalization performed by the adapter at the "provider capability boundary": only the adapter layer knows which providers cannot carry images in tool results. Hence this conversion lives in the adapter, transparent to all policies and upper-level Agents — no metatool or extra round-trips needed. **Tool / source adapter (MCP / LangChain) — receive-side conversion to `toolContent`.** When a tool itself returns multimodal content blocks (including images), the adapter converts them into `toolContent` so they flow as native model-visible content into the send-side above; **plain text or unrecognized results pass through unchanged**, preserving existing behavior. - **MCP** (`formatCallToolResult`): `image` blocks → image `ContentPart`; `resource` blocks degraded by text/label. - **LangChain** (`fromLangChainTool`): Classic `image_url` and standard `image` (`source_type: "base64" | "url"`) blocks in content-block arrays → image `ContentPart`. # LangChain Tool Adapter `@rejelly/adapter-langchain` provides `fromLangChainTool` for converting LangChain tools into Rejelly `ToolDefinition`, enabling reuse of the LangChain tool ecosystem. For the receive-side contract of multimodal tool results, see [Adapter · Multimodal Tool Results](/en/api/adapter/#multimodal-tool-results). ## `fromLangChainTool(tool, options?)` **Basic usage:** ```typescript import { createAgent, equipSystem, equipTool, promptAgent } from '@rejelly/core'; import { fromLangChainTool } from '@rejelly/adapter-langchain'; import { TavilySearchResults } from "@langchain/community/tools/tavily_search"; import { Calculator } from "@langchain/community/tools/calculator"; // Instantiate LangChain tools const tavilyTool = new TavilySearchResults({ apiKey: "..." }); const calcTool = new Calculator(); const ResearchAgent = createAgent({ id: 'researcher', model: openaiModel, handler: async (props) => { equipSystem('You are a researcher who excels at using tools.'); // Directly convert and equip a LangChain tool equipTool(fromLangChainTool(tavilyTool)); // Rename and customize description (optional) equipTool(fromLangChainTool(calcTool, { name: 'math_calculator', description: 'Must use this tool when precise numerical calculations are needed' })); const result = await promptAgent(ResultSchema); return result; } }); ``` **fromLangChainTool interface:** ```typescript /** * Adapts a LangChain tool to a Rejelly tool * @param tool - LangChain tool instance (compatible with StructuredTool and most tool implementations) * @param options - Optional override configuration */ function fromLangChainTool( tool: LangChainToolLike, options?: FromLangChainToolOptions ): ToolDefinition; interface LangChainToolLike { name: string; description: string; schema?: z.ZodTypeAny; invoke?(input: any, options?: any): Promise; call?: (input: any, options?: any): Promise; func?: (input: any, options?: any): Promise; } interface FromLangChainToolOptions { /** Override the tool name */ name?: string; /** Override the tool description */ description?: string; } ``` **How it works:** - **Duck typing compatibility**: Uses a minimal interface (`LangChainToolLike`) to avoid a hard dependency on the `langchain` library - **Schema extraction**: Automatically extracts the Zod Schema from LangChain tools (modern tools typically expose it directly) - **Execution method adaptation**: Supports `invoke()`, `call()`, `func()` and other execution methods (priority: invoke > call > func) - **AbortSignal injection**: Automatically retrieves `AbortSignal` from AgentContext and passes it to LangChain tools - **Multimodal results**: Content-block arrays returned by tools that include image blocks are converted to `toolContent` (see [Adapter · Multimodal Tool Results](/en/api/adapter/#multimodal-tool-results)); plain text / unrecognized results pass through unchanged - **Error handling**: Friendly error wrapping that preserves the original error context **Notes:** - If a tool lacks a Zod Schema, it degrades to `z.any()` and prints a warning (this may reduce LLM call accuracy) - Use modern LangChain tools that support Zod Schema (e.g. `StructuredTool`) for best results - You can override the tool name and description via `options` to better fit the current Agent context - The adapter automatically handles AbortSignal passing, but some older tools may not support it # MCP (Model Context Protocol) Adapter `@rejelly/adapter-mcp` provides `equipMCP` and `fromMCPTool`. It does not create transports or processes — instead, it receives an already-connected MCP Client from the business layer and converts MCP tools, resources, and prompt capabilities into Rejelly-compatible toolkits. For the receive-side contract of multimodal tool results, see [Adapter · Multimodal Tool Results](/en/api/adapter/#multimodal-tool-results). ## `equipMCP(client, options)` **MCP bridging Hook.** Does not create transports or processes inside the adapter — you establish the connection outside the Agent using the official `@modelcontextprotocol/sdk` (or another implementation), then pass the **already connected client** to `equipMCP`. The adapter handles JSON Schema → Zod, assembles `MCPKit`, and caches `tools/list` via `equipMemo` (avoiding RPC on every `reborn()` round). **`kit.inject()`** calls `equipTool` to register with the current Agent. **Recommended combination:** 1. `equipResource('mcp:…', { create, destroy, expose })` — connection lifecycle and sub-agent exposure (see [equipResource](/en/api/equip#equipresource)). 2. `const kit = await equipMCP(client, { clientId, … })` — fetches and converts tool definitions; then `kit.inject()` calls `equipTool` to register with the current Agent. **Example (stdio + official SDK):** ```typescript import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { equipMCP } from '@rejelly/adapter-mcp'; import { equipResource, promptAgent } from '@rejelly/core'; const client = await equipResource('mcp:fs', { create: async () => { const transport = new StdioClientTransport({ command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/data'], }); const c = new Client({ name: 'app', version: '1.0.0' }); await c.connect(transport); return c; }, destroy: async (c) => { await c.close(); }, deps: ['/data'], expose: true, }); const kit = await equipMCP(client, { clientId: 'fs-main', namespace: 'fs', enableResources: true, }); kit.inject({ injectResourceTools: true }); const result = await promptAgent(ResultSchema); ``` **Standalone conversion (single tool):** The exported function `fromMCPTool(mcpTool, client, { name? })` can convert a single MCP Tool into `ToolDefinition`, useful for custom composition or testing. **MCPKit:** `equipMCP` returns `client`, `tools` (MCP business tools only), `resourceTools` (composite tools for `list_resources` / `read_resource`), `toolMap`, `resourceToolMap`, `prompts` (`list` / `get` + **`asInstruction()`**, returns `MessageContent` that can be passed directly to `equipInstruction`), and `inject`. Calling **`inject({ injectTools?, injectResourceTools?, middleware? })`** triggers `equipTool`: default is **`injectTools: true`**, **`injectResourceTools: false`** (to avoid cluttering the Prompt with resource URIs; enable when needed). **The `kit.tools` returned by `await equipMCP(...)` is the snapshot from this fetch/cache parse** — it won't silently change later. To get the latest list, pass **`forceRefresh: true`** on the **next** `equipMCP` call (see below). **Naming contract:** Names in `options.tools` **always use the original MCP server tool names** (for filtering and `callTool`); **`namespace`** only affects the name registered to the Agent / LLM side (avoids `read_file` name collisions when multiple MCPs coexist). If `tools` are declared but cannot all be found within pagination and `maxItems`, the adapter **throws** (Fail Fast), preventing calls to the model with an incomplete tool set. **Force refresh list (declarative):** `forceRefresh: true` means **this** `equipMCP` call **skips the tools/list memo**, re-fetches from the MCP server, and **writes back** to the `equipMemo` cache (by advancing the list epoch). Use this when you know the server's tool set has changed, or when you want **every** `equipMCP` call to re-`listTools` (pass `forceRefresh: true` each time). ## EquipMCPOptions Interface ```typescript interface EquipMCPOptions { clientId: string; /** Namespace for tool names registered to the Agent; `tools` filtering still uses MCP native names */ namespace?: string; tools?: string[]; /** Skip memo this round, re-listTools and overwrite the cache */ forceRefresh?: boolean; /** Whether to follow listTools nextCursor to fetch all pages, default true */ autoPaginate?: boolean; /** Maximum number of Tool items to collect (guard), default 100 */ maxItems?: number; /** * When true, generates synthetic list/read resource tools in `resourceTools` (unrelated to ClientCapabilities in the MCP protocol). */ enableResources?: boolean; middleware?: ToolMiddleware[]; } ``` **MCPClientAdapter (Facade):** Different from the official `@modelcontextprotocol/sdk` `Client` type; a simplified contract for Rejelly. `inputSchema` retains the server's JSON Schema; `callTool`'s `content` supports `text` / `image` / `audio` / `resource` blocks; `isError` follows the protocol. Optional methods align with the SDK at runtime (e.g., `getPrompt` typically accepts a single object parameter). ## How It Works 1. **Connection**: You `connect` the official Client inside [equipResource](/en/api/equip#equipresource)'s `create`; `close` in `destroy`. 2. **Tools**: `listTools` supports `nextCursor` via `autoPaginate` + `maxItems`; if `tools` (native name list) is configured but cannot all be found within pagination and limits, **throws immediately**; results are cached via `equipMemo` by default (same `clientId` and deps — `reborn()` doesn't repeat RPC); **`forceRefresh: true`** skips cache hit and refreshes; generates a `ToolDefinition` for each MCP tool (JSON Schema goes directly into `jsonSchemaToZod`); registers when **`kit.inject({ injectTools: true })`** is called. 3. **Invocation**: `callTool` is compatible with both the SDK's `callTool({ name, arguments })` and `(name, args)` signatures; plain text results are returned as text; non-text blocks (including images) are converted to `toolContent` (see [Adapter · Multimodal Tool Results](/en/api/adapter/#multimodal-tool-results)). 4. **Resources**: When **`enableResources`** is true, generates **`resourceTools`** (`list_resources` with cursor, `read_resource` by URI) — **not fully listed at equip time**; only registered via **`inject({ injectResourceTools: true })`**. 5. **Prompts**: `kit.prompts.list()` / `get()` let you manually `equipInstruction` in your business logic; `get` returns an object with **`asInstruction()`** (`MessageContent`: `string | ContentPart[]`, including text / image / video, consistent with `@rejelly/core` definitions). ## Working with equipResource - Typical pattern: `await equipResource('mcp:', { create, destroy, deps, expose: true })` for a long-lived Client, then `await equipMCP(client, { clientId, … })` to get `kit`, and finally `kit.inject()`. - For sub-agents that need to use the same connection directly in code, use [expectResource](/en/api/expect#expectresource): **`expectResource('mcp:')`**, the key must exactly match the parent's `equipResource` key (including the `mcp:` prefix). **Notes:** - Before a sub-agent can read a parent-exposed Client, the parent Agent must have already called `equipResource('mcp:…', { expose: true })` and **completed registration before** the sub-agent executes. - To bypass memo and re-`listTools` each time, pass **`forceRefresh: true`** (can be passed on every `equipMCP` call). - The official SDK's `readResource` is often `readResource({ uri })`, which may not match the `MCPClientAdapter` doc signature — adapt or assert types on the business side as needed. ## Parent-Child Agent Example (Child Uses `expectResource`) ```typescript import type { MCPClientAdapter } from '@rejelly/adapter-mcp'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { equipMCP } from '@rejelly/adapter-mcp'; import { createAgent, equipResource, expectResource, equipScope, expectScope, promptAgent } from '@rejelly/core'; import { z } from 'zod'; const ParentAgent = createAgent({ id: 'parent', handler: async () => { const fsClient = await equipResource('mcp:filesystem', { create: async () => { const t = new StdioClientTransport({ command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], }); const c = new Client({ name: 'app', version: '1.0.0' }); await c.connect(t); return c; }, destroy: async (c) => { await c.close(); }, deps: ['/tmp'], expose: true, }); const kit = await equipMCP(fsClient, { clientId: 'fs', namespace: 'filesystem' }); kit.inject(); equipScope({ workspace: '/tmp' }); return await ChildAgent({ task: 'analyze' }); }, }); const ChildAgent = createAgent({ id: 'child', handler: async () => { const { workspace } = expectScope(z.object({ workspace: z.string() })); const fsClient = expectResource('mcp:filesystem'); const config = await fsClient.readResource(`file://${workspace}/config.json`); const configData = JSON.parse(config.contents[0].text as string); equipInstruction(`Working directory: ${workspace}\nConfiguration: ${JSON.stringify(configData)}`); return await promptAgent(ResultSchema); }, }); ``` # Model Adapter (OpenAI / Gemini) `createOpenAIAdapter` (`@rejelly/adapter-openai`) and `createGeminiAdapter` (`@rejelly/adapter-gemini`) wrap provider models into Rejelly's `ModelAdapter` and deliver the JSON Schema from `promptAgent(schema)` to the underlying model. For the send-side contract of multimodal tool results, see [Adapter · Multimodal Tool Results](/en/api/adapter/#multimodal-tool-results). ## Creating a Model Adapter ### OpenAI ```typescript import { createOpenAIAdapter } from '@rejelly/adapter-openai'; const model = createOpenAIAdapter({ modelId: 'gpt-5.6-luna', apiKey: process.env.OPENAI_API_KEY, schemaMode: 'json_schema', }); ``` `createOpenAIAdapter` also works with OpenAI-compatible providers: ```typescript import { createOpenAIAdapter } from '@rejelly/adapter-openai'; const model = createOpenAIAdapter({ id: 'deepseek-chat', provider: 'deepseek', modelId: 'deepseek-chat', apiKey: process.env.DEEPSEEK_API_KEY, baseURL: 'https://api.deepseek.com', schemaMode: 'json_object', chatCompletionParams: { temperature: 0.2, }, requestOption: { timeout: 60_000, }, }); ``` ### Gemini ```typescript import { createGeminiAdapter } from '@rejelly/adapter-gemini'; const model = createGeminiAdapter({ modelId: 'gemini-2.5-pro', apiKey: process.env.GEMINI_API_KEY, schemaMode: 'json_schema', }); ``` In Node environments, if `apiKey` is not explicitly passed, Gemini reads `GEMINI_API_KEY` or `GOOGLE_API_KEY`; in browser environments, it should be passed explicitly. Use `generateContentParams` to forward Gemini request parameters: ```typescript import { createGeminiAdapter } from '@rejelly/adapter-gemini'; const model = createGeminiAdapter({ modelId: 'gemini-2.5-flash', schemaMode: 'prompt', generateContentParams: { temperature: 0.3, }, }); ``` ### Using in an Agent The created object is a Rejelly `ModelAdapter` and can be passed directly to `createAgent({ model })`, or injected uniformly via `runWith`'s model registry. ```typescript import { createAgent, promptAgent } from '@rejelly/core'; import { createOpenAIAdapter } from '@rejelly/adapter-openai'; import { z } from 'zod'; const model = createOpenAIAdapter({ modelId: 'gpt-5.6-luna', apiKey: process.env.OPENAI_API_KEY, }); const AnswerSchema = z.object({ answer: z.string(), }); export const AnswerAgent = createAgent({ id: 'answer', model, handler: async () => { return await promptAgent(AnswerSchema); }, }); ``` ### Cost Calculation To have the Budget mechanism record real costs, provide `calculateCost` on the adapter. Return values use integer units, e.g., `micro_usd`. ```typescript import { createOpenAIAdapter } from '@rejelly/adapter-openai'; const model = createOpenAIAdapter({ modelId: 'gpt-5.6-luna', apiKey: process.env.OPENAI_API_KEY, calculateCost: (usage) => ({ micro_usd: Math.ceil(usage.promptTokens * 1) + Math.ceil(usage.completionTokens * 6), }), }); ``` ## Prompt Schema Injection Choose the schema delivery method via `schemaMode`: - `"prompt"`:Injects the schema into the system prompt — broadest compatibility (OpenAI default). - `"json_object"`:Sends `response_format: { type: "json_object" }` (Gemini: `responseMimeType: "application/json"`), while also injecting the schema into the prompt for field constraints. Suitable for models with JSON mode but without strict schema support (e.g., DeepSeek). - `"json_schema"`:Native Structured Outputs (OpenAI strict / Gemini `responseSchema`), where the model strictly enforces fields (Gemini default). # Budget The Budget mechanism provides hierarchical resource consumption monitoring, tracking costs and token usage along the Agent call chain, and supports callbacks via `onUpdate` whenever usage is updated. ## Core Concepts ### BudgetState Every Agent's Context maintains a `BudgetState` with two dimensions of usage statistics: - **`own`**: The current Agent's direct consumption (Self Time) - Records consumption from Model and Tool calls made directly by the current Agent - Used for analyzing the Agent's own behavior patterns - **`aggregate`**: Aggregated consumption (Total Time) - Includes the sum of the current Agent's own consumption plus all sub-agents' consumption - Consumption items with the same type and name are merged (aggregated by `type + name`) ```typescript interface BudgetState { own: UsageStats // Current Agent's own consumption aggregate: UsageStats // Current Agent + all sub-agents aggregated consumption } ``` ### getUsageStats() Retrieves the current Agent's usage statistics, returning a `BudgetState` with `own` and `aggregate` (safe clone — feel free to modify). ```typescript const budgetState = getUsageStats() // Current Agent's own consumption (costs: integer values per billing unit, e.g. micro_usd) console.log('Own costs:', budgetState.own.costs, budgetState.own.totalTokens) // Aggregated consumption including sub-agents console.log('Aggregate costs:', budgetState.aggregate.costs, budgetState.aggregate.totalTokens) ``` ## Hierarchical Budget Tracking ### Update Chain When a usage update occurs (LLM call or `recordToolUsage`): 1. **Update chain**: Traverses up the parent chain from the current Context, updating each node's `own` (initiating node only) and `aggregate` 2. **onUpdate callback**: For each level in the chain, calls the `onUpdate` of each config in that level's `budgetConfigs` with `{ delta, aggregate }` (current context first, then parent) ```typescript // Parent Agent const ParentAgent = createAgent({ id: 'ParentAgent', handler: async () => { equipBudget({ onUpdate: ({ delta, aggregate }) => { console.log('Parent aggregate', aggregate.costs) } }) await ChildAgent({ ... }) } }) // Child Agent const ChildAgent = createAgent({ id: 'ChildAgent', handler: async () => { equipBudget({ onUpdate: ({ delta, aggregate }) => { console.log('Child aggregate', aggregate.costs) } }) await promptAgent(...) // Each usage update triggers the child Agent's onUpdate first, then the parent Agent's onUpdate } }) ``` ## Usage Examples ### Basic Usage ```typescript import { createAgent, equipBudget, getUsageStats, promptAgent } from '@rejelly/core' const MyAgent = createAgent({ id: 'MyAgent', handler: async () => { await promptAgent(...) const state = getUsageStats() console.log('Own usage:', state.own) console.log('Aggregate usage:', state.aggregate) } }) ``` ### Using onUpdate ```typescript equipBudget({ onUpdate: ({ delta, aggregate }) => { console.log('Delta:', delta.costs, delta.totalTokens) console.log('Aggregate:', aggregate.costs, aggregate.totalTokens) } }) ``` ### Viewing Total Consumption in Hierarchy ```typescript const ParentAgent = createAgent({ id: 'ParentAgent', handler: async () => { equipBudget({ onUpdate: ({ aggregate }) => report(aggregate) }) await ChildAgent1({ ... }) await ChildAgent2({ ... }) const state = getUsageStats() console.log('Total costs:', state.aggregate.costs) } }) ``` ## API Reference ### equipBudget(config) Registers a budget configuration (required). To query current usage, use `getUsageStats()`. **Parameters:** - `config`: Required, contains `onUpdate` and other callbacks **Returns:** `void` ```typescript interface BudgetUpdateArg { delta: UsageStats // Usage delta for this update aggregate: UsageStats // Current context aggregate usage (self + sub-agents) own: UsageStats // Current context own usage } interface BudgetConfig { onUpdate: (arg: BudgetUpdateArg) => void // Required } ``` ### getUsageStats() Retrieves the current Agent's usage statistics (safe clone). **Returns:** `BudgetState` ```typescript interface BudgetState { own: UsageStats // Current Agent's own consumption aggregate: UsageStats // Aggregated consumption including all sub-agents } interface UsageStats { /** Integer consumption aggregated by billing unit (e.g. micro_usd); separate from physical unit (e.g. image) */ costs: Record totalTokens: number promptTokens: number completionTokens: number callCount: number items: UsageItem[] // Detailed consumption item list } ``` ### recordToolUsage(toolUsage) Records tool consumption in scenarios without LLM calls (e.g., external APIs, image generation). Participates in hierarchical updates and triggers `onUpdate` on all levels of the chain. ```typescript recordToolUsage({ name: 'dall-e', costs: { micro_usd: 40_000 }, quantity: 1, unit: 'image' }) ``` ## Notes 1. **BudgetState always updates**: Even without calling `equipBudget(config)`, `BudgetState` is updated on every LLM/tool consumption. 2. **onUpdate call order**: Starts from the current context, traversing each level's config `onUpdate` up the parent chain; multiple configs at the same level are called in array order. 3. **Shared state**: If multiple Contexts share the same `budgetState` instance, duplicate parents are skipped during chain traversal to avoid duplicate updates and callbacks. # Core ## `createAgent(config)` Defines a reusable Agent unit. ```typescript import { createAgent, ModelAdapter } from '@rejelly/core'; // Assume an OpenAI adapter is already available const openaiModel: ModelAdapter = createOpenAIAdapter({ modelId: 'gpt-5.6-luna' }); // Handler receives props (instructions, parameters) from the caller export const SearchAgent = createAgent({ id: 'search_agent', model: openaiModel, // ModelAdapter instance maxRetries: 3, // Optional, max retries on promptAgent(schema) / expectValidator validation failure (default 3) handler: async (props) => { // ... logic code return result; } }); // ✅ Agent as a Function: call directly, no .handler // The framework automatically handles context creation, reborn loop, and other runtime details const result = await SearchAgent({ topic: 'hello' }); ``` **createAgent options:** | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| | `id` | `string` | ✅ | - | Unique Agent identifier, used for logging and persistence | | `model` | `string \| ModelAdapter` | ❌ | - | Model: pass `ModelAdapter` instance for direct use; pass `string` to resolve at runtime from `runWith`'s `modelRegistry` (i.e., `shared.modelRegistry`) by id, useful for multi-tenant injection of models with rate-limiting and other middleware per tenant | | `maxRetries` | `number` | ❌ | `3` | Max retries when promptAgent(schema) / expectValidator validation fails | | `maxReborns` | `number` | ❌ | `100` | Max reborn count (counts **reborn events**, not generations: `N` reborns = `N+1` generations). Exceeding throws `RebornLimitExceededError`. A liveness guard against infinite reborn loops — typical cause: using handler-local variables as cross-generation counters that reset to zero on each reborn and never converge; use `equipMemory` for cross-generation counting (call `set` before `return reborn()`). Increase this value for legitimate deep reborn chains | | `handler` | `(props) => Promise` | ✅ | - | Core logic function | ## `ModelAdapter` Interface ```typescript interface ModelAdapter { /** Model identifier (for logging/debugging) */ id: string; /** Model provider (optional) */ provider?: string; /** Streaming LLM call */ stream(messages: Message[], options?: ModelStreamOptions): AsyncGenerator; /** Cost calculator (optional); returns integers per billing unit, e.g. micro_usd, credit (consistent with Budget / equipBudget costs) */ calculateCost?(usage: TokenUsage): Record; } interface ModelStreamOptions { schema?: JsonSchema; // Optional JSON Schema for structured output tools?: ToolDefinition[]; // Tool definitions available for this round toolChoice?: ToolChoice; // Tool call strategy (paired with tools) signal?: AbortSignal; // Optional AbortSignal for interrupting streaming output additionalOptions?: Record; // Extended options forwarded to each adapter } /** Streaming tool call delta (similar to OpenAI streaming tool_calls, merged by index) */ interface ToolCallChunk { index: number; id?: string; name?: string; arguments?: string; extra?: Record; } // Stream event types (Tagged Union) type StreamEvent = | { type: 'text'; content: string } // Text delta | { type: 'reasoning'; content: string } // Reasoning/thinking delta (chain-of-thought models like DeepSeek-R1) | { type: 'tool_call'; toolCall: ToolCallChunk } // Streaming tool call chunk (accumulated by index into a complete ToolCall) | { type: 'extra'; extra: Record } // Extra metadata from the adapter/model | { type: 'usage'; usage: TokenUsage } // Statistics (may appear multiple times during streaming) | { type: 'finish'; finishReason: FinishReason; usage?: TokenUsage } // Stream end (typically the last event) | { type: 'error'; error: unknown }; // Streaming error interface TokenUsage { promptTokens: number; completionTokens: number; totalTokens: number; details?: { reasoningTokens?: number; // Reasoning tokens (billing-related) cacheReadTokens?: number; // Tokens read from prompt cache cacheWriteTokens?: number; // Tokens written to prompt cache [key: string]: number | undefined; }; } interface Message { role: MessageRole; content: MessageContent | null; reasoning_content?: string; tool_calls?: ToolCall[]; tool_call_id?: string; // Tool call ID (used only by tool role) name?: string; extra?: Record; } ``` `ToolDefinition` and `ToolChoice` in `ModelStreamOptions` match the types used by the framework's equip tool system and are exported by `@rejelly/core`. `MessageContent` is `string | ContentPart[]` (multimodal content including text, images, video), `FinishReason` values include: `stop`, `length`, `tool_calls`, `content_filter`, `error`, `unknown`. **OpenAI Adapter implementation example:** For production, **prefer** importing `createOpenAIAdapter` from `@rejelly/adapter-openai` (install — see [Adapter · Model](/en/api/adapter/model)), rather than copying the handwritten `stream` below. **The entire block below is for demonstration only**: to help understand how `ModelAdapter` and `calculateCost` (with `micro_usd` / `credit`) work with Budget. **Using the adapter directly (recommended):** ```typescript import { createOpenAIAdapter } from '@rejelly/adapter-openai'; const model = createOpenAIAdapter({ modelId: 'gpt-5.6-luna', apiKey: process.env.OPENAI_API_KEY!, // Optional: integer Record for budget (e.g. micro_usd + credit); see demo below // calculateCost: (usage) => ({ ... }), }); ``` **Hand-written sample:** ```typescript const openaiAdapter: ModelAdapter = { id: 'gpt-5.6-luna', provider: 'openai', async *stream(messages, options) { const response = await openai.chat.completions.create({ model: 'gpt-5.6-luna', messages, stream: true, stream_options: { include_usage: true }, response_format: options?.schema ? { type: 'json_schema', json_schema: options.schema } : undefined, }, { signal: options?.signal, }); let usage: TokenUsage | undefined; for await (const chunk of response) { if (options?.signal?.aborted) { throw new Error('Aborted'); } if (chunk.usage) { usage = { promptTokens: chunk.usage.prompt_tokens, completionTokens: chunk.usage.completion_tokens, totalTokens: chunk.usage.total_tokens, }; } const text = chunk.choices[0]?.delta?.content; if (text) { yield { type: 'text', content: text }; } if (chunk.choices[0]?.finish_reason === 'stop' && usage) { yield { type: 'usage', usage }; } } }, calculateCost(usage) { // GPT-5.6 Luna: $1/1M input, $6/1M output — integer micro USD per token (see budget.md / model-pricing examples) const microUsd = Math.round(usage.promptTokens * 1 + usage.completionTokens * 6); if (microUsd === 0) return {}; // Credit is calculated based on micro_usd (not the number of tokens), with 1 credit approximately equal to 1 milli-USD. const MICRO_USD_PER_CREDIT_PARITY = 10_000; // Margin >= 1: commercial premium so internal credits do not undercharge vs provider cost (tune per product) const CREDIT_MARGIN = 1.15; const credit = Math.ceil((microUsd / MICRO_USD_PER_CREDIT_PARITY) * CREDIT_MARGIN); return { micro_usd: microUsd, credit }; } }; ``` ## `augmentModel(model, middlewares)` Enhances a ModelAdapter using an onion model (composed right-to-left, executed outside-to-inside). ```typescript import { augmentModel } from '@rejelly/core'; import { withSimpleLimit } from '@rejelly/limit-model'; // Assuming user has implemented withLog for logging before and after requests const openaiAdapter = createOpenAIAdapter({ modelId: 'gpt-5.6-luna' }); const enhancedModel = augmentModel(openaiAdapter, [ withSimpleLimit({ rpm: 60, concurrency: 5 }), // Rate limiting (from limit-model) withLog(), // Logging ]); // Onion model: middlewares are composed right-to-left, executing request through outermost to innermost, response returns the same way // request → limit → log → openai // response ← limit ← log ← openai ``` ## `augmentTool(tool, middlewares)` Adds static middleware to a ToolDefinition ("burned into" the tool definition — define once, reuse globally). These static middlewares execute after dynamic middlewares (from `equipTool`'s `middleware` option) and before the handler. ```typescript import { augmentTool, equipTool } from '@rejelly/core'; const BaseSearchTool: ToolDefinition = { name: 'search', description: 'Search the web', parameters: z.object({ query: z.string() }), handler: async ({ query }) => await searchAPI(query), }; const SafeSearchTool = augmentTool(BaseSearchTool, [ wrapLog(), // Outer: logging wrapRetry({ n: 3 }) // Inner: retry mechanism ]); // Use in Agent const ResearchAgent = createAgent({ id: 'researcher', model: enhancedModel, handler: async (props) => { equipTool(SafeSearchTool); equipTool(SafeSearchTool, { middleware: [ async (ctx, next) => { const [history, setHistory] = equipMemory('history', []); const result = await next(); setHistory([...history, `Used ${ctx.toolName}`]); return result; } ] }); return await promptAgent(ResultSchema); } }); ``` ## `equipToolCallLoopMiddleware(middleware)` ### Internal flow of `promptAgent` and the middleware insertion point A single `promptAgent(schema)` drives a "model request → parse response" loop within a **single Generation**, until a structured output matching the schema is obtained (or failure). A typical flow can be summarized as: 1. **First packet and subsequent turns**: Calls the model's streaming interface with the current draft (system, instruction, tools, history messages, etc.). 2. **If the model returns `tool_calls`**: The framework enters **one step** of the **tool call loop** — first running the **`ToolCallLoopMiddleware` chain** (if any registered), then executing each tool's handler (including `equipTool` dynamic / `augmentTool` static middleware) for the call list, converting results into tool messages written back to the conversation, and continuing the model request. 3. **If the model returns final content matching the schema**: The loop ends, `promptAgent` returns the parsed result. 4. If more rounds of "model → tool → model" are needed, repeat steps 2–3 until the end condition is met or the framework limit is reached. **Actual position of this middleware**: Only at **step 2** above, and only within the **`promptAgent` internal** handling of "this round's `tool_calls` from the model" — i.e., **before** handing this batch to the underlying `executeToolOutputs` for execution, filtering or short-circuiting the **entire batch** of `ToolCall[]`. It does not participate in composing system/instruction, nor does it replace the per-tool `ToolMiddleware`. > **⚠️ Scope: tool call loop inside promptAgent, NOT "global tool execution"** > > `ToolCallLoopMiddleware` is bound to **`promptAgent`'s built-in tool round-trip loop** (corresponding one-to-one with tool rounds in the model conversation). It does **not** trigger when tools are executed through other entry points. > > For example: when **directly executing** a `ToolDefinition` via **`callTool(tool, args)`** in code, it goes through the single-tool core path and does **not** pass through `equipToolCallLoopMiddleware`. Similarly, any path that does not go through "model produces `tool_calls` → `promptAgent` internally dispatches execution" will not execute Loop middleware. If unified interception is needed for "manual tool invocation" scenarios, handle it at the single-tool **`equipTool(..., { middleware })` / `augmentTool`** level, rather than relying on Loop middleware. (Note: `callTool` directly returns the handler's raw output, and on failure it **throws** — no fallback to string.) **Registration semantics (connecting to above):** `equipToolCallLoopMiddleware` inserts a layer at step 2 above — after the model has produced `tool_calls` but **before** entering each tool's handler / single-tool middleware. It is unrelated to "modifying system / instruction / schema": the middleware **cannot** modify the prompt and schema that have already participated in hashing and snapshotting for this round. It only acts on the **jump before the entire batch of tool calls executes**, suitable for rate limiting, authorization, filtering calls, or **short-circuiting** some calls with synthetic `ToolOutput` (which the framework then converts to protocol-layer `role: "tool"` messages). **Must be called before `promptAgent()`** (same draft barrier as `equipTool`; violation throws `AfterPromptAgentError`). **Execution order (onion, consistent with `equipTool` dynamic middleware):** Array order: **earlier registrations are outer layers**, later ones are closer to actual execution. When the outer layer calls `next(filteredCalls)`, the inner layer receives **`currentCalls` as the filtered list**; the outermost layer's first received `currentCalls` matches `ctx.originalCalls` (the model's original `tool_calls` for this round). `ctx` is a read-only snapshot where `originalCalls` is always the model's request; **the `currentCalls` parameter is passed through the middleware chain**. If interception only passes "approved" calls to `next(filtered)` but **does not** provide synthetic `ToolOutput` for the intercepted calls, the tool results for this batch will be fewer than the number of `tool_calls` the model requested, and the conversation cannot align. After merging middleware return values, the framework validates: **each `ToolCall.id` in the current batch must have exactly one corresponding `ToolOutput` with matching `callId`**; if any are missing and you did not provide fallback, a **`LoopMissingToolOutputError`** is thrown (exported by `@rejelly/core`, checkable via `isLoopMissingToolOutputError`). The correct approach: **Splitting** → call `next` on the approved list to get real execution results → **merge with the synthetic results from the blocked side** before `return`, ensuring "N inputs = N outputs." ```typescript import { equipToolCallLoopMiddleware } from '@rejelly/core'; equipToolCallLoopMiddleware({ name: 'rate-limit-search', handler: async (ctx, currentCalls, next) => { const validCalls = []; const blockedOutputs = []; // 1. Splitting: approve legitimate calls, intercept illegal ones with synthetic output for (const call of currentCalls) { if (call.name === 'search') { blockedOutputs.push({ callId: call.id, // Must return the callId content: JSON.stringify({ error: 'Search tool is currently rate-limited and blocked.', }), }); } else { validCalls.push(call); } } // 2. Pass approved calls down to get real execution results const realOutputs = validCalls.length > 0 ? await next(validCalls) : []; // 3. Must merge! Ensure N inputs correspond to N outputs return [...realOutputs, ...blockedOutputs]; }, }); ``` **`ToolCallLoopMiddleware` shape summary:** ```typescript handler: ( ctx: ToolCallLoopContext, currentCalls: ToolCall[], next: (calls: ToolCall[]) => Promise, ) => Promise; ``` - **`ctx`**: Read-only fields including the current loop step **`step`**, `systemInstruction`, `instruction`, completed round **`toolTurns`** (structured history), and this round's original model request **`originalCalls`**, etc. - **`currentCalls`**: The actual call list received by the current layer (may be shorter after outer-layer filtering). - **Return value**: Same as `next()`, a **`ToolOutput[]`** (`callId` + `content`) — the framework handles mapping to tool messages sent to the model; the middleware does not need to construct `role` / `tool_call_id`. ## `augmentAgent(agent, middlewares)` Adds static middleware to an AgentFunction (executed in an onion model when the Agent is called). ```typescript import { augmentAgent } from '@rejelly/core'; const BaseAgent = createAgent({ id: 'researcher', model: enhancedModel, handler: async (props) => { equipTool(SafeSearchTool); return await promptAgent(ResultSchema); } }); const LoggedAgent = augmentAgent(BaseAgent, [ { name: 'log', handler: async (ctx, next) => { console.log(`Agent ${ctx.agentId} called with`, ctx.props); const result = await next(); console.log(`Agent ${ctx.agentId} returned`, result); return result; } }, { name: 'monitor', handler: async (ctx, next) => { const start = Date.now(); const result = await next(); const duration = Date.now() - start; console.log(`Agent ${ctx.agentId} execution time: ${duration}ms`); return result; } } ]); // Execution order (onion model, composed right-to-left): // request → log → monitor → BaseAgent handler // response ← log ← monitor ← BaseAgent handler ``` **ModelMiddleware Interface:** ```typescript interface ModelMiddleware { /** Middleware name (for debugging and logging) */ name: string; /** Wrapping logic */ wrap: (inner: ModelAdapter) => ModelAdapter; } ``` **ToolMiddleware Interface:** ```typescript interface ToolMiddleware { /** Middleware name (for debugging and logging) */ name: string; /** Middleware handler function */ handler: (ctx: ToolContext, next: () => Promise) => Promise; /** Optional configuration snapshot (for debugging/display) */ config?: Record; } interface ToolContext { toolName: string; input: any; parameters: z.ZodTypeAny; description: string; metadata: { agentId: string; sessionId?: string; traceId?: string; }; readonly definition: ToolDefinition; } ``` **Built-in model middlewares:** - **Rate limiting**: From `@rejelly/limit-model`, provides **`withLimit(options)`** (Rule-level, pluggable store) and **`withSimpleLimit(options)`** (rpm/tpm/concurrency simplified wrapper). ```typescript import { augmentModel } from '@rejelly/core'; import { withSimpleLimit } from '@rejelly/limit-model'; const limitedModel = augmentModel(baseModel, [ withSimpleLimit({ rpm: 60, tpm: 90000, concurrency: 5, key: 'my-model' }), ]); ``` Full API (rule types for `withLimit`, MemoryStore / RedisStore selection and multi-process warnings, Redis Cluster hash tag, multi-tenant example, error types) see [Limit Model](/en/api/limit-model). **AgentMiddleware Interface:** ```typescript interface AgentMiddleware { /** Middleware name (for debugging and logging) */ name: string; /** Middleware handler function */ handler: ( ctx: AgentMiddlewareContext, next: () => Promise ) => Promise; } interface AgentMiddlewareContext { /** Agent ID */ agentId: string; /** Props when the Agent was called */ props: Props; } ``` > **⚠️ AgentMiddleware execution timing and idempotency** > > `AgentMiddleware` runs **after ctx creation, before handler execution**. > In reborn scenarios, it executes **multiple times** per generation — therefore do not place non-idempotent or explicitly non-repeatable tasks (e.g., one-time billing, non-reentrant external writes) inside middleware, unless you provide deduplication / idempotency protection. **Tool middleware execution order:** When a tool has both static middleware (added via `augmentTool`) and dynamic middleware (added via `equipTool`), the execution order is: ``` Dynamic (outer) → Static (middle) → Handler (inner) ``` Rationale: - Static middleware (e.g., retry) should be closer to the handler, preventing network jitter from causing dynamic middleware (e.g., memory writes) to execute repeatedly - Dynamic middleware can access Agent context (memory, scope, etc.), suitable for business logic > **⚠️ Layer by replay safety (implicit contract — must follow this)** > > The true basis for this order is not "who defined first" but **idempotency**: **replayable** logic must be innermost, wrapping only the replayable handler; **non-reentrant side effects** must be outermost, outside the replay boundary. Accordingly: > > - **Retry / backoff / circuit-breaker and other replayable logic → put in `augmentTool` static layer (inner)**. A single jitter only replays the handler itself, without re-triggering outer side effects. > - **Non-reentrant side effects (deduction, memory writes, counting, logging) → put in `equipTool({ middleware })` dynamic layer (outer)** — executed once per logical call. > > Putting retry in the dynamic layer (outer) would cause a single network jitter to **re-trigger** all inner side effects (double billing, lost updates, etc.). The layer names (static/dynamic) themselves do not imply this contract — placing them wrong will not cause errors, but the above mapping must be followed. **Agent middleware execution order:** Agent middleware uses an onion model, composed right-to-left, executed outside-to-inside: ``` Middleware[0] (outer) → Middleware[1] → ... → Handler (inner) ``` Middleware can access the Agent ID and the current call's props, making it suitable for logging, monitoring, permission checks, and similar concerns. ## `promptAgent(schema)` Executes an LLM call and returns output conforming to the schema definition, with automatic type inference. **Generation and a single promptAgent:** A **Generation** is one execution round of the Agent: the framework opens a new Generation each time before entering the handler (one round in the reborn loop), resets the draft (equip/expect, etc.), collects all equip/expect in this round, and initiates one LLM call. Therefore **each Generation can only call `promptAgent()` once**; subsequent calls within the same handler throw `PromptAgentAlreadyCalledError`. For multiple LLM calls, split them into multiple rounds via `reborn` (one Generation per round, one `promptAgent()` per Generation). > **⚠️ Key rule: call order constraints** > > **Functions that must be called before `promptAgent()`** (violating order within the same Generation throws `AfterPromptAgentError`): > > - `equipSystem()` - Must be before promptAgent > - `equipInstruction()` - Must be before promptAgent > - `equipTool()` - Must be before promptAgent (e.g., MCP's `kit.inject()` internally calls `equipTool`, also subject to this constraint) > - `equipToolCallLoopMiddleware()` - Must be before promptAgent (registers the "before tool execution" loop middleware, same draft barrier as Prompt-composing equip) > - `equipBudget(config)` - Must be before promptAgent > - `expectValidator()` - Must be before promptAgent > - `onStream()` - Must be before promptAgent > > **Functions that do NOT need to be called before `promptAgent()`:** > > - `equipMemory()` / `equipMemo()` - Bound to **Agent-level** `ctx.memory` (in-memory only, lives for the single Agent invocation, survives reborn, destroyed on Agent return; cross-Agent / cross-session persistence uses `runWith({ providers })` to inject real persistent clients read via `expectResource()`). Can be called at any time within the Generation (including first-time key registration, including after promptAgent), used for cross-reborn read/write state; **not** subject to `AfterPromptAgentError` (unlike draft equip for prompt building) > - `equipScope()` - Must be called before invoking a sub-agent (unrelated to promptAgent) > - `expectScope()` - Can be called anywhere (for reading parent Agent's scope) > - `expectResource()` - Can be called anywhere (for reading parent Agent's exposed resources) > > **Rationale**: > - **promptAgent-related**: The framework collects all configured draft that participates in prompt building (system/instruction/tools/expect/onStream, etc.) when calling `promptAgent()`, builds the complete Prompt, and sends it to the LLM. If any API from the "must be before" list above is called after `promptAgent()` (violating order), `AfterPromptAgentError` is thrown directly. `equipMemory` / `equipMemo` (memory-based), `expectScope` / `expectResource` (reading dependencies, not draft) do not participate in this barrier. > - **Sub-agent related**: `equipScope()` provides scope for sub-agents, must be called before invoking a sub-agent — unrelated to `promptAgent()`. > - **Reading dependencies**: `expectScope()` and `expectResource()` read scope and resources provided by the parent Agent, can be called anywhere (including after promptAgent). > - **Dependency array comparison**: `equipMemo` uses **deep compare** (convenient for inline objects like config, params — less boilerplate); `equipResource` uses **shallow compare (React-style)**, and its **`deps` allows non-serializable values** (class instances, closures, symbol-bearing references, etc. — different from `equipMemo`'s pure-data orientation; suitable for non-serializable, side-effect-having entities). See [Equip](/en/api/equip) for details. **Call order example:** ```javascript handler: async (props) => { // ============ 1. Equip Phase (must be before promptAgent) ============ equipSystem('You are an assistant'); equipInstruction('Answer the question'); const [state, setState] = equipMemory('history', []); // ============ 2. Expect Phase (must be before promptAgent) ============ // Use expectValidator to validate LLM output expectValidator((data) => { if (!data.answer || data.answer.length < 10) { return 'Answer too short, please elaborate'; } return true; }); // Use onStream to listen to Agent-level stream events onStream(async (stream) => { for await (const event of stream) { if (event.type === 'structured_data') { console.log('Structured stream result:', event.status, event.data); } } }); // ============ 3. Call LLM ============ // promptAgent(schema) directly defines structured output with automatic type inference const output = await promptAgent(z.object({ answer: z.string() })); // ============ 4. Business Logic (after promptAgent) ============ // ❌ Can no longer call equip/expect/onStream that would be included in this round's Prompt (throws AfterPromptAgentError) // equipInstruction('Additional instruction'); // ❌ AfterPromptAgentError // expectValidator(...); // ❌ AfterPromptAgentError // ✅ equipMemory can still read/write (e.g., record results, count) // const [n, setN] = equipMemory('post_prompt_count', 0); // ✅ Business logic validation (using plain if checks) if (someBusinessCondition) { return reborn(); // Re-execute handler } return output; } ``` **Note the distinction:** - `expectValidator` - validates **LLM output**, must be before promptAgent - Business logic validation - validates **handler's final result**, use plain if checks, after promptAgent ## `onStream(consumer, options?)` Listens to Agent-level stream events within the current Generation. `onStream` does not expose the raw events of the underlying `ModelAdapter.stream()` directly, but provides a higher-level multicast stream on top of the policy / turn / tool loop. **Must be called before `promptAgent()` / `promptChat()`**; otherwise throws `AfterPromptAgentError`. **Function signature:** ```typescript onStream( consumer: (stream: AsyncGenerator) => void | Promise, options?: { signal?: AbortSignal; awaitOnEnd?: boolean; // Default true } ): void; ``` **`options` explanation:** - `signal`: User-defined cancel signal; merged with the current Generation's internal signal — if either aborts, the consumer ends. - `awaitOnEnd`: Whether to wait for the consumer's own cleanup logic when the Generation ends. Default `true`; when `true`, the framework awaits the consumer's Promise when the Generation closes. Pure UI fire-and-forget consumers can set this to `false`. **Lifecycle semantics:** - `onStream` is bound to the **current Generation** and does not survive reborn. - The consumer starts after `promptAgent()` / `promptChat()` crosses the draft barrier; even if no stream events follow (e.g., model call fails immediately), the consumer receives an end signal, and `finally` still executes. - When the current Generation ends (normal return, error, cancellation, reborn), the framework actively closes that Generation's stream. **Event model:** ```typescript type AgentStreamEvent = | { type: 'turn_start'; turnIndex: number } | { type: 'text'; turnIndex: number; delta: string; } | { type: 'reasoning'; turnIndex: number; delta: string } | { type: 'structured_data'; turnIndex: number; status: 'partial' | 'complete' | 'error'; data: Partial; isValid: boolean; } | { type: 'tool_call_stream'; turnIndex: number; chunk: ToolCallChunk } | { type: 'tool_call'; turnIndex: number; toolCall: ToolCall } | { type: 'usage'; turnIndex: number; usage: TokenUsage } | { type: 'extra'; turnIndex: number; extra: Record } | { type: 'turn_done'; turnIndex: number; finishReason: FinishReason; } | { type: 'error'; turnIndex: number; error: unknown }; ``` **Key event descriptions:** - `turn_start`: A prompt turn begins; suitable for clearing the previous turn's local UI state. - `text`: Text delta; tool parameter streaming uses the separate `tool_call_stream` event. - `structured_data`: Structured parsing result from the current text buffer. - `status: 'partial'`: Stream not yet ended, currently an intermediate state. - `status: 'complete'`: Stream ended, final structured result is valid. - `status: 'error'`: Stream ended, but final structured result is invalid or incomplete. - `tool_call_stream`: Underlying tool call chunks, preserving raw fragments. - `tool_call`: Framework has merged chunks into a complete `ToolCall`. - `extra`: Extra metadata from the adapter/model. - `turn_done`: Current turn completed; useful for transitioning from "streaming state" to "static state." **Common UI strategy: output `text` first, once `structured_data` is received, stop subsequent plain `text` for this turn.** This is because both usually come from the same model output segment, and `structured_data` typically provides a structured view covering the same content; rendering both simultaneously often creates duplication. **Example:** ```typescript handler: async () => { onStream( async (stream) => { let sawStructuredData = false; for await (const event of stream) { switch (event.type) { case 'turn_start': sawStructuredData = false; resetTurnUI(event.turnIndex); break; case 'text': if (!sawStructuredData) { appendPlainText(event.delta); } break; case 'structured_data': sawStructuredData = true; renderForm(event.data, { status: event.status, isValid: event.isValid, }); break; case 'tool_call': console.log('Complete tool call:', event.toolCall); break; case 'turn_done': finishTurn(event.turnIndex, event.finishReason); break; } } }, { awaitOnEnd: true }, ); return await promptAgent(z.object({ answer: z.string() })); }; ``` ## `promptChat()` Executes the standard chat policy, returning the model's final text and messages newly added in this round. **Differences from `promptAgent(schema)`:** - `promptChat()` returns `{ data: string, delta: Message[] }`, where `data` is the final text, and `delta` contains messages newly added in this round that can be persisted. - `promptChat()` follows the chat policy's multi-round tool loop; `promptAgent(schema)` is for structured output. - `promptChat()` also depends on the already-equipped system/instruction/tools for this round. **Tool loop rules:** - The preset policy does **not** set `toolChoice` (the model decides whether to call tools by default policy); if "force tool use" is needed, write a custom policy controlled via `executeTurn({ toolChoice })` per turn. Generation-level parameters (temperature, etc.) are configured when constructing the model adapter. - Each round with `tool_calls` appends an assistant message, then executes tools and writes tool messages back to the conversation, continuing to the next round. **Termination and exceptions:** - Ends when the model returns regular content that passes validator checks, returning `{ data, delta }` (if content is not a string, treated as empty string). - When `maxTurnSteps` is reached without content, throws `ToolLoopExceededError` (the independent `TurnBudgetExceededError` is the `executeTurn` layer's total budget guardrail — validation retries count toward it; see [Policy - Two-Layer Turn Budget](policy.md#two-layer-turn-budget)). ## `dumpSnapshot()` Exports a snapshot of the current Agent execution state for persistence and future replay. API imported from `@rejelly/core/debugger`. Usage, return value, and notes see [Time Travel](./time-travel.md#dumpsnapshot). ## `runWith(fn, options?)` Executes a function at the top level; optionally accepts a snapshot for context restoration. Without a snapshot, executes directly; with a snapshot, restores the root context from the snapshot first, then executes. Snapshot restoration, replay mechanism, and complete examples see [Time Travel](./time-travel.md#runwithfn-options-and-snapshots). ```typescript import { runWith } from '@rejelly/core'; // Normal execution (no snapshot) const result = await runWith(async () => { const agent = createAgent({ ... }); return await agent({ input: 'test' }); }); // Execute with snapshot restoration (snapshot from dumpSnapshot or restoreSnapshot in @rejelly/core/debugger) const resultFromSnapshot = await runWith(async () => { ... }, { snapshot }); // Bind with external cancel source (e.g., HTTP Request, UI "Stop" button): after abort, the root context's signal synchronously enters aborted state; subsequent model calls and cancelable tools receive it const ac = new AbortController(); const resultWithSignal = await runWith( async () => { const agent = createAgent({ ... }); return await agent({ input: 'test' }); }, { signal: ac.signal }, ); // Call ac.abort() elsewhere to cancel async work along this run's chain ``` **Function signature:** ```typescript export function runWith( fn: () => Promise, options?: RunWithOptions ): Promise; export function runWith( fn: (props: P) => Promise, options?: RunWithOptions

): Promise; interface RunWithOptions

{ /** Initial props, passed to fn */ initialProps?: P; /** Inject snapshot for context restoration; if provided, restores root context from snapshot before execution */ snapshot?: AgentSnapshot; /** Custom trace event emitter; only needs to implement emit(event) */ eventBus?: TraceEventEmitter; /** Root-injected dependencies; readable via expectResource(key) */ providers?: Record; /** Root context model adapter (Agents on the chain without a specified model can inherit from parent) */ model?: ModelAdapter; /** Model registry: id -> ModelAdapter. Injected into root context's shared.modelRegistry; Agents with model as string resolve at runtime from this */ modelRegistry?: Record; /** Whether to enable snapshots (default IS_DEV); when disabled, dumpSnapshot throws, recordJournal/saveChildFrame return immediately */ enableSnapshot?: boolean; /** Distributed tracing: traceId, parentSpanId, global attributes */ trace?: { traceId?: string; parentSpanId?: string; attributes?: Record }; /** Optional; linked with root context AbortSignal (e.g., request cancellation, user abort). When external aborts, root context signal enters aborted with same reason */ signal?: AbortSignal; } ``` **Brief explanation:** - **No snapshot**: Directly executes `fn`, no context restoration. - **With snapshot**: Restores root context (memory, replay cache, etc.) from the snapshot's root frame, then executes `fn`; child Agents auto-restore from `snapshot.children`. - **modelRegistry**: A dictionary of id → ModelAdapter, injected into the root context's `shared.modelRegistry`, shared across the chain. When an Agent's `model` is a string, it resolves at runtime from this; throws `ModelRegistryNotFoundError` if id does not exist. - **enableSnapshot**: Default `IS_DEV` (true in dev/test). When false, no journal recording, no child frame saving, and `dumpSnapshot()` throws; see [Time Travel - enableSnapshot](./time-travel.md#time-travel). - **signal**: The passed `AbortSignal` is connected to the root `createAgentContext`: once externally `abort`ed, the root context's `controller` receives the same reason, `ctx.signal` enters aborted; child Agents still cascade parent signal by existing rules. Useful for aligning with HTTP `Request.signal`, front-end stop buttons, and other cancel sources. **Integration with `enableReview()`:** - If Trace needs to be reported to Review Server in real time, call `enableReview()` during app startup (API see [debug.md](./debug.md#review-exporter)); it does not conflict with `runWith()` — trace events produced within `runWith` automatically flow into the enabled Review exporter. - In Next.js / Vite HMR / local hot-reload scenarios, module initialization code may execute repeatedly, causing `enableReview()` to be registered multiple times, leading to **duplicate reporting**. Consumers should perform **one-time registration / idempotency protection** (e.g., attach to a `globalThis` symbol marker, enabling only once). ## `restoreSnapshot(trace, options?)` Restores an AgentSnapshot from a linear event trace array (TraceEvent[]) for time travel. API imported from `@rejelly/core/debugger`. Signature, anchor modes, usage examples, and error handling see [Time Travel](./time-travel.md#restoresnapshottrace-options). # Debug (Debugging & Observability) The Debugger module provides logging and observability tools to help developers debug and monitor Agent execution. ## Module Overview The Debugger module includes two categories of debugging and observability integrations: 1. **OTLP Exporter**: Sends trace events to OTLP-compatible servers (e.g., Jaeger, Zipkin, Tempo) 2. **Review Exporter**: Sends trace events in real time to the Rejelly Review Server for visual debugging Both capabilities are built on the framework's EventBus mechanism, recording or exporting Agent execution traces by subscribing to events. `withCustomSpan` is the manual instrumentation API: it creates child spans during Agent execution and produces `custom:span:start` / `custom:span:end` events, consumed uniformly by the OTLP Exporter and Review Exporter. ## Custom Span ### `withCustomSpan(name, fn, options?)` Manually creates a tracing span to mark business steps, external calls, batch processing phases, and other execution segments that the framework cannot automatically identify. It must be called within an Agent runtime context; throws `RequiresAgentContextError` if no current AgentContext exists. - `name`: Span name - `fn`: Async function to execute under this span, receives `span` parameter - `options.attributes`: Static attributes written when the span starts - `span.setAttribute(key, value)`: Append a single attribute during execution - `span.setAttributes(attributes)`: Append multiple attributes during execution ```typescript import { withCustomSpan } from '@rejelly/core'; const result = await withCustomSpan('fetch-user-data', async (span) => { span.setAttribute('user_id', userId); const data = await fetchUserData(userId); span.setAttribute('data_size', data.length); return data; }); await withCustomSpan( 'process-batch', async (span) => { span.setAttributes({ batch_size: items.length }); const count = await processItems(items); span.setAttribute('processed', count); }, { attributes: { operation: 'batch-process' } }, ); ``` ## OTLP Exporter OTLP exporter sends trace events to OTLP-compatible servers (e.g., Jaeger, Zipkin, Tempo, Grafana Cloud, etc.). Uses HTTP/JSON format — no protobuf dependency required. **Basic usage:** ```typescript import { enableOTLP } from '@rejelly/core/debugger'; // Enable OTLP traces export const disable = enableOTLP({ endpoint: 'http://localhost:4318/v1/traces', serviceName: 'my-agent-app', }); await MyAgent({ topic: 'test' }); // Disable and flush remaining events await disable(); ``` **Configuration options:** ```typescript interface OTLPOptions { /** OTLP endpoint URL (e.g., 'http://localhost:4318/v1/traces') */ endpoint: string; /** Custom event bus (optional, defaults to global bus) */ eventBus?: EventBus; /** Service name (default: 'rejelly') */ serviceName?: string; /** Additional resource attributes */ resourceAttributes?: Record; /** Custom headers for HTTP requests */ headers?: Record; /** Event filter (optional) */ filter?: (event: TraceEvent) => boolean; /** Transform events at the export boundary; runs after filter, before OTLP conversion */ convert?: TraceEventConverter; /** Batch size before sending (default: 10) */ batchSize?: number; /** Flush interval in ms (default: 5000) */ flushInterval?: number; /** Max memory queue length — discards oldest events beyond this (default: 5000) */ maxQueueSize?: number; /** Max retries for retryable HTTP failures (default: 3) */ maxRetries?: number; /** Lifecycle hooks, see "Process Exit Handling" below */ registerShutdown?: ExporterRegisterShutdown | false; } ``` **Examples:** ```typescript // Basic config const disable = enableOTLP({ endpoint: 'http://localhost:4318/v1/traces', serviceName: 'my-agent-app', }); // Full config const disable = enableOTLP({ endpoint: 'https://api.grafana.cloud/otlp/v1/traces', serviceName: 'production-agent', resourceAttributes: { 'environment': 'production', 'version': '1.0.0', }, headers: { 'Authorization': 'Basic ' + Buffer.from('user:pass').toString('base64'), }, batchSize: 20, flushInterval: 10000, }); // Using a custom event bus const customEventBus = createEventBus(); const disable = enableOTLP({ endpoint: 'http://localhost:4318/v1/traces', eventBus: customEventBus, }); // Redact or transform events before export const disable = enableOTLP({ endpoint: 'http://localhost:4318/v1/traces', convert: (event) => ({ ...event, props: event.props ? redactSecrets(event.props) : event.props, }), }); ``` **Event conversion rules:** - **Span events**: `:start` events are ignored by default; lifecycle `:end` events are converted to OTLP Spans - **Instant events**: Trace-local events without a `duration` field are folded into their parent Span's OTLP Span Events — zero-duration Spans are not exported - **Error handling**: Error information is converted to OTLP exception events, including `exception.type`, `exception.message`, and `exception.stacktrace` **Batch sending mechanism:** - Events are cached in memory - When the cache reaches `batchSize`, the batch is sent automatically - Auto-flushes every `flushInterval` ms - Only one send task is allowed at a time; no new HTTP requests are initiated concurrently during retries - When the queue reaches `maxQueueSize`, the oldest events are dropped — newest observations are prioritized - Calling `disable()` flushes all remaining events **Process exit handling:** - By default listens for `SIGINT`, `SIGTERM`, and `beforeExit` via `registerNodeShutdown()` - Tries to flush remaining events before exit; forced-exit timeout during flush is controlled by `registerNodeShutdown`'s `shutdownTimeout` (default 3000ms). For custom timing, pass `registerShutdown: registerNodeShutdown({ shutdownTimeout: 5000 })` - To flush on `uncaughtException` / `unhandledRejection`, pass `registerShutdown: registerNodeShutdown({ catchGlobalErrors: true })` (may conflict with Sentry, etc.) **Supported OTLP servers:** - Jaeger - Zipkin - Tempo - Grafana Cloud - Any server compatible with OTLP HTTP/JSON format **Notes:** - Only lifecycle `:end` events are exported as Spans; instant events are attached as Span Events to the target Span; `:start` events are ignored - Uses HTTP/JSON format — no protobuf dependency required - Trace IDs and Span IDs are automatically normalized to 32-hex-character and 16-hex-character strings - If the parent Span ID does not exist, the `parentSpanId` field is omitted (some servers require this) ## Review Exporter Review exporter sends trace events in real time to the Rejelly Review Server. Supports both `:start` and `:end` events for real-time visual tracing. Uses the raw event format (no OTLP conversion). **Basic usage:** ```typescript import { enableReview } from '@rejelly/core/debugger'; // Enable Review export const disable = enableReview({ endpoint: 'http://localhost:5789/api/v1/traces', }); await MyAgent({ topic: 'test' }); // Disable and flush remaining events await disable(); ``` **Configuration options:** ```typescript import type { TraceEvent } from '@rejelly/core'; interface ReviewOptions { /** Review server endpoint URL (default: REJELLY_REVIEW_ENDPOINT or 'http://localhost:5789/api/v1/traces') */ endpoint?: string; /** Custom event bus (optional, defaults to global bus) */ eventBus?: EventBus; /** Custom HTTP headers (e.g., pass API Key via `Authorization`) */ headers?: Record; /** Event filter (optional) */ filter?: (event: TraceEvent) => boolean; /** Batch size before sending (default: 10) */ batchSize?: number; /** Flush interval in ms (default: 5000) */ flushInterval?: number; /** Max memory queue length — discards oldest events beyond this (default: 5000) */ maxQueueSize?: number; /** Max retries for retryable HTTP failures (default: 3) */ maxRetries?: number; /** Lifecycle hooks, see "Process Exit Handling" below */ registerShutdown?: ExporterRegisterShutdown | false; } ``` **Examples:** ```typescript // Basic config const disable = enableReview({ endpoint: 'http://localhost:5789/api/v1/traces', }); // Using a custom event bus const customEventBus = createEventBus(); const disable = enableReview({ endpoint: 'http://localhost:5789/api/v1/traces', eventBus: customEventBus, }); // Filter events (predicate) const disable = enableReview({ endpoint: 'http://localhost:5789/api/v1/traces', filter: (event) => ['agent:start', 'agent:end', 'turn:start', 'turn:end'].includes(event.type), }); ``` **Real-time mode features:** - **Supports Start events**: Unlike the OTLP Exporter, Review Exporter supports `:start` events for true real-time tracing - **Raw event format**: Sends events in raw format — no OTLP conversion - **Real-time visualization**: Default batch size is 10, flush interval is 5 seconds; adjust `batchSize` and `flushInterval` for lower latency **Batch sending mechanism:** - Events are cached in memory - When the cache reaches `batchSize`, the batch is sent automatically - Auto-flushes every `flushInterval` ms - Only one send task is allowed at a time; no new HTTP requests are initiated concurrently during retries - When the queue reaches `maxQueueSize`, the oldest events are dropped — newest observations are prioritized - Calling `disable()` flushes all remaining events **Process exit handling:** - By default listens for `SIGINT`, `SIGTERM`, and `beforeExit` via `registerNodeShutdown()` - Tries to flush remaining events before exit; forced-exit timeout during flush is controlled by `registerNodeShutdown`'s `shutdownTimeout` (default 3000ms). For custom timing, pass `registerShutdown: registerNodeShutdown({ shutdownTimeout: 5000 })` - To flush on `uncaughtException` / `unhandledRejection`, pass `registerShutdown: registerNodeShutdown({ catchGlobalErrors: true })` (may conflict with Sentry, etc.) **Notes:** - Supports `:start` and `:end` events for real-time visualization - Uses raw event format — no OTLP conversion - Adjust `batchSize` and `flushInterval` to tune real-time responsiveness vs. request frequency - Errors are logged to console and do not throw (to avoid affecting the main flow) # Effect Handles process display only — it does not intervene in control flow. ## `onStream(consumer, options?)` Listens to Agent-level stream events within the current Generation for real-time display of model output, structured data parsing progress, tool call processes, token usage, etc. Must be registered **before** `promptAgent()` / `promptChat()` within the same Generation; calling `onStream()` afterwards throws `AfterPromptAgentError` (exported by `@rejelly/core`). ```typescript onStream( async (stream) => { for await (const event of stream) { if (event.type === 'structured_data') { // event.status: 'partial' | 'complete' | 'error' // event.data: currently parseable structured data // event.isValid: whether current data passes lightweight schema check updateUI(event.data); } if (event.type === 'text') { // Raw text delta; for typewriter effects or fallback display appendRawText(event.delta); } if (event.type === 'tool_call') { showToolCall(event.toolCall); } } }, { awaitOnEnd: true }, ); const result = await promptAgent(ResponseSchema); ``` ### Common Events | Event | Description | |-------|-------------| | `turn_start` | An LLM turn begins | | `text` | Model text delta, field is `delta` | | `reasoning` | Reasoning/thinking delta, field is `delta` | | `structured_data` | Structured data parsed from the current text buffer | | `tool_call_stream` | Streaming chunks of tool call parameters | | `tool_call` | Tool call fully assembled | | `usage` | Token usage | | `extra` | Extra metadata returned by the adapter/model | | `turn_done` | An LLM turn ends | | `error` | An error occurred during streaming | ### `structured_data` `structured_data` is the most commonly used event for structured output UIs: ```typescript onStream( async (stream) => { for await (const event of stream) { if (event.type !== 'structured_data') continue; if (event.status === 'partial' && event.isValid) { renderPartial(event.data); } if (event.status === 'complete') { renderFinal(event.data); } if (event.status === 'error') { showFallback(); } } }, { awaitOnEnd: true }, ); ``` - `status: 'partial'`:Stream is still in progress; `data` is the currently parseable partial structure. - `status: 'complete'`:Turn ended and parsing succeeded. - `status: 'error'`:Turn ended but structured parsing failed. - `isValid`:Whether the current `data` passes a lightweight schema check; it is not a full Zod validation. `options.awaitOnEnd` defaults to `true` — before the current generation ends, it waits for the stream consumer to finish processing events and complete its own cleanup (e.g., testing, logging, syncing UI state). Pure UI fire-and-forget consumers can set this to `false`. # Equip (Input & Context) This phase is responsible for building the System Prompt and Context Window. ## Prompt Building ### `equipSystem(text: string)` Inserts a system-level instruction (highest priority). Multiple calls are concatenated in order into the System Prompt. ### `equipInstruction(content: string | ContentPart[])` Inserts the current task instruction. Multiple calls are concatenated in order to form the complete task description. Supports plain text (`string`) and multimodal content (`ContentPart[]` — text and image interleaved; `video` type is reserved, not yet enabled): ```typescript // Plain text equipInstruction('Analyze the following customer question:'); // Multimodal content equipInstruction([ { type: 'text', text: 'Please look at this chart:' }, { type: 'image', image: { url: 'https://example.com/chart.png' } }, { type: 'text', text: 'Is the trend in the chart rising or falling?' } ]); ``` ## Tool Registration ### `equipTool(tool: ToolDefinition, options?: EquipToolOptions)` Registers a tool in the tool list for the LLM to call during conversation. **Execution flow:** 1. **Tool registration**: After registering a tool via `equipTool`, the framework passes the tool's description and parameter information (converted by the model adapter to the corresponding format, e.g., OpenAI function calling) to the LLM API, informing the LLM of the function details. 2. **Tool call decision**: When generating a response, the LLM may choose to call a tool. At this point, the LLM may skip the output schema and directly execute a tool call. 3. **Automatic execution**: When the LLM decides to call a tool, the framework automatically executes the corresponding handler function and returns the result to the LLM. 4. **Subsequent decision**: Based on the tool's result, the LLM decides whether to: - Continue using other tools (if more information is needed) - Return output matching the schema (if enough information has been gathered) **Features:** - Supports dynamic middleware (via `options.middleware`) for injecting Agent-context-dependent business logic. - Must be re-registered after each reborn. - Tool execution is automatic — the LLM can call tools directly without manual intervention. **ToolDefinition interface:** ```typescript interface ToolDefinition { name: string; // Tool name description: string; // Tool description, tells the LLM when to use it parameters: z.ZodSchema; // Parameter schema (defined with Zod) handler: (params: any) => Promise; // Tool execution function middlewares?: ToolMiddleware[]; // Static middleware (optional, added via augmentTool) } interface EquipToolOptions { middleware?: ToolMiddleware[]; // Dynamic middleware array (optional) } ``` **Basic example:** ```typescript equipTool({ name: 'search', description: 'Search the web for real-time information', parameters: z.object({ query: z.string().describe('Search keyword'), limit: z.number().optional().describe('Number of results to return') }), handler: async ({ query, limit = 10 }) => { return await searchAPI(query, limit); } }); ``` **Example with dynamic middleware:** ```typescript // Define base tool const BaseSearchTool: ToolDefinition = { name: 'search', description: 'Search the web for real-time information', parameters: z.object({ query: z.string() }), handler: async ({ query }) => await searchAPI(query), }; // Use augmentTool to add static middleware (global reuse) const SafeSearchTool = augmentTool(BaseSearchTool, [ wrapLog(), // Logging wrapRetry({ n: 3 }) // Retry mechanism ]); // Use in Agent with dynamic middleware (context-dependent) equipTool(SafeSearchTool, { middleware: [ // Interceptor: sync results to Agent memory async (ctx, next) => { const result = await next(); const [history, setHistory] = equipMemory('history', []); setHistory([...history, `Used ${ctx.toolName}: ${JSON.stringify(result)}`]); return result; }, // Interceptor: risk control async (ctx, next) => { if (ctx.input.query.length > 100) { return "Query too long"; // Block execution } return await next(); } ] }); ``` **External tool adapters:** Rejelly provides adapter modules for integrating external tool ecosystems. See [Adapter](/en/api/adapter/) for details. ### `equipToolCallLoopMiddleware(middleware: ToolCallLoopMiddleware)` Intervenes at the point **after the model has returned this round's `tool_calls`, but before executing the actual tools** (before the per-tool handler / dynamic `middleware` of `equipTool`). Used for filtering calls, authorization, rate limiting, or **short-circuiting** some calls with synthetic `ToolOutput[]` (which the framework maps to protocol-level tool messages). **Differences from `equipTool` dynamic middleware:** | | `equipToolCallLoopMiddleware` | `equipTool(..., { middleware })` | |---|------------------------------|----------------------------------| | Timing | Before the entire round of tool calls (cross-tool) | When a single tool is about to execute | | Input | `ToolCallLoopContext` + `currentCalls` + `next(calls)` | `ToolContext` + `next()` | | Output | `ToolOutput[]` (`callId` + `content`) | Return value of that tool's handler | **Constraints:** - **Must be called before `promptAgent()`** (same draft barrier as `equipTool`, otherwise `AfterPromptAgentError`). - **Must not** modify the snapshot-participating system / instruction / schema of the current round; `ctx` is read-only (contains `toolTurns`, `originalCalls`, etc.). - Multiple registrations follow an **onion** pattern: **earlier registration = outer layer**, `next(filteredCalls)` passes the filtered **`currentCalls`** inward; `ctx.originalCalls` always holds the model's original list. See [Core · equipToolCallLoopMiddleware](/en/api/core#equiptoolcallloopmiddlewaremiddleware). **Type summary:** ```typescript interface ToolCallLoopMiddleware { name: string; handler: ( ctx: ToolCallLoopContext, currentCalls: ToolCall[], next: (calls: ToolCall[]) => Promise, ) => Promise; config?: Record; } ``` ### Streaming Options Relocation The old capability to write streaming options to the draft via equip has been removed (it was an ambient configuration implicitly read and modified by preset policies). The lifecycle has been relocated to two places: - **`toolChoice`** (per-turn directive): Explicitly passed via `executeTurn(messages, { toolChoice })`. Preset policies (`promptChat` / `promptAgent`) **deliberately do not expose** it; if you need "force tool use," write a custom policy that decides `toolChoice` per turn. - **`additionalOptions`** (temperature, top_p, etc. provider parameters): Generation-level parameters should be configured when **constructing the model adapter** (set once, effective for the entire run). To override per turn, custom policies can use `executeTurn(messages, { additionalOptions })`, forwarded via `callLLM` to `model.stream()`. The `StreamOptions` type has also been removed; `ModelStreamOptions.{toolChoice, additionalOptions}` on the model adapter interface remain unchanged. See [Policy](./policy.md) for details. ### `equipMemory(key, initialVal)` **Core Hook.** Retrieves memory. If this is a re-run triggered by `reborn`, returns the value from the last modification. **⚠️ Important limitation**: Only JSON-serializable values can be stored. **Basic usage:** ```typescript // Basic usage const [count, setCount] = equipMemory('counter', 0); // setState supports two calling patterns: setCount(10); // Directly set a new value setCount(prev => prev + 1); // Functional update // Note: setCount does not affect the already-extracted count variable. // If you need the new value in the current round, use a local variable or call equipMemory again const newCount = count + 1; setCount(newCount); equipInstruction(`Current count: ${newCount}`); // ✅ Use local variable ``` **Serialization constraints:** `equipMemory` can only store JSON-serializable values. - ✅ **Allowed types**: `string`, `number`, `boolean`, `null`, plain objects, arrays - ❌ **Forbidden types**: `function`, `symbol`, `undefined`, class instances (`Date`, `Map`, `Set`, etc.), circular references ```typescript // ✅ Correct usage equipMemory('str', 'hello'); equipMemory('num', 42); equipMemory('obj', { a: 1, b: 'test' }); equipMemory('arr', [1, 2, 3]); equipMemory('nested', { a: { b: { c: 123 } } }); // ❌ Incorrect usage (throws error) equipMemory('fn', () => {}); // function forbidden equipMemory('date', new Date()); // class instance forbidden equipMemory('map', new Map()); // class instance forbidden equipMemory('symbol', Symbol('id')); // symbol forbidden equipMemory('undefined', undefined); // undefined forbidden ``` **Behavior notes:** - **Scope and lifecycle**: Bound to the **current Agent invocation**'s `ctx.memory` (in-memory only). It **survives `reborn`** (multiple generations within the same Agent invocation share reads/writes), but is **destroyed when the Agent returns** — it does not persist across different Agent calls or conversation turns. For cross-Agent / cross-session persistence, inject real database, Redis, or SDK clients via `runWith({ providers })` and read with `expectResource()`. - **Write is immediate, read is a snapshot**: `setState` **immediately** updates the underlying `ctx.memory`, but does not backfill the local variable you already destructured. To observe the new value in the current round, call `equipMemory(key)` again, or use a local variable (see example above). - Return value is a snapshot (deep clone) to prevent direct mutation of stored values. - All values are validated and cloned on read/write to ensure immutability and serializability. - If a value does not meet serialization requirements, an error is thrown on set. **⚠️ Concurrency and multi-tool writes (avoid lost updates):** A single model call may trigger **multiple tools** in parallel, all running on the **same** `ctx.memory`. If two handlers both "read a snapshot before `await` → write back the entire snapshot after `await`," the later write will overwrite the earlier one with a stale baseline — **silently losing one update** (classic lost update). ```typescript // ❌ Dangerous: holding a snapshot across await and overwriting entirely; concurrent writes cause lost updates const [board, setBoard] = equipMemory('board', []); const snapshot = board; // snapshot before await const extra = await fetchSomething(); setBoard([...snapshot, extra]); // overwrites with stale snapshot // ✅ Safe: functional update reads the latest value at write time — atomic read-modify-write under single thread setBoard(prev => [...prev, extra]); ``` - **Always use functional updates for accumulation/derivation** `set(prev => ...)` — it synchronously reads the latest value on commit, so concurrent calls do not overwrite each other. - **Parallel tool handlers writing to different keys** is also safe (different keys don't overlap); lost updates only happen when multiple handlers **snapshot-overwrite the same key**. - For **aggregating results across multiple tools**, a safer place is in [`equipToolCallLoopMiddleware`](/en/api/core#equiptoolcallloopmiddlewaremiddleware) (sequential, sees the entire batch of tool outputs at once), or after `promptAgent` returns — neither has a concurrent window. ### `equipMemo(key, factory, deps)` **Memoization Hook** (syntactic sugar, backed by `equipMemory`). Caches the result of an async factory function based on a dependency array. When dependencies are unchanged, returns the cached value; otherwise, executes the factory function and updates the cache. Useful for expensive async computations, API calls, and other scenarios needing cached results. **⚠️ Important limitation**: The value returned by `factory` must be JSON-serializable. **Basic usage:** ```typescript // Basic usage: cache expensive async computation const result = await equipMemo( 'expensive-computation', async () => { // Perform expensive async operation return await computeHeavyData(input); }, [input.id, input.version] // Dependency array ); // Cache API call result const userData = await equipMemo( 'user-data', async () => await fetchUser(userId), [userId] // When userId unchanged, returns cached value directly ); // Usage in Agent handler handler: async (props) => { // First call: executes factory and caches result const data = await equipMemo('api-data', async () => { return await fetchAPI(props.endpoint); }, [props.endpoint]); // If props.endpoint is unchanged, subsequent reborns return cached value directly return data; } ``` **How it works:** - Cache key: uses internal prefix `__reagent_internal_memo__::` + `key` - Cache structure: `{ deps: previous dependency array, value: previous computed result }` - **Dependency comparison: deep compare (deepEqual)**. Objects with the same structure (e.g., config, params) are treated as unchanged — inline objects can be passed directly, reducing boilerplate without needing stable references. - **Why deep compare (rather than shallow compare like `equipResource`)**: Memo's cache (including `deps`) is stored via `equipMemory`, which deep clones on both reads and writes — reference identity is destroyed during cloning. Therefore `cache.deps[i]` is never reference-equal to the newly passed `deps[i]`. Object/array dependencies can only be compared **by value (deep)**; if shallow comparison (`Object.is`) were used, any non-primitive dependency would be judged as "changed" and the cache would never hit. This is also why `deps` must be serializable (they need to be cloneable). The difference from `equipResource` is not arbitrary naming, but a necessary consequence of their different storage models. - Cache hit: When dependencies are unchanged, returns `cache.value` directly. - Cache miss: When dependencies change or cache doesn't exist, executes `factory()` and updates the cache. **Serialization constraints:** Since `equipMemo` uses `equipMemory` for storage, the value returned by `factory` must be JSON-serializable. - ✅ **Allowed types**: `string`, `number`, `boolean`, `null`, plain objects, arrays - ❌ **Forbidden types**: `function`, `symbol`, `undefined`, class instances (`Date`, `Map`, `Set`, etc.), circular references ```typescript // ✅ Correct usage const data = await equipMemo('api-data', async () => { return { name: 'Alice', age: 30 }; // Plain object }, [userId]); // ❌ Incorrect usage (throws error) const date = await equipMemo('date', async () => { return new Date(); // Date instance is not serializable }, []); const fn = await equipMemo('fn', async () => { return () => {}; // function is not serializable }, []); ``` **Notes:** - `factory` must be an async function (returns `Promise`) - The value returned by `factory` must be JSON-serializable (string, number, boolean, null, plain object, array) - Dependencies should also be JSON-serializable - Cache persists across reborn, but is recomputed when dependencies change - **Dependency comparison**: Memo uses **deep compare**, convenient for inline objects like config, params — less boilerplate code ## Resource Management ### `equipResource(key, config)` **Resource Management Hook** (syntactic sugar). The underlying mechanism is **not** `equipMemory`: previous `deps` are stored separately at runtime (not in `memory` snapshot, no JSON serialization); resource instances and metadata are in **`ctx.resources`** (two Maps: `active` / `metadata`). Manages resource lifecycle (create and destroy) based on a dependency array. When dependencies change, automatically destroys the old resource and creates a new one; when unchanged, returns the cached resource instance. Suitable for resources needing lifecycle management, such as database connections, file handles, network connections, etc. Supports exposing resources to child Agents via `expose: true`, which can be retrieved by child Agents via `expectResource`. **Basic usage:** ```typescript // Database connection resource (private, visible only to the current Agent) const db = await equipResource('db_conn', { create: async () => await connectDB(), destroy: async (conn) => await conn.close(), deps: [dbConfig.host, dbConfig.port] // Auto-reconnect when config changes }); // File handle resource const file = await equipResource('file_handle', { create: async () => await openFile(path), destroy: async (handle) => await handle.close(), deps: [path] // Auto-close old file and open new one when path changes }); // Expose resource to child Agents const db = await equipResource('database', { create: async () => await connectDB(), destroy: async (conn) => await conn.close(), deps: [], expose: true // Key: allows child Agents to retrieve via expectResource('database') }); // Usage in Agent handler handler: async (props) => { // First call: create resource and cache const db = await equipResource('db', { create: async () => await connectDB(props.dbConfig), destroy: async (conn) => await conn.close(), deps: [props.dbConfig.host, props.dbConfig.port] }); // If dbConfig is unchanged, subsequent reborns return the cached connection // If dbConfig changes, auto-closes the old connection and creates a new one return await db.query('SELECT * FROM users'); } ``` **ResourceConfig interface:** ```typescript interface ResourceConfig { /** Async function to create the resource */ create: () => Promise; /** * Async function to destroy/clean up the resource (optional). * * Omitting means the resource does not need teardown — it's a pure derived value, * or a borrowed handle the current Agent doesn't "own" * (e.g., an application-level connection pool injected via runWith({ providers })). * Omitting also means no global teardown is registered, and no destroy resource:op span is emitted. * * Whether destroy is provided is the signal for "own vs borrow": given = I own it, I close it; * omitted = I only borrow/derive. */ destroy?: (resource: T) => Promise; /** Dependency array (for cache invalidation; allows non-serializable values — see below) */ deps: unknown[]; /** Whether to expose this resource to child Agents (default: false, visible only to the current Agent) */ expose?: boolean; } ``` **How it works:** - **deps storage**: The framework saves the previous `deps` at runtime (internally uses a prefixed key, **allows non-serializable values**, unlike `equipMemory` / `equipMemo`) - **Resource and metadata**: Resource instances live under `ctx.resources.active[key]`; metadata lives under `ctx.resources.metadata[key]`, value is `{ expose, unregister?, destroyOnce? }` — `expose` is declaration-time visibility; `unregister` unregisters from global Teardown; `destroyOnce` is a per-instance at-most-once destroy gate (shared by both teardown and deps-change cleanup paths — even if both paths race, user's `destroy` runs at most once). When `destroy` is not provided, the latter two are `undefined` - **Dependency comparison: shallow compare (React-style)**. Compares each item by `Object.is`; reference change = deps change. Suitable for non-serializable, side-effect-having entities (Sockets, handles, callbacks, etc.). Keeping stable references avoids unnecessary destroy+create. - **Why shallow compare (rather than deep compare like `equipMemo`)**: Resource's `deps` are stored by reference in `ctx.ephemeral` (**not cloned**), so reference identity is preserved across reborn — shallow comparison makes sense. Also, `deps` allows non-serializable values (Sockets, handles, closures, class instances), for which **deep comparison is impossible**. Therefore "shallow" is forced by the storage model and serializability constraints, not an inconsistency with `equipMemo` — each uses the only comparison semantics that work for its storage approach. - When dependencies change: 1. **Clean up old resource** (if exists): - **First**, unregister the old resource's teardown from the global Teardown queue, narrowing the race window with concurrent teardown destruction - Then, via the `destroyOnce` gate, call `destroy(old resource)` (even if racing with teardown, at most once); if the old resource has no `destroy`, skip destruction and remove directly 2. **Create new resource**: Calls `create()` to create the new resource 3. **Register in global Teardown** (only when `destroy` is provided): Registers the new resource's destroy function in the Agent's teardown queue - This ensures resources are auto-released when the Agent is closed directly 4. **Update cache**: Saves the new resource, dependency array, and metadata (`expose` / `unregister` / `destroyOnce`) - When dependencies are unchanged: directly returns the cached resource instance **Automatic cleanup mechanism:** - After creation, resources are automatically registered in the Agent's global Teardown queue - When the Agent finishes execution (success or failure), all teardown functions are executed in LIFO order in a `finally` block - This ensures resources are properly released even without a reborn trigger - When dependencies change, the old resource's teardown is automatically unregistered to avoid double-destruction - Resources without `destroy` (borrowed/derived) are not registered in teardown and are not cleaned up when the Agent ends **Notes:** - `create` must be async; `destroy` is **optional**, and when provided must also be async - `destroy` receives the resource instance as a parameter and is responsible for cleanup (e.g., closing connections, releasing handles); omitting `destroy` means the resource is borrowed/derived and needs no cleanup (see `ResourceConfig` above) - If `destroy` fails, a warning is logged but does not block new resource creation or Agent execution - **`deps` allows non-serializable values** (functions, class instances, closures, `Symbol`, etc.), stored by reference at runtime; **shallow compare**, reference change = deps change - Resources persist across reborn but are automatically rebuilt when dependencies change - Suitable for resources requiring explicit cleanup (database connections, file handles, network connections), not for pure data caching (use `equipMemo` instead) - Resource cleanup follows LIFO (last in, first out) order, ensuring resources with correct dependency ordering are cleaned up properly - When **`expose: true`**, the resource is stored in the `ctx.providers` Map and child Agents can retrieve it via `expectResource` - Even on cache hit, if `expose: true`, the resource is ensured to be in the `providers` Map (handles reborn scenarios) - **Dependency comparison**: Resource uses **shallow compare (React-style, `Object.is` per item)**; `deps` may contain non-serializable values but require stable references (extract to external variables or use `useCallback`-like patterns), avoiding inline objects/anonymous functions that cause unnecessary rebuilds - ⚠️ **Call order**: When using `expose: true`, call `equipResource` **before** calling the child Agent — otherwise, the resource is not yet registered in `providers` when the child Agent executes, and `expectResource` will throw a "resource not found" error - For managing MCP Clients in `equipResource` and combining with `equipMCP`, see [Adapter · MCP](/en/api/adapter/mcp) for conventions and examples of child Agents using `expectResource` to retrieve exposed Clients ## Persistent State Unlike **in-memory-only** `equipMemory` / `equipMemo` (which survive `reborn` but are destroyed when the Agent returns), cross-Agent / cross-session persistence should use `runWith({ providers })` to inject real database, Redis, or SDK clients and read them via `expectResource()`. Full details and examples in [Expect · Persistent State](/en/api/expect#persistent-state). ## Budget Control > 📖 **Detailed documentation**: See [Budget](/en/api/budget) for hierarchical tracking, usage statistics, and `onUpdate` callbacks. ### `equipBudget(config)` Registers a budget configuration (required). Adds the configuration to the current context's `budgetConfigs`. The `config.onUpdate` callback is invoked on each usage update (traversing the context chain). To query current usage, use `getUsageStats()`. ```typescript equipBudget({ onUpdate: ({ delta, aggregate }) => { console.log('Updated', aggregate.costs, aggregate.totalTokens); } }); ``` **BudgetUpdateArg / BudgetConfig:** ```typescript interface BudgetUpdateArg { delta: UsageStats; // Usage delta for this update aggregate: UsageStats; // Current context aggregate usage (self + sub-agents) own: UsageStats; // Current context own usage } interface BudgetConfig { /** Callback on usage update, called sequentially along the context chain (required) */ onUpdate: (arg: BudgetUpdateArg) => void; } interface BudgetState { own: UsageStats; // Current Agent's own consumption aggregate: UsageStats; // Current Agent + all sub-agents aggregated consumption } interface UsageStats { costs: Record; promptTokens: number; completionTokens: number; totalTokens: number; callCount: number; items: UsageItem[]; } ``` ## Scope Passing ### `equipScope(data)` Provides environment variables (scope data) for child Agents. Data is layered onto the scope stack hierarchically; child Agents read it via `expectScope`. **Key-level shadowing**: child-layer keys shadow parent-layer keys (no deep merge). **Lifecycle**: Draft state — must be re-called after reborn. **Value constraints**: Only JSON-serializable values are allowed, except `undefined`. `undefined` is allowed and **shadows the parent's value for the same key** during merge, useful for "clearing" upstream fields. ```typescript // Parent Agent provides scope handler: async () => { equipScope({ userId: 'u_123', config: { debug: true } }); // Child Agent can read this data await ChildAgent({ ... }); } // Child layer uses undefined to shadow parent's same key, useful for clearing upstream values equipScope({ theme: 'dark' }); // Parent // Child: equipScope({ theme: undefined }) → merged result: theme is undefined ``` **Basic example (from expect-scope test):** ```typescript // Parent provides, child reads const ParentAgent = createAgent({ id: 'parent', model: adapter, handler: async () => { equipScope({ userId: 'u_123', debug: true }); return await ChildAgent({}); }, }); const ChildAgent = createAgent({ id: 'child', model: adapter, handler: async () => { const ctx = expectScope(z.object({ userId: z.string(), debug: z.boolean(), })); // ctx.userId === 'u_123', ctx.debug === true return { done: true }; }, }); ``` **Example of undefined shadowing a previous value:** ```typescript // Parent sets theme, child clears with undefined, grandchild reads as optional const GrandchildAgent = createAgent({ id: 'grandchild', model: adapter, handler: async () => { const ctx = expectScope(z.object({ theme: z.string().optional(), })); expect(ctx.theme).toBeUndefined(); // Shadowed by child's undefined return { done: true }; }, }); const ChildAgent = createAgent({ id: 'child', model: adapter, handler: async () => { equipScope({ theme: undefined }); // Explicitly clear parent's theme return await GrandchildAgent({}); }, }); const ParentAgent = createAgent({ id: 'parent', model: adapter, handler: async () => { equipScope({ theme: 'dark' }); return await ChildAgent({}); }, }); ``` **The following throw TypeError:** ```typescript equipScope({ handler: () => {} }); // function forbidden equipScope({ id: Symbol('id') }); // symbol forbidden equipScope({ date: new Date() }); // class instance forbidden equipScope({ items: new Map() }); // class instance forbidden ``` ## Trace Attributes ### `equipTraceAttr(attrs: Record, options?: EquipTraceAttrOptions)` Attaches tracing attributes (span attributes) to the current Agent run. By default (`target: 'agent'`), these attributes are merged into the **`agent:end`** and each round's **`generation:end`** event's `trace.attributes`, facilitating filtering and aggregation by dimensions such as request ID, user ID, etc. in distributed tracing or logs. **EquipTraceAttrOptions:** ```typescript interface EquipTraceAttrOptions { /** * Attribute attachment target: * - 'agent' (default): Merged into draft, emitted with this agent's agent:end and each generation:end event * - 'local': Immediately merged into the current trace span * - 'root': Traverses up the context chain to the top-level (runWith) context, immediately merges into its span; * visible on runWith:end (runWith:start has already been emitted). Useful for tagging the entire run's * root span from a deeply nested child Agent. */ target?: 'agent' | 'local' | 'root'; } ``` **Behavior:** - Multiple calls merge into the same object: **same key → later overwrites earlier**, different keys accumulate. - Only **JSON-serializable** values are supported (same constraint as `equipScope`), otherwise throws `TypeError`. - **Lifecycle**: `target: 'agent'` (default) is Draft state — cleared on each reborn; if tagging is needed in the next round, call again. `'local'` / `'root'` are **written immediately** to the corresponding span and are not cleared by reborn. - Calling with default `target: 'agent'` at the runWith root context (outside the Agent handler) triggers a warning — there is no `agent:end` event to attach to; use `{ target: 'local' }` or `{ target: 'root' }` instead. - **Key naming**: Keys become OTLP span attributes as-is (no prefix). The `rejelly.` prefix is reserved by the framework — keys starting with it are skipped with a warning. Avoid reusing OTel semantic convention keys (`http.*`, `service.*`, `user.id`, etc.) to avoid confusion with OTel-aware backends. **Differences from runWith's trace:** - `runWith`'s `trace` option is used for **forwarding external links** (traceId, parentSpanId, root span name, initial attributes), taking effect when the root context is created. - `equipTraceAttr` tags **inside the handler** on demand, ultimately reflected in the span of that agent's agent:end and each generation:end. ```typescript handler: async (props) => { equipTraceAttr({ userId: props.userId, requestId: props.requestId }); // ... subsequent agent:end / generation:end trace.attributes will include the above fields return await promptAgent(schema); } // Multiple calls: later overwrites earlier on same key, new keys merge equipTraceAttr({ a: 1 }); equipTraceAttr({ b: 2 }); equipTraceAttr({ a: 3 }); // Final attributes: { a: 3, b: 2 } // Tag the entire run's root span from a deeply nested child Agent (written immediately, visible on runWith:end) equipTraceAttr({ runLabel: 'nightly-batch' }, { target: 'root' }); ``` # Expect (Output & Validation) This phase centers on the **`expect*` API**: custom validation (`expectValidator`) and reading parent-provided dependencies (`expectScope` / `expectResource`). ## Schema Validation Schema is directly defined and validated by `promptAgent(schema)`: ```typescript const result = await promptAgent( z.object({ action: z.enum(['search', 'answer']), content: z.string(), }) ) ``` ## Custom Validation ### `expectValidator(validator)` / `expectValidator(schema, validator)` **validator**: `(data) => boolean | string | Promise` - Returns `true`: validation passes - Returns `string`: validation fails, the error message is appended to the prompt for retry - Returns `false`: validation fails, uses the default error message `"Validation failed"` (returning a descriptive string is recommended) An optional first parameter `schema` (Zod Schema) is used solely for inferring `data`'s type, not for runtime validation (runtime Schema validation is handled by `promptAgent(schema)`). **Retries**: Controlled by `createAgent`'s `maxRetries` config (default 3). **Exhausted attempts**: Throws `AttemptsExhaustedError` (message contains "All attempts exhausted"), with `attempts` / `issues` / `lastFailureType` / `lastData` / `lastRawText` on the instance. There is no validator-level callback; fallback logic should be implemented at the Agent call site. ```typescript import { AttemptsExhaustedError, expectValidator } from '@rejelly/core'; import { QuoteSchema } from './schemas'; // Basic usage: returning a string triggers retry; schema is only for type inference expectValidator(QuoteSchema, (data) => { if (data.price < 0) return "Price cannot be negative, please correct"; if (data.price > 10000 && !data.isVip) return "Non-VIP users cannot exceed 10000 per item"; return true; }); // Exhausted attempts: catch at the Agent call site, throw a custom error or return a fallback value try { return await QuoteAgent({ sku }); } catch (err) { if (err instanceof AttemptsExhaustedError) { console.warn(`Failed to generate valid price: ${err.issues.join(', ')}`); return { price: 0, status: 'error' }; // fallback value } throw err; } ``` ## Persistent State Core no longer provides a dedicated KV facade. Cross-agent, cross-session, or cross-process persistent state should be injected via `runWith({ providers })` with real clients and read via `expectResource()`. ```typescript import { expectResource, runWith } from '@rejelly/core'; await runWith(async () => { const redis = expectResource('redis'); await redis.incr(`quota:${userId}`); // Use the client's native atomic capabilities }, { providers: { redis, }, }); ``` Principles: - Keys should be explicitly constructed by business logic, e.g., `user:${userId}:cart` — do not derive persistent keys from the call tree position. - Consistency-sensitive state (billing, inventory, quotas, cross-key invariants) should use your own database transactions, Redis atomic operations, CAS/OCC, or queue/single-writer models. - For call-level observability, wrap injected clients with `instrument(client, { name, ops, derive })`. ## Dependency Declaration ### `expectScope(schema)` Declares and reads scope data provided by the parent Agent. Validates with a Zod Schema for type safety. **Fast-fail**: throws `ScopeError` immediately on validation failure, no tokens consumed. **Return value**: Deep Readonly object (recursively frozen via `Object.freeze`). ```typescript import { expectScope } from '@rejelly/core'; import { z } from 'zod'; // Child Agent declares dependency handler: async () => { const ctx = expectScope(z.object({ userId: z.string(), })); // TypeScript knows ctx.userId is string console.log(ctx.userId); // ctx.userId = 'xxx'; // ❌ Runtime error (Object.freeze) } // Optional fields with default values const ctx = expectScope(z.object({ debug: z.boolean().default(false), retries: z.number().default(3) })); // Fast-fail — no tokens consumed const ctx = expectScope(z.object({ requiredApiKey: z.string() // If not provided, throws ScopeError immediately })); ``` **equipScope/expectScope usage example:** ```typescript // Parent Agent: provides scope const ParentAgent = createAgent({ id: 'parent', handler: async () => { equipScope({ userId: 'u_123', permissions: ['read', 'write'], config: { timeout: 5000 } }); return await ChildAgent({ task: 'analyze' }); } }); // Child Agent: declares and reads scope const ChildAgent = createAgent({ id: 'child', handler: async (props) => { // Declare required scope fields const scope = expectScope(z.object({ userId: z.string(), permissions: z.array(z.string()), config: z.object({ timeout: z.number().default(3000) }) })); equipInstruction(`User ${scope.userId} with permissions: ${scope.permissions.join(', ')}`); return await promptAgent(ResultSchema); } }); ``` **Scope hierarchy and shadowing:** ```typescript // Grandparent Agent equipScope({ theme: 'dark', lang: 'en' }); // Parent Agent equipScope({ lang: 'zh' }); // Shadows grandparent's lang // Child Agent const scope = expectScope(z.object({ theme: z.string(), // 'dark' (from grandparent) lang: z.string() // 'zh' (from parent, shadowing grandparent's 'en') })); ``` ### `expectResource(key, options?)` Declares and retrieves a resource exposed by the parent Agent. Searches up the Context chain recursively, returning the first match (nearest parent first). **Fast-fail**: throws `ResourceNotFoundError` immediately if the resource is not found — no tokens consumed. Supports TypeScript generics for type inference. **options.optional**: Passing the literal `{ optional: true }` makes the resource optional — returns `undefined` instead of throwing when not found, with the return type becoming `T | undefined`. TypeScript forces null-checking before use. Note the overload only accepts the literal `true`: `{ optional: false }` or a boolean variable will not match any overload (compile error) — a resource is either explicitly optional or required. ```typescript import { expectResource } from '@rejelly/core'; // Child Agent declares a resource dependency (required: throws ResourceNotFoundError if not found) handler: async () => { // Get the database connection exposed by the parent Agent const db = expectResource('database'); // TypeScript knows db is of type Database const users = await db.query('SELECT * FROM users'); return { users }; } // Optional resource: { optional: true } returns T | undefined, no throw on miss handler: async () => { const cache = expectResource('cache', { optional: true }); if (cache) { return await cache.get('key'); } return null; // Degraded path when no cache available } ``` **equipResource/expectResource usage example:** ```typescript // Parent Agent: creates and exposes a resource const ParentAgent = createAgent({ id: 'parent', handler: async () => { // Create and expose a database connection const db = await equipResource('database', { create: async () => await connectDB(), destroy: async (conn) => await conn.close(), deps: [], expose: true // Key: expose to child Agents }); // Pass data context equipScope({ tenantId: '1001' }); return await ChildAgent({ task: 'analyze' }); } }); // Child Agent: declares and reads resource dependency const ChildAgent = createAgent({ id: 'child', handler: async () => { // Get data dependency const { tenantId } = expectScope(z.object({ tenantId: z.string() })); // Get resource dependency (Runtime Object) const db = expectResource('database'); // Use the resource directly return await db.query('SELECT * FROM users WHERE tenant_id = ?', [tenantId]); } }); ``` **Resource lookup rules:** - Starts from the current Context and traverses up the parent Context chain - Searches for the specified key in each Context's `providers` Map - Returns the first resource found (nearest parent first) - If the entire Context chain yields no match, throws `ResourceNotFoundError` (returns `undefined` when `{ optional: true }`) **Notes:** - The resource key must exactly match the key used in `equipResource` - The parent Agent must set `expose: true` for the resource to be accessible to child Agents - Resource lookup is synchronous — no `await` needed - Supports TypeScript generics for type safety - When multiple parents expose the same resource key, the nearest parent's resource is used (nearest parent first) MCP: After the parent Agent exposes an official Client via `equipResource('mcp:…', { expose: true })`, the child Agent uses **`expectResource('mcp:…')`** to retrieve the same instance (type from `@rejelly/adapter-mcp`). For combination with `equipMCP`, see [Adapter · MCP](/en/api/adapter/mcp). # Flow These are return values or interrupt operations of the async handler. ## Calling Sub-Agents Call a sub-agent function directly and await its result. _Parent-child relationship:_ Stack-based invocation. ```typescript // Directly call a sub-agent const result = await ChildAgent({ query: 'hello' }); // Call a sub-agent within a parent Agent handler: async (props) => { equipSystem('You are a coordinator'); // Call a search Agent const searchResult = await SearchAgent({ query: props.query }); // Call an analysis Agent const analysis = await AnalyzeAgent({ data: searchResult }); return { searchResult, analysis }; } ``` ## `reborn(newProps?)` **Reload directive.** Immediately ends the current function execution, **preserves the current `equipMemory` state**, and re-runs `handler`. You may pass new `newProps`; if omitted, the old props are reused. **Note:** Memory updates should be done via `equipMemory`'s `setState` function before calling `reborn()`, not through the `reborn` parameter. ```typescript // Update memory then reborn const [attempts, setAttempts] = equipMemory('attempts', 0); setAttempts(attempts + 1); return reborn(); // Uses current props // Update props and reborn return reborn({ topic: 'new topic', retry: true }); // Only update props, keep memory return reborn({ attempt: props.attempt + 1 }); ``` ## `return result` Ends the current Agent task and returns the result to the caller (parent Agent or user). # Rejelly API Documentation > For design philosophy see [Guide · Introduction](/en/guide/); this page is an API map organized by **lifecycle phase**. ## API Document Structure Rejelly's API is organized by **lifecycle phase**, following a clear execution flow: ### 1. [Core](/en/api/core) - `createAgent` - Define an Agent - `promptAgent` - Call the LLM - `ModelAdapter` - Model adapter interface - `dumpSnapshot` / `restoreSnapshot` / `runWith` - Snapshot and restore - `augmentModel` / `augmentTool` / `augmentAgent` - Augmentation mechanism (model / tool / Agent) - `callTool` - Manually invoke a single tool (bypassing the model tool-call loop) ### 2. [Equip (Input & Context)](/en/api/equip) - `equipSystem` / `equipInstruction` - Prompt building - `equipMemory` / `equipMemo` - Agent memory (within a single invocation, across `reborn`, destroyed on Agent return) - `equipResource` - Resource management - `equipTool` - Tool registration - `equipBudget` / `equipScope` - Budget and scope - `equipTraceAttr` - Add attributes to traces - [Budget](/en/api/budget) - Budget control and usage statistics ### 3. [Adapter](/en/api/adapter/) - [Model Adapter](/en/api/adapter/model) - OpenAI / Gemini model adaptation, `schemaMode` and provider configuration - [MCP](/en/api/adapter/mcp) - `equipMCP` / `MCPKit` / resource tools / parent-child Agent exposure - [LangChain](/en/api/adapter/langchain) - `fromLangChainTool` - [Limit Model](/en/api/limit-model) - `withLimit` / `withSimpleLimit` model rate-limiting middleware, MemoryStore / RedisStore - [Multimodal tool results](/en/api/adapter/#multimodal-tool-results) - Shared contract between model adapter send-side and tool adapter receive-side ### 4. [Expect (Output & Validation)](/en/api/expect) - `expectValidator` - Custom validation - `expectScope` / `expectResource` - Dependency declaration ### 5. [Effect (Side Effects)](/en/api/effect) - `onStream` - Agent-level streaming event listener ### 6. [Flow](/en/api/flow) - `reborn` - Reload directive - Calling sub-agents ### 7. [Policy](/en/api/policy) - `createAgentPolicy` - Custom policy factory (prompt lifecycle, telemetry, Runtime Seal) - `executeTurn` / `executeTools` / `executeValidation` - Execution primitives (internal to policies) - `executeValidatedLoopTurn` / `transferJsonSchema` - Preset loop combinators - All imported from the independent sub-path `@rejelly/core/policy` ### 8. [Testing](/en/api/testing) - `createMockModel` - Create a Mock Model - Mock Model API - Rule configuration, call records ### 9. [Debug](/en/api/debug) - Console Logger - Console logging - File Logger - File logging - OTLP Exporter - Distributed tracing - Review Exporter - Real-time trace visualization - `withCustomSpan` - Manual trace spans --- ## Quick Start ```typescript import { createAgent, equipSystem, equipInstruction, promptAgent } from '@rejelly/core'; import { z } from 'zod'; const MyAgent = createAgent({ id: 'my_agent', handler: async (props) => { equipSystem('You are an assistant'); equipInstruction(`Task: ${props.task}`); const ResultSchema = z.object({ answer: z.string() }); return await promptAgent(ResultSchema); } }); const result = await MyAgent({ task: 'Answer a question' }); ``` --- ## Core Mechanisms ### Validation Retry (Self-Correction) When Schema validation of `promptAgent(schema)` or `expectValidator` validation fails, the framework automatically performs Self-Correction retries: ``` LLM output → Validate → Fail → Append error hint to prompt → Re-call LLM → Validate → ... ↓ Retries exhausted ↓ Throws AttemptsExhaustedError ``` ### Reborn: Rebuild Context Across Rounds When an Agent needs to proceed to the next reasoning round, the recommended approach is `return reborn()` to end the current Generation and re-run the handler. This way `equipSystem` / `equipInstruction` / `equipTool` and other draft components are freshly collected from the latest state, and the Prompt is naturally refreshed. If you manually write a loop inside a single handler execution and repeatedly process historical messages, you risk falling back to an append-only conversation pattern: the context grows longer, and the Prompt building logic becomes scattered across loop branches. `reborn()` returns an object marked with a special Symbol (`RebornSignal`). The framework checks the return value via `isRebornSignal()` to decide whether to re-run. ## Call Order Constraints **Functions that must be called before `promptAgent()`:** - `equipSystem()` - Must be called before promptAgent - `equipInstruction()` - Must be called before promptAgent - `equipTool()` - Must be called before promptAgent - `equipBudget()` - Must be called before promptAgent - `expectValidator()` - Must be called before promptAgent - `onStream()` - Must be called before promptAgent **Functions that do NOT need to be called before `promptAgent()`:** - `equipScope()` - Must be called before invoking a sub-agent (unrelated to promptAgent) - `equipTraceAttr()` - Can be called anywhere in the handler to tag the current agent/generation span (exposed in trace.attributes of agent:end, generation:end) - `expectScope()` - Can be called anywhere (used to read parent Agent's scope) - `expectResource()` - Can be called anywhere (used to read parent Agent's exposed resources) - `equipMemory()` / `equipMemo()` - Can be called anywhere (Agent memory: within a single invocation, across reborn, destroyed on Agent return) **Rationale**: - **promptAgent-related**: The framework collects all configured equip/expect calls when `promptAgent()` is invoked, building the complete Prompt and sending it to the LLM. Functions called after `promptAgent()` have no effect. - **Sub-agent related**: `equipScope()` provides scope for sub-agents and must be called before invoking a sub-agent — unrelated to `promptAgent()`. - **Reading dependencies**: `expectScope()` and `expectResource()` read scope and resources provided by the parent Agent and can be called anywhere (including after promptAgent). Cross-agent / cross-session persistent state is injected via `runWith({ providers })` using real database, Redis, or SDK clients, then read via `expectResource()`. # Limit Model (Rate Limiting) `@rejelly/limit-model` provides model rate-limiting middleware, mountable on any `ModelAdapter` via [`augmentModel`](/en/api/core#augmentmodel-model-middlewares): - **`withSimpleLimit(options)`**: Simplified wrapper for rpm / tpm / concurrency, quick to set up. - **`withLimit(options)`**: Rule-level, each rule has its own key, supports multi-dimensional combinations and pluggable Store. Rate limiting uses a **fast-fail** strategy: exceeding the limit throws an error directly ([error types](#error-types)), no queuing, no "rate-limit waiting" events emitted. ## `withSimpleLimit(options)` ```typescript import { augmentModel } from '@rejelly/core'; import { withSimpleLimit } from '@rejelly/limit-model'; const simpleLimitedModel = augmentModel(baseModel, [ withSimpleLimit({ rpm: 60, tpm: 90000, concurrency: 5, key: 'my-model', store: undefined, // if omitted, creates a new MemoryStore for this middleware }), ]); ``` **Options (SimpleLimitOptions):** | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| | `rpm` | `number` | ❌ | - | Max requests per minute | | `tpm` | `number` | ❌ | - | Max tokens per minute | | `concurrency` | `number` | ❌ | - | Max concurrency | | `key` | `string` | ❌ | `"default-model"` | Unique identifier for rate-limit scope, prevents rule overlap across models | | `store` | `RateLimitStore` | ❌ | New independent MemoryStore | Custom storage (e.g., RedisStore); if omitted, each middleware instance creates its own in-memory store | At least one of `rpm`, `tpm`, or `concurrency` must be provided. Internally maps to corresponding Rules (key format: `${key}:rpm`) and routes through `withLimit`. ## `withLimit(options)` Rule-level rate limiting: each rule has its own `key`, and a single request can be constrained by multiple rules simultaneously (e.g., tenant-global concurrency + per-model RPM). ```typescript import { augmentModel } from '@rejelly/core'; import { withLimit, MemoryStore, RedisStore } from '@rejelly/limit-model'; const store = new MemoryStore(); // or new RedisStore(redisClient) const ruleLimitedModel = augmentModel(baseModel, [ withLimit({ store, rules: [ // Global gpt4 level { type: 'concurrency', key: 'gpt4', limit: 100 }, { type: 'request', key: 'gpt4', limit: 500, windowMs: 60000 }, { type: 'token', key: 'gpt4', limit: 500000, windowMs: 60000 }, // Tenant A dimension { type: 'concurrency', key: 'tenant:A', limit: 10 }, { type: 'request', key: 'tenant:A:gpt4', limit: 50, windowMs: 60000 }, { type: 'token', key: 'tenant:A:gpt4', limit: 90000, windowMs: 60000 }, ], }), ]); ``` **Options (WithLimitOptions):** | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| | `store` | `RateLimitStore` | ✅ | - | Injected storage (MemoryStore / RedisStore / custom implementation) | | `rules` | `RateLimitRule[]` | ✅ | - | Array of rate-limit rules, checked in array order — first exceeded limit causes failure | | `calculatePreDeduct` | `(messages) => number` | ❌ | Input text length / 4 | Token pre-deduction estimation: pre-deducts before the request, refunds excess after streaming completes based on actual usage | | `retryAfterBufferMs` | `number` | ❌ | `100` | Buffer added to the store's returned `retryAfterMs`, preventing clients from hitting the limit again when retrying exactly on time | **Rule Types:** | Type | Description | Fields | |------|-------------|--------| | `ConcurrencyRule` | Concurrency count | `type: 'concurrency'`, `key`, `limit` (no windowMs) | | `RequestRule` | Requests per minute (RPM) | `type: 'request'`, `key`, `limit`, `windowMs` | | `TokenRule` | Tokens per minute (TPM) | `type: 'token'`, `key`, `limit`, `windowMs` | ## Store - **MemoryStore**: Single-process in-memory, suitable for development or **single-process** deployment. - **RedisStore**: Production use, requires a `RedisLike` client (e.g., ioredis). Concurrency uses ZSET (member=requestId, score=expiration time), self-healing via `ZREMRANGEBYSCORE` in Lua after a process kill. Token/Request use Hash buckets. Key format is `{hashTag}:key:type` — **hash tag** determines the Redis Cluster slot: defaults to `limit-model` so all keys share the same slot, and any rule combination can be checked atomically within a single Lua script. > **⚠️ MemoryStore is not suitable for multi-process** > > MemoryStore data resides **in-process memory** and is not shared across processes. In **PM2 multi-process / cluster mode, multi-instance deployments, Serverless multi-instance** scenarios, each process counts independently — rate limits are effectively multiplied (e.g., 4 workers = 4x effective concurrency), making rate limiting ineffective. Such deployments **must** use **RedisStore** (or another cross-process shared Store). > > **⚠️ RedisStore has a single-point bottleneck** > > **Rate limiting implementation note**: RedisStore relies internally on Redis Lua scripts + same-slot keys for consistency. With the default fixed hash tag, **all** rate-limit traffic concentrates on a single node (Lua executes single-threaded, equivalent to a single core). Do not use in very high QPS scenarios (typically QPS > 10K). In multi-tenant scenarios, use the `hashTag` function to distribute tenants across different slots (see options and example below), at the cost that cross-tenant global rules cannot be mixed within the same check. **RedisStore Options (RedisStoreOptions):** | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| | `maxConcurrencyTimeoutMs` | `number` | ❌ | `60000` | Max hold time for a concurrency slot; cleaned up by Lua on timeout (prevents deadlock after process kill) | | `prefix` | `string` | ❌ | - | Key prefix (does not participate in hash tag, does not affect slot) | | `hashTag` | `string \| (rule) => string` | ❌ | `"limit-model"` | Redis Cluster hash tag. string: entire store shares one tag (e.g., isolate deployments). function: compute tag per rule (recommend deriving from `rule.key` only, e.g., tenant segment), distributing tenants across slots. **All rules in the same check must compute to the same tag** — mixing tags throws an error before sending to Redis | ```typescript // Multi-tenant hot-spot distribution: derive hash tag from the tenant segment of rule.key // (the split rule must match your key naming convention) const store = new RedisStore(redisClient, { hashTag: (rule) => rule.key.split(':')[0], }); // Same check, same tenant's two rules — same tag, same slot, atomic ✓ // { type: 'concurrency', key: 'tenantA' } → {tenantA}:tenantA:concurrency // { type: 'request', key: 'tenantA:gpt-4' } → {tenantA}:tenantA:gpt-4:request // tenantB's check lands on a different slot — hot-spot distributed // { type: 'token', key: 'tenantB:gpt-4' } → {tenantB}:tenantB:gpt-4:token ``` After partitioning by tenant tag, the atomic unit shrinks from "entire store" to "rule set with the same tag": cross-tenant global rules (e.g., global `gpt4` concurrency + tenant rules) can no longer be mixed in the same check — either drop the global rule, or split into two checks and accept non-atomicity. ## Multi-Tenant Full Example Group by `tenantId` into `modelRegistry`, `withLimit` key uses tenant id, quotas read from `tenantLimits`; store uses RedisStore with per-tenant hash tag, distributing each tenant's rate-limit keys across different Redis Cluster slots. ```typescript import { augmentModel, runWith, type ModelAdapter } from '@rejelly/core'; import { withLimit, RedisStore } from '@rejelly/limit-model'; // ⚠️ The split rule must match your key naming convention: this example's keys start with the tenant id // (`tenantId`, `${tenantId}:cheap`), so taking the first segment yields the tenant. // If copied from the withLimit example above with 'tenant:A:gpt4' naming, all tenants would compute // the same tag "tenant" and partitioning would fail. // After partitioning by tag, a single withLimit's rules must not mix cross-tenant global rules // (e.g., global 'gpt4') — different tags will throw directly. const tenantStore = new RedisStore(redisClient, { hashTag: (rule) => rule.key.split(':')[0], }); function getModelRegistry(tenantId: string): Record { const expensiveBase = createOpenAIAdapter({ modelId: 'gpt-5.6-sol' }); const cheapBase = createOpenAIAdapter({ modelId: 'gpt-5.6-luna' }); const limits = tenantLimits[tenantId]; // e.g., VIP { concurrency: 100 } vs normal { concurrency: 5 } return { 'expensive': augmentModel(expensiveBase, [ withLimit({ store: tenantStore, rules: [{ type: 'concurrency', key: tenantId, limit: limits.concurrency }] }), ]), 'cheap': augmentModel(cheapBase, [ withLimit({ store: tenantStore, rules: [{ type: 'concurrency', key: `${tenantId}:cheap`, limit: limits.concurrency * 2 }] }), ]), }; } await runWith(async () => await MyAgent(props), { modelRegistry: getModelRegistry(req.tenantId) }); ``` ## Error Types | Error | code | When thrown | Key fields | |-------|------|-------------|------------| | `RateLimitExceededError` | `429` | Store determines limit exceeded (concurrency / RPM / TPM) | `retryAfterMs` (maps directly to HTTP `Retry-After`), `reason` (limit dimension), `failedRule` (the rule that caused the limit) | | `TokenLimitExceededError` | `413` | Estimated tokens exceed a token rule's **absolute limit** — waiting won't help | `estimatedTokens`, `limit`; client must shorten input, not retry | Both are exported from `@rejelly/limit-model`. Note the distinction: 429 means "retry later", 413 means "will never succeed" — do not confuse them when mapping to HTTP status codes. # Policy `promptChat` and `promptAgent` are two preset variants of the same tool-call-loop policy. They are not two independent execution engines — they are convenience strategies composed from lower-level primitives. These lower-level primitives include: - `executeTurn`: Execute a single model call. - `executeTools`: Execute tool calls returned by the model. - `executeValidation`: Parse model output (optionally with an `OutputParser`) and run validators registered via `expectValidator`, while reporting validation telemetry events. All three are exported by `@rejelly/core/policy`. Extended policies should be composed from these primitives, rather than copying the preset implementations. All three primitives are **internal to policies**: they only accept a runtime derived from the current policy's `PromptContext` (see [Runtime Seal](#runtime-seal) below). Calling them directly in an agent handler throws `InvalidPromptRuntimeError`. If you need to make a model call outside the preset policies, the escape hatch is to **write a small custom policy** instead of bypassing the policy boundary. ## Basic Model A policy is a composition layer between the agent handler and the execution primitives. A custom policy is created with the public `createAgentPolicy` factory. The policy factory handles the common prompt lifecycle: - Reads the current agent context. - Ensures that a prompt policy can only be called once per run. - Freezes the draft into a base `PromptContext` (containing `messages`, system, instruction, tools, tool loop middleware, etc.). - Creates prompt-level telemetry spans and events. - Calls the specific policy handler. - Reports success or failure result metadata. The specific policy handler then decides how to combine `executeTurn`, `executeTools`, parsing, validation, retry, and loop logic. ## Tool-Call-Loop Policy The shared preset loop is `runToolCallLoopPolicy`. Its flow is: 1. Read the initial `messages` from the base `PromptContext`. 2. Fork the history via `ctx.fork({ messages })` and call `executeTurn` with that runtime. 3. If the model returns content, perform parsing and validation. 4. If validation fails, append error feedback to the current step's temporary conversation and retry within the same turn step. 5. If the model returns tool calls, execute them via `executeTools` using the same runtime view. 6. Append assistant message and tool output messages to history, then continue to the next round. 7. If `maxTurnSteps` is exceeded, throw `ToolLoopExceededError`. ### Two-Layer Turn Budget `maxTurnSteps` operates at two layers, corresponding to two **independent** error types (deliberately unrelated by inheritance — the failure vocabulary belongs to each layer's perspective, and `actualTurns` measures different quantities): - **Engine guardrail (`TurnBudgetExceededError`)**: `executeTurn` itself tracks the total model call count for this Generation (**validation retries included**, `actualTurns` is that count) and throws when `maxTurnSteps` is reached. This is policy-agnostic — it is a safety net against infinite loops and retry storms in extended policies. For graph/ToT policies, this is the "per-Generation node budget." - **Tool loop non-convergence (`ToolLoopExceededError`)**: The preset tool-call-loop policy's failure perspective — the model kept returning tool calls over `maxTurnSteps` rounds without producing final content (`actualTurns` is loop rounds; validation retries within a round count as one). Suggestions to increase `maxTurnSteps` apply only to this layer. To handle "turns exhausted" uniformly, combine two type guards: `isTurnBudgetExceededError(e) || isToolLoopExceededError(e)`. `executeTurn` can be called concurrently within the same policy via `Promise.all` or similar; each call synchronously occupies a turn slot before the first `await`, so concurrent branches share the same Generation's `maxTurnSteps` budget — fan-out cannot bypass the engine guardrail. Extended policies can compare `PromptContext.usedTurnSteps` (real-time, including validation retries) against `maxTurnSteps` before fan-out to assess remaining budget and degrade gracefully (e.g., reduce beam width), rather than hitting the engine guardrail mid-failure. This way the tool loop behavior is reusable, while different public prompt APIs only need to define their own input parameter and return value shapes. ## Base Runtime and Fork Execution primitives (`executeTurn` / `executeTools`) consume a **runtime snapshot**: `messages`, system, instruction, tools, tool loop middleware, etc. — "the data needed for this model call and subsequent tool execution." The strategy entry point receives a `PromptContext` which is the base runtime (plus `fork()`, budget, `span`, and other execution concerns). It is obtained by freezing the draft once at the policy entry. Afterwards, the draft should no longer be treated as a configuration source — when different nodes, branches, or tool sets are needed, derive a fork from the base runtime. These are the phase boundaries within a single Agent run: 1. **Equip Phase**: The agent handler calls `equipSystem`, `equipInstruction`, `equipTool`, `expectValidator`, etc., collecting the current generation's draft. 2. **Policy Barrier**: `promptAgent`, `promptChat`, or an extended policy is called. `createAgentPolicy` crosses the barrier, freezing the draft into a base `PromptContext`. 3. **Policy Execution Phase**: The policy consumes the base runtime. When different message histories, tool sets, or tool loop middleware are needed, derive a local runtime via `ctx.fork(...)`, then pass it explicitly to the execution primitives. In early terms this can be understood as **load before consume**; more precisely, the handler loads the base runtime, and the policy forks the runtime during execution. The policy execution phase follows these runtime contracts: 1. **Single source**: `runtime` is a **required** parameter for `executeTurn` / `executeTools` — tools, tool loop middleware, and loop history are all read from it. The legacy fallback ("if no runtime is passed, fall back to the live draft") **has been removed** — that fallback was the root cause of primitives appearing to "work" when called directly in handlers (unfrozen draft, validators silently skipped, turn spans detached from prompt spans). 2. **Early binding**: The base `PromptContext` is frozen at the policy entry. Calling `equip*` mid-generation **does not** backfill into already-forked runtimes — configure before invoking the prompt policy. 3. **Fork shares generation-global state**: Turn budget (`maxTurnSteps` / step count), journal, `span`, and abort signal are attached to the underlying `AgentContext` and shared **by reference** across all forks. Therefore, concurrent fan-out branches forking different messages/tools **do not split budget or lose cache** (consistent with "Two-Layer Turn Budget" above). `fork()` only overrides runtime view fields — it never copies these global states. 4. **offered == executed is your responsibility**: If the **same** runtime is fed to both `executeTurn` and `executeTools`, the tool set offered to the model equals what the executor actually acknowledges. Passing different tools to each causes a mismatch between "what the model sees" and "what can be executed." 5. **messages also belong to fork**: Conversation history is no longer a scattered "external parameter" in policy-local variables. Before each turn, put the current history into a fork: `const turnRuntime = ctx.fork({ messages })`. Use this runtime's `messages` as the history during tool execution as well. ### Runtime Seal "Primitives can only be used during the policy execution phase" is not a documentation convention — it is enforced at runtime. The mechanism is a **capability**, not an ambient flag: - The policy barrier (`createAgentPolicy`), when freezing the base `PromptContext`, creates a seal for **this policy execution**, attached to the runtime as a hidden property (symbol key). The symbol key does not appear in `Object.keys` / JSON serialization — the seal never leaks into traces or snapshots. - All `fork()` calls share the **same** seal by **reference** — isomorphic to how turn budget, journal, and other generation-global states are shared (see rule 3 above). - When the policy handler returns or throws, the seal is invalidated in the barrier's `finally` block — **all forks are invalidated together**. A runtime cannot outlive its policy. All three primitives check the seal on entry — `executeTurn` / `executeTools` **before** consuming turn budget, `executeValidation` **before** opening a validation span and writing the `validationRan` flag. The four rejection paths correspond to the `reason` field of `InvalidPromptRuntimeError` (accompanying type guard `isInvalidPromptRuntimeError`, `apiName` indicates which primitive rejected): | `reason` | Scenario | |---|---| | `missing` | No runtime passed (only reachable via untyped JS calls; TypeScript treats it as a compile error) | | `unsealed` | Hand-constructed runtime object, not derived from a policy's `PromptContext` | | `expired` | The owning policy has already returned/thrown — includes storing `PromptContext` in an external variable and calling it later in the handler | | `foreign` | Seal is still alive, but the runtime belongs to a different generation — e.g., smuggled into a sub-agent's handler via closure | For `executeValidation`, `runtime` is purely a **capability credential**: it still reads validators registered via `expectValidator` from the draft, not from the runtime. The credential is mandatory because of bookkeeping safety — `executeValidation` sets `validationRan`, and this is the sole basis for the policy barrier's "validator contract bypassed" warning (see ⚠️ block below). If a handler could call it bare (e.g., misused as a generic validation utility once), this safety net would be silently dismantled. An edge-case caveat: spreading a runtime (`{ ...runtime, tools: [...] }`) carries the **same** seal reference via the symbol property and will still be accepted — it is semantically a derived view of the same policy execution. However, `fork()` is the recommended approach — it also handles array copying and functional overrides correctly. Being rejected by the seal **does not mean your need is invalid** — only that you are in the wrong place. To make model calls outside `promptAgent` / `promptChat` (pre-flight classification, routing, graph nodes…), write an inline custom policy — `createAgentPolicy` costs only a few lines (see skeleton below), gaining prompt spans, stream runtime, `expectValidator` contracts, and lifecycle preservation. Multiple independent LLM calls can be split into separate generations via `reborn`, or delegated to sub-agents. Manually executing a single tool (bypassing the model tool-call loop) is not restricted by this — use `callTool`. `PromptContext.fork()` can derive these runtime fields: ```ts const nodeRuntime = ctx.fork({ messages: [...ctx.messages, { role: "user", content: "node prompt" }], tools: [searchTool, citeTool], toolCallLoopMiddlewares: [], }); ``` Model call options are not runtime fields — they belong to each `executeTurn` call: ```ts const { message } = await executeTurn(nodeRuntime.messages, { runtime: nodeRuntime, toolChoice: "auto", additionalOptions: { temperature: 0 }, }); ``` A minimal tool-loop policy skeleton: ```ts import { createAgentPolicy, executeTools, executeTurn, executeValidation, } from "@rejelly/core/policy"; import type { Message } from "@rejelly/core"; export const myPolicy = createAgentPolicy({ policyId: "my-policy", handler: async (ctx) => { const messages: Message[] = [...ctx.messages]; const delta: Message[] = []; while (ctx.usedTurnSteps < ctx.maxTurnSteps) { const runtime = ctx.fork({ messages }); const { message } = await executeTurn(runtime.messages, { runtime }); if (message.tool_calls?.length) { messages.push(message); delta.push(message); const toolRuntime = ctx.fork({ messages }); const toolMessages = await executeTools(message.tool_calls, { runtime: toolRuntime, }); messages.push(...toolMessages); delta.push(...toolMessages); continue; } const rawText = typeof message.content === "string" ? message.content : ""; const validation = await executeValidation(rawText, { runtime }); if (!validation.success) { throw new Error(validation.errors.join("\n")); } delta.push({ ...message, content: validation.data as string }); return { data: validation.data, delta }; } throw new Error("Policy did not converge before maxTurnSteps."); }, }); ``` > Streaming call parameters do not belong to runtime: `toolChoice` is a per-turn parameter of `executeTurn({ toolChoice })`, provider options like temperature are overridden per-turn via `executeTurn({ additionalOptions })`. The preset tool-call-loop policy deliberately does not set `toolChoice` or `additionalOptions` — when such control is needed, compose `executeTurn` directly in an extended policy. ## Syntactic Sugar: `executeValidatedLoopTurn` There are only three primitives (`executeTurn` / `executeTools` / `executeValidation`). The bookkeeping in the skeleton above — "call model once → if content returned, validate and retry with feedback within the **same turn step**; if tool calls returned, hand back to the outer loop" — would need to be rewritten in every tool-call-loop policy. `executeValidatedLoopTurn` solidifies it into an exported **syntactic sugar** — like `equipMemo` is to `equipMemory`: it reuses primitives, encapsulates retry counting and `AttemptsExhaustedError`, but is **not a fourth primitive** — the primitive vocabulary remains three. ```ts import { executeValidatedLoopTurn, type LoopTurnResult } from "@rejelly/core/policy"; const result: LoopTurnResult = await executeValidatedLoopTurn({ runtime: ctx.fork({ messages }), jsonSchema, // optional: structured output parser, // optional: omitted means plain-text path (validators still run) maxRetries: ctx.maxRetries, }); if (result.kind === "content") { // Already passed executeValidation — final result + this step's delta messages return { data: result.data, delta: result.deltaMessages }; } // result.kind === "tool_calls": hand result.calls to executeTools, outer loop continues ``` Its return type `LoopTurnResult` (discriminated union of `content | tool_calls`) is **deliberately loop-shaped**: the preset `runToolCallLoopPolicy` and evil-jelly's resilient chat policy both reuse it directly, differing only in the **outer loop** (how to execute tools, control tool, abort/cleanup, pendingUserInput injection, and other real product behaviors). Both branches carry `deltaMessages` (all messages added this step), with different executable payloads (`content` gives `data`, `tool_calls` gives `calls`). The outer loop can therefore first `push(...result.deltaMessages)` universally, then branch by `kind`. This is critical: if a validation retry appends "failed output + feedback" and **then** turns into tool calls, those intra-step messages are returned via `deltaMessages` and are not lost (otherwise the recorded conversation history would misalign with what the model actually saw). This also delineates the applicability boundary: when **different delta shaping or retry semantics** are needed, don't add parameters to this sugar — drop down to composing the three primitives yourself, just as you would switch from `equipMemo` to `equipMemory` when a custom caching strategy is needed. Sugar is convenience, not the only entry point. ## Preset Variants ### `promptAgent(schema)` `promptAgent` is the structured output variant. It: - Directly accepts a Zod schema. - Converts the Zod schema to JSON Schema and passes it to the model call. - Uses a JSON output parser for parsing and validation. - Returns only the validated structured data. This variant is suitable when the agent handler needs a typed final result. ### `promptChat(options)` `promptChat` is the chat variant. It: - Accepts an additional message via `options.message`. - Optionally accepts `options.schema`. - Returns `{ data, delta }`. - `delta` contains only the messages newly produced by this loop, such as assistant messages, tool output, and validation feedback. Without a schema, `data` is the validated raw text. With a schema, `data` is the parsed structured value. ## Extending Policies Extending prompt behavior should follow the same pattern: 1. Use `createAgentPolicy` to access the agent context, lifecycle constraints, and telemetry. 2. From the received `PromptContext`, use `ctx.fork(...)` to derive each turn/node's runtime. 3. Compose `executeTurn`, `executeTools`, `executeValidation`, and other execution primitives as needed. 4. Add parsing, validation, retry, or tool-loop behavior inside the policy. 5. Place the public API's parameter and return value shapes at the policy boundary, rather than stuffing them into the underlying primitives. This keeps core execution primitives simple and reusable, while letting policies define application-facing product behavior. Conversely, policies are the **only** place to use primitives: calling `executeTurn` / `executeTools` / `executeValidation` directly in a handler without going through `createAgentPolicy` is rejected by the [Runtime Seal](#runtime-seal). Even for a one-off model call, wrap it in an inline small policy — the cost is a few lines of boilerplate, the benefit is that telemetry, validation contracts, and lifecycle have no blind spots. > **⚠️ Policy final content must go through `executeValidation`** > > `expectValidator()` is a public contract of the prompt phase: validators registered by an Agent should take effect for **any** prompt policy. Validators are stored in the draft, and **the only entry point for executing them is `executeValidation`** — both preset `promptAgent` / `promptChat` call it internally. > > An extended policy that parses model output directly with `OutputParser.parse` (bypassing `executeValidation`) will **silently skip** registered validators, and no validation events will appear in traces / Review. The framework detects this when the policy returns successfully and prints a warning (validators exist in the draft but `executeValidation` was never called this round). > > Correct usage: call `executeValidation(rawText, { runtime, parser, attempt })` on the final model output (`runtime` is required, same [Runtime Seal](#runtime-seal) gate as `executeTurn`). Returns `ValidationAttemptResult`, and on failure the `failure` is a `ValidationFailure` (`failure.type`: `no_content` / `parse_error` / `schema` / `validator`) — the policy decides the feedback and retry strategy (e.g., append `errors` as a user message and retry within the same turn step). If the policy needs to incorporate other failures (e.g., "LLM call failed") into the same attempt bookkeeping, it can extend the vocabulary (e.g., `type AttemptFailure = ValidationFailure | { type: "llm_error" }`) — the failure vocabulary belongs to the policy's own perspective, and core does not provide a canonical version (`AttemptsExhaustedError.lastFailureType` is a plain string — custom vocabularies pass through this boundary directly). When `executeValidation` is called without a `parser`, it takes the plain-text path: skipping parsing, but **still** running validators — plain-text output is also subject to `expectValidator` constraints. # Testing Rejelly provides a mock model for simulating LLM behavior in test environments. ## Mock Model ### `createMockModel()` Creates a configurable Mock Model for testing Agent behavior. **Returns**: `MockModel` instance ```typescript import { createMockModel } from '@rejelly/core/testing' import { createAgent } from '@rejelly/core' const mock = createMockModel() // Set default response mock.setDefaultResponse({ result: 'ok' }) // Create Agent const agent = createAgent({ id: 'test', model: mock.adapter, handler: async () => { // ... agent logic } }) ``` ### MockModel API #### `mock.when(condition)` Defines a matching rule, returns a `RuleBuilder` for configuring the response behavior. **Parameters**: - `condition`: `RuleCondition` - Matching condition - `{ input: string | RegExp }` - Match user message text (multiple messages are `join(' ')` first, then matched) - `{ toolName: string }` - Match a tool name: last message is a tool with matching name, or last assistant's tool_calls contains that name, or any user message text contains that string - `(payload: RulePayload) => boolean` - Custom matching function **Returns**: `RuleBuilder` ```typescript // Match input content mock.when({ input: 'hello' }) .thenReturn({ response: 'hi' }) // Use regular expression mock.when({ input: /greet/i }) .thenReturn({ greeting: 'Hello!' }) // Match tool call mock.when({ toolName: 'calculator' }) .thenCallTools([{ id: 'call_1', name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } }]) // Custom matching function mock.when((payload) => { return payload.messages.length > 5 }).thenReturn({ result: 'long conversation' }) ``` #### RuleBuilder API ##### `thenReturn(response)` Immediately returns a response. ```typescript mock.when({ input: 'hello' }) .thenReturn({ message: 'hi' }) .withUsage({ promptTokens: 10, completionTokens: 5 }) ``` ##### `thenStream(chunksOrObject, chunkSize?)` Returns a response via streaming. - **chunksOrObject**: When `string[]`, yields each string in order sequentially. When an object or other JSON value, it's `JSON.stringify`'d and split into character-sized chunks. - **chunkSize**: Only effective when **chunksOrObject** is an object. Number of characters per chunk, default `1`. ```typescript mock.when({ input: 'stream' }) .thenStream([ 'Hello', ' ', 'World', '!' ]) // Pass an object: stringified then chunked by chunkSize (default 1 character per chunk) mock.when({ input: 'stream' }) .thenStream({ reply: 'hello world' }) // Specify chunk size mock.when({ input: 'stream' }) .thenStream({ reply: 'hello' }, 2) // yields "he", "ll", "o" in order ``` ##### `thenCallTools(toolCalls)` Simulates tool calls. ```typescript mock.when({ toolName: 'search' }) .thenCallTools([ { id: 'call_1', name: 'search', arguments: { query: 'test' } } ]) ``` ##### `thenThrow(error)` Throws an error. ```typescript mock.when({ input: 'error' }) .thenThrow(new Error('Mock error')) ``` ##### `thenDo(fn)` Executes a custom async function to return a response. ```typescript mock.when({ input: 'custom' }) .thenDo(async (payload) => { // payload contains messages, schema, userMessages, etc. return { result: `processed ${payload.lastUserMessage}` } }) ``` ##### `withDelay(ms)` Adds a delay before the response (simulates network latency). ```typescript mock.when({ input: 'slow' }) .thenReturn({ result: 'ok' }) .withDelay(1000) // 1 second delay ``` ##### `withChunkInterval(ms)` Sets the interval between each chunk in streaming output (simulates real streaming transmission). ```typescript mock.when({ input: 'stream' }) .thenStream(['Hello', ' ', 'World', '!']) .withChunkInterval(50) // 50ms between chunks ``` ##### `withUsage(usage)` Sets token usage. ```typescript mock.when({ input: 'hello' }) .thenReturn({ result: 'ok' }) .withUsage({ promptTokens: 100, completionTokens: 50, totalTokens: 150 // optional, auto-calculated }) ``` #### `mock.setDefaultResponse(response)` Sets a default response (used when no rule matches). If no default response is set and no rule matches, throws: `No matching rule found. Use mock.when(...), mock.sequence(...), or mock.setDefaultResponse(...).` ```typescript mock.setDefaultResponse({ result: 'default' }) ``` #### `mock.setDefaultUsage(usage)` Sets the default token usage for responses. ```typescript mock.setDefaultUsage({ promptTokens: 10, completionTokens: 5 }) ``` #### `mock.setDefaultDelay(ms)` Sets the default response delay (ms). ```typescript mock.setDefaultDelay(500) // 500ms default delay ``` #### `mock.sequence(steps)` Defines a sequence of responses, returned in call order. Priority: Sequence > Rule Match > Default. ```typescript mock.sequence([ { type: 'json', content: { step: 'first' } }, { type: 'json', content: { step: 'second' } }, { type: 'json', content: { step: 'third' } }, ]) ``` #### `mock.setSequenceUsage(usage)` Sets token usage for sequence responses. ```typescript mock.setSequenceUsage([ { promptTokens: 10, completionTokens: 5 }, { promptTokens: 20, completionTokens: 10 }, ]) ``` #### `mock.setSequenceDelay(ms)` Sets the delay for sequence responses (ms). ```typescript mock.setSequenceDelay(200) // 200ms per sequence response ``` #### `mock.setCostCalculator(calculator)` Custom cost calculation function, overriding the default token-based cost logic. ```typescript mock.setCostCalculator((usage) => { return { usd: usage.promptTokens * 0.00003 + usage.completionTokens * 0.00006, } }) ``` #### `mock.calls` Call recording API for inspecting Agent calls to the LLM. ```typescript // Get all call records const allCalls = mock.calls.all() // Get call count const count = mock.calls.count() // Get first call const firstCall = mock.calls.first() // Get last call const lastCall = mock.calls.last() // Clear call records mock.calls.clear() ``` **CallRecord structure**: ```typescript interface CallRecord { messages: Message[] // Messages sent to the model schema?: JsonSchema // JSON Schema (if any) timestamp: number // Timestamp index: number // Call index (0-based) } ``` #### `mock.reset()` Resets all rules and call records. ```typescript mock.reset() ``` #### `mock.adapter` ModelAdapter instance for passing to `createAgent`. ```typescript const agent = createAgent({ id: 'test', model: mock.adapter, handler: async () => { /* ... */ } }) ``` ## Complete Example ```typescript import { createMockModel } from '@rejelly/core/testing' import { createAgent, equipSystem, equipInstruction, promptAgent } from '@rejelly/core' import { z } from 'zod' // Create Mock Model const mock = createMockModel() // Configure response rules mock.when({ input: 'hello' }) .thenReturn({ answer: 'Hello, World!' }) .withUsage({ promptTokens: 10, completionTokens: 5 }) mock.when({ input: 'calculate' }) .thenCallTools([ { id: 'call_1', name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } } ]) // Set default response mock.setDefaultResponse({ result: 'default response' }) // Create test Agent const TestAgent = createAgent({ id: 'test_agent', model: mock.adapter, handler: async (props: { query: string }) => { equipSystem('You are an assistant') equipInstruction(`User query: ${props.query}`) const ResultSchema = z.object({ answer: z.string() }) return await promptAgent(ResultSchema) } }) // Run test const result = await TestAgent({ query: 'hello' }) console.log(result) // { answer: 'Hello, World!' } // Check call records const lastCall = mock.calls.last() console.log(lastCall?.messages) // View messages sent to the model console.log(mock.calls.count()) // Number of calls ``` ## Type Definitions ```typescript interface MockModel { when(condition: RuleCondition): RuleBuilder calls: CallsAPI adapter: ModelAdapter reset(): void setDefaultResponse(response: MockResponse): void setDefaultUsage(usage: MockUsage): void setDefaultDelay(ms: number): void sequence(steps: MockSequenceStep[]): void setSequenceUsage(usage: MockUsage[]): void setSequenceDelay(ms: number): void setCostCalculator(calculator: (usage: TokenUsage) => Record): void } interface RuleBuilder { thenReturn(response: MockResponse): RuleBuilder /** chunksOrObject: string[] or JsonValue; chunkSize only valid for objects, chars per chunk, default 1 */ thenStream(chunksOrObject: StreamInput, chunkSize?: number): RuleBuilder thenCallTools(toolCalls: MockToolCall[]): RuleBuilder thenThrow(error: Error): RuleBuilder thenDo(fn: (payload: RulePayload) => Promise): RuleBuilder withDelay(ms: number): RuleBuilder withChunkInterval(ms: number): RuleBuilder withUsage(usage: MockUsage): RuleBuilder withExtra(extra: Record): RuleBuilder } type RuleCondition = | { input: string | RegExp } | { toolName: string } | ((payload: RulePayload) => boolean) interface RulePayload { messages: Message[] schema?: JsonSchema /** The last user message text (multimodal: image/video becomes "[image]"/"[video]") */ lastUserMessage?: string /** Source of all user messages concatenated (same, text parts only) */ userMessages: string[] } interface CallsAPI { all(): CallRecord[] count(): number first(): CallRecord | undefined last(): CallRecord | undefined clear(): void } interface CallRecord { messages: Message[] schema?: JsonSchema timestamp: number index: number } /** String or JSON-serializable value (object, array, primitive) */ type MockResponse = string | JsonValue /** Stream input: string array (yield in order), or object/value (stringified and chunked by chunkSize) */ type StreamInput = string[] | JsonValue type MockSequenceStep = | ({ delay?: number; chunkInterval?: number; extra?: Record; usage?: MockUsage } & { type: 'text' content: string }) | ({ delay?: number; chunkInterval?: number; extra?: Record; usage?: MockUsage } & { type: 'json' content: JsonValue }) | ({ delay?: number; chunkInterval?: number; extra?: Record; usage?: MockUsage } & { type: 'stream' chunks: string[] }) | ({ delay?: number; chunkInterval?: number; extra?: Record; usage?: MockUsage } & { type: 'stream_object' content: JsonValue chunkSize?: number }) | ({ delay?: number; chunkInterval?: number; extra?: Record; usage?: MockUsage } & { type: 'tool_calls' calls: MockToolCall[] }) | ({ delay?: number; chunkInterval?: number; extra?: Record; usage?: MockUsage } & { type: 'error' error: Error }) | ({ delay?: number; chunkInterval?: number; extra?: Record; usage?: MockUsage } & { type: 'custom' handler: (payload: RulePayload) => Promise }) interface MockUsage { promptTokens?: number completionTokens?: number totalTokens?: number /** Extra token dimensions forwarded to TokenUsage.details (e.g. reasoningTokens, cacheReadTokens) */ details?: Record } interface MockToolCall { id: string name: string arguments: Record /** Tool-call-level metadata forwarded to StreamEvent.tool_call.toolCall.extra */ extra?: Record } ``` # Time Travel Snapshots enable persistence, restoration, and replay of execution state, supporting checkpoint resume, recovery from event traces, and prompt/tool cache replay based on `contentHash`. Related APIs are imported from `@rejelly/core/debugger`; `runWith` is imported from `@rejelly/core`. > **Warning: Snapshot throws by default in production.** **enableSnapshot:** The root context's `enableSnapshot` is determined by `runWith`'s `options.enableSnapshot`, which defaults to `IS_DEV` (`true` when `NODE_ENV === 'development'` or `'test'`; the default can be overridden via `runWith` options). When `enableSnapshot` is `false`: journal recording and child Agent frame saving are both skipped; calling `dumpSnapshot()` throws `SnapshotDisabledError`. If snapshots are needed in **production**, collect trace events (TraceEvent) during runtime and use `restoreSnapshot(trace.events)` to reconstruct the snapshot from the event timeline afterward, then replay and debug locally. --- ## `dumpSnapshot(options?)` Exports a snapshot of the current Agent execution state for persistence and future replay. The function automatically retrieves state from the current context, so no context argument is required; optional `options.metadata` attaches user-defined tags. ```typescript import { dumpSnapshot } from '@rejelly/core/debugger'; // Export snapshot during Agent execution (auto-retrieves current context) const snapshot = dumpSnapshot(); // Optional: attach user-defined tags const taggedSnapshot = dumpSnapshot({ metadata: { reason: 'before migration' } }); // Save snapshot to file or database await saveSnapshot(snapshot); ``` **Return value (core structure; fields stay aligned with the types exported by `@rejelly/core/debugger`):** ```typescript interface AgentSnapshot { /** Process identifier */ processId: string; /** Snapshot creation timestamp */ timestamp: number; /** Root frame snapshot (contains all sub-agent execution records) */ root: AgentFrameSnapshot; /** Trace provenance and recovery anchor */ provenance: { traceId: string; spanId?: string; anchor?: 'before' | 'after'; source?: 'dump' | 'restore' }; /** Snapshot format version */ version: number; /** User-defined tags */ metadata?: Record; } interface AgentFrameSnapshot { /** Call ID */ callId: string; /** Agent ID */ agentId: string; /** Memory state (JSON-serializable) */ memory: Record; /** Execution journal (for replay interception). prompt/tool keys are contentHash (input hash), decoupled from callId */ journal: { /** LLM call records, key = contentHash */ prompt: Record; /** Tool call records, key = contentHash */ tool: Record; }; /** Sub-agent execution history tree, key = callId */ children: Record; /** Execution status */ state: { /** Status: 'running' | 'completed' | 'failed' */ status: 'running' | 'completed' | 'failed'; /** If status === 'completed', stores the return value */ output?: unknown; /** If status === 'failed', stores error info */ error?: unknown; }; /** Usage for this frame and its descendants */ budgetState: BudgetState; } ``` **Call ID and Journal Responsibility Separation:** - **callId**: Format is `${parentCallId}/${type}:${configId}:${seq}`. `seq` is strictly incremented by the context's `callCounters` on each call (agent / prompt / tool), ensuring multiple calls in the same run (e.g., 3 consecutive `search` calls) get different callIds (e.g., `root/tool:search:0`, `:1`, `:2`). Used for: - Topology: `children` keys, parent-child frame relationships - Distributed tracing (OpenTelemetry) and DevTool timeline tree view, avoiding Span ID conflicts - **Journal**: Only responsible for "input → output" caching. The prompt/tool journal is read/written using **contentHash** (input hash) as the key, independent of callId; on replay, the current request's contentHash is looked up in the table, and the cached result is returned on hit. Thus cache logic is decoupled from "which call number." **How it works:** 1. **Traverse context chain**: Walks up from the current context to the root context, building the complete context chain 2. **Build nested frames**: Creates frame snapshots for each Agent in the context chain, including: - Memory state (converts from `ctx.memory` Map to a plain object) - Execution journal (copies from `ctx.draft.journal`; prompt/tool keys are contentHash) - Sub-agent frames (copies from `ctx.draft.children`, keyed by callId) - Execution status (set based on whether currently running) 3. **Handle running state**: If an Agent is still running, it's marked as `status: 'running'` **Use cases:** - **Persist execution state**: Save the complete Agent state at a certain point for later restoration - **Debugging and auditing**: Record the full execution trace, including all sub-agent call history - **Checkpoint resume**: Periodically save snapshots in long-running Agents, supporting resume after interruption **Notes:** - Only callable when the current context's `enableSnapshot` is `true`; otherwise throws `SnapshotDisabledError` (checkable via `isSnapshotDisabledError` from `@rejelly/core`). - Snapshots only contain JSON-serializable data (memory, journal, etc.) - Non-serializable data (functions, class instances) are ignored or marked as errors - Snapshots are deep copies — modifying a snapshot does not affect the original context - promptAgent's input hash includes prompt, schema, model id, model provider - Tool caching works via a cache middleware added at the innermost layer of the onion model --- ## `runWith(fn, options?)` and Snapshots Executes a function in an optionally snapshot-restored context. If `options.snapshot` is provided, the root context is restored from the snapshot before execution; otherwise, normal execution proceeds. ```typescript import { runWith } from '@rejelly/core'; import { dumpSnapshot } from '@rejelly/core/debugger'; // Normal execution (no snapshot) const result = await runWith(async () => { const agent = createAgent({ ... }); const result = await agent({ input: 'test' }); const snapshot = dumpSnapshot(); // note: this API is limited to debug environments return result; }); // Execute with snapshot restoration const snapshot = await loadSnapshot(); // Load from file or database const result = await runWith(async () => { const agent = createAgent({ ... }); return await agent({ input: 'test' }); // With snapshot, Agent replays quickly, skipping tool and LLM execution via journal cache }, { snapshot }); ``` **Snapshot-related options:** ```typescript interface RunWithOptions

{ /** Inject snapshot for context restoration; if provided, restores root context from snapshot before execution */ snapshot?: AgentSnapshot; /** * Whether to enable snapshots (default IS_DEV). * When true: records journal, saves child frames, dumpSnapshot is callable. * When false: recordJournal / saveChildFrame returns immediately, dumpSnapshot() throws SnapshotDisabledError. */ enableSnapshot?: boolean; // ... other options in core docs } ``` **How it works with a snapshot:** 1. Restores root context from the snapshot's root frame 2. Restores memory state (from `frame.memory` to `ctx.memory`) 3. Sets the snapshot on the context (for replay interception) 4. Executes the function in the restored context 5. Sub-agents automatically restore their own frames from the parent context's `snapshot.children` on invocation **Replay mechanism:** When an Agent executes in a restored context: - **Prompt replay**: If the snapshot contains a matching prompt call record (matched by `contentHash`), returns the cached result directly — no LLM call - **Tool replay**: If the snapshot contains a matching tool call record (matched by `contentHash`), returns the cached result directly — no tool execution - **Sub-agent replay**: Sub-agents restore their own frames from the parent context's `snapshot.children` and recursively apply the replay mechanism **Use cases:** - **Testing and debugging**: Reproduce specific execution scenarios using saved snapshots - **Cost optimization**: Use snapshots in development environments to avoid repeated LLM and tool calls - **Checkpoint resume**: Resume execution from a saved snapshot, continuing unfinished tasks - **Auditing and compliance**: Reproduce historical execution, verify result consistency **Snapshot restoration notes:** - If a `snapshot` is used, it is restored only once at the start of `runWith`. - The restored context state (memory, replay cache, etc.) persists for the entire `runWith` call. - Prompt and tool calls in the snapshot are matched by hash value — the cache is hit only when inputs are identical. If inputs change, execution proceeds normally and the snapshot is updated. - `runWith` only injects the snapshot; sub-agents restore themselves automatically on invocation. - Sub-agent restoration depends on `snapshot.children[callId]`. The `seq` in `callId` is assigned in the order of actual `SubAgent(...)` calls. When calling the same sub-agent in parallel, ensure sub-agent calls happen during the synchronous construction phase so that both the original run and the replay have consistent ordering: ```typescript // Recommended: SubAgent(...) starts in the synchronous phase of map, seq assigned stably by items order const tasks = items.map(item => SubAgent({ text: item.text })); await Promise.all(tasks); ``` - Avoid inserting `await`, `setTimeout`, or other scheduling-uncertain logic before calling sub-agents. Otherwise, parallel sub-agents with the same `configId` may receive different `seq` values due to varying start times, causing `snapshot.children[callId]` misalignment or misses: ```typescript // Not recommended: SubAgent(...) happens after await, seq depends on async completion order const tasks = items.map(async item => { const prepared = await prepare(item); return SubAgent({ text: prepared.text }); }); await Promise.all(tasks); ``` - If each item needs async preparation, prepare data in parallel first, then synchronously start sub-agents in stable array order: ```typescript const prepared = await Promise.all(items.map(item => prepare(item))); const tasks = prepared.map(item => SubAgent({ text: item.text })); await Promise.all(tasks); ``` - Non-serializable data is marked as errors in the snapshot and skipped during replay. --- ## `restoreSnapshot(trace, options?)` Restores an AgentSnapshot from a linear array of event traces (TraceEvent[]), enabling time travel. Rebuilds the nested frame structure to restore the complete Agent execution state. ```typescript import { EVENTS } from '@rejelly/core'; import type { TraceEvent, AgentEndEvent } from '@rejelly/core'; import { restoreSnapshot } from '@rejelly/core/debugger'; // Obtain captured events from Review, an EventBus subscription, or persistent storage declare const events: TraceEvent[]; // Method 1: Simplest usage — restore to latest state (recommended) const snapshot = restoreSnapshot(events); // Method 2: Restore to a specific span's end event const agentEndEvent = events.find( e => e.type === EVENTS.AGENT_END ) as AgentEndEvent | undefined; if (agentEndEvent) { const snapshot = restoreSnapshot(events, { spanId: agentEndEvent.trace.spanId, anchor: 'after' }); const result = await runWith(async () => { return await MyAgent({ input: 'test' }); }, { snapshot }); } ``` **Function signature:** ```typescript export function restoreSnapshot( trace: TraceEvent[], options?: RestoreOptions ): AgentSnapshot; interface RestoreOptions { /** * Target span ID, determines the point in time to restore to * If not provided, defaults to restoring to the last event of the Trace (latest state) */ spanId?: string; /** * Restore time anchor * - 'before': Restore to before this span started (for retry scenarios) * Snapshot does not include this span's execution records; Agent re-executes after restoration * - 'after': Restore to after this span ended (for resume scenarios, default value) * Snapshot includes this span's results and cache; Agent skips actual execution on restoration * @default 'after' */ anchor?: 'before' | 'after'; } ``` **How it works:** The function uses a stack-based state machine to rebuild nested frame hierarchies from the flat event timeline: 1. **Locate truncation point**: Determines which events to include based on the target `spanId` and `anchor` mode - If `spanId` is not provided: defaults to the last event of the Trace (latest state) - `anchor: 'after'`: Finds the span's end event, includes that event and everything before it - `anchor: 'before'`: Finds the span's start event, only includes events before it (excluding the start event) 2. **Replay state**: Processes events chronologically, rebuilding: - **Frame structure**: Rebuilds Agent hierarchy via `AGENT_START` / `AGENT_END` events - **Memory state**: Restores memory from `GENERATION_END` events - **Prompt cache**: Restores LLM call cache from `TURN_END` events (based on `contentHash`) - **Tool cache**: Restores tool call cache from `TOOLS_EXECUTE_END` events (based on `contentHash`) 3. **State repair**: - Marks frames still running at truncation as `status: 'running'` - **Best Effort auto-correction**: If a parent Agent ends with unclosed child Agents still on the stack (zombie child nodes), they are automatically marked as `failed` with error information recorded. This ensures logical consistency of the snapshot data structure (no completed parent containing running children). **Anchor mode explanation:** - **`'after'` (default)**: Restore to after the target span ends - Snapshot includes the span's execution results and cache entries - On restoration, Agent skips actual execution and uses cached results directly - Suitable for "resume" scenarios, e.g., continuing from a completed step - **`'before'`**: Restore to before the target span starts - Snapshot does not include any trace of this span's execution - On restoration, Agent re-executes from scratch - Suitable for "retry" scenarios, e.g., re-executing after a step failed **Usage examples:** ```typescript import { runWith, EVENTS } from '@rejelly/core'; import type { TraceEvent, AgentStartEvent, AgentEndEvent } from '@rejelly/core'; import { restoreSnapshot } from '@rejelly/core/debugger'; // Scenario 1: Restore to latest state async function restoreToLatest(trace: TraceEvent[]) { const snapshot = restoreSnapshot(trace); return await runWith(async () => await MyAgent({ input: 'test' }), { snapshot }); } // Scenario 2: Resume execution (skip completed steps) async function resumeExecution(trace: TraceEvent[]) { const stepEndEvent = trace.find( e => e.type === EVENTS.AGENT_END && e.trace.spanId === 'step_123' ) as AgentEndEvent; const snapshot = restoreSnapshot(trace, { spanId: stepEndEvent.trace.spanId, anchor: 'after' }); return await runWith(async () => await MyAgent({ input: 'test' }), { snapshot }); } // Scenario 3: Retry execution (re-execute failed steps) async function retryExecution(trace: TraceEvent[]) { const stepStartEvent = trace.find( e => e.type === EVENTS.AGENT_START && e.trace.spanId === 'step_123' ) as AgentStartEvent; const snapshot = restoreSnapshot(trace, { spanId: stepStartEvent.trace.spanId, anchor: 'before' }); return await runWith(async () => await MyAgent({ input: 'test' }), { snapshot }); } ``` **Differences from `dumpSnapshot()`:** - **`dumpSnapshot()`**: Exports a snapshot directly from the currently running Agent context. Requires the Agent to be executing and the current context's `enableSnapshot` to be `true`. - **`restoreSnapshot()`**: Restores a snapshot from historical event traces. Does not require the Agent to be executing and does not depend on `enableSnapshot`. Can restore from any point in time. Even in **production** with snapshots disabled, if event traces were collected during runtime, you can use `restoreSnapshot(trace.events)` afterward to rebuild a Snapshot for replay, auditing, or issue reproduction. **Relationship with `runWith()`:** - `restoreSnapshot()` generates a snapshot; `runWith()` uses the snapshot to restore execution - Together they provide full time-travel functionality: restore a snapshot from event traces, then use the snapshot to resume execution **Error handling:** The function throws errors in these cases: - Trace is empty - Target `spanId` does not exist in the event trace (when `spanId` is provided) - `anchor: 'before'` and the target span is at the beginning of the trace (cannot go further back) - Unable to create a root Agent frame (typically when `anchor: 'before'` and the target is the root Agent) **Notes:** - **Default behavior**: If `spanId` is not provided, automatically restores to the last event in the Trace - Event traces are automatically sorted by timestamp to ensure chronological processing - Prompt and tool cache in the restored snapshot is matched by `contentHash`, ensuring identical inputs produce cache hits - Non-serializable data (functions, class instances) is not saved in event traces and will be lost on restoration - The restored snapshot can be used like one generated by `dumpSnapshot()`, passed to `runWith()` for execution restoration # Create Rejelly: One-Command Scaffolding ## What It Is `create-rejelly` is the official Rejelly scaffolding tool, generating a runnable Rejelly project in seconds. The generated project is based on built-in templates and automatically injects the corresponding source code and configuration based on your chosen model adapter. ## Usage Run the following in the directory where you want to create the project: ```bash pnpm create rejelly # or npx create-rejelly ``` Follow the prompts: 1. **Project name**: Directory name for the project, defaults to `rejelly-app` 2. **Which template would you like?**: Project template - **Basic (chat)**: Basic chat template, great for getting started quickly - **Router**: Router pattern template, suitable for multi-sub-agent dispatching 3. **Which model adapter would you like to start with?**: Preferred model adapter - **OpenAI (GPT)**: Uses OpenAI API (including compatible endpoints) - **Gemini (Google)**: Uses Google Gemini API If the directory already exists, the CLI reports an error and exits; choose a different name and run it again. Canceling the interactive prompt also exits. ## Post-Generation Steps ```bash cd pnpm install ``` Edit the **.env** file in the project root, filling in the required environment variables for your chosen adapter: | Adapter | Required | Optional | |---------|----------|----------| | OpenAI | `OPENAI_API_KEY` | `OPENAI_MODEL_ID` (default per template), `OPENAI_BASE_URL` | | Gemini | `GEMINI_API_KEY` (or `GOOGLE_API_KEY`) | `GEMINI_MODEL_ID` (default per template) | Then start: ```bash pnpm start ``` ## Extending Later - To switch or add model adapters, simply install the corresponding package, e.g.: `pnpm add @rejelly/adapter-openai`, `pnpm add @rejelly/adapter-mcp`. - For more APIs and usage, see the [API docs](/en/api/) and [Introduction](/en/guide/). # DevTool DevTool is Rejelly's local debugging tool for receiving, storing, and inspecting Agent runtime Traces. It includes a local Server, built-in UI, HTTP API, MCP tools, and optional AI-assisted analysis. ## Installation Install in the project where you want to debug your Rejelly application: ```bash pnpm add -D @rejelly/devtool ``` Or use other package managers: ```bash npm install -D @rejelly/devtool yarn add -D @rejelly/devtool ``` ### pnpm 10 and better-sqlite3 DevTool uses SQLite to store Traces, depending on a native package like `better-sqlite3`. pnpm 10 may intercept its build script by default. If you encounter `better-sqlite3` errors after installation, first allow it to build: ```bash pnpm approve-builds ``` Select `better-sqlite3` in the interactive list. To persist the approval in your project config, add this to `package.json`: ```json { "pnpm": { "onlyBuiltDependencies": ["better-sqlite3"] } } ``` Then reinstall or rebuild: ```bash pnpm install pnpm rebuild better-sqlite3 ``` ## Quick Start Start DevTool: ```bash pnpm exec rejelly-devtool ``` Default addresses: | Address | Purpose | |---------|---------| | `http://127.0.0.1:5789` | DevTool UI | | `http://127.0.0.1:5789/docs` | HTTP API docs | | `http://127.0.0.1:5789/api/v1/traces` | Trace reporting endpoint | | `http://127.0.0.1:5789/mcp` | MCP endpoint | By default, DevTool listens only on `127.0.0.1`, and Trace data is saved to `./.rejelly/devtool.sqlite3` in the current working directory. ## Connecting Your Rejelly Application Once DevTool is running, you need to configure your Rejelly app to send Traces to it. The easiest way is to enable the Review exporter during app startup: ```typescript import { enableReview } from "@rejelly/core/debugger"; const disableReview = enableReview({ endpoint: "http://127.0.0.1:5789/api/v1/traces", }); ``` If using the default address, you can also configure it via environment variable: ```bash REJELLY_REVIEW_ENDPOINT=http://127.0.0.1:5789/api/v1/traces ``` `enableReview()` batches and uploads Trace events during execution. To flush any cached events before the program exits, call the returned close function: ```typescript await disableReview(); ``` For more Review exporter options, see the [Debugger API](/en/api/debug#review-exporter). ## Common Commands and Options `rejelly-devtool` starts the local Server without any subcommand: ```bash pnpm exec rejelly-devtool ``` Common options: | Option | Purpose | |--------|---------| | `-p, --port ` | Change the listen port, default `5789` | | `--host ` | Change the listen address, default `127.0.0.1` | | `--db ` | Specify Trace SQLite DB path, default `./.rejelly/devtool.sqlite3` | | `--review` | Record Traces produced by DevTool's own AI Agent | Examples: ```bash pnpm exec rejelly-devtool --port 5790 pnpm exec rejelly-devtool --db ./.rejelly/local-devtool.sqlite3 pnpm exec rejelly-devtool --host 0.0.0.0 ``` Corresponding environment variables: | Environment Variable | Purpose | |---------------------|---------| | `REJELLY_DEVTOOL_PORT` | Default port | | `REJELLY_DEVTOOL_HOST` | Default listen address | | `REJELLY_DEVTOOL_DB_PATH` | Default DB path | | `REJELLY_REVIEW_ENDPOINT` | Default reporting endpoint for Rejelly apps | If you set `--host` to `0.0.0.0`, other devices on the local network may access DevTool. Traces typically contain prompts, model outputs, tool call parameters, error information, and other debugging data — only expose in trusted networks. ## Using the UI Open `http://127.0.0.1:5789` to enter the DevTool UI. Common views include: - **Trace History**: View recently reported or imported Traces. - **Waterfall**: View Agent, model calls, tool calls, and custom spans along a timeline. - **Detail**: View input, output, messages, tool calls, errors, and token usage for a selected node. - **Filter / Search**: Filter Traces by status, name, time, type, model usage, cost fields, or tool execution information. - **Ask AI**: Perform auxiliary analysis on the current Trace after configuring AI parameters. If the UI shows no Traces, first verify that your application has called `enableReview()` and that the `endpoint` points to the current DevTool's `/api/v1/traces`. ## API Docs Once the DevTool Server is running, open in your browser: ```text http://127.0.0.1:5789/docs ``` This provides the HTTP API documentation for the current Server, covering Trace listing, search, details, event reading, import/reporting, metadata updates, AI analysis, and more. ## Importing and Exporting Traces DevTool supports importing Traces from local files and exporting current Traces to files, making it easy to reproduce and share debugging sessions. ### Importing Traces In the UI, select or drag and drop a Trace file. Supported formats: - `.jsonl` / `.ndjson`: One `TraceEvent` JSON object per line; a single line may also contain an array of events. - `.json`: A `TraceEvent[]` array, or a `{ "events": [...] }` structure. On import, DevTool uploads raw events to the local Server and writes them to the current DB. If the file contains multiple `traceId` values, they are grouped and imported by `traceId`. ### Exporting Traces In the UI, export the current Trace — it reads the Trace's raw events from the Server and downloads them as: ```text trace-.jsonl ``` Exported files can be re-imported into DevTool. Note that import/export uses raw `TraceEvent` objects, not the normalized trace objects used internally by the UI. ## MCP Tools DevTool also exposes a Streamable HTTP MCP endpoint: ```text http://127.0.0.1:5789/mcp ``` Once an MCP client connects, you can use DevTool's Trace introspection tools to query Traces in the current DB. Common tools include: | Tool | Purpose | |------|---------| | `search_traces` | Search the Trace list | | `get_trace_profile` | Get Trace overview | | `inspect_node` | View details of a specific node | | `list_message` | List messages | | `search_trace_messages` | Search message content | | `list_agent_tool` | View available Agent tools | | `search_trace_events` | Search raw events | | `list_tool_calls` | List tool calls | Most tools accept a `traceId` parameter; if omitted, they typically use the latest Trace in the DB. Tool parameters are JSON objects, following each tool's own schema. You can also run the same tools directly from the command line: ```bash pnpm exec rejelly-devtool tools --list pnpm exec rejelly-devtool tools --describe list_message pnpm exec rejelly-devtool tools get_trace_profile --json pnpm exec rejelly-devtool tools inspect_node --args '{"nodeRef":"n1"}' pnpm exec rejelly-devtool tools list_message --trace-id ``` To query a different DB: ```bash pnpm exec rejelly-devtool tools --db ./.rejelly/devtool.sqlite3 --list ``` ## AI Agent Features DevTool's AI features are optional, used for generating Trace filter conditions, analyzing the current Trace, and assisting in anomaly identification. Before enabling, configure OpenAI-compatible model parameters for the DevTool Server: ```bash OPENAI_API_KEY= OPENAI_MODEL_ID=gpt-5.6-luna OPENAI_BASE_URL=https://api.openai.com/v1 ``` Only `OPENAI_API_KEY` is required; `OPENAI_MODEL_ID` defaults to `gpt-5.6-luna`, and `OPENAI_BASE_URL` can be set when using a compatible gateway or private model service. After configuration, restart DevTool: ```bash pnpm exec rejelly-devtool ``` If `OPENAI_API_KEY` is not set, AI-related interfaces will return `AI_NOT_CONFIGURED`, and the AI features in the UI will not function. To observe the Traces produced by DevTool's own AI Agent, start with `--review`: ```bash pnpm exec rejelly-devtool --review ``` This writes DevTool's own AI analysis traces back to the current DevTool DB, useful for debugging the AI features themselves. ## Frequently Asked Questions ### better-sqlite3 error on startup This is typically caused by pnpm 10 blocking the native dependency's build script. Run: ```bash pnpm approve-builds pnpm rebuild better-sqlite3 ``` And confirm that `better-sqlite3` is allowed to build. ### Port already in use Start on a different port: ```bash pnpm exec rejelly-devtool --port 5790 ``` Then update the reporting endpoint in your application: ```text http://127.0.0.1:5790/api/v1/traces ``` ### No Traces in the UI Check three things first: 1. Has your Rejelly application called `enableReview()`? 2. Is the `endpoint` pointing to the current DevTool's `/api/v1/traces`? 3. Has your application actually executed an Agent and produced Trace events? ### AI features not available Ensure the DevTool process can read: ```bash OPENAI_API_KEY ``` When using an OpenAI-compatible gateway, also verify that `OPENAI_BASE_URL` and `OPENAI_MODEL_ID` are correct. ### Wrong Trace data found Confirm the DB path used by the current Server. The default path is relative to the current working directory of the startup command: ```text ./.rejelly/devtool.sqlite3 ``` You can specify it explicitly: ```bash pnpm exec rejelly-devtool --db /path/to/devtool.sqlite3 ``` # Event System Rejelly's event system provides full observability, allowing developers to monitor Agent execution in real time, debug issues, collect metrics, and build visualization tools. ## Overview The event system follows a publish-subscribe (Pub/Sub) pattern. The framework automatically emits various events during execution, and developers can subscribe to these events to monitor Agent execution status. ### Core Features - **Automatic Emission**: Events are emitted automatically during framework execution — no manual triggering required - **Type Safety**: All events have explicit type definitions with full TypeScript support - **Trace Context**: Every event carries complete tracing context (traceId, spanId, parentSpanId) for distributed tracing - **Non-Intrusive**: The event system does not affect Agent execution logic — fully decoupled - **Flexible Subscription**: Supports subscribing to specific event types or all events (using the `'*'` wildcard) ## Event Type Reference Events cover every phase of Agent execution. The table below is organized by category, showing **when each event fires** and its **key payload** fields. For the exhaustive field list, refer to the type definitions (see [Authoritative Source for Fields](#authoritative-source-for-fields)) — only the most commonly used fields are listed here to avoid drift from the source of truth. Most categories come in Span pairs (`:start` / `:end`); `:end` events universally include `duration`, `success`, and `error` (`ErrorInfo`) on failure — these are not repeated in the table. | Category | Event | When It Fires | Key Payload (beyond common fields) | |----------|-------|---------------|------------------------------------| | RunWith | `runWith:start` / `:end` | Outer execution entry open/close, **all other events occur between these** | start: `props`, whether snapshot/eventBus is used, registered model/provider key | | Agent Lifecycle | `agent:start` / `:end` | Agent full lifecycle open/close | start: `props`, `middlewares`; end: `result`, `generationCount` | | | `agent:reborn` | Agent triggers reborn | `generationId` (0-based), `newProps` (if changed) | | Generation | `generation:start` / `:end` | Each generation (first or after reborn) open/close | start: `generationId` (0-based), `props` (draft empty, memory unchanged); end: assembled `draft` view for DevTool, this generation's `memory`, `endReason` (`'return'` \| `'error'` \| `'reborn'`) | | PromptAgent | `promptAgent:start` / `:end` | Before `promptAgent()` call / after all tool loop iterations | start: `generationId`, `schema`, `policyId`; end: `totalSteps`, `cache` | | Turn | `turn:start` / `:end` | Single step open/close | start: `step` (0-based), `messages`, `schema`, `toolConfig`, `messageCount`; end: `resultType` (`'content'` \| `'tool_calls'`), `message`, `contentHash`, `cache` | | Validation | `validation:success` / `:fail` | Output validation passes / fails (scoped under `promptAgent`) | Shared: `rawText`, `attempt`; success: `data`; fail: partial `data`, `errors` (`ErrorInfo[]`) | | Model Call | `model:call:start` / `:end` | Model adapter **physical request** open/close | start: `adapterId`, `provider`, `messageCount`, `usedTools`, `middlewares`, `networkAttempt`; end: `rawText` / `reasoning` / `toolCalls`, `ttft`, `usage` (reasoning models may include `details.reasoningTokens`), `costs`, `finishReason` | | Budget | `budget:update` | When `updateBudgetChain` records LLM/tool usage | `identifiers`, `delta`, `aggregate` | | Instrument | `instrument:op:start` / `:end` | `instrument()` wrapped dependency methods (Redis / DB / SDK / vector store…) open/close | `name` (category), `operation` (method name); sanitized metadata in `trace.attributes` | | Resource | `resource:op:start` / `:end` | `equipResource` create / destroy open/close | `operation` (`'create'` \| `'destroy'`), `resourceId`; end: `reused` (an existing instance was reused during create) | | Tool Execution | `tools:execute:start` / `:end` | Batch of `tool_calls` execution open/close | start: `toolCallsCount`, `toolNames`, `toolMiddlewares`; end: `successCount`, `failureCount`, `toolResults[]` (`callId` / `toolName` / `input` / `output` / `duration` / `success` / `error` / `cache` / `contentHash`) | | Custom Span | `custom:span:start` / `:end` | Manual span open/close | `name`; attributes in `trace.attributes` (start: static subset, end: merged full set) | | Error | `error` | An error occurs | `ErrorInfo`: `name`, `message`, `stack?`, `cause?` (error chain), `details?` (custom error class fields) | | System Log | `sys:log` | Internal Logger bridge, **only emitted when there's an active trace context** (otherwise falls back to `console.warn`) | `level` (`debug` \| `info` \| `warning` \| `error`), `message`, `args`; always includes `trace` context | > **Rate-limiting does not emit events**: Rate limiting by [`@rejelly/limit-model`](/en/api/limit-model) uses `withLimit` / `withSimpleLimit` with a fast-fail strategy that throws errors directly — no "rate-limit waiting" events are emitted. ### Authoritative Source for Fields The table above deliberately avoids exhaustive field listings — payload structures evolve, and prose lists decay fastest. For the **complete, current** fields of any event, read the type definitions directly: - Event type constants: `EVENTS` (exported from `@rejelly/core`) - Payload types: `packages/core/src/core/domain/event-payload.ts` and `events.ts` ## Event Structure All events inherit from the base event structure (`BaseTraceEvent`) and include these common fields: - **`type`**: Event type (e.g. `'agent:start'`), matching constants in `EVENTS` - **`trace`**: Tracing context (`TraceContext`), containing `traceId`, `spanId`, `parentSpanId`, etc. - **`timestamp`**: Event timestamp (milliseconds) - **`agentId`**: Agent ID (optional) Events that need scoping declare `generationId` on the event body. Different event types also carry their own specific fields — Start events typically carry input parameters, End events carry results, timing (`duration`), success status (`success`), and `ErrorInfo` on failure. **Error Info (ErrorInfo)**: End events and error-related events uniformly use serializable `ErrorInfo`, containing `name`, `message`, optional `stack`, optional `cause` (error chain), and optional `details` (structured fields from custom error classes, usable by DevTool, etc.). It can be constructed from an `Error` object or directly. ## Execution Hierarchy and Context Isolation The event system reflects the hierarchical structure of execution: ### Execution Hierarchy 1. **`runWith`**: The outermost execution entry, always at the top of the call stack. All other events fire between `runWith:start` and `runWith:end`. 2. **Agent and Generation**: Executed inside `runWith`, representing the Agent's full lifecycle. 3. **Other Events**: Events like PromptAgent, Turn, Model Call, Validation, etc., fire during Agent execution. ### Context Isolation Agents create their own child contexts for isolating memory, resources, and tracing scopes. Sub-agents can explicitly read upstream-exposed data via scope/resource. ## Usage Event publishing and subscription are provided by **`EventBus`** (implementation: `packages/core/src/core/observability/event-bus.ts`). The framework no longer exports top-level `subscribe` / `subscribeOnce` / `subscribeMany` sugar functions — call these on the bus instance directly. ### Subscribing to Events Get the default bus via `getGlobalEventBus()` and subscribe to a specific type or the wildcard `'*'`: ```typescript import { getGlobalEventBus, EVENTS } from '@rejelly/core'; const bus = getGlobalEventBus(); const offStart = bus.subscribe(EVENTS.AGENT_START, (event) => { console.log('Agent started:', event.agentId); }); const offAll = bus.subscribe('*', (event) => { console.log('Event:', event.type, event.timestamp); }); offStart(); offAll(); ``` ### One-Time Subscription Use `subscribeOnce` to listen only once — it auto-unsubscribes after firing: ```typescript import { getGlobalEventBus, EVENTS } from '@rejelly/core'; const bus = getGlobalEventBus(); bus.subscribeOnce(EVENTS.AGENT_END, (event) => { console.log('Agent completed:', event.success); }); ``` ### Subscribing to Multiple Event Types Use `subscribeMany` to register for several event types at once: ```typescript import { getGlobalEventBus, EVENTS } from '@rejelly/core'; const bus = getGlobalEventBus(); const unsubscribe = bus.subscribeMany( [EVENTS.AGENT_START, EVENTS.AGENT_END], (event) => { console.log('Agent lifecycle event:', event.type); }, ); unsubscribe(); ``` ### Custom Event Bus The default execution path emits events on the global bus. If you need isolation (e.g. a sandbox, or collecting events for a specific `runWith`), call `createEventBus()` to get an independent instance — its API is identical to the global bus. Then inject it via `runWith(..., { eventBus })` so that execution emits only on that bus: ```typescript import { createEventBus, runWith } from '@rejelly/core'; const customEventBus = createEventBus(); customEventBus.subscribe('*', (event) => { console.log('Custom event:', event.type); }); await runWith(async () => { return await MyAgent({ input: 'test' }); }, { eventBus: customEventBus }); ``` ## Use Cases ### Logging The most common use of the event system is execution logging. The framework provides built-in observability exporters: - **Review Exporter**: Use `enableReview()` to send trace events in real time to the Rejelly Review Server for visual debugging - **OTLP Exporter**: Use `enableOTLP()` to send trace events to OTLP-compatible servers (Jaeger, Zipkin, Tempo, etc.) ### Performance Monitoring By subscribing to model call and tool execution events, you can collect performance metrics: - Monitor the duration of each LLM call - Track token usage and cost - Measure tool execution time ### Debugging and Visualization The event system provides the data foundation for debugging tools: - **DevTool**: Rejelly DevTool receives events via WebSocket, rendering the execution process in real time - **Distributed Tracing**: Via the OTLP exporter, events can be converted to standard tracing data - **Time-Travel Debugging**: Via `restoreSnapshot()`, you can restore execution state from event traces ### Custom Monitoring Developers can build custom monitoring solutions on top of the event system: - Real-time alerts: subscribe to error events, trigger notifications - Cost accounting: aggregate model call events, compute total cost - Execution analysis: analyze event sequences, identify performance bottlenecks - Audit logging: record all critical operations for compliance ## Relationship with the Snapshot System The event system and snapshot system work closely together: - **Event Tracing**: The event sequence records the complete execution history - **Snapshot Restoration**: Via `restoreSnapshot()`, snapshots can be rebuilt from event traces for time-travel debugging - **Replay Mechanism**: Restored snapshots can replay historical execution for testing and debugging ## Best Practices 1. **Subscribe on Demand**: Only subscribe to the event types you need — avoid processing unnecessary events 2. **Unsubscribe Promptly**: Remember to unsubscribe when components unmount or tasks complete to avoid memory leaks 3. **Error Handling**: Errors in event callbacks do not affect Agent execution, but should be handled properly 4. **Performance**: Keep event callbacks lightweight to avoid blocking the event bus 5. **Type Safety**: Use TypeScript type checking to ensure type-safe event handling ## Summary The event system is the core of Rejelly's observability, providing: - **Complete Execution Tracing**: Covers all phases of Agent execution - **Flexible Subscription Mechanism**: Supports multiple subscription patterns - **Rich Use Cases**: Logging, monitoring, debugging, visualization, and more - **Snapshot System Integration**: Supports time-travel debugging and state restoration Through the event system, developers gain deep insight into Agent execution, quickly identify issues, optimize performance, and build powerful debugging and monitoring tools. # Example Index Runnable examples are located in the [`examples/`](https://github.com/waht41/rejelly/tree/main/examples) directory, organized by difficulty: **01-basics**, **02-patterns**, **03-advanced**. Below is a summary with links to the corresponding code (GitHub: ). ## 01 · Basics | Description | Code Location | |-------------|---------------| | **Chat Agent**: Multi-turn conversational flow; uses `reborn` + `equipMemory` inside an Agent to manage conversation state, rebuilding the Prompt from current state each round. | [`examples/01-basics/chat-agent/`](https://github.com/waht41/rejelly/tree/main/examples/01-basics/chat-agent) · Entry [`index.ts`](https://github.com/waht41/rejelly/blob/main/examples/01-basics/chat-agent/index.ts) | | **Multi-model Agent**: Multi-modal input (image/video); passes structured `ContentPart[]` via `equipInstruction`. | [`examples/01-basics/multi-model-agent/`](https://github.com/waht41/rejelly/tree/main/examples/01-basics/multi-model-agent) · Core [`multi-model-agent.ts`](https://github.com/waht41/rejelly/blob/main/examples/01-basics/multi-model-agent/multi-model-agent.ts) | | **MCP Integration**: Connects to MCP Server via `@rejelly/adapter-mcp`, working with `equipResource` / `equipMCP`. | [`examples/01-basics/mcp-integration/`](https://github.com/waht41/rejelly/tree/main/examples/01-basics/mcp-integration) · Entry [`index.ts`](https://github.com/waht41/rejelly/blob/main/examples/01-basics/mcp-integration/index.ts) | ## 02 · Patterns | Description | Code Location | |-------------|---------------| | **Router**: Intent recognition + Zod-structured routing decisions, dispatching to sub-agents via native `switch`. | [`examples/02-patterns/router-agent/`](https://github.com/waht41/rejelly/tree/main/examples/02-patterns/router-agent) · Logic [`router-agent.ts`](https://github.com/waht41/rejelly/blob/main/examples/02-patterns/router-agent/router-agent.ts) | | **Coding Agent**: A minimal coding agent inside a sandbox workspace — explore → edit → run → verify. File/shell tools are native `ToolDefinition` objects. Logging and human approval are mounted per-tool as tool middleware (read-only passes, write requires gate). A single `promptChat` drives the full tool loop. | [`examples/02-patterns/coding-agent/`](https://github.com/waht41/rejelly/tree/main/examples/02-patterns/coding-agent) · Core [`coding-agent.ts`](https://github.com/waht41/rejelly/blob/main/examples/02-patterns/coding-agent/coding-agent.ts), tools [`tools.ts`](https://github.com/waht41/rejelly/blob/main/examples/02-patterns/coding-agent/tools.ts) | ## 03 · Advanced | Description | Code Location | |-------------|---------------| | **Fan-in / Fan-out**: Parallel Worker Agents (`Promise.all`), then aggregated into a single Summarizer. | [`examples/03-advanced/fan-in-fan-out/`](https://github.com/waht41/rejelly/tree/main/examples/03-advanced/fan-in-fan-out) · Entry [`index.ts`](https://github.com/waht41/rejelly/blob/main/examples/03-advanced/fan-in-fan-out/index.ts) | | **Time-travel**: `dumpSnapshot` / `restoreSnapshot` with trace replay (reproduction path without extra LLM calls). | [`examples/03-advanced/time-travel/`](https://github.com/waht41/rejelly/tree/main/examples/03-advanced/time-travel) · [`dump-example.ts`](https://github.com/waht41/rejelly/blob/main/examples/03-advanced/time-travel/dump-example.ts), [`restore-example.ts`](https://github.com/waht41/rejelly/blob/main/examples/03-advanced/time-travel/restore-example.ts) | | **Graph Policy**: A LangGraph-style writer–critic graph (typed state, conditional edges, cycles, critic concurrent fan-out) implemented as a custom prompt policy. Built on `createAgentPolicy` + `executeTurn` + `executeValidation` — core has zero knowledge of graphs. Uses `usedTurnSteps` for graceful degradation under budget constraints. | [`examples/03-advanced/graph-policy/`](https://github.com/waht41/rejelly/tree/main/examples/03-advanced/graph-policy) · Runtime [`graph-policy.ts`](https://github.com/waht41/rejelly/blob/main/examples/03-advanced/graph-policy/graph-policy.ts), specific graph [`writer-critic-agent.ts`](https://github.com/waht41/rejelly/blob/main/examples/03-advanced/graph-policy/writer-critic-agent.ts) | ## Shared & Running | Description | Code Location | |-------------|---------------| | **Shared models and pricing**: OpenAI adapter, `calculateCost` and `model-pricing` table shared across examples. | [`examples/shared/`](https://github.com/waht41/rejelly/tree/main/examples/shared) · e.g. [`openai-model.ts`](https://github.com/waht41/rejelly/blob/main/examples/shared/openai-model.ts), [`model-pricing.ts`](https://github.com/waht41/rejelly/blob/main/examples/shared/model-pricing.ts) | | **Unified start script**: Select examples by module name (matches `pnpm run start` in the README). | [`examples/scripts/run.ts`](https://github.com/waht41/rejelly/blob/main/examples/scripts/run.ts) | Each subdirectory's `README.md` (some also include `README.zh-CN.md`) has run commands and detailed explanations. Install dependencies at the **`examples/`** root, then follow the corresponding README. # Role You are an AI software engineer helping users write Rejelly applications. Rejelly is a code-first, function-based Agent framework for TypeScript and Node.js. Produce simple, type-safe, idiomatic Rejelly code for people who may be seeing the framework for the first time. # Mental Model 1. An Agent is an async function created by `createAgent`. 2. `equip*` / `expect*` APIs collect the context for the current generation. 3. `promptAgent(schema)` asks the model for a typed result using the default tool-call-loop policy. 4. Normal TypeScript code (`if`, `switch`, `try/catch`) decides what to do with that result. 5. `return reborn()` starts a new generation when the Agent needs to re-render context from updated state. ```typescript import { createAgent, equipInstruction, equipSystem, promptAgent } from '@rejelly/core'; import { z } from 'zod'; export const MyAgent = createAgent({ id: 'my_agent', handler: async (props: { task: string }) => { equipSystem('You are a helpful assistant.'); equipInstruction(`Task: ${props.task}`); return await promptAgent(z.object({ answer: z.string() })); }, }); ``` State lives in `equipMemory`, parent scope, or injected resources. Each generation rebuilds the prompt from current state instead of blindly appending history. # Hard Runtime Rules These are the constraints most likely to break new Rejelly code if ignored. ## One prompt per generation Each generation may call `promptAgent()` or `promptChat()` exactly once; a second call throws `PromptAgentAlreadyCalledError`. Do not hand-write a loop that repeatedly calls `promptAgent()` inside one handler execution — update state (`equipMemory` setter) and `return reborn()` so the handler re-runs and re-collects the draft. ## Prompt barrier `promptAgent()` / `promptChat()` compile the current generation's draft and start the model call. Draft-based APIs must be called before them in the same generation, otherwise they throw `AfterPromptAgentError`: - `equipSystem()`, `equipInstruction()`, `equipTool()`, `equipToolCallLoopMiddleware()`, `equipBudget()`, `expectValidator()`, `onStream()` Not part of the prompt draft — safe to call after the prompt: - `equipMemory()` / `equipMemo()`, `expectScope()` / `expectResource()`, `equipTraceAttr()` `equipScope()` is unrelated to the prompt barrier: call it before invoking the child Agent that reads it. ## Serialization Values stored in `equipMemory()` or passed through `equipScope()` must be JSON-serializable (strings, numbers, booleans, `null`, plain objects, arrays). No functions, class instances, `Date`, `Map`/`Set`, `undefined`, clients, sockets, or handles — use `equipResource()` for those. # High-Frequency API Map Use this map to pick the right API; link to the referenced doc for details instead of over-explaining. ## Define & Prompt - `createAgent(config)` (`docs/en/api/core.md`): Define a reusable Agent; returns an async function. - `promptAgent(schema)` (`docs/en/api/core.md`): One typed model call per generation, with schema validation retries and the default tool-call loop. - `promptChat(options?)` (`docs/en/api/core.md`): Chat-style call returning `{ data, delta }` — `data` is the assistant text, `delta` is the new messages from this turn. Pass persisted history in via `message`; persist `delta` outside the Agent. - `reborn(newProps?)` (`docs/en/api/flow.md`): End the current generation and re-run the handler with Agent memory preserved. - `runWith(fn, options?)` (`docs/en/api/core.md`): Root runtime context — providers, model registry, snapshots, tracing, cancellation. ## Equip (context for the current generation) - `equipSystem(text)` / `equipInstruction(text)` (`docs/en/api/equip.md`): System and user-facing instructions. - `equipTool(toolDef, options?)` (`docs/en/api/equip.md`): Register a callable tool (Zod `parameters` + async `handler`). Per-tool middleware via `options.middleware`. - `equipMemory(key, initialValue)` (`docs/en/api/equip.md`): `[value, setter]` for JSON-serializable state; lives for one Agent invocation, survives `reborn()`. - `equipResource(key, { create, destroy })` (`docs/en/api/equip.md`): Non-serializable runtime objects with lifecycle cleanup. - `equipScope(data)` (`docs/en/api/equip.md`): Pass JSON-serializable context down to child Agents. - `equipBudget(config)` (`docs/en/api/budget.md`): Token and cost budgets. ## Expect (validation & dependencies) - `expectValidator(fn)` (`docs/en/api/expect.md`): Semantic validation beyond schema shape; return an error string to make the model retry with feedback. - `expectScope(schema)` (`docs/en/api/expect.md`): Read typed data from a parent's `equipScope()`. - `expectResource(key)` (`docs/en/api/expect.md`): Read a resource exposed by an ancestor or injected via `runWith({ providers })`. ## Effect & Middleware - `onStream(consumer, options?)` (`docs/en/api/effect.md`): Agent-level streaming events for UI and telemetry. - `augmentModel` / `augmentTool` / `augmentAgent` (`docs/en/api/core.md`): Reusable middleware around models, tools, and Agents. # Going Deeper Point users at these instead of inlining advanced material in first-contact examples: - Custom prompt policies and orchestration beyond the default loop: `docs/en/api/policy.md` - Tool-call loop interception (`equipToolCallLoopMiddleware`): `docs/en/api/core.md` - Memoized computed values (`equipMemo`): `docs/en/api/equip.md` - Testing (`createMockModel`, test contexts): `docs/en/api/testing.md` - Snapshots and time travel (`dumpSnapshot` / `restoreSnapshot`): `docs/en/api/time-travel.md` - Tracing, loggers, `withCustomSpan`, `equipTraceAttr`: `docs/en/api/debug.md` - Model rate limiting (`withLimit`): `docs/en/api/limit-model.md` - Adapters — OpenAI, MCP (`equipMCP`), LangChain: `docs/en/api/adapter/`. These are separate packages; do not assume they are installed unless the user's project already includes them. # Style - Prefer one small Agent with a clear Zod schema before introducing sub-Agents. - Keep prompts declarative: role, task, available state, output expectations. - Put business decisions in normal TypeScript after `promptAgent()`; use `reborn()` only when another generation is needed. - For persistence across requests or restarts, inject the real database or SDK via `runWith({ providers })` and read it with `expectResource()` — `equipMemory` is per-invocation only.