# Instructor for PHP > Structured data extraction in PHP, powered by LLMs. Define a PHP class, get a validated object back. This file contains the complete documentation for Instructor for PHP. It is optimized for LLM consumption and includes all documentation pages concatenated into a single file. ================================================================================ FILE: index.md ================================================================================ Instructor for PHP is a lightweight library that makes it easy to get structured outputs from Large Language Models (LLMs). Built on top of modern PHP 8.3+ features, it provides a simple, type-safe way to work with AI models. ## Key Features - **Type Safety**: Full PHP 8.3+ type system support with strict typing - **Multiple LLM Support**: Works with OpenAI, Anthropic, Gemini, Cohere, and more - **Validation**: Built-in validation with custom rules and LLM-powered validation - **Streaming**: Real-time partial object updates for better UX - **Function Calling**: Native support for LLM function/tool calling - **Zero Dependencies**: Clean, lightweight implementation ## Quick Example ```php withResponseClass(Person::class) ->withMessages($text) ->get(); echo $person->name; // "Jason" echo $person->age; // 25 echo $person->occupation; // "software engineer" ``` ## Getting Started Choose your path: - **[Quick Start](/packages/instructor/quickstart)** - Get up and running in 5 minutes - **[Setup Guide](/packages/instructor/setup)** - Detailed installation and configuration - **[Cookbook](/cookbook/introduction)** - Practical examples and recipes ## Architecture This project consists of several modular packages: - **[Instructor](/packages/instructor/introduction)** - Main structured output library - **[Polyglot](/packages/polyglot/overview)** - Low-level LLM abstraction layer - **[HTTP Client](/packages/http/1-overview)** - Flexible HTTP client for API calls - **[HTTP Pool](/packages/http/6-pooling)** - Concurrent request execution for fan-out workloads ## Community - **GitHub**: [cognesy/instructor-php](https://github.com/cognesy/instructor-php) - **Issues**: [Report bugs or request features](https://github.com/cognesy/instructor-php/issues) - **Discussions**: [Join the conversation](https://github.com/cognesy/instructor-php/discussions) --- *Instructor for PHP - Making AI outputs predictable and type-safe.* ================================================================================ FILE: getting-started.md ================================================================================ Get structured data from LLMs in under 5 minutes. ## Prerequisites - PHP 8.3 or higher - Composer - An API key from any [supported LLM provider](/packages/instructor/misc/llm_providers) ## Installation ```bash composer require cognesy/instructor-struct ``` ## Configuration Create a `.env` file in your project root with your API key: ```bash # For OpenAI (default) OPENAI_API_KEY=sk-your-api-key-here # Or for other providers ANTHROPIC_API_KEY=your-key GEMINI_API_KEY=your-key GROQ_API_KEY=your-key ``` ## Your First Extraction ### Step 1: Define Your Data Structure Create a PHP class that represents the data you want to extract: ```php withResponseClass(Movie::class) ->withMessages($text) ->get(); echo $movie->title; // "The Matrix" echo $movie->year; // 1999 echo $movie->director; // "The Wachowskis" print_r($movie->genres); // ["science fiction"] ``` ### Step 3: Add Validation (Optional) Use Symfony Validator attributes for automatic validation: ```php withResponseClass(Movie::class) ->withMessages($text) ->get(); // Anthropic Claude $result = StructuredOutput::using('anthropic')->withResponseClass(Movie::class) ->withMessages($text) ->get(); // Google Gemini $result = StructuredOutput::using('gemini')->withResponseClass(Movie::class) ->withMessages($text) ->get(); // Local Ollama $result = StructuredOutput::using('ollama')->withResponseClass(Movie::class) ->withMessages($text) ->get(); ``` ## Processing Images Extract data from images using vision-capable models: ```php withResponseClass(Receipt::class) ->with( messages: Image::fromFile('path/to/receipt.jpg')->toMessage(), prompt: "Extract all information from this receipt", ) ->get(); echo $receipt->vendor; // "Whole Foods" echo $receipt->total; // 47.23 ``` ## Streaming Responses Get partial results as they arrive: ```php withResponseClass(Movie::class) ->with( messages: $text, options: ['stream' => true] ) ->stream(); foreach ($stream->partials() as $partial) { echo "Title so far: " . ($partial->title ?? 'loading...') . "\n"; } $movie = $stream->finalValue(); ``` ## Next Steps You now have the basics. Here's where to go next: | Goal | Resource | |------|----------| | Learn core concepts | [Why Instructor](why-instructor) | | See practical examples | [Cookbook](/cookbook/introduction) | | Explore all features | [Features Overview](features) | | Configure providers | [LLM Providers](/packages/instructor/misc/llm_providers) | | Advanced validation | [Validation Guide](/packages/instructor/essentials/validation) | ## Common Patterns ### Extract Multiple Items ```php withResponseClass(Sequence::of(Movie::class)) ->withMessages("List the top 3 Nolan films") ->get(); // $movies is iterable and has array-like access foreach ($movies as $movie) { echo $movie->title; } ``` ### Add Context with System Messages ```php withResponseClass(Movie::class) ->withMessages([ ['role' => 'system', 'content' => 'You are a film expert. Be precise with dates.'], ['role' => 'user', 'content' => $text] ]) ->get(); ``` ### Set Max Retries ```php withResponseClass(Movie::class) ->withMessages($text) ->withMaxRetries(3) ->get(); ``` --- **Need help?** Check out the [Cookbook](/cookbook/introduction) for 60+ working examples, or [open an issue](https://github.com/cognesy/instructor-php/issues) on GitHub. ================================================================================ FILE: features.md ================================================================================ A comprehensive overview of Instructor's capabilities. ## Core Features ### Structured Output Extraction Define a PHP class, get a populated object back: ```php withResponseClass(Person::class) ->withMessages("Extract: Sarah, 32, software architect") ->get(); ``` **Key capabilities:** - Works with any PHP class with typed properties - Supports nested objects and arrays - Handles nullable fields gracefully - Preserves type information throughout ### Automatic Validation Built-in support for Symfony Validator: ```php withMaxRetries(3); $result = (new StructuredOutput($runtime)) ->withResponseClass(User::class) ->withMessages($text) ->get(); ``` **Retry behavior:** 1. LLM generates response 2. Response validated against constraints 3. On failure: errors sent back to LLM with context 4. LLM attempts correction 5. Repeat until valid or max retries reached --- ## Input Flexibility ### Text Input Simple string input: ```php withMessages("John is 25 years old and works at Acme Corp"); ``` ### Chat Messages OpenAI-style message arrays: ```php withMessages([ ['role' => 'system', 'content' => 'You are a data extraction expert.'], ['role' => 'user', 'content' => 'Extract the person: John, 25, engineer'] ]); ``` ### Image Input Process images with vision-capable models: ```php with(messages: Image::fromFile('path/to/image.jpg')->toMessage()) ->withPrompt("Extract all text from this document"); ``` **Supported formats:** JPEG, PNG, GIF, WebP ### Structured Input Pass objects or arrays as input: ```php $documentText, 'metadata' => ['source' => 'email', 'date' => '2024-01-15'] ]; $result = (new StructuredOutput) ->withResponseClass(Analysis::class) ->withInput($inputData) ->get(); ``` --- ## Output Modes ### Tools Mode (Default) Uses LLM function/tool calling: ```php withOutputMode(OutputMode::Tools); ``` Best for: OpenAI, Anthropic, most modern models ### JSON Schema Mode Strict schema enforcement: ```php withOutputMode(OutputMode::JsonSchema); ``` Best for: GPT-4, models with strict JSON Schema support ### JSON Mode Basic JSON response format: ```php withOutputMode(OutputMode::Json); ``` Best for: Models supporting JSON mode without strict schemas ### Markdown JSON Mode Prompting-based extraction: ```php withOutputMode(OutputMode::MdJson); ``` Best for: Models without JSON mode, fallback option --- ## Response Types ### Single Object ```php withResponseClass(Person::class) ->get(); ``` ### Arrays of Objects Use `Sequence::of()` to extract lists: ```php withResponseClass(Sequence::of(Person::class)) ->withMessages($text) ->get(); // Iterate over results foreach ($people as $person) { echo $person->name; } // Or use array-like access $first = $people->first(); $count = $people->count(); $all = $people->toArray(); ``` ### Scalar Values Extract simple types with adapters: ```php withResponseClass(Scalar::boolean('isSpam')) ->get(); // Integer $count = (new StructuredOutput) ->withResponseClass(Scalar::integer('count')) ->get(); // String $summary = (new StructuredOutput) ->withResponseClass(Scalar::string('summary')) ->get(); ``` ### Enums ```php withResponseClass(Scalar::enum(Sentiment::class, 'sentiment')) ->get(); ``` --- ## Streaming ### Partial Updates Get incremental results as they arrive: ```php withResponseClass(Article::class) ->with( messages: $text, options: ['stream' => true] ) ->stream(); foreach ($stream->partials() as $partial) { // $partial has incrementally populated fields updateUI($partial); } $final = $stream->finalValue(); ``` Or subscribe to streaming events: ```php onEvent(PartialResponseGenerated::class, fn(PartialResponseGenerated $event) => updateUI($event->partialResponse)); $stream = (new StructuredOutput) ->withRuntime($runtime) ->with( responseModel: Article::class, messages: $text, options: ['stream' => true], ) ->stream(); $article = $stream->finalValue(); ``` ### Sequence Streaming Stream sequence items as they complete: ```php withResponseClass(Sequence::of(Person::class)) ->with( messages: $text, options: ['stream' => true], ) ->stream(); foreach ($list->sequence() as $seq) { processComplete($seq->last()); } $final = $list->finalValue(); ``` --- ## LLM Providers ### Supported Providers | Provider | API Type | Streaming | Vision | Tool Calling | |----------|----------|:---------:|:------:|:------------:| | OpenAI | Native | ✓ | ✓ | ✓ | | Anthropic | Native | ✓ | ✓ | ✓ | | Google Gemini | Native | ✓ | ✓ | ✓ | | Azure OpenAI | OpenAI-compatible | ✓ | ✓ | ✓ | | Mistral | Native | ✓ | - | ✓ | | Cohere | OpenAI-compatible | ✓ | - | ✓ | | Groq | OpenAI-compatible | ✓ | - | ✓ | | Fireworks AI | OpenAI-compatible | ✓ | ✓ | ✓ | | Together AI | OpenAI-compatible | ✓ | ✓ | ✓ | | Ollama | OpenAI-compatible | ✓ | ✓ | ✓ | | OpenRouter | OpenAI-compatible | ✓ | ✓ | ✓ | | Perplexity | OpenAI-compatible | ✓ | - | - | | DeepSeek | OpenAI-compatible | ✓ | - | ✓ | | xAI (Grok) | OpenAI-compatible | ✓ | - | ✓ | | Cerebras | OpenAI-compatible | ✓ | - | ✓ | | SambaNova | OpenAI-compatible | ✓ | - | ✓ | ### Provider Selection ```php withRuntime( StructuredOutputRuntime::fromConfig( \Cognesy\Polyglot\Inference\Config\LLMConfig::fromDsn('preset=anthropic,model=claude-3-5-sonnet-latest') ) ); ``` --- ## Schema Definition ### Type-Hinted Classes ```php string('name') ->int('age', required: false) ->collection('tags', 'string', required: false) ->build(); $result = (new StructuredOutput) ->with( messages: 'Extract user profile from this text...', responseModel: $schema, ) ->get(); ``` --- ## Advanced Features ### Context Caching Reduce costs with cached context (Anthropic): ```php withCachedContext([ 'Large document or context here...', 'This won\'t be re-sent on retries' ]) ``` ### Custom Prompts Override default extraction prompts: ```php withPrompt("Extract the following fields precisely: ...") ->withConfig(new StructuredOutputConfig( retryPrompt: "The previous attempt had errors: {errors}. Please correct." )) ``` ### Event System Monitor internal processing: ```php onEvent(StructuredOutputRequestReceived::class, function($event) { logger()->info('Request received', $event->toArray()); }); $runtime->onEvent(StructuredOutputResponseGenerated::class, function($event) { logger()->info('Response generated', $event->toArray()); }); ``` ### Debug Mode See all LLM interactions: ```php wiretap(fn($event) => logger()->debug((string) $event)); ``` Outputs: - Full request payloads - Raw LLM responses - Validation errors - Retry attempts --- ## Framework Integration ### Laravel ```php with( messages: $text, responseModel: Person::class, )->get(); ``` ```php with(messages: $text, responseModel: Person::class) ->get(); } } ``` ### Symfony ```yaml # services.yaml services: Cognesy\Instructor\StructuredOutput: autowire: true ``` ```php with(messages: $text, responseModel: Person::class)->get(); return $this->json($result); } } ``` ### Standalone ```php withResponseClass(Person::class) ->withMessages($text) ->get(); ``` --- ## Observability ### Token Usage ```php withResponseClass(Person::class) ->withMessages($text) ->getResponse(); echo $response->usage->inputTokens; echo $response->usage->outputTokens; echo $response->usage->totalTokens; ``` ### Timing ```php timing->total; // Total processing time ``` ### Event-Based Logging ```php onEvent('*', function($event) { $logger->log($event->name(), $event->toArray()); }); ``` --- ## What's Next - **[Getting Started](getting-started)** - Quick installation guide - **[Why Instructor](why-instructor)** - Understanding the value proposition - **[Use Cases](use-cases)** - Industry-specific examples - **[Cookbook](/cookbook/introduction)** - 60+ working examples - **[API Reference](/packages/instructor/introduction)** - Complete documentation ================================================================================ FILE: packages.md ================================================================================ ## Start Here **Most PHP developers need just one package:** ```bash composer require cognesy/instructor-struct ``` This gives you everything: structured output extraction, validation, retries, and support for all major LLM providers. You're ready to go. --- ## When You Need More Control Instructor is built on a modular architecture. If you need to work at a lower level or integrate with specific frameworks, these packages are available separately. ### The Stack ![Instructor Stack](images/instructor-diagram.png) --- ## Package Details ### Instructor **The main package. Start here.** Structured data extraction powered by LLMs. Define a PHP class with typed properties, pass it to Instructor with some text, get a validated object back. ```php withResponseClass(Person::class) ->withMessages("John is 25 years old") ->get(); // $person->name = "John" // $person->age = 25 ``` **Why use it:** - Type-safe outputs (your IDE understands the response) - Automatic validation with Symfony Validator - Self-correcting retries (LLM gets feedback on errors) - Works with any provider through Polyglot [**→ Instructor Documentation**](/packages/instructor/introduction) --- ### Polyglot **Use this when you need direct LLM access without structured extraction.** A unified interface for LLM providers. Write code once, run it against any provider. Useful when you're building chat interfaces, agents, or need raw completions. ```php using('anthropic')->chat("Explain PHP generators"); // Switch providers with one line $response = (new LLM)->using('openai')->chat("Explain PHP generators"); $response = (new LLM)->using('gemini')->chat("Explain PHP generators"); ``` **Why use it:** - Same code works with 20+ providers - No vendor lock-in - Streaming, embeddings, tool calling - Test with cheap/fast models, deploy with powerful ones [**→ Polyglot Documentation**](/packages/polyglot/overview) --- ### HTTP Client **Use this when you need low-level HTTP control.** The HTTP layer that powers Polyglot. Most developers never touch this directly, but it's available if you need custom HTTP handling, middleware, or want to build your own LLM integrations. ```php handle($request); // Streaming responses foreach ($client->stream($request) as $chunk) { echo $chunk; } ``` **Why use it:** - Streaming-first design - Middleware pipeline - Multiple backends - Single-request transport only [**→ HTTP Client Documentation**](/packages/http/1-overview) --- ### HTTP Pool **Use this when you need concurrent request execution.** `http-pool` handles fan-out workloads. It uses the same request and response objects as `http-client`, but the execution model is separate and focused on batching. ```php pool($requests, maxConcurrent: 4); ``` **Why use it:** - Concurrent request execution - Typed request and response collections - Separate from single-request transport [**→ HTTP Pool Documentation**](/packages/http/6-pooling) --- ### Laravel Integration **Use this if you're building with Laravel.** Adds Laravel-specific conveniences: service provider, facades, config publishing, and testing fakes. ```php with( messages: $text, responseModel: Person::class, )->get(); ``` ```php with(messages: $text, responseModel: Person::class) ->get(); } } ``` **Why use it:** - Auto-discovery (just install and use) - Laravel-style configuration - Testing fakes for unit tests - Integrates with Laravel's logging [**→ Laravel Documentation**](/packages/laravel/installation) --- ## Internal Packages These packages are used internally by Instructor and Polyglot. They're not meant for direct use, but they're available if you're extending the library or curious about the architecture. | Package | Purpose | |---------|---------| | `addons` | Optional extensions (image handling, web scraping, agents) | | `schema` | PHP class → JSON Schema conversion | | `messages` | Message/conversation handling | | `events` | Internal event system | | `config` | Configuration management | | `evals` | LLM evaluation tools | | `metrics` | Usage tracking and observability | | `templates` | Prompt templating | | `stream` | Stream processing utilities | --- ## Quick Decision Guide | I want to... | Use this | |--------------|----------| | Extract structured data from text | **Instructor** | | Extract data from images | **Instructor** | | Build a chatbot or agent | **Polyglot** | | Switch between LLM providers easily | **Polyglot** (or Instructor, which includes it) | | Use Instructor in Laravel | **Instructor** + **Laravel** package | | Build custom LLM integrations | **HTTP Client** + **Polyglot** | | Just get started quickly | **Instructor** (includes everything) | --- ## Installation ```bash # Most developers - get everything composer require cognesy/instructor-struct # Direct LLM access only (no structured extraction) composer require cognesy/polyglot # Laravel integration composer require cognesy/instructor-laravel # Low-level HTTP only composer require cognesy/http-client ``` ================================================================================ FILE: use-cases.md ================================================================================ Instructor powers structured data extraction across industries. Here's how teams are using it. ## E-Commerce ### Product Data Enrichment Transform sparse product listings into rich, searchable data: ```php withResponseClass(ProductEnrichment::class) ->withMessages("Blue cotton t-shirt, size M, machine washable") ->get(); ``` ### Review Analysis Extract structured insights from customer reviews: ```php chat([ 'messages' => [['role' => 'user', 'content' => 'Extract the person name and age from: "John is 25"']] ]); $text = $response['choices'][0]['message']['content']; // $text = "The person's name is John and they are 25 years old." // or "Name: John, Age: 25" // or "{ name: 'John', age: 25 }" // or something else entirely... // Now you need to: // 1. Parse this somehow // 2. Handle all possible formats // 3. Validate the data // 4. Handle errors // 5. Retry on failure // 6. Hope it works ``` **The result?** Fragile code, inconsistent data, and endless edge cases. ## The Solution Instructor gives you **structured, validated, type-safe outputs**: ```php withResponseClass(Person::class) ->withMessages('John is 25') ->get(); // Always a Person object // Always with string $name // Always with int $age // Validated automatically // Retries on failure ``` ## How It Works Instructor uses a three-step process: ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Define │ ──▶ │ Extract │ ──▶ │ Validate │ │ PHP Class │ │ via LLM │ │ & Return │ └─────────────┘ └─────────────┘ └─────────────┘ ``` 1. **Define** - You create a PHP class with typed properties 2. **Extract** - Instructor sends your schema to the LLM with optimized prompts 3. **Validate** - Results are validated; failures trigger automatic retry with feedback ## Key Benefits ### 1. Type Safety Your IDE understands the response. Autocomplete works. Static analysis catches errors. ```php withResponseClass(Person::class)->get(); // IDE knows $person->name is a string // IDE knows $person->age is an int // Typos like $person->naem are caught immediately ``` ### 2. Automatic Validation Use Symfony Validator constraints. Invalid responses trigger automatic retry: ```php withResponseClass(Person::class) ->withMessages($text) ->withMaxRetries(3) // Try up to 3 times ->get(); ``` On validation failure, Instructor tells the LLM exactly what went wrong: ``` "Validation failed: age must be greater than 0. Please correct and try again." ``` ### 4. Provider Independence Write once, run anywhere. Switch LLM providers without changing your code: ```php withResponseClass(Task::class)->get(); // Staging: Use Groq for speed $result = StructuredOutput::using('groq')->withResponseClass(Task::class)->get(); // Production: Use OpenAI for quality $result = StructuredOutput::using('openai')->withResponseClass(Task::class)->get(); ``` ### 5. Multiple Output Modes Works with any model capability: | Mode | Best For | How It Works | |------|----------|--------------| | `Tools` | OpenAI, Claude | Uses function/tool calling | | `JsonSchema` | GPT-4, newer models | Strict JSON Schema mode | | `Json` | Most models | JSON response format | | `MdJson` | Any model | Prompting-based extraction | ### 6. Streaming Support Get partial results as they arrive: ```php withResponseClass(Person::class) ->with(messages: $text, options: ['stream' => true]) ->stream(); foreach ($stream->partials() as $partial) { echo "Processing: " . ($partial->name ?? '...') . "\n"; } $person = $stream->finalValue(); ``` ### 7. Multimodal Inputs Process text, images, and chat conversations with the same API: ```php withMessages("Extract from this text...") // Images ->with(messages: Image::fromFile('receipt.jpg')->toMessage()) ->withPrompt("Extract line items") // Chat history ->withMessages([ ['role' => 'system', 'content' => 'You extract data'], ['role' => 'user', 'content' => 'Process this...'] ]) ``` ## Comparison ### Without Instructor ```php chat(['messages' => [...]]); $json = json_decode($response['choices'][0]['message']['content'], true); if (json_last_error() !== JSON_ERROR_NONE) { // Handle JSON parse error // Try to extract with regex? // Log and retry? } if (!isset($json['name']) || !is_string($json['name'])) { // Handle missing/invalid field } if (!isset($json['age']) || !is_int($json['age'])) { // Handle missing/invalid field } if ($json['age'] < 0) { // Handle validation error // Retry somehow? } $person = new Person(); $person->name = $json['name']; $person->age = $json['age']; ``` ### With Instructor ```php withResponseClass(Person::class) ->withMessages($text) ->get(); ``` **Same result. Zero boilerplate.** ## Why Not Just Use JSON Mode / JSON Schema? "But OpenAI has `response_format: json_object` and strict JSON Schema mode now. Why do I need Instructor?" Good question. Here's what you're still stuck with: ### 1. Provider Inconsistency Every provider does it differently: | Provider | JSON Mode | JSON Schema | Tool Calling | |----------|:---------:|:-----------:|:------------:| | OpenAI | `response_format: {type: "json_object"}` | `response_format: {type: "json_schema", ...}` | Yes | | Anthropic | ❌ No native support | ❌ No native support | Yes (different format) | | Gemini | Different API entirely | Different API entirely | Yes (different format) | | Mistral | Partial support | No | Yes | | Ollama | Model-dependent | Model-dependent | Model-dependent | **With raw APIs:** You write different code for each provider. **With Instructor:** One API. Instructor picks the best extraction method automatically. ```php withResponseClass(Person::class) ->get(); ``` ### 2. No Object Hydration JSON Schema gives you... JSON. Not objects. ```php chat([ 'messages' => [...], 'response_format' => [ 'type' => 'json_schema', 'json_schema' => [ 'name' => 'person', 'schema' => [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string'], 'age' => ['type' => 'integer'], ], 'required' => ['name', 'age'], ], ], ], ]); $json = json_decode($response['choices'][0]['message']['content'], true); // $json = ['name' => 'John', 'age' => 25] // Now you manually hydrate: $person = new Person(); $person->name = $json['name']; $person->age = $json['age']; // For nested objects? More manual work. // For arrays of objects? Even more. ``` **With Instructor:** Direct to typed objects, including nested structures. ```php withResponseClass(Person::class) ->get(); // $person is already a Person object ``` ### 3. Schema Definition Hell JSON Schema is verbose and lives separately from your code: ```php 'object', 'properties' => [ 'name' => [ 'type' => 'string', 'description' => 'The person\'s full name', 'minLength' => 1, ], 'age' => [ 'type' => 'integer', 'description' => 'Age in years', 'minimum' => 0, 'maximum' => 150, ], 'email' => [ 'type' => 'string', 'format' => 'email', 'description' => 'Contact email', ], ], 'required' => ['name', 'age'], 'additionalProperties' => false, ]; ``` **With Instructor:** Your PHP class IS the schema. ```php chat([...]); $json = json_decode($response['choices'][0]['message']['content'], true); } catch (Exception $e) { // Now what? // Retry with same prompt? Probably same error. // Modify the prompt? How? // Log and give up? } ``` **With Instructor:** Automatic retry with error feedback. ```php withResponseClass(Person::class) ->withMaxRetries(3) ->get(); // On failure, Instructor tells the LLM: // "Validation failed: 'age' must be positive. You returned -5. Please correct." // LLM tries again with that context. ``` ### 6. No Streaming Support for Structured Data JSON Schema mode gives you complete-or-nothing: ```php withResponseClass(Person::class) ->with(messages: $text, options: ['stream' => true]) ->stream(); foreach ($stream->partials() as $partial) { updateUI($partial); } $person = $stream->finalValue(); ``` ### 7. Anthropic Doesn't Have JSON Mode Claude is one of the best models, but Anthropic has no native JSON mode: ```php messages([ 'response_format' => ['type' => 'json_object'], // ❌ Not supported ]); // You're stuck with: // - Prompt engineering ("respond only in JSON...") // - Hoping it complies // - Parsing whatever comes back ``` **With Instructor:** Works seamlessly with Claude. ```php withResponseClass(Person::class) ->get(); // Instructor uses tool calling or optimized prompts automatically ``` ### 8. The Real-World Comparison | Capability | Raw JSON/JSON Schema | Instructor | |------------|:--------------------:|:----------:| | Works with all providers | ❌ Different APIs | ✅ Unified | | Object hydration | ❌ Manual | ✅ Automatic | | Nested objects | ❌ Manual recursion | ✅ Automatic | | Business validation | ❌ None | ✅ Full | | Retry on failure | ❌ Manual | ✅ Automatic | | Error feedback to LLM | ❌ None | ✅ Built-in | | Streaming partials | ❌ Not possible | ✅ Supported | | Type safety in IDE | ❌ None | ✅ Full | | Schema = Code | ❌ Separate | ✅ Same file | | Works with Claude | ❌ No JSON mode | ✅ Yes | ### The Bottom Line JSON Schema mode is a step forward, but it's a **low-level primitive**. You still need to: - Write provider-specific code - Manually deserialize to objects - Implement your own validation - Build your own retry logic - Handle streaming yourself - Maintain schemas separate from code Instructor handles all of this. You define a PHP class and call `->get()`. ## When to Use Instructor **Great for:** - Extracting structured data from unstructured text - Building forms that accept natural language - Processing documents (invoices, resumes, contracts) - Content classification and tagging - Data transformation pipelines - Any task requiring reliable LLM output structure **Not designed for:** - Open-ended creative writing - Tasks where free-form text is the desired output - Simple completions without structure requirements ## The Instructor Family Instructor exists in multiple languages with consistent APIs: | Language | Repository | |----------|------------| | **PHP** (this) | [cognesy/instructor-php](https://github.com/cognesy/instructor-php) | | Python (original) | [jxnl/instructor](https://github.com/jxnl/instructor) | | JavaScript | [instructor-ai/instructor-js](https://github.com/instructor-ai/instructor-js) | | Elixir | [instructor-ai/instructor-ex](https://github.com/instructor-ai/instructor-ex) | | Ruby | [instructor-ai/instructor-rb](https://github.com/instructor-ai/instructor-rb) | --- **Ready to get started?** Jump to the [Getting Started Guide](getting-started) or explore the [Cookbook](/cookbook/introduction) for practical examples. ================================================================================ FILE: packages/index.md ================================================================================ ## Start Here **Most PHP developers need just one package:** ```bash composer require cognesy/instructor-struct ``` This gives you everything: structured output extraction, validation, retries, and support for all major LLM providers. You're ready to go. --- ## When You Need More Control Instructor is built on a modular architecture. If you need to work at a lower level or integrate with specific frameworks, these packages are available separately. ### The Stack ![Instructor Stack](images/instructor-diagram.png) --- ## Package Details ### Instructor **The main package. Start here.** Structured data extraction powered by LLMs. Define a PHP class with typed properties, pass it to Instructor with some text, get a validated object back. ```php withResponseClass(Person::class) ->withMessages("John is 25 years old") ->get(); // $person->name = "John" // $person->age = 25 ``` **Why use it:** - Type-safe outputs (your IDE understands the response) - Automatic validation with Symfony Validator - Self-correcting retries (LLM gets feedback on errors) - Works with any provider through Polyglot [**→ Instructor Documentation**](/packages/instructor/introduction) --- ### Polyglot **Use this when you need direct LLM access without structured extraction.** A unified interface for LLM providers. Write code once, run it against any provider. Useful when you're building chat interfaces, agents, or need raw completions. ```php using('anthropic')->chat("Explain PHP generators"); // Switch providers with one line $response = (new LLM)->using('openai')->chat("Explain PHP generators"); $response = (new LLM)->using('gemini')->chat("Explain PHP generators"); ``` **Why use it:** - Same code works with 20+ providers - No vendor lock-in - Streaming, embeddings, tool calling - Test with cheap/fast models, deploy with powerful ones [**→ Polyglot Documentation**](/packages/polyglot/overview) --- ### HTTP Client **Use this when you need low-level HTTP control.** The HTTP layer that powers Polyglot. Most developers never touch this directly, but it's available if you need custom HTTP handling, middleware, or want to build your own LLM integrations. ```php handle($request); // Streaming responses foreach ($client->stream($request) as $chunk) { echo $chunk; } ``` **Why use it:** - Streaming-first design - Middleware pipeline - Multiple backends - Single-request transport only [**→ HTTP Client Documentation**](/packages/http/1-overview) --- ### HTTP Pool **Use this when you need concurrent request execution.** `http-pool` handles fan-out workloads. It uses the same request and response objects as `http-client`, but the execution model is separate and focused on batching. ```php pool($requests, maxConcurrent: 4); ``` **Why use it:** - Concurrent request execution - Typed request and response collections - Separate from single-request transport [**→ HTTP Pool Documentation**](/packages/http/6-pooling) --- ### Laravel Integration **Use this if you're building with Laravel.** Adds Laravel-specific conveniences: service provider, facades, config publishing, and testing fakes. ```php with( messages: $text, responseModel: Person::class, )->get(); ``` ```php with(messages: $text, responseModel: Person::class) ->get(); } } ``` **Why use it:** - Auto-discovery (just install and use) - Laravel-style configuration - Testing fakes for unit tests - Integrates with Laravel's logging [**→ Laravel Documentation**](/packages/laravel/installation) --- ## Internal Packages These packages are used internally by Instructor and Polyglot. They're not meant for direct use, but they're available if you're extending the library or curious about the architecture. | Package | Purpose | |---------|---------| | `addons` | Optional extensions (image handling, web scraping, agents) | | `schema` | PHP class → JSON Schema conversion | | `messages` | Message/conversation handling | | `events` | Internal event system | | `config` | Configuration management | | `evals` | LLM evaluation tools | | `metrics` | Usage tracking and observability | | `templates` | Prompt templating | | `stream` | Stream processing utilities | --- ## Quick Decision Guide | I want to... | Use this | |--------------|----------| | Extract structured data from text | **Instructor** | | Extract data from images | **Instructor** | | Build a chatbot or agent | **Polyglot** | | Switch between LLM providers easily | **Polyglot** (or Instructor, which includes it) | | Use Instructor in Laravel | **Instructor** + **Laravel** package | | Build custom LLM integrations | **HTTP Client** + **Polyglot** | | Just get started quickly | **Instructor** (includes everything) | --- ## Installation ```bash # Most developers - get everything composer require cognesy/instructor-struct # Direct LLM access only (no structured extraction) composer require cognesy/polyglot # Laravel integration composer require cognesy/instructor-laravel # Low-level HTTP only composer require cognesy/http-client ``` ================================================================================ FILE: packages/instructor/introduction.md ================================================================================ Instructor is a PHP library for extracting structured, validated data from LLM responses. You define the shape of the data you need using plain PHP classes, and Instructor handles the rest: schema generation, prompt construction, response parsing, validation, and automatic retries. The library is inspired by the [Instructor](https://jxnl.github.io/instructor/) library for Python created by [Jason Liu](https://twitter.com/jxnlco). ```php use Cognesy\Instructor\StructuredOutput; final class City { public string $name; public string $country; public int $population; } $city = StructuredOutput::using('openai') ->with( messages: 'What is the capital of France?', responseModel: City::class, ) ->get(); echo $city->name; // Paris echo $city->country; // France echo $city->population; // 2148000 // @doctest id="84a1" ``` The package is distributed as `cognesy/instructor-struct` and requires PHP 8.3+. ## Core Architecture Instructor's API is built around four types, each with a distinct responsibility: | Type | Role | |------|------| | `StructuredOutput` | Builds and executes a single request. Provides the primary developer-facing API. | | `StructuredOutputRuntime` | Holds provider configuration, retry policy, output mode, event listeners, and pipeline extensions. Reusable across requests. | | `PendingStructuredOutput` | A lazy handle returned by `create()`. Execution happens only when you call `get()`, `response()`, or `stream()`. | | `StructuredOutputStream` | Exposes streaming partial updates, sequence items, and the final response. | ## How It Works 1. **Define a response model.** Use a PHP class with typed public properties. Instructor generates a JSON Schema from the class and sends it to the LLM. 2. **Build a request.** Provide input messages, select a provider, and optionally customize the system prompt, examples, or model options. 3. **Read the result.** Call `get()` for the deserialized object, `stream()` for partial updates, or `response()` for the full response wrapper including raw LLM output. Under the hood, Instructor translates your response model into a schema the LLM understands, wraps it in the appropriate output mode (tool calls, JSON mode, or JSON Schema mode), and deserializes the response back into your PHP object. If the response fails validation, Instructor feeds the errors back to the LLM and retries automatically. ## Feature Highlights ### Structured Responses & Validation - Extract typed objects, arrays, or scalar values from LLM responses - Automatic validation of returned data using Symfony Validator constraints - Configurable retry policy when the LLM returns invalid data ### Flexible Inputs - Process text, chat message arrays, or images - Provide examples to improve extraction quality - Structure-to-structure processing: pass an object or array as input and receive a typed result ### Multiple Response Model Formats - **PHP classes** with typed properties and optional validation attributes - **JSON Schema arrays** for dynamic or runtime-defined shapes - **Scalar types** via built-in helpers (`getString()`, `getInt()`, `getBoolean()`, etc.) ### Sync & Streaming - Synchronous extraction with `get()` - Streaming partial updates with `stream()->partials()` - Streaming completed sequence items with `stream()->sequence()` ### Provider Support - Works with OpenAI, Anthropic, Google Gemini, Cohere, Azure OpenAI, Groq, Mistral, Fireworks AI, Ollama, OpenRouter, Together AI, and more - Switch providers by changing a single preset name or `LLMConfig` ### Observability - Fine-grained event system for monitoring every stage of the extraction pipeline - Wiretap support for logging and debugging ## Start Here - [Quickstart](quickstart) -- extract your first typed object in minutes - [Setup](setup) -- installation and provider configuration - [Usage](essentials/usage) -- the full request-building API - [Data Model](essentials/data_model) -- defining response models - [Validation](essentials/validation) -- validation rules and retry behavior - [Partials](advanced/partials) -- streaming partial updates ## Instructor in Other Languages Instructor has been implemented across multiple technology stacks: - [Python](https://www.github.com/jxnl/instructor) (original) - [JavaScript / TypeScript](https://github.com/instructor-ai/instructor-js) - [Elixir](https://github.com/thmsmlr/instructor_ex/) - [Ruby](https://ruby.useinstructor.com/) - [Go](https://go.useinstructor.com/) ================================================================================ FILE: packages/instructor/quickstart.md ================================================================================ This guide walks you through installing Instructor and running your first extraction. For detailed configuration options, see [Setup](setup). ## Installation Install the package via Composer: ```bash composer require cognesy/instructor-struct # @doctest id="8050" ``` > Instructor requires **PHP 8.3** or later. ## Your First Extraction ### Step 1: Set Your API Key Instructor needs credentials for the LLM provider you plan to use. The simplest approach is to export an environment variable before running your script: ```bash export OPENAI_API_KEY="sk-your-key-here" # @doctest id="52a3" ``` > In a real project, store API keys in a `.env` file or your framework's secret > manager. Never hard-code keys in source files. ### Step 2: Define a Response Model Create a PHP class with typed public properties. Instructor will generate a JSON Schema from this class and instruct the LLM to return data matching that shape: ```php class City { public string $name; public string $country; public int $population; } // @doctest id="2736" ``` ### Step 3: Run the Extraction Use `StructuredOutput` to send a request and receive a typed result: ```php with( messages: 'What is the capital of France?', responseModel: City::class, ) ->get(); echo $city->name; // Paris echo $city->country; // France echo $city->population; // 2148000 // @doctest id="6357" ``` The `get()` method returns a fully hydrated `City` instance. Public typed properties define the schema that Instructor sends to the model. ## Alternative API Styles Instructor offers several ways to build the same request. Choose whichever reads best in your codebase. ### Fluent Builder Chain individual `with*` methods for maximum readability: ```php $city = StructuredOutput::using('openai') ->withMessages('What is the capital of France?') ->withResponseClass(City::class) ->get(); // @doctest id="9fb6" ``` ### Compact `with()` Call Pass everything as named arguments to a single `with()` call: ```php $city = StructuredOutput::using('openai') ->with( messages: 'What is the capital of France?', responseModel: City::class, model: 'gpt-4o-mini', ) ->get(); // @doctest id="3c11" ``` ### Explicit Provider Configuration When you need full control over the provider and model, use `LLMConfig` directly: ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Polyglot\Inference\Config\LLMConfig; $city = StructuredOutput::fromConfig( LLMConfig::fromDsn('driver=openai,model=gpt-4o-mini') ) ->with( messages: 'What is the capital of France?', responseModel: City::class, ) ->get(); // @doctest id="c214" ``` ## Streaming Partial Updates For long extractions or real-time UIs, stream partial updates as the LLM generates its response: ```php $stream = StructuredOutput::using('openai') ->with( messages: 'What is the capital of France?', responseModel: City::class, ) ->stream(); foreach ($stream->partials() as $partial) { echo "Partial: " . ($partial->name ?? '...') . "\n"; } $city = $stream->finalValue(); echo $city->name; // Paris // @doctest id="9f00" ``` ## Adding Validation Add Symfony Validator constraints to your response model. If the LLM returns data that fails validation, Instructor will automatically retry with the error details: ```php use Symfony\Component\Validator\Constraints as Assert; class City { #[Assert\NotBlank] public string $name; #[Assert\NotBlank] public string $country; #[Assert\Positive] public int $population; } $city = StructuredOutput::using('openai') ->with( messages: 'What is the capital of France?', responseModel: City::class, ) ->get(); // @doctest id="2ca6" ``` To enable retries, configure `maxRetries` on the runtime. See [Validation](essentials/validation) for details. ## Choosing the Right Entry Point | Scenario | Entry Point | |----------|-------------| | Quick extraction with a preset | `StructuredOutput::using('openai')` | | Explicit provider/model control | `StructuredOutput::fromConfig(LLMConfig::fromDsn(...))` | | Retries, events, or custom pipeline | `StructuredOutputRuntime` | ## Next Steps - [Setup](setup) -- installation details and provider configuration - [Usage](essentials/usage) -- the full request-building API - [Data Model](essentials/data_model) -- defining response model classes - [Validation](essentials/validation) -- validation and retry behavior - [Modes](essentials/modes) -- output modes (tool calls, JSON, JSON Schema) ================================================================================ FILE: packages/instructor/setup.md ================================================================================ Getting started with Instructor requires two things: 1. Install the `cognesy/instructor-struct` package 2. Provide LLM provider credentials ## Installation ```bash composer require cognesy/instructor-struct # @doctest id="8c9e" ``` > Instructor requires **PHP 8.3** or later. ## Providing API Keys Instructor reads provider credentials from environment variables. The simplest approach is to set them in your shell or a `.env` file at the root of your project: ```ini # .env OPENAI_API_KEY=sk-your-key-here # @doctest id="56cd" ``` For other providers, set the corresponding variable: ```ini ANTHROPIC_API_KEY=your-key GEMINI_API_KEY=your-key GROQ_API_KEY=your-key MISTRAL_API_KEY=your-key # @doctest id="5f56" ``` > Never commit API keys to version control. Add `.env` to your `.gitignore` file. ## Preset-Based Setup Presets are the fastest way to get started. A preset name maps to a provider configuration that reads credentials from the environment: ```php use Cognesy\Instructor\StructuredOutput; $result = StructuredOutput::using('openai') ->with( messages: 'What is the capital of France?', responseModel: City::class, ) ->get(); // @doctest id="285f" ``` You can switch providers by changing the preset name: ```php // Use Anthropic instead of OpenAI $result = StructuredOutput::using('anthropic') ->with( messages: 'What is the capital of France?', responseModel: City::class, ) ->get(); // @doctest id="cc90" ``` ## Explicit Provider Configuration When you need full control over the driver, model, API base URL, or other connection parameters, use `LLMConfig` directly: ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Polyglot\Inference\Config\LLMConfig; $result = StructuredOutput::fromConfig( LLMConfig::fromDsn('driver=openai,model=gpt-4o-mini') )->with( messages: 'What is the capital of France?', responseModel: City::class, )->get(); // @doctest id="6e3b" ``` You can also construct `LLMConfig` from an array for more detailed configuration: ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; $config = LLMConfig::fromArray([ 'driver' => 'openai', 'model' => 'gpt-4o-mini', 'apiKey' => $_ENV['OPENAI_API_KEY'], 'apiUrl' => 'https://api.openai.com/v1', 'maxTokens' => 4096, ]); $result = StructuredOutput::fromConfig($config) ->with( messages: 'What is the capital of France?', responseModel: City::class, ) ->get(); // @doctest id="7e9c" ``` ## Runtime Configuration `StructuredOutput` handles single requests. When you need to configure behavior that applies across multiple requests -- retries, output mode, event listeners, or custom pipeline extensions -- use `StructuredOutputRuntime`: ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\Config\LLMConfig; $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') )->withMaxRetries(3); $structured = (new StructuredOutput)->withRuntime($runtime); $city = $structured ->with( messages: 'What is the capital of France?', responseModel: City::class, ) ->get(); // @doctest id="ef62" ``` ### What Belongs Where Understanding the separation of concerns helps you structure your application: | Layer | Responsibility | Examples | |-------|---------------|----------| | `LLMConfig` | Provider connection details | Driver, model, API key, base URL, max tokens | | `StructuredOutputConfig` | Extraction behavior | Output mode, retry prompt template, schema naming | | `StructuredOutputRuntime` | Runtime behavior | Max retries, event listeners, custom validators/transformers | | `StructuredOutput` | Single request | Messages, response model, system prompt, examples | ### Output Modes Instructor supports multiple strategies for getting structured output from the LLM. The default mode (`Tools`) uses the provider's function/tool calling API. You can switch modes via the runtime: ```php use Cognesy\Instructor\Enums\OutputMode; $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') )->withOutputMode(OutputMode::JsonSchema); // @doctest id="bb7f" ``` Available modes: | Mode | Description | |------|-------------| | `OutputMode::Tools` | Uses the provider's tool/function calling API (default) | | `OutputMode::Json` | Requests JSON output via the provider's JSON mode | | `OutputMode::JsonSchema` | Sends a JSON Schema and requests strict conformance | | `OutputMode::MdJson` | Asks the LLM to return JSON inside a Markdown code block | | `OutputMode::Text` | Extracts JSON from unstructured text responses | | `OutputMode::Unrestricted` | No output constraints; extraction is best-effort | ### Event Listeners The runtime exposes a full event system for monitoring and debugging: ```php use Cognesy\Instructor\Events\StructuredOutput\StructuredOutputRequestReceived; $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') ); // Listen for a specific event $runtime->onEvent( StructuredOutputRequestReceived::class, fn($event) => logger()->info('Request received', [ 'requestId' => $event->data['requestId'], 'executionId' => $event->data['executionId'], 'phaseId' => $event->data['phaseId'], ]), ); // Or wiretap all events $runtime->wiretap( fn($event) => logger()->debug(get_class($event)), ); // @doctest id="c95c" ``` ## Using a Local Model with Ollama Instructor works with local models through Ollama. Install Ollama, pull a model, and point Instructor at the local endpoint: ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Polyglot\Inference\Config\LLMConfig; $result = StructuredOutput::fromConfig( LLMConfig::fromDsn('driver=ollama,model=llama3.1') )->with( messages: 'What is the capital of France?', responseModel: City::class, )->get(); // @doctest id="6b01" ``` ## Framework Integration Instructor is a standalone library that works in any PHP application. It does not require published config files, service providers, or framework-specific bindings. For Laravel-specific installation, configuration, facades, events, and testing, use the dedicated Laravel package docs: - [Instructor for Laravel installation guide](../../laravel/docs/installation.md) ## Next Steps - [Quickstart](quickstart) -- run your first extraction - [Usage](essentials/usage) -- the full request-building API - [Configuration](essentials/configuration) -- advanced configuration options - [Modes](essentials/modes) -- output mode details and trade-offs - [LLM Providers](misc/llm_providers) -- supported providers and driver options ================================================================================ FILE: packages/instructor/testing-doubles.md ================================================================================ ## Overview Instructor supports deterministic tests at three different seams. - use `FakeInferenceDriver` when you want to drive Instructor directly with queued raw responses or streaming deltas - use `MockHttp` when you want to keep the provider adapter and HTTP response path in play - use probe helpers when you need to assert streaming timing, call counts, or emission order Pick the shallowest seam that still exercises the behavior you care about. ## `FakeInferenceDriver` `FakeInferenceDriver` lives in `packages/instructor/tests/Support` and is the main contributor-facing fake for deterministic Instructor tests. Use it when you want to test: - deserialization and validation behavior - retry logic - stream accumulation and final response behavior - partial and sequence handling without real HTTP or provider adapters It supports two modes: - queued `InferenceResponse` objects for sync execution - queued `PartialInferenceDelta` batches for streaming execution Minimal example: ```php use Cognesy\Instructor\Tests\Support\FakeInferenceDriver; use Cognesy\Polyglot\Inference\Data\InferenceResponse; $driver = new FakeInferenceDriver( responses: [new InferenceResponse(content: '{"name":"Jason","age":28}')], ); // @doctest id="bd38" ``` Choose this seam for most unit and regression tests inside `packages/instructor`. ## `MockHttp` `MockHttp` lives in `packages/instructor/tests` and builds an HTTP client around `MockHttpDriver`. Use it when you want to test: - provider-specific adapter behavior - request and response wiring that still goes through HTTP - response payload shapes such as OpenAI or Anthropic fixtures Minimal example: ```php use Cognesy\Instructor\Tests\MockHttp; $http = MockHttp::get(['{"name":"Jason","age":28}']); // @doctest id="6b52" ``` Choose this seam when the HTTP transport and provider adapter still matter to the test. If they do not, prefer `FakeInferenceDriver`. ## Streaming Probes `ProbeStreamDriver` and `ProbeIterator` live in `packages/instructor/tests/Integration/Support`. Use them when you need to assert: - that streaming updates are emitted immediately - how many sync versus stream reads occurred - exact delta ordering in an integration-style test These helpers are narrower than `FakeInferenceDriver`. They are for observation, not for general-purpose fixture setup. ## Which One To Use Use this rule of thumb: - `FakeInferenceDriver` for most deterministic Instructor behavior tests - `MockHttp` for adapter- and payload-level coverage - probe helpers for streaming immediacy and observation-heavy assertions If a test only needs structured-output behavior, prefer the fake driver. If the test is really about provider response shape or HTTP wiring, keep the mock HTTP path. ================================================================================ FILE: packages/instructor/upgrade.md ================================================================================ The current docs are written for the 2.0 structured-output API. ## What Changed The public model is now: - `StructuredOutput` for request construction - `StructuredOutputRuntime` for runtime behavior - `PendingStructuredOutput` for lazy execution - `StructuredOutputResponse` as the primary final response object - `StructuredOutputStream` for streaming reads and final stream access ## Response Ownership Older docs and examples often treated the raw Polyglot response as the main response object. That is no longer the intended API. - use `response()` when you want the final Instructor response - use `get()` when you want only the parsed value - use `inferenceResponse()` or `finalInferenceResponse()` only when you need raw transport-level details ## Streaming Contract Streaming is now built around Instructor-owned stream state. - Polyglot streams deltas - Instructor accumulates those deltas in `StructuredOutputStreamState` - final stream reads return `StructuredOutputResponse`, not raw partial snapshot objects If you relied on old partial snapshot behavior, update that code to consume: - `stream()->responses()` for partial and final `StructuredOutputResponse` items - `stream()->partials()` for parsed partial values - `stream()->sequence()` for completed sequence items ## Runtime Setup Runtime configuration belongs on `StructuredOutputRuntime`, not on a global Instructor object. - `create()` returns a lazy handle - `stream()` returns a dedicated stream object - `StructuredOutput::fromConfig(...)` and `StructuredOutput::using(...)` remain valid entry points - published config files are optional ## Migration Rule If you are updating older code, rewrite it around one of these shapes: - `StructuredOutput->with(...)->get()` - `StructuredOutput->with(...)->response()` - `StructuredOutput->with(...)->stream()` ================================================================================ FILE: packages/instructor/cli_tools.md ================================================================================ The structured-output package does not require a CLI tool to work. In the monorepo and broader InstructorPHP stack, companion tools include: - `bin/instructor-docs` for documentation workflows - `bin/instructor-setup` for resource publishing in larger setups - `bin/instructor-hub` for example and demo workflows Treat those as project tooling, not as part of the core request API. ================================================================================ FILE: packages/instructor/concepts/overview.md ================================================================================ Instructor is a library that turns LLM responses into typed, validated PHP data. It is powered by Large Language Models and works with multiple providers out of the box. Rather than parsing raw text or hand-rolling JSON extraction, you define a PHP class that describes the shape of the data you want. Instructor handles the prompt construction, the LLM call, deserialization, validation, and retries -- so the result that reaches your code is always a typed object you can trust. The library is inspired by [Instructor for Python](https://jxnl.github.io/instructor/) created by Jason Liu. ## How It Works The high-level flow is straightforward: 1. You describe the shape of the data you need (a response model). 2. You provide input text, chat messages, or even another object. 3. Instructor calls the LLM, extracts structured JSON, deserializes it into your model, validates the result, and retries if necessary. ```php use Cognesy\Instructor\StructuredOutput; final class User { public string $name; public int $age; } $user = (new StructuredOutput) ->with( messages: 'Jason is 25 years old.', responseModel: User::class, ) ->get(); // $user->name === 'Jason' // $user->age === 25 // @doctest id="9505" ``` Behind the scenes, Instructor builds a JSON schema from the `User` class, instructs the LLM to respond in that format, maps the JSON back into a `User` instance, and runs any validation rules before returning the object. ## Core Concepts Instructor keeps its model intentionally small. There are only a handful of concepts you need to understand to be productive. ### Response Model The response model defines the shape you want back from the LLM. It is the contract between your code and the model. Common choices: - **A PHP class** -- the most typical approach. Instructor derives a JSON schema from the class's typed properties automatically. - **A JSON schema array** -- useful when the shape is dynamic or defined at runtime. - **Helper wrappers** -- `Scalar` for single values, `Sequence` for lists of objects, and `Maybe` for results that may not exist. A well-designed response model is small, focused, and uses clear property names. Nested objects and enums are fully supported. ### Request A request combines your input with a response model and optional parameters. The `StructuredOutput` class provides two equivalent styles for building one. The compact style passes everything through `with()`: ```php $user = (new StructuredOutput) ->with( messages: 'Jason is 25 years old.', responseModel: User::class, system: 'Extract accurate data.', model: 'gpt-4o', ) ->get(); // @doctest id="015c" ``` The fluent style chains individual methods: ```php $user = (new StructuredOutput) ->withMessages('Jason is 25 years old.') ->withResponseModel(User::class) ->withSystem('Extract accurate data.') ->withModel('gpt-4o') ->get(); // @doctest id="59ad" ``` Both produce identical requests. `StructuredOutput` is immutable -- every method returns a new instance, so you can safely branch from a shared base. ### Runtime `StructuredOutputRuntime` owns provider setup and runtime behavior. It holds the LLM connection, retry policy, output mode, event dispatcher, and pipeline extension points such as custom validators, transformers, deserializers, and extractors. You typically create a runtime once and share it across many requests: ```php use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\Config\LLMConfig; $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') )->withMaxRetries(3); $user = (new StructuredOutput) ->withRuntime($runtime) ->with(messages: 'Jason is 25 years old.', responseModel: User::class) ->get(); // @doctest id="6c59" ``` If you do not provide a runtime, `StructuredOutput` creates one from default settings automatically. ### Execution Execution is lazy. Calling `with()` or the fluent methods only builds a description of the work. The LLM is not contacted until you read the result. Three classes participate in execution: | Class | Role | |---|---| | `StructuredOutput` | Builds the request and delegates to the runtime | | `PendingStructuredOutput` | A lazy handle returned by `create()`. Execution starts when you call `get()`, `response()`, `stream()`, or any other read method | | `StructuredOutputStream` | Handles streaming. Yields partial objects as the LLM generates tokens, then provides the final validated result | ### Validation and Retries After the LLM responds, Instructor deserializes the JSON into your response model and runs validation. If validation fails and retries are configured, Instructor sends the validation errors back to the LLM and asks it to correct its response. Validation uses Symfony validation attributes by default: ```php use Symfony\Component\Validator\Constraints as Assert; final class UserDetails { #[Assert\NotBlank] public string $name; #[Assert\Email] public string $email; } $user = (new StructuredOutput) ->withRuntime( StructuredOutputRuntime::fromDefaults()->withMaxRetries(2) ) ->with( messages: 'You can reach me at jason@gmailcom -- Jason', responseModel: UserDetails::class, ) ->get(); // If the LLM returns an invalid email on the first attempt, // Instructor retries up to 2 more times to get a valid result. // @doctest id="1141" ``` This self-correcting loop is one of Instructor's most powerful features. The LLM sees exactly which fields failed and why, giving it a strong signal for the next attempt. ## Where To Go Next - [Why Use Instructor?](why) -- the motivation behind a schema-first approach - [Usage](../essentials/usage) -- the day-to-day API reference - [Data Model](../essentials/data_model) -- choosing the right response model - [Validation](../essentials/validation) -- validation rules and custom validators - [Streaming](../essentials/usage#streaming-support) -- working with partial results ================================================================================ FILE: packages/instructor/concepts/why.md ================================================================================ Large Language Models produce text exceptionally well. Applications, however, almost always need typed data -- objects, numbers, enums, validated fields. Bridging that gap by hand means writing fragile parsing code, guessing at JSON shapes, and hoping the model cooperates. Instructor closes the gap by letting you declare what you need and handling the rest: extraction, deserialization, validation, and retries all happen before the result reaches your code. ```php use Cognesy\Instructor\StructuredOutput; final class User { public string $name; public int $age; } $user = (new StructuredOutput) ->with( messages: 'Jason is 25 years old.', responseModel: User::class, ) ->get(); // $user is a fully typed User object -- no parsing, no guessing. // @doctest id="7602" ``` ## What You Gain ### Response Models Replace Manual Parsing Without Instructor, extracting structured data from an LLM means defining verbose function-call schemas, parsing JSON responses, and mapping fields to your domain objects by hand. With Instructor, you write a plain PHP class and let the library derive the schema, call the model, and hydrate the result automatically. Your code becomes simpler and easier to reason about. The response model _is_ the documentation of what you expect. ### Validation Before You Trust The Data LLM output is probabilistic. A model might return a malformed email address, a negative age, or a value outside an expected set. Instructor validates every response against your rules before returning it. Validation uses Symfony validation attributes, so you can apply the same constraints you already use in the rest of your PHP application: ```php use Symfony\Component\Validator\Constraints as Assert; final class UserDetails { #[Assert\NotBlank] public string $name; #[Assert\Email] public string $email; } // @doctest id="1b2d" ``` You can also build fully custom validation logic using Symfony's `#[Assert\Callback]` annotation. This lets you enforce cross-field rules, business logic, or any constraint that goes beyond simple attribute checks: ```php use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; final class UserDetails { public string $name; public int $age; #[Assert\Callback] public function validateName(ExecutionContextInterface $context, mixed $payload): void { if ($this->name !== strtoupper($this->name)) { $context->buildViolation('Name must be in uppercase.') ->atPath('name') ->setInvalidValue($this->name) ->addViolation(); } } } // @doctest id="941c" ``` ### Self-Correcting Retries When validation fails, Instructor does not simply throw an exception. If retries are configured, it sends the validation errors back to the LLM as context and asks it to try again. The model sees exactly which fields failed and why, giving it a strong signal for correction. ```php use Cognesy\Instructor\StructuredOutputRuntime; $runtime = StructuredOutputRuntime::fromDefaults()->withMaxRetries(2); $user = (new StructuredOutput) ->withRuntime($runtime) ->with( messages: 'You can reach me at jason@gmailcom -- Jason', responseModel: UserDetails::class, ) ->get(); // The LLM may initially return "jason@gmailcom". Instructor catches the // validation failure, feeds the error back, and the model self-corrects // to "jason@gmail.com" on the next attempt. // @doctest id="ffbb" ``` This retry loop dramatically improves reliability without any manual intervention. ### Streaming Without Changing The Request Shape You can stream partial results as the LLM generates tokens. The request definition stays the same -- you simply read the result differently: ```php $stream = (new StructuredOutput) ->with(messages: 'Jason is 25 years old.', responseModel: User::class) ->stream(); foreach ($stream->partials() as $partial) { echo $partial->name ?? '...'; } $user = $stream->lastUpdate(); // @doctest id="82f9" ``` For lists of objects, the `Sequence` wrapper combined with `stream()->sequence()` yields each completed item as soon as it is ready, so your application can begin processing before the full response arrives. ### A Provider-Agnostic API Instructor works with OpenAI, Anthropic, Google, Azure, and other providers through the Polyglot inference layer. Switching providers is a configuration change, not a code rewrite: ```php // OpenAI $user = StructuredOutput::using('openai') ->with(messages: 'Jason is 25 years old.', responseModel: User::class) ->get(); // Anthropic $user = StructuredOutput::using('anthropic') ->with(messages: 'Jason is 25 years old.', responseModel: User::class) ->get(); // @doctest id="ffab" ``` Your response models, validation rules, and application logic remain identical regardless of which LLM provider backs the request. ## The Workflow At A Glance Working with Instructor follows a consistent three-step pattern. **Step 1: Define the data model.** Create a PHP class with typed public properties that maps to the information you want to extract: ```php final class Lead { public string $name; public string $company; public string $email; } // @doctest id="2aa0" ``` **Step 2: Extract.** Pass your input and the response model to `StructuredOutput`: ```php $lead = (new StructuredOutput) ->with( messages: $emailBody, responseModel: Lead::class, ) ->get(); // @doctest id="21f2" ``` **Step 3: Use the result.** The returned object is fully typed, validated, and ready to use in your application -- no additional parsing required: ```php echo $lead->name; // "Jason Liu" echo $lead->company; // "Acme Corp" echo $lead->email; // "jason@acme.com" // @doctest id="123f" ``` ## When Instructor Is A Good Fit Instructor works well when you need to: - Extract structured records from unstructured text (emails, documents, chat logs) - Classify or label content into predefined categories - Transform one structured format into another via an LLM - Generate data that must conform to a strict schema - Build pipelines where the output of one LLM step is the typed input to the next If your use case involves free-form text generation where structure is not important, you may not need Instructor at all. But whenever your application consumes the LLM output as data rather than prose, a schema-first approach will save you time and reduce errors. ================================================================================ FILE: packages/instructor/essentials/usage.md ================================================================================ ## Basic Usage Instructor extracts structured data from text using LLM inference. You define a PHP class that describes the shape of the data you want, and Instructor takes care of building the prompt, calling the model, and deserializing the response into a typed object. ```php use Cognesy\Instructor\StructuredOutput; class Person { public string $name; public int $age; } $person = (new StructuredOutput) ->with( messages: 'Jason is 28 years old.', responseModel: Person::class, ) ->get(); echo $person->name; // Jason echo $person->age; // 28 // @doctest id="5718" ``` > By default, Instructor looks for the `OPENAI_API_KEY` environment variable. You can also > choose a provider explicitly with `StructuredOutput::using('openai')` or by passing > a runtime configured with `LLMConfig`. ## Building The Request The `with()` method covers the common path. It accepts all the parameters you typically need in a single call: ```php $person = (new StructuredOutput) ->with( messages: 'Jason is 28 years old.', responseModel: Person::class, system: 'Extract accurate data.', prompt: 'Identify the person mentioned.', model: 'gpt-4o', ) ->get(); // @doctest id="f4e8" ``` When you prefer a more explicit, step-by-step style, use the fluent API: ```php $person = (new StructuredOutput) ->withMessages('Jason is 28 years old.') ->withResponseModel(Person::class) ->withSystem('Extract accurate data.') ->withPrompt('Identify the person mentioned.') ->withModel('gpt-4o') ->get(); // @doctest id="7f0d" ``` Both approaches produce identical requests. Use whichever reads better in your code. ### Request Methods | Method | Purpose | |---|---| | `withMessages(...)` | Set the chat messages | | `withInput(...)` | Set input from a string, array, or object (converted to messages) | | `withResponseModel(...)` | Set the response model (class string, instance, or schema array) | | `withResponseClass(...)` | Set the response model from a class name | | `withResponseObject(...)` | Set the response model from an object instance | | `withResponseJsonSchema(...)` | Set the response model from a JSON Schema array | | `withSystem(...)` | Set the system prompt (`string\|\Stringable`) | | `withPrompt(...)` | Set additional prompt text (`string\|\Stringable`) | | `withExamples(...)` | Provide few-shot examples | | `withModel(...)` | Override the model name | | `withOptions(...)` | Pass provider-specific options | | `withOption(...)` | Set a single provider option | | `withStreaming(...)` | Enable or disable streaming | | `withCachedContext(...)` | Set cached context for providers that support prompt caching | ## Reading The Result Instructor provides several ways to consume the response depending on your needs. ### `get()` - The Parsed Value The most common method. Returns the deserialized, validated object (or scalar when using the `Scalar` adapter): ```php $person = (new StructuredOutput) ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->get(); // @doctest id="ce6b" ``` ### `response()` - The Full Response Envelope Returns a `StructuredOutputResponse` that wraps both the parsed value and the raw LLM response, giving you access to usage metadata, finish reason, and more: ```php $response = (new StructuredOutput) ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->response(); $person = $response->value(); $usage = $response->usage(); // @doctest id="3fa5" ``` ### `inferenceResponse()` - The Underlying Inference Response Returns the low-level `InferenceResponse` from the Polyglot layer, useful when you need direct access to HTTP response data or provider-specific details: ```php $raw = (new StructuredOutput) ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->inferenceResponse(); // @doctest id="47f5" ``` ### `stream()` - Streaming Partial Results Returns a `StructuredOutputStream` for real-time processing. Streaming is enabled automatically when you call `stream()`: ```php $stream = (new StructuredOutput) ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->stream(); foreach ($stream->partials() as $partial) { echo $partial->name ?? '...'; } $person = $stream->lastUpdate(); // @doctest id="6728" ``` ### `create()` - Lazy Execution Returns a `PendingStructuredOutput` handle without triggering the LLM call. Nothing executes until you read from it: ```php $pending = (new StructuredOutput) ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->create(); // execution happens here $person = $pending->get(); // @doctest id="a448" ``` `PendingStructuredOutput` exposes the same reading methods as `StructuredOutput` plus a few utility helpers: | Method | Return type | |---|---| | `get()` | The parsed value | | `response()` | `StructuredOutputResponse` | | `inferenceResponse()` | `InferenceResponse` | | `stream()` | `StructuredOutputStream` | | `toJson()` | JSON string of the extracted data | | `toArray()` | Associative array of the extracted data | | `toJsonObject()` | `Json` object | ## Typed Convenience Methods When working with `Scalar` responses or any result where you know the expected PHP type, you can skip `get()` and call a typed accessor directly: ```php $age = (new StructuredOutput) ->with(messages: 'Jason is 28.', responseModel: Scalar::integer('age')) ->getInt(); // @doctest id="2de6" ``` Available typed methods: `getString()`, `getInt()`, `getFloat()`, `getBoolean()`, `getObject()`, `getArray()`. ## String As Input You can pass a plain string anywhere messages are expected. Instructor wraps it into a user message automatically: ```php $person = (new StructuredOutput) ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->get(); // @doctest id="a37c" ``` This is equivalent to passing `[['role' => 'user', 'content' => 'Jason is 28 years old.']]`. ## Structured-To-Structured Processing The `input` parameter accepts objects, arrays, or strings. This lets you transform one structured representation into another: ```php class Email { public function __construct( public string $address = '', public string $subject = '', public string $body = '', ) {} } $email = new Email( address: 'joe@gmail.com', subject: 'Status update', body: 'Your account has been updated.', ); $translated = (new StructuredOutput) ->withInput($email) ->with( responseModel: Email::class, prompt: 'Translate the text fields to Spanish. Keep other fields unchanged.', ) ->get(); // @doctest id="04ed" ``` ## Output Formats By default, Instructor returns an instance of your response model class. You can change this with the output format methods: ```php // Return as an associative array instead of an object $data = (new StructuredOutput) ->withResponseClass(User::class) ->intoArray() ->with(messages: 'John Doe, 30 years old') ->get(); // ['name' => 'John Doe', 'age' => 30] // Use one class for the schema but hydrate into a different class $dto = (new StructuredOutput) ->withResponseClass(UserProfile::class) ->intoInstanceOf(UserDTO::class) ->with(messages: 'Extract user data') ->get(); // @doctest id="459c" ``` Three output format methods are available: | Method | Effect | |---|---| | `intoArray()` | Skip deserialization, return a raw associative array | | `intoInstanceOf($class)` | Use the schema from the response model but hydrate into a different class | | `intoObject($obj)` | Pass a self-deserializing object that implements `CanDeserializeSelf` | ## Using A Runtime For applications that share provider configuration and behavior across many requests, create a `StructuredOutputRuntime` once and reuse it: ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\Config\LLMConfig; $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') )->withMaxRetries(2); $person = (new StructuredOutput) ->withRuntime($runtime) ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->get(); // @doctest id="b061" ``` The runtime holds settings like retries, output mode, validators, transformers, and deserializers. Individual requests stay lightweight and focused on content. You can also use the static shorthand to pick a provider without building a full runtime: ```php $person = StructuredOutput::using('anthropic') ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->get(); // @doctest id="e8db" ``` ## Streaming Support Instructor supports streaming of partial results, allowing you to process data as it arrives from the model: ```php $stream = (new StructuredOutput) ->with(messages: 'Jason is 28 years old.', responseModel: Person::class) ->stream(); foreach ($stream->partials() as $partialPerson) { echo "Name: " . ($partialPerson->name ?? '...'); echo "Age: " . ($partialPerson->age ?? '...'); } // After the stream completes, retrieve the final validated object $person = $stream->lastUpdate(); // @doctest id="064b" ``` The `StructuredOutputStream` provides several iteration methods: | Method | Yields | |---|---| | `partials()` | Partially filled objects as they arrive | | `sequence()` | Completed items when using `Sequence` as the response model | | `responses()` | Full `StructuredOutputResponse` snapshots | | `finalValue()` | Drains the stream and returns the final parsed value | | `finalResponse()` | Drains the stream and returns the final `StructuredOutputResponse` | ================================================================================ FILE: packages/instructor/essentials/data_model.md ================================================================================ The response model is the contract between your code and the LLM. It tells Instructor what schema to send to the model and how to deserialize the response back into a PHP object. ## Plain PHP Classes For most cases, a class with public typed properties is all you need: ```php class Person { public string $name; public int $age; } // @doctest id="70c8" ``` Instructor reads the property types, builds a JSON Schema from them, and hydrates the response back into the class. Public properties are filled by the LLM; private and protected properties are left untouched with their default values. ## Supported Response Model Shapes Instructor accepts several forms as the `responseModel` parameter: | Shape | Example | When to use | |---|---|---| | Class string | `Person::class` | Most common path | | Object instance | `new Person()` | When you need to pre-populate defaults | | JSON Schema array | `['type' => 'object', ...]` | Dynamic or externally defined schemas | | `Scalar` helper | `Scalar::integer('age')` | Single value extraction | | `Sequence` helper | `Sequence::of(Person::class)` | Lists of objects | | `Maybe` helper | `Maybe::is(Person::class)` | Optional data that might not exist | ## Type Hints Use standard PHP type hints to specify the type of each field. Instructor supports all common types: `string`, `int`, `float`, `bool`, `array`, objects, and enums. Use nullable types to indicate that a field is optional: ```php class Person { public string $name; public ?int $age; public Address $address; } // @doctest id="a54c" ``` > Instructor only sets public fields. Private and protected fields are ignored unless the > class defines matching setter methods or constructor parameters. ## DocBlock Type Hints When you cannot or prefer not to use PHP type hints, DocBlock comments work as well. This is particularly useful for typed arrays, since PHP does not support generic array type hints natively: ```php class Person { /** @var string */ public $name; /** @var int */ public $age; /** @var Address $address Person's home address */ public $address; } // @doctest id="6536" ``` ## Typed Collections And Arrays PHP does not support generics, so you need DocBlock comments to specify array element types. Instructor reads these annotations and includes them in the schema: ```php class Event { public string $title; /** @var Person[] List of event participants */ public array $participants; } // @doctest id="0641" ``` When you need a top-level list rather than an object with an array property, use the `Sequence` helper instead: ```php use Cognesy\Instructor\Extras\Sequence\Sequence; $people = (new StructuredOutput) ->with( messages: $text, responseModel: Sequence::of(Person::class), ) ->get(); foreach ($people as $person) { echo $person->name; } // @doctest id="56dc" ``` ## Nested Objects And Enums Nested objects and backed enums are part of the normal path. If your class graph is simple and typed, it works out of the box: ```php enum SkillType: string { case Technical = 'technical'; case Other = 'other'; } class Skill { public string $name; public SkillType $type; } class Person { public string $name; public int $age; /** @var Skill[] */ public array $skills; } $person = (new StructuredOutput) ->with( messages: 'Alex is a 25-year-old software engineer who knows PHP, Python, and plays guitar.', responseModel: Person::class, ) ->get(); echo $person->skills[0]->name; // PHP echo $person->skills[0]->type; // SkillType::Technical // @doctest id="8ae5" ``` ## Describing Your Model To The LLM You can guide the model by adding descriptions and instructions to your classes and properties. Instructor includes these in the schema sent to the LLM. ### PHP DocBlocks DocBlock comments on classes and properties are automatically extracted: ```php /** * Represents a skill and the context in which it was mentioned. */ class Skill { public string $name; /** @var SkillType $type Type of skill, derived from description and context */ public SkillType $type; /** Directly quoted, full sentence mentioning the skill */ public string $context; } // @doctest id="7a2b" ``` ### Attributes The `#[Description]` and `#[Instructions]` attributes provide a structured alternative to DocBlocks: ```php use Cognesy\Schema\Attributes\Description; use Cognesy\Schema\Attributes\Instructions; #[Description("Information about a user")] class User { #[Description("User's age in years")] public int $age; #[Instructions("Normalize the name to ALL CAPS")] public string $name; #[Description("User's current profession")] #[Instructions("Ignore hobbies, identify only the profession")] public string $job; } // @doctest id="2a94" ``` You can combine attributes and DocBlocks on the same class. Instructor merges them into a single description block. ## Optional Data With `Maybe` The `Maybe` helper wraps any response model to handle cases where the requested data might not be present in the input: ```php use Cognesy\Instructor\Extras\Maybe\Maybe; $result = (new StructuredOutput) ->with( messages: 'The document discusses market trends but mentions no individuals.', responseModel: Maybe::is(Person::class, 'person', 'Person data if found'), ) ->get(); if ($result->hasValue()) { $person = $result->get(); echo $person->name; } else { echo 'Not found: ' . $result->error(); } // @doctest id="b60e" ``` `Maybe` asks the model to set a `hasValue` boolean and, when the data is missing, to explain why in an `error` string. This is more reliable than using nullable types when you need to distinguish "data not found" from "data is null." ## Best Practices - **Use public typed properties.** They give Instructor the clearest possible schema. - **Keep names descriptive.** Property names like `$customerEmail` produce better results than `$e`. - **Put validation close to the model.** Use Symfony constraints or `ValidationMixin` directly on the response class. - **Prefer small, focused models.** A `ContactInfo` class with three fields extracts more reliably than a catch-all `Document` class with twenty. - **Use enums for constrained values.** Backed enums produce an `enum` constraint in the schema, which dramatically improves accuracy for categorical fields. ================================================================================ FILE: packages/instructor/essentials/validation.md ================================================================================ Validation runs after deserialization and before the result is returned. If the extracted data does not meet your rules, Instructor can automatically retry the request, feeding the validation errors back to the model so it can self-correct. ## Symfony Validation Attributes Instructor uses the Symfony Validator component under the hood. Add constraint attributes to your response model to enforce field-level rules: ```php use Symfony\Component\Validator\Constraints as Assert; class Person { #[Assert\NotBlank] #[Assert\Length(min: 3)] public string $name; #[Assert\PositiveOrZero] public int $age; } // @doctest id="23b8" ``` If the model returns a name shorter than three characters or a negative age, validation fails and Instructor can retry the request automatically. > For a full list of available constraints, see the > [Symfony Validation documentation](https://symfony.com/doc/current/validation.html#constraints). ## Retries Retries are configured on the runtime, not on individual requests. When validation fails and retries are available, Instructor sends the validation errors back to the model and asks it to try again: ```php use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\Config\LLMConfig; $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') )->withMaxRetries(3); // @doctest id="6c21" ``` The `maxRetries` value controls how many additional attempts are allowed after the first one. With `maxRetries(3)`, Instructor will try up to 4 times total (1 initial + 3 retries). If all attempts fail validation, Instructor throws an exception. ```php use Cognesy\Instructor\StructuredOutput; use Symfony\Component\Validator\Constraints as Assert; class Person { #[Assert\Length(min: 3)] public string $name; #[Assert\PositiveOrZero] public int $age; } $person = (new StructuredOutput) ->withRuntime($runtime) ->with( messages: 'His name is JX, aka Jason, he is -28 years old.', responseModel: Person::class, ) ->get(); // @doctest id="f52f" ``` In this example, the model might initially return `name: "JX"` and `age: -28`. Validation catches both issues, and the retry prompt tells the model what went wrong so it can return `name: "Jason"` and `age: 28` on the next attempt. ## Custom Validation With `ValidationMixin` For object-level validation logic that goes beyond simple field constraints, use the `ValidationMixin` trait. Implement a `validate()` method that returns a `ValidationResult`: ```php use Cognesy\Instructor\Validation\Traits\ValidationMixin; use Cognesy\Instructor\Validation\ValidationResult; use Cognesy\Instructor\Validation\ValidationError; class UserDetails { use ValidationMixin; public string $name; public int $age; public function validate(): ValidationResult { if ($this->name !== strtoupper($this->name)) { return ValidationResult::fieldError( field: 'name', value: $this->name, message: 'Name must be in uppercase.', ); } return ValidationResult::valid(); } } // @doctest id="7102" ``` The `ValidationResult` class provides several factory methods: | Method | Purpose | |---|---| | `ValidationResult::valid()` | Indicates the object passed validation | | `ValidationResult::invalid($errors)` | Wraps one or more `ValidationError` instances | | `ValidationResult::fieldError($field, $value, $message)` | Shorthand for a single field error | | `ValidationResult::make($errors, $message)` | General-purpose constructor | | `ValidationResult::merge($results)` | Combines multiple validation results | When validation fails, Instructor feeds the error messages back to the LLM on retry, just like with Symfony constraints: ```php $user = (new StructuredOutput) ->withRuntime($runtime) ->with( messages: 'jason is 25 years old', responseModel: UserDetails::class, ) ->get(); assert($user->name === 'JASON'); // @doctest id="9dd8" ``` ## Custom Validation With Symfony `#[Assert\Callback]` You can also use Symfony's `#[Assert\Callback]` attribute directly for full access to the Symfony validation context. This is useful when you want to leverage Symfony's violation builder API: ```php use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; class UserDetails { public string $name; public int $age; #[Assert\Callback] public function validateName(ExecutionContextInterface $context, mixed $payload): void { if ($this->name !== strtoupper($this->name)) { $context->buildViolation('Name must be in uppercase.') ->atPath('name') ->setInvalidValue($this->name) ->addViolation(); } } } // @doctest id="1f14" ``` > See the [Symfony Callback constraint docs](https://symfony.com/doc/current/reference/constraints/Callback.html) > for more details on the violation builder API. ## How Retries Work When a response fails validation, Instructor: 1. Collects all validation errors (from Symfony constraints, `ValidationMixin`, or both). 2. Formats them into a retry prompt that describes what went wrong. 3. Appends the retry prompt to the conversation history. 4. Sends the updated conversation back to the model for another attempt. This self-correction loop continues until validation passes or the retry limit is reached. The default retry prompt is `"JSON generated incorrectly, fix following errors:\n"`, followed by the list of violations. You can customize it through `StructuredOutputConfig`: ```php use Cognesy\Instructor\Config\StructuredOutputConfig; $config = new StructuredOutputConfig( maxRetries: 3, retryPrompt: 'The previous response had errors. Please correct them:', ); // @doctest id="7449" ``` ================================================================================ FILE: packages/instructor/essentials/modes.md ================================================================================ Output mode controls how Instructor communicates the desired response schema to the LLM. Different providers and models support different modes, so choosing the right one can improve reliability and compatibility. ## Setting The Mode Output mode is a runtime concern. Set it on `StructuredOutputRuntime` so it applies to every request that uses that runtime: ```php use Cognesy\Instructor\Enums\OutputMode; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\Config\LLMConfig; $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') )->withOutputMode(OutputMode::Tools); // @doctest id="2c0f" ``` ## Available Modes ### `OutputMode::Tools` (Default) Uses the provider's tool calling (function calling) API. Instructor sends your response model as a tool definition, and the model responds with a structured tool call. This is the default and the most reliable mode. It works well with OpenAI, Anthropic, Mistral, and other providers that support tool calling. - [OpenAI Function Calling](https://platform.openai.com/docs/guides/function-calling) - [Anthropic Tool Use](https://docs.anthropic.com/en/docs/build-with-claude/tool-use) - [Mistral Function Calling](https://docs.mistral.ai/capabilities/function_calling/) ### `OutputMode::Json` Sends the response schema as a JSON Schema and instructs the model to respond with a JSON object. Many providers and open-source models support this natively. Use this when a provider does not support tool calling, or when you prefer a JSON-first workflow. - [OpenAI JSON Mode](https://platform.openai.com/docs/guides/text-generation/json-mode) - [Mistral JSON Mode](https://docs.mistral.ai/capabilities/json_mode/) ### `OutputMode::JsonSchema` Uses strict JSON Schema enforcement, where supported. When the provider offers native JSON Schema mode, the response is guaranteed to match the schema. For providers without native support, behavior falls back to best-effort JSON output. This mode is currently best supported by newer OpenAI models. Check your provider's documentation for compatibility. > OpenAI's JSON Schema mode does not support optional properties. If your schema requires > nullable fields, use `OutputMode::Tools` or `OutputMode::Json` instead. - [OpenAI Structured Outputs](https://platform.openai.com/docs/guides/structured-outputs) ### `OutputMode::MdJson` Asks the model to return a JSON object inside a Markdown code block. This is the most basic extraction mode and works as a fallback for models that support neither tool calling nor native JSON output. Instructor scans the response for a JSON fragment inside a ` ```json ``` ` code block and extracts it, ignoring any surrounding text. This mode is the least reliable and most prone to deserialization errors, but it provides the broadest model compatibility. Including the JSON Schema in the prompt (which Instructor does automatically) improves results significantly. ## Choosing A Mode | Mode | Reliability | Compatibility | Best for | |---|---|---|---| | `Tools` | Highest | Providers with tool calling | Most use cases (default) | | `JsonSchema` | High | OpenAI (newer models) | Strict schema guarantees | | `Json` | Good | Most providers | JSON-first workflows | | `MdJson` | Moderate | Any model | Legacy or minimal models | Start with `Tools`. Switch to another mode only when your provider requires it or when you have a specific reason to prefer JSON-based extraction. ## Additional Modes Two other modes exist for non-structured use cases. They are not useful with `StructuredOutput` but can be used with the lower-level `Inference` class: - `OutputMode::Text` -- plain text generation - `OutputMode::Unrestricted` -- no format enforcement at all ================================================================================ FILE: packages/instructor/essentials/scalars.md ================================================================================ Sometimes you need a single value -- a number, a string, a boolean -- without the overhead of defining a dedicated response class. The `Scalar` adapter handles this by wrapping a single typed field in a minimal schema. ## Basic Usage ```php use Cognesy\Instructor\Extras\Scalar\Scalar; use Cognesy\Instructor\StructuredOutput; $age = (new StructuredOutput) ->with( messages: 'Jason is 28 years old.', responseModel: Scalar::integer('age'), ) ->get(); // int(28) // @doctest id="5cc3" ``` The first argument to each factory method is the field name, which gives the model semantic context about what value to extract. An optional second argument provides a description for additional guidance. ## Available Types ### String ```php $name = (new StructuredOutput) ->with( messages: 'Jason is 28 years old.', responseModel: Scalar::string(name: 'firstName'), ) ->get(); // string("Jason") // @doctest id="d86f" ``` ### Integer ```php $age = (new StructuredOutput) ->with( messages: 'Jason is 28 years old.', responseModel: Scalar::integer('age'), ) ->get(); // int(28) // @doctest id="ef6e" ``` ### Float ```php $time = (new StructuredOutput) ->with( messages: 'His 100m sprint record is 11.6 seconds.', responseModel: Scalar::float(name: 'recordTime'), ) ->get(); // float(11.6) // @doctest id="15c9" ``` ### Boolean ```php $isAdult = (new StructuredOutput) ->with( messages: 'Jason is 28 years old.', responseModel: Scalar::boolean(name: 'isAdult'), ) ->get(); // bool(true) // @doctest id="0e11" ``` ### Enum Use `Scalar::enum()` to select one value from a backed enum: ```php enum CitizenshipGroup: string { case EU = 'eu'; case US = 'us'; case Other = 'other'; } $group = (new StructuredOutput) ->with( messages: 'Jason is 28 years old and lives in Germany.', responseModel: Scalar::enum(CitizenshipGroup::class, name: 'citizenshipGroup'), ) ->get(); // CitizenshipGroup::Other // @doctest id="a536" ``` The model sees the enum's backed values as the allowed options and returns one of them. Instructor deserializes it back into the enum instance. ## Factory Method Signatures All factory methods accept the same optional parameters: ```php Scalar::string( name: 'value', // Field name shown to the model description: 'Response value', // Additional guidance required: true, // Whether the field is required defaultValue: null, // Default if not extracted ); // @doctest id="081e" ``` | Factory | PHP return type | |---|---| | `Scalar::string(...)` | `string` | | `Scalar::integer(...)` | `int` | | `Scalar::float(...)` | `float` | | `Scalar::boolean(...)` | `bool` | | `Scalar::enum(...)` | The backed enum instance | ## Typed Convenience Methods When you are already working with a `StructuredOutput` or `PendingStructuredOutput` instance, you can skip `get()` and call a typed accessor that validates the return type: ```php $age = (new StructuredOutput) ->with(messages: 'Jason is 28.', responseModel: Scalar::integer('age')) ->getInt(); $name = (new StructuredOutput) ->with(messages: 'Jason is 28.', responseModel: Scalar::string('name')) ->getString(); // @doctest id="78a3" ``` Available typed methods: `getString()`, `getInt()`, `getFloat()`, `getBoolean()`. These methods throw an exception if the result is not of the expected type, which provides an extra safety check beyond what `get()` offers. ================================================================================ FILE: packages/instructor/essentials/demonstrations.md ================================================================================ Demonstrations (few-shot examples) help the model understand the style and structure of the output you expect. They are especially useful when the extraction task is ambiguous or when you want consistent formatting across responses. ## When To Use Examples Examples are most valuable in `OutputMode::Json` and `OutputMode::MdJson` modes, where the model relies on the prompt to understand the expected output shape. In `OutputMode::Tools`, the schema itself provides strong guidance, but examples can still help clarify edge cases or normalize output style. Keep examples short and representative. They should clarify the task, not replace the prompt. ## The `Example` Class Each example pairs an input with the expected output. The `input` is a string describing the scenario, and the `output` is an array representing the correct extraction result: ```php use Cognesy\Instructor\Extras\Example\Example; use Cognesy\Instructor\StructuredOutput; class User { public int $age; public string $name; } $user = (new StructuredOutput) ->with( messages: 'Our user Jason is 25 years old.', responseModel: User::class, examples: [ new Example( input: 'John is 50 and works as a teacher.', output: ['name' => 'John', 'age' => 50], ), new Example( input: 'We recently hired Ian, who is 27 years old.', output: ['name' => 'Ian', 'age' => 27], ), ], ) ->get(); // @doctest id="29b6" ``` Instructor appends the examples to the prompt, rendering each output array as JSON. ## Factory Methods The `Example` class provides several factory methods for different input formats: ### `fromText()` - String Input The most common form. Equivalent to the constructor: ```php $example = Example::fromText( input: 'Ian is 27 years old.', output: ['name' => 'Ian', 'age' => 27], ); // @doctest id="c705" ``` ### `fromChat()` - Chat Messages Use when you want to demonstrate a multi-turn conversation as input: ```php $example = Example::fromChat( messages: [ ['role' => 'user', 'content' => 'Ian is 27 years old.'], ], output: ['name' => 'Ian', 'age' => 27], ); // @doctest id="971a" ``` ### `fromData()` - Structured Data Accepts any data type as input. Objects and arrays are automatically serialized to JSON: ```php $example = Example::fromData( input: ['firstName' => 'Ian', 'lastName' => 'Brown', 'birthDate' => '1994-01-01'], output: ['name' => 'Ian Brown', 'age' => 27], ); // @doctest id="6f78" ``` ## Using The Fluent API You can also set examples with the fluent `withExamples()` method: ```php $user = (new StructuredOutput) ->withExamples([ Example::fromText('Jane, 31', ['name' => 'Jane', 'age' => 31]), ]) ->with( messages: 'Our user Jason is 25 years old.', responseModel: User::class, ) ->get(); // @doctest id="045f" ``` ## Custom Templates By default, Instructor formats each example using a built-in template. You can override this with a custom template string that uses `{input}` and `{output}` placeholders: ```php $example = new Example( input: 'John is 50 and works as a teacher.', output: ['name' => 'John', 'age' => 50], template: "EXAMPLE:\n<|input|> => <|output|>\n", ); // @doctest id="e955" ``` When the input or output is an array, Instructor automatically converts it to a JSON string before replacing the placeholders. ## Best Practices - **Use one or two examples** for most tasks. More is rarely better -- it adds tokens without proportional improvement. - **Make examples diverse.** Show different edge cases rather than repeating similar inputs. - **Match the real task.** Examples should reflect the actual complexity and format of your production data. - **Keep output arrays minimal.** Include only the fields relevant to the extraction to avoid confusing the model. ================================================================================ FILE: packages/instructor/essentials/customize_prompts.md ================================================================================ Instructor builds a structured prompt from several components: system text, user messages, a mode-specific instruction prompt, examples, and retry context. You can customize most of these to tune extraction behavior without changing the underlying extraction flow. There are currently two prompt materializers in the package: - `RequestMaterializer` is the legacy/default path - `StructuredPromptRequestMaterializer` is the new path using prompt classes and markdown templates Both can be selected through `StructuredOutputRuntime::withRequestMaterializer()`. ## System And Prompt Text The two most common customization points are the system message and the prompt text: ```php use Cognesy\Instructor\StructuredOutput; $result = (new StructuredOutput) ->withSystem('You are a precise data extraction assistant. Return only factual data.') ->withPrompt('Extract the contact details from the text below.') ->with(messages: $text, responseModel: Contact::class) ->get(); // @doctest id="5f44" ``` - **System text** sets the model's persona and overall behavior. Use it for stable instructions that apply across many requests. - **Prompt text** provides task-specific instructions for this particular extraction. On the new structured prompt path it is rendered inside the single system prompt body alongside the mode-specific extraction instructions. You can also pass both through the `with()` method: ```php $result = (new StructuredOutput) ->with( messages: $text, responseModel: Contact::class, system: 'Return concise, accurate data.', prompt: 'Extract the contact details.', ) ->get(); // @doctest id="f628" ``` ## Examples Few-shot examples are another prompt component. On the new structured prompt path they are rendered as markdown inside the system prompt to demonstrate the expected extraction style: ```php use Cognesy\Instructor\Extras\Example\Example; $result = (new StructuredOutput) ->withExamples([ Example::fromText('Jane Doe, 31', ['name' => 'Jane Doe', 'age' => 31]), ]) ->with(messages: $text, responseModel: Person::class) ->get(); // @doctest id="23d6" ``` See the [Demonstrations](demonstrations.md) page for details on the `Example` class. ## Cached Context Some providers (notably Anthropic) support prompt caching, where stable parts of the conversation are cached between requests to reduce latency and cost. Use `withCachedContext()` to mark content as cacheable: ```php $result = (new StructuredOutput) ->withCachedContext( messages: $referenceDocument, system: 'You are a document analyst.', prompt: 'Extract entities from the document.', examples: $examples, ) ->with(messages: 'Now extract from this specific paragraph...', responseModel: Entity::class) ->get(); // @doctest id="ad46" ``` The cached context is placed before the per-request content in the prompt. On the new structured prompt path, cached system text, cached task text, and cached examples are rendered into a cached system prompt and projected through provider-native cached context. Content passed through `withCachedContext()` is marked with cache control headers where the provider supports them. ## Mode-Specific Prompts Instructor uses a default prompt for each output mode that tells the model how to format its response. On the legacy path these prompts are inline strings. On the new path they are prompt classes backed by markdown templates and configured in `StructuredOutputConfig`. | Mode | Default prompt behavior | |---|---| | `Tools` | "Extract correct and accurate data from the input using provided tools." | | `Json` | Includes the JSON Schema and asks for a strict JSON response | | `JsonSchema` | Asks for a strict JSON response following the provided schema | | `MdJson` | Includes the JSON Schema and asks for JSON inside a Markdown code block | ### Overriding Mode Prompts Legacy inline prompt override: ```php use Cognesy\Instructor\Config\StructuredOutputConfig; use Cognesy\Instructor\Enums\OutputMode; $config = new StructuredOutputConfig( modePrompts: [ OutputMode::Tools->value => 'Use the provided tool to extract data accurately.', OutputMode::Json->value => "Respond with a JSON object matching this schema:\n<|json_schema|>\n", ], ); // @doctest id="4db3" ``` New prompt-class override: ```php $config = new StructuredOutputConfig( modePromptClasses: [ OutputMode::Tools->value => App\Prompts\ToolsSystemPrompt::class, OutputMode::Json->value => App\Prompts\JsonSystemPrompt::class, OutputMode::JsonSchema->value => App\Prompts\JsonSchemaSystemPrompt::class, OutputMode::MdJson->value => App\Prompts\MdJsonSystemPrompt::class, ], retryPromptClass: App\Prompts\RetryFeedbackPrompt::class, deserializationErrorPromptClass: App\Prompts\DeserializationRepairPrompt::class, ); // @doctest id="5626" ``` If you store these in YAML, use FQN strings: ```yaml modePromptClasses: tool_call: 'App\\Prompts\\ToolsSystemPrompt' json: 'App\\Prompts\\JsonSystemPrompt' json_schema: 'App\\Prompts\\JsonSchemaSystemPrompt' md_json: 'App\\Prompts\\MdJsonSystemPrompt' retryPromptClass: 'App\\Prompts\\RetryFeedbackPrompt' deserializationErrorPromptClass: 'App\\Prompts\\DeserializationRepairPrompt' # @doctest id="8860" ``` ### Template Placeholders Mode prompts support the `<|json_schema|>` placeholder, which Instructor replaces with the JSON Schema generated from your response model. This is particularly important for `Json` and `MdJson` modes, where the schema must be embedded in the prompt: ```php $config = new StructuredOutputConfig( modePrompts: [ OutputMode::Json->value => "Your task is to respond with a JSON object. " . "Response must follow this JSON Schema:\n<|json_schema|>\n", ], ); // @doctest id="faab" ``` ## Tool Name And Description In `OutputMode::Tools`, the tool definition sent to the model includes a name and description. These provide semantic context that can improve extraction quality: ```php use Cognesy\Instructor\Config\StructuredOutputConfig; $config = new StructuredOutputConfig( toolName: 'extract_person', toolDescription: 'Extract personal information from the provided text.', ); // @doctest id="fa8c" ``` The defaults are `extracted_data` and `Function call based on user instructions.` respectively. Overriding them with task-specific values can help the model understand what the tool represents. > `OutputMode::Json` and `OutputMode::MdJson` ignore tool name and description since > they do not use tool calling. ## Retry Prompt When validation fails and retries are enabled, Instructor appends a retry prompt to the conversation. The default is: ``` JSON generated incorrectly, fix following errors: // @doctest id="2c83" ``` Legacy inline retry prompt override: ```php $config = new StructuredOutputConfig( retryPrompt: 'The previous response had validation errors. Please correct them:', ); // @doctest id="b7eb" ``` New prompt-class override: ```php $config = new StructuredOutputConfig( retryPromptClass: App\Prompts\RetryFeedbackPrompt::class, ); // @doctest id="8c68" ``` The same pattern applies to deserialization repair via `deserializationErrorPromptClass`. ## Chat Structure Instructor assembles the final prompt from named sections in a specific order. The default structure includes sections for system messages, cached context, prompt, examples, messages, and retries. You can reorder or extend this through `StructuredOutputConfig`: ```php $config = new StructuredOutputConfig( chatStructure: [ 'system', 'pre-cached', 'cached-prompt', 'cached-examples', 'cached-messages', 'post-cached', 'pre-prompt', 'prompt', 'post-prompt', 'pre-examples', 'examples', 'post-examples', 'pre-messages', 'messages', 'post-messages', 'pre-retries', 'retries', 'post-retries', ], ); // @doctest id="394e" ``` Most applications will never need to modify the chat structure. It is exposed for advanced use cases where you need precise control over prompt ordering. ================================================================================ FILE: packages/instructor/essentials/configuration.md ================================================================================ Instructor separates configuration into three layers: the LLM provider, the runtime, and the individual request. This keeps most applications simple -- one runtime handles shared behavior, while each request stays focused on content. ## Provider Configuration Use `LLMConfig` to choose which provider and model to connect to. The simplest approach is a preset name that maps to your environment variables: ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; $config = LLMConfig::fromPreset('openai'); // @doctest id="12cb" ``` You can also create a `StructuredOutput` directly from a preset: ```php use Cognesy\Instructor\StructuredOutput; $result = StructuredOutput::using('anthropic') ->with(messages: 'Jason is 28.', responseModel: Person::class) ->get(); // @doctest id="5295" ``` Provider configuration covers connection details: API keys, base URLs, default model names, and HTTP client settings. ## Runtime Configuration `StructuredOutputRuntime` holds behavior that is shared across many requests. Create one runtime and reuse it throughout your application: ```php use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Instructor\Enums\OutputMode; use Cognesy\Polyglot\Inference\Config\LLMConfig; $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') ) ->withMaxRetries(3) ->withOutputMode(OutputMode::Tools); // @doctest id="19e4" ``` ### Runtime Settings | Method | Purpose | |---|---| | `withMaxRetries($n)` | Number of retry attempts after validation failure | | `withOutputMode($mode)` | How the model produces structured output (Tools, Json, etc.) | | `withValidator($validator)` | Override the validator (implements `CanValidateObject`) | | `withTransformer($transformer)` | Override the response transformer (implements `CanTransformData`) | | `withDeserializer($deserializer)` | Override the deserializer (implements `CanDeserializeClass`) | | `withExtractor($extractor)` | Override the response extractor (implements `CanExtractResponse`) | | `withConfig($config)` | Pass a full `StructuredOutputConfig` object | | `withDefaultToStdClass($bool)` | Fall back to `stdClass` for unknown types | ### Advanced Configuration With `StructuredOutputConfig` For fine-grained control, build a `StructuredOutputConfig` directly: ```php use Cognesy\Instructor\Config\StructuredOutputConfig; use Cognesy\Instructor\Enums\OutputMode; $config = new StructuredOutputConfig( outputMode: OutputMode::JsonSchema, maxRetries: 5, retryPrompt: 'Fix the validation errors and try again.', toolName: 'extract_data', toolDescription: 'Extract structured data from the input.', ); $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai'), structuredConfig: $config, ); // @doctest id="52d7" ``` `StructuredOutputConfig` includes settings for: - **Output mode** -- which structured output strategy to use - **Retry behavior** -- max retries and the prompt sent on validation failure - **Tool metadata** -- tool name and description for `OutputMode::Tools` - **Schema metadata** -- schema name and description - **Mode prompts** -- per-mode prompt templates (e.g., how JSON Schema is embedded) - **Chat structure** -- the ordering of prompt sections - **Deserialization** -- error prompt template, `stdClass` fallback, object references ## Request Configuration `StructuredOutput` handles per-request concerns. These are the things that change from one call to the next: ```php use Cognesy\Instructor\StructuredOutput; $person = (new StructuredOutput) ->withRuntime($runtime) ->with( messages: 'Jason is 28 years old.', responseModel: Person::class, system: 'Extract accurate data.', prompt: 'Identify the person in the text.', model: 'gpt-4o', ) ->get(); // @doctest id="9308" ``` ### Request Methods | Method | Purpose | |---|---| | `withMessages(...)` | Set the chat messages | | `withInput(...)` | Input data (string, array, or object) | | `withResponseModel(...)` | Response model (class, instance, or schema) | | `withSystem(...)` | System prompt text (`string\|\Stringable`) | | `withPrompt(...)` | Additional prompt text (`string\|\Stringable`) | | `withExamples(...)` | Few-shot examples | | `withModel(...)` | Model name override | | `withOptions(...)` | Provider-specific options | | `withStreaming(...)` | Enable streaming | | `withCachedContext(...)` | Cached context for prompt caching | ## Event Handling The runtime exposes an event system for observing the processing pipeline: ```php use Cognesy\Instructor\Events\StructuredOutput\StructuredOutputRequestReceived; $runtime->onEvent(StructuredOutputRequestReceived::class, function ($event) { logger()->info('Request received', [ 'requestId' => $event->data['requestId'], 'executionId' => $event->data['executionId'], 'phaseId' => $event->data['phaseId'], ]); }); // Or listen to all events $runtime->wiretap(function ($event) { logger()->debug(get_class($event)); }); // @doctest id="4625" ``` ## Putting It Together A typical application creates one runtime at bootstrap and passes it to each request: ```php // Bootstrap $runtime = StructuredOutputRuntime::fromConfig( LLMConfig::fromPreset('openai') )->withMaxRetries(2); // Request 1 $person = (new StructuredOutput) ->withRuntime($runtime) ->with(messages: $text1, responseModel: Person::class) ->get(); // Request 2 $summary = (new StructuredOutput) ->withRuntime($runtime) ->with(messages: $text2, responseModel: Summary::class) ->get(); // @doctest id="f79d" ``` This keeps configuration centralized and each request minimal. ================================================================================ FILE: packages/instructor/advanced/partials.md ================================================================================ Streaming lets you receive partial updates as the LLM generates its response, rather than waiting for the complete result. This is useful for improving perceived latency in user interfaces -- you can render data progressively as it arrives. ## Basic Streaming Call `stream()` instead of `get()` to receive a `StructuredOutputStream`. The `partials()` method yields parsed partial objects as the response is built. ```php use Cognesy\Instructor\StructuredOutput; $stream = (new StructuredOutput) ->with(messages: $text, responseModel: Person::class) ->stream(); foreach ($stream->partials() as $partial) { // $partial is a Person object with fields populated so far updateUI($partial); } // @doctest id="5e6d" ``` Instructor is smart about updates. It calculates and compares hashes of the previous and newly deserialized version of the model, so your callback only fires when a property actually changes -- not on every token received. Partial updates are deserialized but **not validated**. Only the final result returned by `finalValue()` is fully validated, making it safe to persist or process further. ## Explicit Streaming Control You can also enable streaming with `withStreaming()` and then call `get()`, which internally drains the stream and returns the final value. ```php $person = (new StructuredOutput) ->withResponseClass(Person::class) ->withStreaming() ->with(messages: $text) ->get(); // @doctest id="780a" ``` ## StructuredOutputStream Methods The `StructuredOutputStream` class provides several ways to consume the stream. ### Iteration Methods | Method | Description | |--------|-------------| | `partials()` | Yields parsed partial values. Only the final update is validated; earlier partials are only deserialized. | | `sequence()` | For `Sequence` response models -- yields only completed items. See [Sequences](sequences.md). | | `responses()` | Yields `StructuredOutputResponse` snapshots as they arrive. | ### Result Access Methods | Method | Description | |--------|-------------| | `finalValue()` | Drains the stream and returns the final parsed, validated result. | | `finalResponse()` | Drains the stream and returns the final `StructuredOutputResponse`. | | `lastUpdate()` | Returns the most recently received parsed value. | | `lastResponse()` | Returns the most recently received `StructuredOutputResponse`. | ### Utility Methods | Method | Description | |--------|-------------| | `usage()` | Returns the latest token usage data from the stream. | ## Example: Streaming with Final Retrieval A common pattern is to stream partials for UI updates, then use the final validated value for persistence. ```php $stream = (new StructuredOutput)->with( messages: "His name is Jason, he is 28 years old.", responseModel: Person::class, )->stream(); foreach ($stream->partials() as $update) { $view->updateView($update); } // Final validated object $person = $stream->finalValue(); $db->savePerson($person); // @doctest id="334c" ``` ## Example: Streaming Sequence Items When using a `Sequence` response model, you can stream completed items individually rather than waiting for the entire list. ```php use Cognesy\Instructor\Extras\Sequence\Sequence; $stream = (new StructuredOutput) ->with( messages: "Jason is 28. Amanda is 26. John is 40.", responseModel: Sequence::of(Person::class), ) ->stream(); foreach ($stream->sequence() as $person) { $view->appendPerson($person); } $people = $stream->finalValue(); $db->savePeople($people->toArray()); // @doctest id="ee74" ``` ## Streaming with Output Formats Streaming works with all output formats. During streaming, partials are always objects regardless of your chosen output format. The final value respects the format you specified. See [Output Formats](output_formats.md) for details. ================================================================================ FILE: packages/instructor/advanced/sequences.md ================================================================================ When the LLM response contains a list of items rather than a single object, use the `Sequence` wrapper. It saves you from creating a dedicated class with a single array property just to hold a list. ## Basic Usage Pass `Sequence::of(ClassName::class)` as the response model to extract a typed collection. ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Instructor\Extras\Sequence\Sequence; class Person { public string $name; public int $age; } $text = <<with( messages: $text, responseModel: Sequence::of(Person::class), )->get(); // @doctest id="59c3" ``` The returned `$list` is a `Sequence` instance containing fully validated `Person` objects. ## Working with Sequences `Sequence` implements `ArrayAccess` and `IteratorAggregate`, so you can use it like an array. It also provides convenience methods for common operations. ```php $list->count(); // Number of extracted items $list->first(); // First item $list->last(); // Last item $list->get(1); // Item at index 1 $list->all(); // All items as a plain array $list->toArray(); // Alias for all() // Iterate directly foreach ($list as $person) { echo $person->name; } // Array access $person = $list[0]; // @doctest id="b98f" ``` ## Streaming Sequences One of the most powerful features of sequences is streaming completed items. While `partials()` yields the entire object on every property change, `sequence()` yields only when a new item in the list is fully populated. ```php $stream = (new StructuredOutput) ->with( messages: $text, responseModel: Sequence::of(Person::class), ) ->stream(); foreach ($stream->sequence() as $person) { // Each yield is an individual completed item echo "Extracted: {$person->name}\n"; } // Get the final, fully validated sequence $people = $stream->finalValue(); // @doctest id="0926" ``` This is ideal for progressive UI updates -- you can render each person in a list as soon as the LLM finishes generating their data, without waiting for the entire response. Keep in mind that items yielded during streaming are deserialized but not yet validated. Only the final sequence returned by `finalValue()` is fully validated. ## Named Sequences You can provide a name and description to give the LLM more context about what the collection represents. ```php $list = (new StructuredOutput)->with( messages: $transcript, responseModel: Sequence::of( class: ActionItem::class, name: 'meetingActionItems', description: 'Action items extracted from a meeting transcript', ), )->get(); // @doctest id="4160" ``` ## Combining with Output Formats You can combine sequences with output formats. For example, to get the sequence data as a plain array of arrays: ```php $data = (new StructuredOutput) ->withResponseModel(Sequence::of(Person::class)) ->intoArray() ->with(messages: $text) ->get(); // Result: ['list' => [['name' => 'Jason', 'age' => 25], ...]] // @doctest id="36f5" ``` ================================================================================ FILE: packages/instructor/advanced/structures.md ================================================================================ When you need to define the shape of extracted data at runtime -- based on user input, configuration, or processing context -- PHP classes are not flexible enough. The `Structure` class from the `cognesy/dynamic` package solves this by letting you define arbitrary data shapes dynamically. ## When to Use Structures Structures are the right choice when: - The data shape is not known at compile time - Users configure what fields to extract - You need to adapt the extraction schema based on context - Defining a PHP class for a one-off shape would be unnecessary ceremony For static, known data shapes, a PHP class is simpler and provides better IDE support. ## Defining a Structure Use `StructureFactory` to build a `Structure` from various sources. The most common approach is building from a JSON Schema array. ```php use Cognesy\Dynamic\StructureFactory; $factory = new StructureFactory(); $structure = $factory->fromJsonSchema([ 'type' => 'object', 'x-title' => 'person', 'description' => 'A person object', 'properties' => [ 'name' => ['type' => 'string', 'description' => 'Name of the person'], 'age' => ['type' => 'integer', 'description' => 'Age of the person'], 'role' => [ 'type' => 'string', 'enum' => ['manager', 'line'], 'description' => 'Role of the person', ], ], 'required' => ['name', 'age', 'role'], ]); // @doctest id="95f7" ``` ### From a String Definition For quick prototyping, you can define structures using a compact string syntax. ```php $structure = $factory->fromString( name: 'person', typeString: 'name:string, age:int, role:string', description: 'A person object', ); // @doctest id="a1cc" ``` ### From a PHP Class You can also create a structure from an existing class, which is useful when you want to manipulate the schema dynamically after reflection. ```php $structure = $factory->fromClass(Person::class); // @doctest id="dbcd" ``` ### From Key-Value Data Infer the schema from sample data. ```php $structure = $factory->fromArrayKeyValues('person', [ 'name' => 'Jane', 'age' => 25, 'active' => true, ]); // @doctest id="e28f" ``` ## Extracting Data Pass a `Structure` as the response model to `StructuredOutput`. The result is a `Structure` object with the extracted data. ```php use Cognesy\Instructor\StructuredOutput; $text = <<with( messages: $text, responseModel: $structure, )->get(); // Access properties directly via __get echo $person->name; // "Jane Doe" echo $person->age; // 25 // Or use get() echo $person->get('role'); // "line" // Convert to array $data = $person->toArray(); // ['name' => 'Jane Doe', 'age' => 25, 'role' => 'line'] // @doctest id="3007" ``` If you prefer a raw array result, use `intoArray()`. ```php $data = (new StructuredOutput)->with( messages: $text, responseModel: $structure, )->intoArray()->get(); // @doctest id="c9c3" ``` ## Working with Structure Objects Structure objects provide `get()` for reading properties and `set()` for creating modified copies. Direct property access via `__get` is also supported for reading. ```php // Reading $name = $person->get('name'); $name = $person->name; // Writing (returns new instance -- Structure is immutable) $updated = $person->set('name', 'John Doe'); // Check if property exists $person->has('name'); // true // Convert to array $person->toArray(); // @doctest id="59d9" ``` Note that `Structure` is immutable. The `set()` method returns a new instance with the updated value, leaving the original unchanged. Direct property assignment via `__set` throws a `BadMethodCallException`. ## Alternative Approaches If you do not need the full `Structure` class, Instructor offers simpler alternatives for dynamic schemas: - **JSON Schema array** -- pass a raw schema array as the response model (see [Manual Schemas](manual_schemas.md)) - **`JsonSchema` builder** -- use the fluent `JsonSchema` API for programmatic schema construction - **`Scalar`** -- extract a single typed value (string, integer, float, boolean, or enum) - **`Sequence`** -- extract a list of typed objects These cover most use cases without requiring the dynamic package. ================================================================================ FILE: packages/instructor/advanced/function_calls.md ================================================================================ Instructor uses tool-calling internally when the runtime operates in `OutputMode::Tools`, which is the default mode. For most applications, this is an implementation detail -- you define a response model and read the result. However, the `FunctionCall` addon takes this concept further by letting you extract arguments for real PHP functions, methods, or closures directly from natural language. This is particularly useful when building tool-use capabilities for AI chatbots or agents. ## Extracting Arguments for a Function The `FunctionCallFactory` inspects a function's signature via reflection and builds a response model that matches its parameters. The LLM then extracts the correct argument values from the input text. ```php use Cognesy\Addons\FunctionCall\FunctionCallFactory; use Cognesy\Instructor\StructuredOutput; /** Save user data to storage */ function saveUser(string $name, int $age, string $country) { // ... } $text = "His name is Jason, he is 28 years old and he lives in Germany."; $args = (new StructuredOutput)->with( messages: $text, responseModel: FunctionCallFactory::fromFunctionName('saveUser'), )->get(); // Call the function with extracted arguments saveUser(...$args); // @doctest id="74f4" ``` The docblock comment on the function is included in the schema sent to the LLM, giving it additional context about what the function does. ## Extracting Arguments for a Method You can also extract arguments for class methods by specifying both the class and method name. ```php use Cognesy\Addons\FunctionCall\FunctionCallFactory; use Cognesy\Instructor\StructuredOutput; class DataStore { /** Save user data to storage */ public function saveUser(string $name, int $age, string $country) { // ... } } $text = "His name is Jason, he is 28 years old and he lives in Germany."; $args = (new StructuredOutput)->with( messages: $text, responseModel: FunctionCallFactory::fromMethodName(DataStore::class, 'saveUser'), )->get(); (new DataStore)->saveUser(...$args); // @doctest id="722d" ``` ## Extracting Arguments for a Callable Closures and other callables work the same way. ```php use Cognesy\Addons\FunctionCall\FunctionCallFactory; use Cognesy\Instructor\StructuredOutput; /** Save user data to storage */ $callable = function(string $name, int $age, string $country) { // ... }; $text = "His name is Jason, he is 28 years old and he lives in Germany."; $args = (new StructuredOutput)->with( messages: $text, responseModel: FunctionCallFactory::fromCallable($callable), )->get(); $callable(...$args); // @doctest id="5e1d" ``` ## How It Works Under the hood, `FunctionCallFactory` uses `CallableSchemaFactory` to reflect on the callable's parameters and produce a `Schema` object. That schema is then wrapped in a `Structure` that Instructor can use as a response model. The LLM receives a JSON Schema derived from the function signature (including parameter names, types, and docblock descriptions) and returns matching values. ## Output Modes By default, Instructor uses `OutputMode::Tools` (tool-calling). You only need to change the mode when a specific provider or workflow requires JSON output instead of tool-calling. The function call extraction works with any output mode. ================================================================================ FILE: packages/instructor/advanced/manual_schemas.md ================================================================================ While Instructor can automatically generate schemas from PHP classes via reflection, you can also define schemas manually. This is useful when the data shape is determined at runtime, when you need fine-grained control over the exact schema sent to the LLM, or when you want to avoid the overhead of reflection. ## Using a Raw Schema Array The simplest approach is to pass a plain JSON Schema array as the response model. ```php use Cognesy\Instructor\StructuredOutput; $schema = [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string'], 'age' => ['type' => 'integer'], ], 'required' => ['name', 'age'], ]; $data = (new StructuredOutput) ->with(messages: 'Jane is 31 years old.', responseModel: $schema) ->getArray(); // @doctest id="b0aa" ``` When a schema array is provided instead of a class name, the result is returned as an associative array. You can also call `->get()` which will return a `Structure` object with dynamic property access. ## Using the JsonSchema Builder For more complex schemas, the `JsonSchema` class provides a fluent API with static factory methods for every JSON Schema type. ### Object Schemas ```php use Cognesy\Utils\JsonSchema\JsonSchema; $schema = JsonSchema::object( name: 'User', description: 'User data', properties: [ JsonSchema::string(name: 'name', description: 'User name'), JsonSchema::integer(name: 'age', description: 'User age'), JsonSchema::boolean(name: 'active', description: 'Is active'), ], requiredProperties: ['name', 'age'], ); // @doctest id="e506" ``` ### Primitive Types ```php JsonSchema::string(name: 'email', description: 'Email address'); JsonSchema::integer(name: 'count', description: 'Number of items'); JsonSchema::number(name: 'price', description: 'Product price'); JsonSchema::boolean(name: 'verified', description: 'Is verified'); // @doctest id="a7d6" ``` ### Arrays and Collections ```php JsonSchema::array( name: 'tags', itemSchema: JsonSchema::string(), description: 'List of tags', ); JsonSchema::collection( name: 'users', itemSchema: JsonSchema::object( name: 'User', properties: [ JsonSchema::string(name: 'name'), JsonSchema::integer(name: 'age'), ], ), description: 'List of users', ); // @doctest id="3607" ``` ### Enums ```php JsonSchema::enum( name: 'status', enumValues: ['pending', 'active', 'completed'], description: 'Order status', ); // @doctest id="5048" ``` ### Parsing an Existing Array If you already have a JSON Schema as an array, you can parse it into a `JsonSchema` object for further manipulation. ```php $schema = JsonSchema::fromArray([ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string'], 'age' => ['type' => 'integer'], ], 'required' => ['name'], ]); // @doctest id="1f8c" ``` ## Using JsonSchema with StructuredOutput `JsonSchema` implements `CanProvideJsonSchema`, so you can pass it directly as a response model. ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Utils\JsonSchema\JsonSchema; $userSchema = JsonSchema::object( name: 'User', properties: [ JsonSchema::string(name: 'name'), JsonSchema::integer(name: 'age'), ], requiredProperties: ['name'], ); $user = (new StructuredOutput) ->with( messages: 'Extract user: John Doe, 30 years old', responseModel: $userSchema, ) ->get(); // @doctest id="a7f1" ``` ## Complex Example Nested schemas with multiple types compose naturally. ```php $orderSchema = JsonSchema::object( name: 'Order', description: 'Customer order', properties: [ JsonSchema::string(name: 'orderId', description: 'Unique order identifier'), JsonSchema::object( name: 'customer', description: 'Customer information', properties: [ JsonSchema::string(name: 'name'), JsonSchema::string(name: 'email'), ], requiredProperties: ['name', 'email'], ), JsonSchema::collection( name: 'items', description: 'Order line items', itemSchema: JsonSchema::object( name: 'LineItem', properties: [ JsonSchema::string(name: 'product'), JsonSchema::integer(name: 'quantity'), JsonSchema::number(name: 'price'), ], requiredProperties: ['product', 'quantity', 'price'], ), ), JsonSchema::enum( name: 'status', enumValues: ['pending', 'shipped', 'delivered'], description: 'Order status', ), ], requiredProperties: ['orderId', 'customer', 'items', 'status'], ); $order = (new StructuredOutput) ->with( messages: 'Extract order details from: ...', responseModel: $orderSchema, ) ->get(); // @doctest id="e7ce" ``` ## When to Use Manual Schemas Manual schemas are the right choice when: - **Dynamic shapes** -- the structure is determined at runtime based on user input or configuration - **Provider optimization** -- you need to tweak schemas for specific LLM providers - **Legacy integration** -- you are working with existing JSON Schema specifications - **No class needed** -- the data shape is simple or used once, so defining a PHP class adds unnecessary ceremony For most cases, defining a PHP class and letting Instructor generate the schema via reflection is simpler, type-safe, and easier to refactor. Choose the approach that best fits your use case. ## Best Practices 1. **Use meaningful descriptions** -- LLMs use property descriptions to understand what data to extract 2. **Mark required fields explicitly** -- do not rely on defaults 3. **Extract common sub-schemas** -- assign reusable parts to variables to keep schemas DRY 4. **Inspect generated output** -- call `$schema->toJsonSchema()` to verify the schema looks correct ================================================================================ FILE: packages/instructor/advanced/json_extraction.md ================================================================================ LLMs do not always return clean JSON. Responses may arrive wrapped in markdown code blocks, surrounded by explanatory text, or with minor formatting errors such as trailing commas or unbalanced braces. Instructor includes a multi-strategy extraction pipeline that handles these edge cases transparently. ## Extraction Pipeline When processing an LLM response, Instructor tries multiple extraction strategies in order until one succeeds. ### 1. Direct JSON Parsing The response content is parsed directly as JSON. This handles the common case where the LLM returns a well-formed JSON object. ```text LLM response: {"name": "John", "age": 30} Result: Parsed successfully // @doctest id="16c2" ``` ### 2. Markdown Code Block Extraction Extracts JSON from fenced code blocks. Some providers (particularly Claude) tend to wrap JSON responses in markdown. ```text LLM response: Here's the data you requested: // @doctest id="ae7a" ```json {"name": "John", "age": 30} ``` Result: Content extracted from between // @doctest id="eeff" ```json and ``` markers ``` ### 3. Bracket Matching Finds the first `{` and last `}` in the response to extract JSON from surrounding text. // @doctest id="c136" ```text LLM response: The user data is {"name": "John", "age": 30} as extracted from the text. Result: JSON extracted from first { to last } ``` ### 4. Smart Brace Matching Handles complex cases with nested braces and escaped quotes inside string values. // @doctest id="df82" ```text LLM response: Here is {"user": {"name": "John \"The Great\"", "age": 30}} extracted. Result: Correctly handles nested braces and escaped quotes ``` ## Resilient Parsing After extraction, if standard `json_decode` fails, Instructor applies automatic repairs before parsing: - **Balance quotes** -- adds missing closing quotes - **Remove trailing commas** -- fixes `{"a": 1,}` patterns - **Balance braces** -- adds missing `}` or `]` characters This is especially valuable during streaming, where partial JSON chunks arrive before the response is complete. A dedicated partial JSON parser handles incomplete data by filling in null values for missing fields. ## Default Extractors The built-in extractor chain includes these extractors, tried in order: | Extractor | Purpose | |-----------|---------| | `DirectJsonExtractor` | Parse content directly as JSON | | `ResilientJsonExtractor` | Handle malformed JSON (trailing commas, unbalanced braces) | | `MarkdownBlockExtractor` | Extract from ` // @doctest id="a89e" ```json ``` ` blocks | | `BracketMatchingExtractor` | Find first `{` to last `}` | | `SmartBraceExtractor` | Handle nested braces and escaped quotes in strings | Most responses succeed on the first strategy. The subsequent strategies add negligible overhead and only activate when needed. ## Custom Extractors You can replace the default extractor with your own by calling `withExtractor()` on the `StructuredOutputRuntime`. Use `ResponseExtractor::fromExtractors()` to compose multiple extractors into a chain. ```php use Cognesy\Instructor\Extraction\Contracts\CanExtractResponse; use Cognesy\Instructor\Extraction\Data\ExtractionInput; use Cognesy\Instructor\Extraction\Exceptions\ExtractionException; class XmlCdataExtractor implements CanExtractResponse { public function extract(ExtractionInput $input): array { if (!preg_match('//s', $input->content, $matches)) { throw new ExtractionException('No CDATA found'); } $json = trim($matches[1]); try { $decoded = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); } catch (\JsonException $e) { throw new ExtractionException('Invalid JSON in CDATA', $e); } if (!is_array($decoded)) { throw new ExtractionException('Expected object or array in CDATA'); } return $decoded; } public function name(): string { return 'xml_cdata'; } } // @doctest id="e6ba" ``` ### Using Custom Extractors Custom extractors are configured on the runtime and apply to both synchronous and streaming responses. ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Instructor\Extraction\Extractors\DirectJsonExtractor; use Cognesy\Instructor\Extraction\ResponseExtractor; $runtime = StructuredOutputRuntime::fromDefaults() ->withExtractor(ResponseExtractor::fromExtractors( new DirectJsonExtractor(), new XmlCdataExtractor(), )); $result = (new StructuredOutput($runtime)) ->with(messages: 'Extract user data', responseModel: User::class) ->get(); // @doctest id="c965" ``` The extractors are tried in the order you provide them. When an extractor throws an `ExtractionException`, the next extractor in the chain is attempted. If all extractors fail, Instructor returns an empty result, triggers a validation error, and initiates the retry mechanism (if configured). ## Error Handling When extraction fails across all strategies, Instructor follows this sequence: 1. Returns an empty array from the extraction pipeline 2. Triggers a validation error on the deserialized object 3. If retries are configured, sends the error feedback to the LLM for self-correction 4. Repeats until the retry limit is reached or extraction succeeds ================================================================================ FILE: packages/instructor/advanced/output_formats.md ================================================================================ By default, Instructor deserializes LLM responses into PHP objects based on your response model class. The Output Format API lets you change this behavior while keeping the same schema definition. This decouples **schema specification** (what structure the LLM should produce) from **output format** (how you receive the result). ```php use Cognesy\Instructor\StructuredOutput; // Schema from User class, output as object (default) $user = (new StructuredOutput) ->with(messages: $text, responseModel: User::class) ->get(); // Same schema, output as array $data = (new StructuredOutput) ->withResponseClass(User::class) ->intoArray() ->withMessages($text) ->get(); // @doctest id="07de" ``` ## Available Output Formats ### intoArray() Returns extracted data as a plain associative array instead of an object. ```php $data = (new StructuredOutput) ->withResponseClass(User::class) ->intoArray() ->with(messages: 'Extract: John Doe, 30 years old') ->get(); // Result: ['name' => 'John Doe', 'age' => 30] // @doctest id="94fa" ``` This is useful for database insertion, JSON API responses, array manipulation, or when integrating with code that expects arrays. ```php // Store directly in database DB::table('users')->insert($data); // Or return as JSON API response return response()->json($data); // @doctest id="d7c5" ``` ### intoInstanceOf() Uses one class for the schema definition and a different class for the output object. ```php class UserProfile { public string $fullName; public int $age; public string $email; public string $phoneNumber; public string $address; } class UserDTO { public function __construct( public string $fullName = '', public string $email = '', ) {} } $user = (new StructuredOutput) ->withResponseClass(UserProfile::class) ->intoInstanceOf(UserDTO::class) ->with(messages: 'Extract: John Smith, 30, john@example.com, 555-1234, 123 Main St') ->get(); // $user is UserDTO with only fullName and email populated // @doctest id="202d" ``` The LLM still sees all five fields from `UserProfile`, ensuring thorough extraction. The output is then hydrated into the simpler `UserDTO`. This is valuable when you want to separate API contracts from internal models, simplify complex extraction results, or decouple domain models from presentation layers. ### intoObject() Provides a custom object that controls its own deserialization from the extracted array. The object must implement the `CanDeserializeSelf` interface. ```php use Cognesy\Instructor\Deserialization\Contracts\CanDeserializeSelf; class Money implements CanDeserializeSelf { public function __construct( private int $amountInCents = 0, private string $currency = 'USD', ) {} public function fromArray(array $data): static { return new self( amountInCents: (int)(($data['amount'] ?? 0) * 100), currency: strtoupper($data['currency'] ?? 'USD'), ); } } $price = (new StructuredOutput) ->withResponseClass(Product::class) ->intoObject(new Money()) ->with(messages: 'Extract price: $19.99 USD') ->get(); // @doctest id="33ae" ``` This is particularly useful with the built-in `Scalar` adapter for extracting single values. ```php use Cognesy\Instructor\Extras\Scalar\Scalar; $rating = (new StructuredOutput) ->with( messages: 'Rate this review: "Absolutely fantastic product!"', responseModel: Scalar::integer( name: 'rating', description: 'Rating from 1 to 5', ), ) ->get(); // Result: 5 (integer) // @doctest id="c2d1" ``` ## Streaming with Output Formats Output formats work seamlessly with streaming. During streaming, partial updates are always returned as objects (for validation and deduplication). The final result respects the output format you specified. ```php $stream = (new StructuredOutput) ->withResponseClass(Article::class) ->intoArray() ->with(messages: 'Extract article data') ->stream(); foreach ($stream->partials() as $partial) { // $partial is an Article object during streaming } $finalArticle = $stream->finalValue(); // Returns: ['title' => '...', 'author' => '...', 'content' => '...'] // @doctest id="89ba" ``` ## Comparison | Feature | Default (Object) | intoArray() | intoInstanceOf() | intoObject() | |---------|------------------|-------------|------------------|--------------| | **Output type** | Schema class | Array | Target class | Custom object | | **Validation** | Yes | Skipped | Yes | Custom | | **Streaming partials** | Object | Object | Object | Object | | **Streaming final** | Object | Array | Target class | Custom object | ## Common Patterns ### Conditional Deserialization Inspect data before choosing the target class. ```php $data = (new StructuredOutput) ->withResponseClass(User::class) ->intoArray() ->with(messages: 'Extract user') ->get(); $user = $data['age'] < 18 ? new MinorUser(...$data) : new AdultUser(...$data); // @doctest id="10cd" ``` ### Multi-Layer Architecture Use a rich domain model for extraction and a simplified DTO for your application layer. ```php class OrderDomain { public string $orderId; public CustomerInfo $customer; public array $items; public PaymentDetails $payment; } class OrderDTO { public function __construct( public string $orderId, public string $customerName, public float $total, ) {} } $order = (new StructuredOutput) ->withResponseClass(OrderDomain::class) ->intoInstanceOf(OrderDTO::class) ->with(messages: 'Extract order details') ->get(); // @doctest id="58a6" ``` ## Pluggable Extraction Instructor uses a pluggable extraction pipeline to convert raw LLM responses into canonical arrays. You can customize this pipeline on the `StructuredOutputRuntime` to support non-standard response formats. See [JSON Extraction](json_extraction.md) for details on the extraction pipeline and how to write custom extractors. ================================================================================ FILE: packages/instructor/advanced/prompts.md ================================================================================ Instructor provides several prompt hooks that let you shape the messages sent to the LLM. These are intentionally simple -- Instructor is not a prompt management framework, but it gives you the building blocks you need for most structured output tasks. During the current rollout there are two materialization paths: - `RequestMaterializer` is the legacy/default implementation - `StructuredPromptRequestMaterializer` is the new implementation built on prompt classes and markdown templates You can switch between them via `StructuredOutputRuntime::withRequestMaterializer()` without changing the caller-facing `StructuredOutput` code. ## Prompt Hooks ### System Message Set a system-level instruction that frames the entire extraction task. ```php use Cognesy\Instructor\StructuredOutput; $result = (new StructuredOutput) ->withSystem('You are a data extraction assistant. Be precise and thorough.') ->with(messages: $text, responseModel: Person::class) ->get(); // @doctest id="33f8" ``` ### User Prompt Add an additional prompt that supplements the input messages. This is useful for providing extraction instructions without mixing them into the data. ```php $result = (new StructuredOutput) ->withPrompt('Extract all person information. If age is not stated, estimate based on context.') ->with(messages: $text, responseModel: Person::class) ->get(); // @doctest id="383b" ``` ### Examples Provide input/output examples to guide the LLM's extraction behavior. Few-shot examples are one of the most effective ways to improve extraction accuracy. ```php use Cognesy\Instructor\Extras\Example\Example; $result = (new StructuredOutput) ->withExamples([ new Example( input: 'Dr. Smith is a 45-year-old cardiologist from Boston.', output: ['name' => 'Dr. Smith', 'age' => 45, 'occupation' => 'cardiologist'], ), ]) ->with(messages: $text, responseModel: Person::class) ->get(); // @doctest id="7c23" ``` ### Combined Usage All prompt hooks can be used together in a single request, either through the fluent API or the `with()` shorthand. ```php $result = (new StructuredOutput) ->withSystem('You are a precise data extraction assistant.') ->withPrompt('Extract person details from the text below.') ->withExamples($examples) ->withMessages($text) ->withResponseClass(Person::class) ->get(); // Or equivalently: $result = (new StructuredOutput)->with( system: 'You are a precise data extraction assistant.', prompt: 'Extract person details from the text below.', examples: $examples, messages: $text, responseModel: Person::class, )->get(); // @doctest id="fff7" ``` ## Using Stringable Objects as Prompts Both `withSystem()` and `withPrompt()` accept `string|\Stringable`, so you can pass any object that implements `Stringable` -- such as an xprompt `Prompt` class -- directly, without calling `->render()` or `(string)` yourself. The value is cast to string immediately at the boundary. ```php use Cognesy\Instructor\StructuredOutput; use App\Prompts\ExtractionSystem; $result = (new StructuredOutput) ->withSystem(ExtractionSystem::with(domain: 'finance')) ->withPrompt('Extract person details from the text below.') ->with(messages: $text, responseModel: Person::class) ->get(); // @doctest id="16b9" ``` ## Cached Context For applications that use the same large context across multiple requests (such as a long document or a set of reference materials), `withCachedContext()` marks content for provider-level prompt caching. This can significantly reduce costs and latency when supported by the provider. ```php $result = (new StructuredOutput) ->withCachedContext( system: 'You are a legal document analyst.', messages: $longDocument, prompt: 'Extract all party names and obligations.', examples: $examples, ) ->with(messages: 'Focus on section 3.', responseModel: ContractDetails::class) ->get(); // @doctest id="1df7" ``` Cached context messages are placed before the regular messages in the chat structure. The exact caching behavior depends on the LLM provider -- Anthropic and OpenAI both support prompt caching with different mechanisms. On the new `StructuredPromptRequestMaterializer` path, cached prompt content is no longer flattened into ordinary live messages. It is projected into `InferenceRequest::cachedContext()` so provider-native caching can take effect, while the per-request prompt remains live. ## Switching Materializers ```php use Cognesy\Instructor\Core\RequestMaterializer; use Cognesy\Instructor\Core\StructuredPromptRequestMaterializer; use Cognesy\Instructor\StructuredOutput; use Cognesy\Instructor\StructuredOutputRuntime; $runtime = StructuredOutputRuntime::fromDefaults() ->withRequestMaterializer(new StructuredPromptRequestMaterializer()); $legacyRuntime = $runtime->withRequestMaterializer(new RequestMaterializer()); $so = (new StructuredOutput) ->withRuntime($runtime) ->with(messages: $text, responseModel: Person::class); // @doctest id="ea6d" ``` This lets you run the same requests against both paths while the new materializer is being proven. ## Template Engine Integration If your application needs a more sophisticated prompt management system with variable interpolation, conditional logic, or template libraries, use the companion `Template` class from the `cognesy/template` package. ```php use Cognesy\Template\Template; $rendered = Template::twig() ->from('Extract {{ entity_type }} from the following text: {{ text }}') ->with(['entity_type' => 'person', 'text' => $input]) ->toText(); $result = (new StructuredOutput) ->with(messages: $rendered, responseModel: Person::class) ->get(); // @doctest id="d486" ``` The `Template` class supports Twig and Blade template engines, front matter metadata, chat message markup, and template libraries loaded from disk. Render your templates into strings or message arrays, then pass the result into `StructuredOutput`. See the Template package documentation for full details. ================================================================================ FILE: packages/instructor/advanced/model_options.md ================================================================================ Instructor provides several levels of configuration for controlling which model is used and how requests are sent to the LLM provider. ## Per-Request Options The simplest way to control the model and options is to pass them directly in the request. This is ideal for one-off adjustments. ```php use Cognesy\Instructor\StructuredOutput; $person = (new StructuredOutput)->with( messages: $text, responseModel: Person::class, model: 'gpt-4o-mini', options: ['temperature' => 0], )->get(); // @doctest id="db0e" ``` You can also use the fluent API for the same result. ```php $person = (new StructuredOutput) ->withMessages($text) ->withResponseClass(Person::class) ->withModel('gpt-4o-mini') ->withOptions(['temperature' => 0]) ->get(); // @doctest id="ac92" ``` The `options` array is passed directly to the LLM provider. Common options include `temperature`, `max_tokens`, and `top_p`, though available options vary by provider and model. ## Using LLMConfig When you need consistent settings across multiple requests -- such as a custom API key, base URL, or organization -- use `LLMConfig` to construct a configured `StructuredOutput` instance. ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Polyglot\Inference\Config\LLMConfig; $config = new LLMConfig( apiUrl: 'https://api.openai.com/v1', apiKey: $yourApiKey, endpoint: '/chat/completions', metadata: ['organization' => ''], model: 'gpt-4o-mini', maxTokens: 128, driver: 'openai', ); $structuredOutput = StructuredOutput::fromConfig($config); $person = $structuredOutput->with( messages: $text, responseModel: Person::class, options: ['temperature' => 0], )->get(); // @doctest id="62b8" ``` Per-request `model` and `options` values override the corresponding `LLMConfig` defaults, so you can set sensible defaults in the config and adjust individual requests as needed. ## Using Presets If you have named LLM configurations defined in a configuration file, you can load them by name. ```php $person = StructuredOutput::using('anthropic') ->with(messages: $text, responseModel: Person::class) ->get(); // @doctest id="7d3c" ``` The preset name is resolved through `LLMConfig::fromPreset()`, which loads the connection details from your configuration. ## Using StructuredOutputRuntime For the highest level of control, create a `StructuredOutputRuntime` directly. This gives you access to output mode, retry settings, custom validators, transformers, deserializers, and extractors. ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Instructor\Enums\OutputMode; $runtime = StructuredOutputRuntime::fromDefaults() ->withOutputMode(OutputMode::Json) ->withMaxRetries(3); $person = (new StructuredOutput($runtime)) ->with(messages: $text, responseModel: Person::class) ->get(); // @doctest id="9502" ``` ## Common Options These options are widely supported across providers, though exact behavior may vary. | Option | Type | Description | |--------|------|-------------| | `temperature` | float | Controls randomness. Lower values (e.g. 0) produce more deterministic output. | | `max_tokens` | int | Maximum number of tokens in the response. | | `top_p` | float | Nucleus sampling threshold. | | `stop` | array | Stop sequences that end generation. | | `stream` | bool | Enable streaming (prefer `withStreaming()` instead). | Provider-specific options (such as `response_format` for OpenAI or `thinking` for Anthropic) can also be passed through the `options` array. Consult your provider's API documentation for details. ================================================================================ FILE: packages/instructor/advanced/structure-to-structure.md ================================================================================ Instructor can accept structured data as input, not just raw text. This enables powerful object-to-object transformations where the LLM acts as an intelligent mapping and enrichment layer between two data shapes. ## Basic Usage Use `withInput()` to pass arrays or objects as input. Instructor serializes them into messages automatically. ```php use Cognesy\Instructor\StructuredOutput; $result = (new StructuredOutput) ->withInput(['name' => 'Jane', 'bio' => 'Engineer from Berlin']) ->withResponseClass(Profile::class) ->get(); // @doctest id="d648" ``` ## Object-to-Object Transformation The most common use case is transforming one object into another, using the LLM to interpret, translate, or enrich the data along the way. ```php use Cognesy\Instructor\StructuredOutput; class Email { public function __construct( public string $address = '', public string $subject = '', public string $body = '', ) {} } $email = new Email( address: 'joe@gmail.com', subject: 'Status update', body: 'Your account has been updated.', ); $translation = (new StructuredOutput) ->withInput($email) ->withPrompt('Translate the text fields of email to Spanish. Keep other fields unchanged.') ->withResponseClass(Email::class) ->get(); // Email { // address: "joe@gmail.com", // subject: "Actualización de estado", // body: "Su cuenta ha sido actualizada." // } // @doctest id="0e68" ``` The input object is serialized into the message content, and the LLM produces a new object of the specified response model class. The `prompt` parameter provides instructions for how to transform the data. ## Array Input Arrays work the same way. This is useful when your source data comes from a database query, API response, or form submission. ```php $result = (new StructuredOutput) ->withInput([ 'product' => 'Wireless Mouse', 'features' => ['Bluetooth 5.0', '1600 DPI', 'USB-C charging'], 'price' => 29.99, ]) ->withPrompt('Generate a marketing-friendly product listing from this data.') ->withResponseClass(ProductListing::class) ->get(); // @doctest id="045c" ``` ## String Input Plain strings are also accepted. In this case, `withInput()` behaves the same as `withMessages()`. ```php $result = (new StructuredOutput) ->withInput('Jane Doe, 31, Berlin') ->withResponseClass(Person::class) ->get(); // @doctest id="6623" ``` ## When to Use Structure-to-Structure This pattern is most valuable when: - **Translating or localizing** structured content while preserving the data shape - **Enriching** existing data with LLM-generated content (e.g., adding descriptions, summaries, or tags) - **Mapping** between different schemas, using the LLM to handle ambiguity that rule-based mapping cannot - **Normalizing** messy or inconsistent structured data into a clean format ================================================================================ FILE: packages/instructor/techniques/prompting.md ================================================================================ Instructor shifts much of the "prompting" work into the response model itself. A well-designed class with clear property names, PHPDoc comments, and typed fields often communicates intent more effectively than a lengthy system prompt. ## General Principles - **State the task plainly.** A short, direct instruction outperforms a wall of text. - **Provide the source material.** Pass the text you want analyzed as part of the message. - **Let the schema do the heavy lifting.** Prefer changing the response model before adding more prompt detail. ## Use PHPDoc Comments as Instructions The model receives your class structure as a JSON Schema. PHPDoc comments on the class and its properties become `description` fields in that schema, giving the model precise guidance without bloating the prompt. ```php /** Extract the user's profile from the provided text. */ final class UserDetail { public int $age; public string $name; /** Assign the most appropriate role based on context. */ public ?Role $role = null; } // @doctest id="77ca" ``` ## Nullable Fields for Optional Data Use PHP's nullable types and set a default of `null` to signal that a field is truly optional. This prevents the model from inventing values when the source text does not contain the information. ```php final class UserDetail { public int $age; public string $name; public ?string $nickname = null; } // @doctest id="5ef0" ``` ## Enums for Standardized Fields Use backed enums whenever a property has a fixed set of valid values. Always include a fallback case so the model can signal uncertainty rather than forcing an incorrect choice. ```php enum Role: string { case Principal = 'principal'; case Teacher = 'teacher'; case Student = 'student'; case Other = 'other'; } final class UserDetail { public int $age; public string $name; /** Correctly assign one of the predefined roles to the user. */ public Role $role; } // @doctest id="6731" ``` ## Chain of Thought Adding a "reasoning" or "chain of thought" field encourages the model to think step-by-step before producing the final answer. This works especially well for classification, entity extraction, and any task where intermediate reasoning improves accuracy. ```php final class Role { /** Think step by step to determine the correct title. */ public string $chainOfThought; public string $title; } final class UserDetail { public int $age; public string $name; public Role $role; } // @doctest id="b780" ``` You can make chain of thought modular by embedding it inside nested components rather than at the top level of the response model. ## Reiterate Long Instructions For complex extraction rules, restate the instructions in the field description. This keeps the guidance close to the point where the model generates its output. ```php /** Extract the role based on the following rules: */ final class Role { /** Restate the instructions and rules to correctly determine the title. */ public string $instructions; public string $title; } // @doctest id="46a3" ``` ## Handle Arbitrary Properties When the set of properties is not known ahead of time, use a list of key-value pairs. ```php final class Property { public string $key; public string $value; } final class UserDetail { public int $age; public string $name; /** @var Property[] Extract any other relevant properties. */ public array $properties; } // @doctest id="3cf9" ``` ### Limiting List Length Control the number of extracted items by stating the constraint in the PHPDoc comment and optionally enforcing it with validation. ```php final class UserDetail { public int $age; public string $name; /** @var Property[] Extracted properties, no more than 3. */ public array $properties; } // @doctest id="5455" ``` ### Consistent Keys Across Records When extracting multiple records with arbitrary properties, instruct the model to use consistent key names so downstream code can process them uniformly. ```php final class UserDetails { /** @var UserDetail[] Use consistent key names for properties across users. */ public array $users; } // @doctest id="978b" ``` ## Define Entity Relationships When relationships exist between extracted entities, model them explicitly with identifiers and reference arrays. ```php final class UserDetail { /** Unique identifier for each user. */ public int $id; public int $age; public string $name; public string $role; /** @var int[] IDs of coworkers this user collaborates with. */ public array $coworkers; } final class UserRelationships { /** @var UserDetail[] Capture all users and their relationships. */ public array $users; } // @doctest id="d261" ``` ## Reuse Components Across Contexts The same class can appear in multiple properties with different PHPDoc descriptions, giving each usage its own semantic meaning. ```php final class TimeRange { /** The start time in hours. */ public int $startTime; /** The end time in hours. */ public int $endTime; } final class UserDetail { public string $name; /** Time range during which the user is working. */ public TimeRange $workTime; /** Time range reserved for leisure activities. */ public TimeRange $leisureTime; } // @doctest id="1e0d" ``` ## Error Handling with Wrapper Models Create a wrapper class that can hold either a successful result or an error message. This lets you stay within structured output even when the input is ambiguous or invalid. ```php final class MaybeUser { public ?UserDetail $result = null; public bool $error = false; public ?string $errorMessage = null; public function get(): ?UserDetail { return $this->error ? null : $this->result; } } // @doctest id="8da5" ``` ================================================================================ FILE: packages/instructor/techniques/classification.md ================================================================================ Text classification is one of the most common tasks in natural language processing. Whether you are detecting spam, categorizing support tickets, or routing content, Instructor makes it straightforward by combining PHP enums with response models. ## Single-Label Classification Define a backed enum for the possible labels and a response model that holds the prediction. ```php use Cognesy\Instructor\StructuredOutput; enum Label: string { case SPAM = 'spam'; case NOT_SPAM = 'not_spam'; } final class SinglePrediction { public Label $classLabel; } $prediction = (new StructuredOutput) ->with( messages: 'Classify the following text: Hello there, I\'m a Nigerian prince and I want to give you money.', responseModel: SinglePrediction::class, ) ->get(); assert($prediction->classLabel === Label::SPAM); // @doctest id="470c" ``` The model sees the enum values in the generated JSON Schema and picks the most appropriate one. Keep the enum small and descriptive -- fewer choices typically produce more accurate results. ## Multi-Label Classification When a single input can belong to several categories at once, use a typed array of enums. ```php use Cognesy\Instructor\StructuredOutput; enum TicketCategory: string { case TECH_ISSUE = 'tech_issue'; case BILLING = 'billing'; case SALES = 'sales'; case SPAM = 'spam'; case OTHER = 'other'; } final class Ticket { /** @var TicketCategory[] */ public array $labels = []; } $ticket = (new StructuredOutput) ->with( messages: 'Classify this support ticket: My account is locked and I can\'t access my billing info.', responseModel: Ticket::class, ) ->get(); assert(in_array(TicketCategory::TECH_ISSUE, $ticket->labels)); assert(in_array(TicketCategory::BILLING, $ticket->labels)); // @doctest id="1c0a" ``` ## Tips for Better Classification ### Always include a fallback option Adding an `OTHER` or `UNKNOWN` case gives the model an escape hatch when the input does not fit neatly into your predefined categories. Without it, the model is forced to pick an incorrect label. ### Use descriptive enum values The string values of your enum cases are included in the JSON Schema that the model receives. Descriptive values like `tech_issue` communicate intent better than opaque codes like `T1`. ### Add PHPDoc descriptions You can annotate enum cases or the response model properties with PHPDoc comments to give the model additional guidance. ```php final class SentimentResult { /** The overall sentiment of the input text. Choose the single best match. */ public Sentiment $sentiment; /** Brief explanation of why this sentiment was chosen. */ public string $reasoning; } // @doctest id="80f2" ``` ### Keep the schema narrow Classification quality improves when the model has fewer valid shapes to choose from. If your response model only needs a label, do not add extra fields. If you need confidence scores or explanations, add them as separate properties so the model can fill them independently. ### Validate with custom rules For multi-label classification, you may want to enforce constraints like "at least one label" or "no more than three labels". Use the `ValidationMixin` trait or implement `CanValidateSelf` to add custom validation logic that Instructor will enforce automatically. ```php use Cognesy\Instructor\Validation\Traits\ValidationMixin; use Cognesy\Instructor\Validation\ValidationResult; final class Ticket { use ValidationMixin; /** @var TicketCategory[] Must contain at least one label. */ public array $labels = []; public function validate(): ValidationResult { if (empty($this->labels)) { return ValidationResult::fieldError( field: 'labels', value: $this->labels, message: 'At least one label is required.', ); } return ValidationResult::valid(); } } // @doctest id="5c95" ``` ================================================================================ FILE: packages/instructor/techniques/search.md ================================================================================ A common use case for structured output is converting free-form text into one or more search queries that your application can execute against an API, database, or search engine. Instructor handles the parsing so you can focus on the domain logic. ## Defining the Query Structure Start by modeling what a single search query looks like. Use an enum when the query type comes from a fixed set, and add a PHPDoc comment to guide the model on how to rewrite the user's request into an effective query. ```php enum SearchType: string { case TEXT = 'text'; case IMAGE = 'image'; case VIDEO = 'video'; } final class SearchQuery { public string $title; /** Rewrite the user's intent as a concise search-engine query. */ public string $query; /** The type of content to search for. */ public SearchType $type; public function execute(): void { // dispatch to the appropriate search backend } } // @doctest id="76d6" ``` ## Segmenting Into Multiple Queries Wrap the query class in a container that holds an array. This lets the model split a complex, multi-part request into separate, actionable queries. ```php final class Search { /** @var SearchQuery[] */ public array $queries = []; } // @doctest id="ce67" ``` ## Putting It Together Pass the user's request as a message and let Instructor decompose it. ```php use Cognesy\Instructor\StructuredOutput; function segment(string $input): Search { return (new StructuredOutput) ->with( messages: "Consider the data below:\n'{$input}'\nand segment it into multiple search queries.", responseModel: Search::class, ) ->get(); } $results = segment('Find a picture of a cat and a video of a dog'); foreach ($results->queries as $query) { $query->execute(); } // @doctest id="15f1" ``` ## Design Tips - **Match the response model to your consumer.** The shape of `SearchQuery` should mirror how your search backend expects input. If your API needs filters, add typed filter properties rather than encoding everything in a single query string. - **Use `Sequence` for streamed results.** When you want to process queries as they arrive rather than waiting for the full response, use Instructor's `Sequence` wrapper with streaming enabled. - **Keep the schema focused.** A small, well-typed class produces better results than a large, generic one. If text and image searches require different parameters, consider separate classes and a discriminated union pattern. ================================================================================ FILE: packages/instructor/internals/instructor.md ================================================================================ ## Overview The public surface of the `cognesy/instructor-struct` package is intentionally small. Most interactions happen through a handful of classes that form a clear request-build-execute pipeline. | Class | Role | |---|---| | `StructuredOutput` | Immutable request builder and main facade | | `StructuredOutputRuntime` | Configures the provider, event handling, and runtime behavior | | `PendingStructuredOutput` | Lazy execution handle returned by `create()` | | `StructuredOutputStream` | Streaming read interface for partial and sequence updates | | `StructuredOutputResponse` | Wraps the parsed value together with the raw provider response | Most users should stay at this level. The sections below explain each type in detail. ## `StructuredOutput` `StructuredOutput` is the main entry point to the library. It is an immutable builder -- every `with*()` call returns a new copy, so you can safely reuse a configured instance across multiple requests. ### Creating an Instance ```php use Cognesy\Instructor\StructuredOutput; // Defaults -- uses the default LLM provider $so = new StructuredOutput(); // From a named preset (resolves YAML config) $so = StructuredOutput::using('openai'); // From an explicit LLMConfig $so = StructuredOutput::fromConfig($llmConfig); // With a custom runtime $so = (new StructuredOutput())->withRuntime($runtime); // @doctest id="18aa" ``` ### Setting Request Parameters The builder exposes fine-grained setters as well as a convenience `with()` method that accepts all parameters at once: ```php $so = (new StructuredOutput()) ->withMessages('Jason is 25 years old') ->withResponseClass(User::class) ->withSystem('Extract user data from the text.') ->withModel('gpt-4o'); // @doctest id="2809" ``` Or equivalently: ```php $so = (new StructuredOutput())->with( messages: 'Jason is 25 years old', responseModel: User::class, system: 'Extract user data from the text.', model: 'gpt-4o', ); // @doctest id="207f" ``` ### Executing and Retrieving Results ```php // Get the parsed value directly $user = $so->get(); // Get a typed scalar $count = $so->getInt(); // Get the full response envelope (value + raw provider response) $response = $so->response(); // Get only the raw inference response $raw = $so->inferenceResponse(); // Stream partial updates $stream = $so->stream(); // @doctest id="e297" ``` All of the above are shortcuts that internally call `create()` to obtain a `PendingStructuredOutput`, then forward to the appropriate method. ## `StructuredOutputRuntime` `StructuredOutputRuntime` assembles the runtime dependencies -- inference provider, event dispatcher, configuration, and optional pipeline customizations (validators, transformers, deserializers, extractors). ```php use Cognesy\Instructor\StructuredOutputRuntime; $runtime = StructuredOutputRuntime::fromConfig($llmConfig); $runtime = StructuredOutputRuntime::fromDefaults(); $runtime = StructuredOutputRuntime::fromProvider($provider); // @doctest id="00bc" ``` ### Event Listeners The runtime owns the event dispatcher. Attach listeners here for logging, monitoring, or debugging: ```php $runtime ->onEvent(ResponseValidationFailed::class, fn($e) => logger()->warning($e)) ->wiretap(fn($event) => $event->print()); // @doctest id="0da1" ``` ### Pipeline Customization You can inject custom validators, transformers, deserializers, and extractors at the runtime level. These apply to every request processed through the runtime: ```php $runtime = $runtime ->withValidator(new MyCustomValidator()) ->withTransformer(new MyTransformer()); // @doctest id="f783" ``` ## `PendingStructuredOutput` `PendingStructuredOutput` is the lazy execution handle returned by `create()`. No network call is made until you ask for a result. It coordinates one-shot access across `get()`, `response()`, `inferenceResponse()`, and `stream()`. ```php $pending = $so->create(); // Trigger execution and get the value $value = $pending->get(); // Or inspect the full response $response = $pending->response(); // Or access the raw inference response $raw = $pending->inferenceResponse(); // Or stream partial updates $stream = $pending->stream(); // @doctest id="5343" ``` The handle also provides typed accessors via the `HandlesResultTypecasting` trait: `getString()`, `getInt()`, `getFloat()`, `getBoolean()`, `getArray()`, `getObject()`, and `getInstanceOf(SomeClass::class)`. ## `StructuredOutputStream` `StructuredOutputStream` exposes streaming reads when the request is executed with streaming enabled. It provides several iteration modes: ```php $stream = $so->stream(); // Iterate over partial parsed values foreach ($stream->partials() as $partial) { echo $partial->name; // progressively updated } // Iterate over completed sequence items only foreach ($stream->sequence() as $item) { // each $item is a fully completed Sequenceable } // Iterate over response snapshots (includes isPartial flag) foreach ($stream->responses() as $response) { // $response->isPartial(), $response->value(), etc. } // Consume the stream and get the final value $final = $stream->finalValue(); // Get the final response envelope $finalResponse = $stream->finalResponse(); // @doctest id="0ca1" ``` ## `StructuredOutputResponse` `StructuredOutputResponse` is a read-only envelope that pairs the parsed value with the raw provider response: ```php $response = $so->response(); $response->value(); // the deserialized object or scalar $response->inferenceResponse(); // InferenceResponse from the provider $response->isPartial(); // false for final responses $response->usage(); // token usage stats $response->finishReason(); // stop, length, tool_calls, etc. $response->content(); // raw content string $response->toolCalls(); // tool call data (when using Tools mode) // @doctest id="96d0" ``` ## Error Handling When a request fails validation or deserialization, the package uses its retry mechanism (controlled by `maxRetries` in `StructuredOutputConfig`) to re-prompt the LLM with error feedback. If all retries are exhausted, an exception is thrown. Unrecoverable errors (e.g., network failures, missing response model) throw immediately without retry. ================================================================================ FILE: packages/instructor/internals/lifecycle.md ================================================================================ ## Overview As Instructor processes your request, it moves through a well-defined series of stages. Understanding this lifecycle helps when debugging unexpected output or building custom extensions. ## Request Lifecycle Steps ### 1. Build the Request The `StructuredOutput` facade collects your messages, response model, system prompt, examples, model overrides, and options into an immutable `StructuredOutputRequest`. ### 2. Analyze the Response Model The `ResponseModelFactory` inspects the `responseModel` parameter and determines how to build a schema. Depending on the type of input, it follows one of several paths (class string, object instance, raw JSON Schema array, schema provider, etc.). The result is a `ResponseModel` containing the target class, a `Schema`, and the rendered JSON Schema for the provider. ### 3. Translate to Provider Schema The schema is rendered into the format required by the selected output mode: | Output Mode | Schema Delivery | |---|---| | `Tools` | Wrapped in a tool-call function definition | | `Json` | Included in the system/user prompt as text | | `JsonSchema` | Sent via the provider's `response_format` parameter | | `MdJson` | Included in the prompt, response expected in a ```json``` codeblock | | `Text` | Schema included in the prompt; JSON extracted from unstructured text | | `Unrestricted` | No output constraints; extraction is best-effort | ### 4. Execute the Inference Request The `PendingStructuredOutput` delegates to the configured `CanCreateInference` implementation (via the Polyglot inference layer). For streaming requests, the response arrives as a series of chunks. ### 5. Extract Structured Data The `CanExtractResponse` extractor pulls JSON data from the raw inference response. The extraction strategy depends on the output mode -- for `Tools` mode it reads tool-call arguments; for `Json`/`JsonSchema` it parses the content directly; for `MdJson` it extracts from a fenced code block. ### 6. Deserialize into the Target Shape The extracted array data is deserialized into the target PHP class (or returned as an array if `intoArray()` was specified). Classes implementing `CanDeserializeSelf` can override this step entirely. ### 7. Validate the Result The deserialized object is validated. Built-in validation uses Symfony Validator constraints declared on the response model class. Classes implementing `CanValidateSelf` can provide their own validation logic. ### 8. Transform (Optional) If the response model implements `CanTransformSelf`, the validated object is transformed into a different value before being returned to the caller. This is how helpers like `Scalar` unwrap a wrapper class into a plain PHP scalar. ### 9. Return the Result The final value is wrapped in a `StructuredOutputResponse` (or yielded as a stream of partial responses) and returned to the caller. ## Retry Loop Steps 4 through 8 run inside a retry loop. When validation or deserialization fails: 1. The error message is formatted as feedback for the LLM. 2. The feedback is appended to the conversation as a retry message. 3. The LLM is called again with the updated context. 4. The cycle repeats until the response passes or `maxRetries` is exhausted. The retry budget is configured via `StructuredOutputConfig::maxRetries`. A value of `0` (the default) means a single attempt with no retries. The total number of attempts is always `maxRetries + 1`. ```php // Allow up to 3 retries (4 total attempts) $runtime = $runtime->withMaxRetries(3); // @doctest id="4d9a" ``` When the retry limit is reached without a valid response, a `StructuredOutputRecoveryException` is thrown. ## Streaming Lifecycle When streaming is enabled, the lifecycle diverges after step 4: 1. Chunks arrive incrementally from the provider. 2. Each chunk is accumulated into a partial JSON string. 3. The partial JSON is deserialized into a partial object (best-effort). 4. A `StructuredOutputResponse` snapshot (marked as partial) is emitted. 5. When the stream completes, the final response goes through the full validation and transformation pipeline (steps 7-8). 6. The finalized response is emitted as a non-partial `StructuredOutputResponse`. Partial updates are available through `StructuredOutputStream::partials()`, `sequence()`, or `responses()`. ================================================================================ FILE: packages/instructor/internals/configuration_path.md ================================================================================ ## Overview The structured-output package itself does not depend on a published configuration path. At the package boundary, configuration is passed as typed objects (`LLMConfig`, `StructuredOutputConfig`, `StructuredOutputRuntime`). Configuration path resolution matters when you load LLM provider presets from YAML files. This page explains how that resolution works. ## Preset-Based Configuration Load a named preset to create an `LLMConfig`: ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; $config = LLMConfig::fromPreset('openai'); // @doctest id="b617" ``` You can also pass an explicit base path: ```php $config = LLMConfig::fromPreset('openai', '/path/to/my/presets'); // @doctest id="9255" ``` ## Path Resolution Order When no explicit base path is given, `LLMConfig::fromPreset()` searches the following locations (in order) and uses the first one that exists: 1. `config/llm/presets/` -- project-level published presets 2. `packages/polyglot/resources/config/llm/presets/` -- monorepo location 3. `vendor/cognesy/instructor-php/packages/polyglot/resources/config/llm/presets/` -- Composer install (monorepo package) 4. `vendor/cognesy/instructor-polyglot/resources/config/llm/presets/` -- Composer install (standalone package) This means presets work automatically in most project layouts without any manual path configuration. ## Environment Variable You can set the `INSTRUCTOR_CONFIG_PATHS` environment variable in your `.env` file to tell the broader InstructorPHP ecosystem where to find configuration files: ```ini INSTRUCTOR_CONFIG_PATHS='config,vendor/cognesy/instructor-php/config' # @doctest id="1883" ``` This variable is used by companion packages and the CLI tooling. The structured-output package does not read it directly -- it relies on `LLMConfig` preset resolution or explicit configuration objects. ## Key Types at the Package Boundary | Type | Purpose | |---|---| | `LLMConfig` | Provider connection settings (API URL, key, model, driver) | | `StructuredOutputConfig` | Structured output behavior (output mode, retries, prompts) | | `StructuredOutputRuntime` | Assembled runtime with inference provider and event handling | If your application resolves presets from files, that resolution happens before these types are constructed. The structured-output package only sees the resulting typed objects. ================================================================================ FILE: packages/instructor/internals/config_files.md ================================================================================ ## Overview The `cognesy/instructor-struct` package works without any published configuration files. All structured-output behavior is controlled through typed configuration objects in code. When configuration files are present in a project, they typically serve the companion Polyglot package (provider presets) rather than the structured-output package itself. ## Provider Presets LLM provider connections are configured through YAML preset files managed by the `cognesy/polyglot` package. Each preset defines the API URL, driver, model, token limits, and other provider-specific settings: ``` packages/polyglot/resources/config/llm/presets/ openai.yaml anthropic.yaml gemini.yaml groq.yaml ... // @doctest id="6c3a" ``` You load a preset with `LLMConfig::fromPreset()`: ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; $config = LLMConfig::fromPreset('openai'); // @doctest id="27ef" ``` The preset resolution searches several paths automatically (project `config/` directory, monorepo paths, and Composer vendor paths), so presets work out of the box in most setups. ## Configuration Groups In the broader InstructorPHP ecosystem, configuration is organized into groups. Each group is stored in a separate file. The main groups are: | Group | Purpose | |---|---| | `llm` | LLM provider connections and presets | | `structured` | Structured output behavior (output mode, retries, prompts) | | `embed` | Embedding provider connections | | `http` | HTTP client configurations | | `prompt` | Prompt libraries and settings | | `web` | Web service providers (scrapers, etc.) | | `debug` | Debugging settings | The structured-output package only consumes the `structured` and `llm` groups. Other groups belong to companion packages. ## Structured Output Configuration Rather than reading from config files, the structured-output package uses the `StructuredOutputConfig` class directly: ```php use Cognesy\Instructor\Config\StructuredOutputConfig; use Cognesy\Instructor\Enums\OutputMode; $config = new StructuredOutputConfig( outputMode: OutputMode::Tools, maxRetries: 2, toolName: 'extract_data', ); // @doctest id="f535" ``` This keeps the package independent from any file-based configuration system while still allowing integration with one when needed (via `fromArray()` or `fromDsn()`). ================================================================================ FILE: packages/instructor/internals/settings_class.md ================================================================================ ## Overview The structured-output package deliberately avoids a single global settings object. Configuration is split across purpose-specific classes, keeping request configuration local and shared behavior reusable. ## Configuration Classes ### `LLMConfig` Holds provider connection settings: API URL, API key, model name, driver, token limits, and provider-specific options. ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; // From a named preset $config = LLMConfig::fromPreset('openai'); // From explicit values $config = new LLMConfig( apiUrl: 'https://api.openai.com/v1', apiKey: 'sk-...', model: 'gpt-4o', driver: 'openai-compatible', maxTokens: 4096, ); // From an array (e.g., loaded from a config file) $config = LLMConfig::fromArray($data); // From a DSN string $config = LLMConfig::fromDsn('driver=openai,model=gpt-4o,apiKey=sk-...'); // @doctest id="e44f" ``` ### `StructuredOutputConfig` Controls the structured-output behavior: output mode, retry settings, prompt templates, schema naming, and response caching. ```php use Cognesy\Instructor\Config\StructuredOutputConfig; use Cognesy\Instructor\Enums\OutputMode; $config = new StructuredOutputConfig( outputMode: OutputMode::Tools, maxRetries: 2, toolName: 'extract_data', toolDescription: 'Extract structured data from the input.', ); // @doctest id="585f" ``` Key settings: | Setting | Default | Purpose | |---|---|---| | `outputMode` | `Tools` | How the schema is delivered to the LLM (`Tools`, `Json`, `JsonSchema`, `MdJson`, `Text`, `Unrestricted`) | | `maxRetries` | `0` | Maximum retry attempts after the first call | | `retryPrompt` | `"JSON generated incorrectly..."` | Template for retry feedback messages | | `toolName` | `"extracted_data"` | Function name used in tool-call mode | | `toolDescription` | `"Function call based on..."` | Description sent with the tool definition | | `schemaName` | `"default_schema"` | Name used in JSON Schema mode | | `useObjectReferences` | `false` | Enable `$ref` usage in generated schemas | | `defaultToStdClass` | `false` | Return `stdClass` instead of arrays for untyped schemas | | `responseCachePolicy` | `None` | Whether to cache streaming response snapshots for replay | ### `StructuredOutputRuntime` Assembles the runtime by combining an inference provider, event dispatcher, structured-output config, and optional pipeline customizations: ```php use Cognesy\Instructor\StructuredOutputRuntime; $runtime = StructuredOutputRuntime::fromConfig($llmConfig); // Or with full customization $runtime = new StructuredOutputRuntime( inference: $inferenceRuntime, events: $eventDispatcher, config: $structuredConfig, validators: [MyValidator::class], transformers: [MyTransformer::class], ); // @doctest id="3754" ``` ## Why No Global Settings? Splitting configuration serves several goals: 1. **Locality** -- each request can use a different provider or different retry settings without affecting other requests. 2. **Testability** -- configuration objects are plain value objects that can be constructed in tests without touching global state. 3. **Composability** -- the same `LLMConfig` can be shared across the structured-output package, the Polyglot inference layer, and other companions without coupling them together. 4. **Immutability** -- both `LLMConfig` and `StructuredOutputConfig` are immutable. Mutation methods return new instances, making configuration safe to share across concurrent or reentrant code paths. ================================================================================ FILE: packages/instructor/internals/environment.md ================================================================================ ## Overview Environment variables are used at provider configuration time, not in the structured-output API itself. Your application or preset loader reads the variables, `LLMConfig` resolves provider settings from them, and `StructuredOutputRuntime` uses the resulting configuration object. This keeps the package independent from any specific framework bootstrap process. ## LLM Provider API Keys Instructor supports many LLM providers. Configure the API keys for the ones you plan to use in your `.env` file: ```ini # Primary providers OPENAI_API_KEY='' ANTHROPIC_API_KEY='' GEMINI_API_KEY='' # Additional providers A21_API_KEY='' ANYSCALE_API_KEY='' AZURE_OPENAI_API_KEY='' CEREBRAS_API_KEY='' COHERE_API_KEY='' FIREWORKS_API_KEY='' GROQ_API_KEY='' MISTRAL_API_KEY='' OLLAMA_API_KEY='' OPENROUTER_API_KEY='' SAMBANOVA_API_KEY='' TOGETHER_API_KEY='' XAI_API_KEY='' # @doctest id="c713" ``` Only configure the providers you plan to use. Empty keys are ignored. ## Embedding Provider Keys If you use embedding features (via companion packages), configure these as well: ```ini AZURE_OPENAI_EMBED_API_KEY='' JINA_API_KEY='' # @doctest id="dc35" ``` ## Web Service Keys Scraping and web-related add-ons use their own API keys: ```ini JINAREADER_API_KEY='' SCRAPFLY_API_KEY='' SCRAPINGBEE_API_KEY='' # @doctest id="47c4" ``` These are not required for core structured-output functionality. ## Configuration Directory Path The `INSTRUCTOR_CONFIG_PATHS` variable tells the InstructorPHP ecosystem where to find configuration files: ```ini INSTRUCTOR_CONFIG_PATHS='config,vendor/cognesy/instructor-php/config' # @doctest id="14f4" ``` This is primarily used by the CLI tooling and companion packages. The structured-output package resolves provider presets through `LLMConfig::fromPreset()`, which has its own path resolution logic (see [Configuration Path](configuration_path.md)). ## How It Fits Together ``` .env file | v LLM provider preset (YAML) <-- reads API key from env | v LLMConfig <-- typed configuration object | v StructuredOutputRuntime <-- assembled runtime | v StructuredOutput <-- your application code // @doctest id="a8ae" ``` The structured-output package never reads environment variables directly. The separation ensures that the same `LLMConfig` can be constructed from environment variables, hardcoded values, a framework service container, or any other source. > **Security:** Keep your `.env` file secure and never commit it to version control. > For production, use your platform's secrets management system. ================================================================================ FILE: packages/instructor/internals/events.md ================================================================================ ## Overview Instructor dispatches events at every significant stage of its execution. You can listen to these events for logging, monitoring, debugging, or custom processing. All event classes extend `Cognesy\Events\Event`. ## Listening to Events ### Targeted Listeners Use `onEvent()` on the `StructuredOutputRuntime` to listen for a specific event type: ```php use Cognesy\Instructor\Events\Response\ResponseValidationFailed; $runtime = StructuredOutputRuntime::fromDefaults() ->onEvent(ResponseValidationFailed::class, function ($event) { logger()->warning('Validation failed', $event->toArray()); }); // @doctest id="5846" ``` ### Wiretap (All Events) Use `wiretap()` to receive every event dispatched by Instructor. This is useful for debugging or comprehensive logging: ```php $runtime = StructuredOutputRuntime::fromDefaults() ->wiretap(fn($event) => $event->print()); // @doctest id="cf35" ``` ### Practical Example ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Instructor\Extras\Scalar\Scalar; use Cognesy\Http\Events\HttpRequestSent; use Cognesy\Http\Events\HttpResponseReceived; $runtime = StructuredOutputRuntime::fromDefaults() // Log HTTP-level details ->onEvent(HttpRequestSent::class, fn($e) => dump($e)) ->onEvent(HttpResponseReceived::class, fn($e) => dump($e)) // Console-friendly output for all events ->wiretap(fn($event) => $event->print()) // Structured logging ->wiretap(fn($event) => YourLogger::log($event->asLog())); $result = (new StructuredOutput($runtime)) ->with( messages: 'What is the population of Paris?', responseModel: Scalar::integer(), ) ->get(); // @doctest id="d988" ``` ## Event Categories Events are organized into namespaces that correspond to the processing stage: ### High-Level Events (`Events\StructuredOutput`) | Event | When | |---|---| | `StructuredOutputStarted` | A structured output operation begins | | `StructuredOutputRequestReceived` | The request has been received by the runtime | | `StructuredOutputResponseGenerated` | The final `StructuredOutputResponse` has been produced | | `StructuredOutputResponseUpdated` | A streaming partial response is emitted | ### Request Events (`Events\Request`) | Event | When | |---|---| | `ResponseModelRequested` | A response model has been submitted for processing | | `ResponseModelBuildModeSelected` | The factory has chosen a build strategy | | `ResponseModelBuilt` | The response model and schema are ready | | `NewValidationRecoveryAttempt` | A retry attempt is about to begin | | `StructuredOutputRecoveryLimitReached` | All retries have been exhausted | | `SequenceUpdated` | A sequence item has been completed during streaming | ### Response Events (`Events\Response`) | Event | When | |---|---| | `ResponseDeserializationAttempt` | Deserialization is about to start | | `ResponseDeserialized` | Deserialization succeeded | | `ResponseDeserializationFailed` | Deserialization failed | | `CustomResponseDeserializationAttempt` | A `CanDeserializeSelf` implementation is being used | | `ResponseValidationAttempt` | Validation is about to start | | `ResponseValidated` | Validation passed | | `ResponseValidationFailed` | Validation failed | | `CustomResponseValidationAttempt` | A `CanValidateSelf` implementation is being used | | `ResponseTransformationAttempt` | Transformation is about to start | | `ResponseTransformed` | Transformation succeeded | | `ResponseTransformationFailed` | Transformation failed | | `ResponseConvertedToObject` | The final object has been produced | | `ResponseGenerationFailed` | The entire response generation pipeline failed | ### Extraction Events (`Events\Extraction`) | Event | When | |---|---| | `ExtractionStarted` | Data extraction from the inference response begins | | `ExtractionCompleted` | Extraction succeeded | | `ExtractionFailed` | Extraction failed | | `ExtractionStrategyAttempted` | A specific extraction strategy is being tried | | `ExtractionStrategySucceeded` | The strategy produced a result | | `ExtractionStrategyFailed` | The strategy did not produce a result | ### Streaming Events (`Events\PartialsGenerator`) | Event | When | |---|---| | `ChunkReceived` | A raw chunk arrived from the provider | | `StreamedResponseReceived` | A streamed response chunk was processed | | `StreamedResponseFinished` | The stream has ended | | `PartialJsonReceived` | A partial JSON fragment was accumulated | | `PartialResponseGenerated` | A partial deserialized response is available | | `PartialResponseGenerationFailed` | Partial deserialization failed (non-fatal) | | `StreamedToolCallStarted` | A tool call began in the stream | | `StreamedToolCallUpdated` | A tool call received more data | | `StreamedToolCallCompleted` | A tool call finished | ## Event Methods Every event inherits the following convenience methods from `Cognesy\Events\Event`: | Method | Description | |---|---| | `print()` | Print a console-friendly representation | | `printLog()` | Print a log-formatted representation | | `printDebug()` | Print console output and dump the full event object | | `asConsole()` | Return the event formatted for console output | | `asLog()` | Return the event formatted for log output | | `toArray()` | Return the event data as an associative array | | `name()` | Return the short class name of the event | Events carry a `$logLevel` property (PSR log level) and a `$data` payload. The `print()` method respects a configurable log-level threshold. Instructor event payloads are normalized arrays. Structured-output lifecycle events also expose correlation fields such as `requestId`, `executionId`, `attemptId`, `phase`, and `phaseId` where applicable. ================================================================================ FILE: packages/instructor/internals/response_models.md ================================================================================ ## Overview The `responseModel` parameter tells Instructor the shape of the data you want back from the LLM. Instructor translates it into a JSON Schema, uses that schema to guide the model, and then deserializes the model's output back into the requested shape. ## Supported Input Types ### Class String The most common approach. Pass a fully qualified class name and Instructor analyzes its properties, type hints, and doc comments to generate the schema: ```php $user = (new StructuredOutput()) ->with( messages: 'Jason is 25 years old', responseModel: User::class, ) ->get(); // @doctest id="5d5d" ``` Using `User::class` gives your IDE full visibility for refactoring, autocompletion, and static analysis. ### Object Instance Pass an object instance when you need to pre-populate default values on the response model: ```php $user = new User(); $user->country = 'US'; // default $result = (new StructuredOutput()) ->with( messages: 'Jason is 25 years old', responseModel: $user, ) ->get(); // @doctest id="6b9e" ``` Instructor inspects the class of the instance and generates the same schema it would for the class string, but uses the instance's property values as defaults. ### JSON Schema Array Pass a raw JSON Schema array when you need full control over the schema definition: ```php $result = (new StructuredOutput()) ->with( responseModel: [ 'x-php-class' => User::class, 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string'], 'age' => ['type' => 'integer'], ], 'required' => ['name', 'age'], ], messages: 'Jason is 25 years old', ) ->get(); // @doctest id="9dc4" ``` > **Important:** The `x-php-class` field is required so Instructor knows which class > to deserialize the response into. Without it, a dynamic `Structure` object is > used instead. ### Helper Wrappers The package ships with convenience wrappers for common patterns: - **`Scalar`** -- extract a single scalar value (string, int, float, bool, or enum) - **`Sequence`** -- extract a list of objects, with per-item streaming support - **`Maybe`** -- extract a value that may or may not be present ```php use Cognesy\Instructor\Extras\Scalar\Scalar; use Cognesy\Instructor\Extras\Sequence\Sequence; // Single integer $age = (new StructuredOutput()) ->with(messages: 'Jason is 25', responseModel: Scalar::integer('age')) ->get(); // List of users $users = (new StructuredOutput()) ->with(messages: $text, responseModel: Sequence::of(User::class)) ->get(); // @doctest id="c9ba" ``` ## Output Formats By default, Instructor returns a typed PHP object. You can change the output format using fluent methods on `StructuredOutput`: ```php // Return an associative array instead of an object $array = (new StructuredOutput()) ->with(responseModel: User::class, messages: '...') ->intoArray() ->get(); // Deserialize into a different class than the schema source $dto = (new StructuredOutput()) ->with(responseModel: UserProfile::class, messages: '...') ->intoInstanceOf(UserDTO::class) ->get(); // Use a self-deserializing object $result = (new StructuredOutput()) ->with(responseModel: Rating::class, messages: '...') ->intoObject(Scalar::integer('rating')) ->get(); // @doctest id="9b29" ``` > **Note:** Instructor always returns objects (or arrays when `intoArray()` is used). > It never returns raw arrays unless explicitly requested. ## Custom Response Handling You can customize how Instructor processes the response model at each stage by implementing one or more of the following contracts on your response model class: | Contract | Phase | Purpose | |---|---|---| | `CanProvideJsonSchema` | Schema generation | Provide a raw JSON Schema array, bypassing class analysis | | `CanProvideSchema` | Schema generation | Provide a `Schema` object, bypassing class analysis | | `CanDeserializeSelf` | Deserialization | Custom deserialization from the extracted JSON data | | `CanValidateSelf` | Validation | Replace the default validation process entirely | | `CanTransformSelf` | Transformation | Transform the validated object before returning it to the caller | These contracts are executed in order during the response processing pipeline. When implementing custom handling, split logic across the relevant methods rather than doing everything in a single block. ### Example Implementations The built-in `Scalar` and `Sequence` helpers are practical examples of this customization pattern. They implement custom schema providers, deserialization, validation, and transformation to support scalar values and ordered lists through wrapper classes: - `packages/instructor/src/Extras/Scalar/` - `packages/instructor/src/Extras/Sequence/` ================================================================================ FILE: packages/instructor/internals/debugging.md ================================================================================ ## Overview When a request does not behave as expected, Instructor provides several inspection points at different levels of detail. Start with the smallest useful tool and escalate as needed. ## Quick Inspection Points ### Response Envelope The `response()` method returns a `StructuredOutputResponse` that pairs the parsed value with the raw provider response: ```php $response = (new StructuredOutput()) ->with(messages: '...', responseModel: User::class) ->response(); // The deserialized value dump($response->value()); // The raw inference response from the provider dump($response->inferenceResponse()); // Token usage statistics dump($response->usage()); // Why the model stopped generating dump($response->finishReason()); // @doctest id="2536" ``` ### Raw Provider Response If you only need the raw inference response without deserialization metadata: ```php $raw = (new StructuredOutput()) ->with(messages: '...', responseModel: User::class) ->inferenceResponse(); dump($raw->content()); dump($raw->toolCalls()); // @doctest id="3c42" ``` ### Execution Metadata Access the execution object from a `PendingStructuredOutput` to inspect the request configuration, attempt history, and output mode: ```php $pending = (new StructuredOutput()) ->with(messages: '...', responseModel: User::class) ->create(); $execution = $pending->execution(); dump($execution->outputMode()); dump($execution->request()); // @doctest id="fc95" ``` ### Streaming Responses When streaming, use `responses()` to inspect each emitted `StructuredOutputResponse`: ```php $stream = (new StructuredOutput()) ->with(messages: '...', responseModel: User::class) ->stream(); foreach ($stream->responses() as $response) { echo $response->isPartial() ? 'partial' : 'final'; dump($response->value()); } // @doctest id="90ab" ``` ## Runtime Events For deeper tracing, attach a wiretap to the runtime. This gives you visibility into every internal stage -- schema building, inference calls, extraction, deserialization, validation, and transformation: ```php $runtime = StructuredOutputRuntime::fromDefaults() ->wiretap(fn($event) => $event->print()); $result = (new StructuredOutput($runtime)) ->with(messages: '...', responseModel: User::class) ->get(); // @doctest id="5044" ``` For targeted debugging, listen to specific event types: ```php use Cognesy\Instructor\Events\Response\ResponseValidationFailed; use Cognesy\Instructor\Events\Response\ResponseDeserializationFailed; use Cognesy\Instructor\Events\Request\NewValidationRecoveryAttempt; $runtime = StructuredOutputRuntime::fromDefaults() ->onEvent(ResponseValidationFailed::class, fn($e) => dump('Validation failed:', $e->data)) ->onEvent(ResponseDeserializationFailed::class, fn($e) => dump('Deserialization failed:', $e->data)) ->onEvent(NewValidationRecoveryAttempt::class, fn($e) => dump('Retrying...', $e->data)); // @doctest id="7953" ``` See the [Events](events.md) page for the full list of available event classes. ## JSON Output Inspection When you need to see the raw JSON that came back from the provider (before deserialization), use the `toJson()` or `toArray()` methods on `PendingStructuredOutput`: ```php $pending = (new StructuredOutput()) ->with(messages: '...', responseModel: User::class) ->create(); // Raw JSON string echo $pending->toJson(); // Parsed array dump($pending->toArray()); // @doctest id="b451" ``` ## Common Debugging Scenarios ### Wrong or Missing Fields Check that your response model class has the correct property types and names. Use `wiretap()` to inspect the raw JSON from the provider and compare it with your schema expectations. ### Validation Failures Exhausting Retries Listen for `ResponseValidationFailed` events to see exactly which validation rules are failing. Consider increasing `maxRetries` or relaxing validation constraints. ### Unexpected Deserialization Errors Listen for `ResponseDeserializationFailed` to see the raw data that could not be mapped to your class. This often reveals type mismatches between the schema and the actual model response. ### Streaming Not Producing Updates Verify that `withStreaming(true)` is set and that you are consuming the stream (e.g., iterating `partials()` or calling `finalValue()`). The stream is lazy -- no data flows until you start reading. ================================================================================ FILE: packages/instructor/misc/help.md ================================================================================ If you need help getting started with Instructor or have questions about advanced usage, the following resources are available. ## Documentation - **[Essentials](/essentials/usage)** -- covers the day-to-day API, data models, validation, and configuration. - **[Techniques](/techniques/classification)** -- practical patterns like classification, search query generation, and prompting guidelines. - **Examples** -- see the repository `examples/` directory for ready-to-run integrations and workflows. ## Community - **[GitHub Discussions](https://github.com/cognesy/instructor-php/discussions)** -- the best place to ask questions. Your question and its answer will help others with similar problems. - **[Discord](https://discord.gg/CV8sPM5k5Y)** -- real-time chat with the community. ## Reporting Issues - **[GitHub Issues](https://github.com/cognesy/instructor-php/issues)** -- use this for bug reports and feature requests. When reporting a bug, include: - The Instructor and PHP versions you are using. - A minimal code example that reproduces the problem. - The full error message or unexpected output. When reporting a documentation issue, include the page path and describe how the current content differs from the actual API behavior. ## Contact You can also reach the maintainer directly: - [@ddebowczyk on Twitter](https://twitter.com/ddebowczyk) - [cognesy.com/blog](https://cognesy.com/blog) -- articles and walkthroughs ================================================================================ FILE: packages/instructor/misc/llm_providers.md ================================================================================ Instructor is provider-agnostic. Provider selection is handled through `LLMConfig` and the Polyglot runtime that powers the underlying inference calls. Once the configuration is resolved, your application code stays the same regardless of which provider you use. ## Selecting a Provider The most common approaches: ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Polyglot\Inference\Config\LLMConfig; // Use a named preset $so = StructuredOutput::using('anthropic'); // Build from a config object $so = StructuredOutput::fromConfig( LLMConfig::fromPreset('openai') ); // @doctest id="e066" ``` Presets are YAML files stored in the `config/llm/presets` directory. Each file defines the API URL, driver, default model, and other provider-specific settings. ## Supported Providers The following providers have built-in presets: | Provider | Preset name | |---|---| | A21 | `a21` | | Anthropic | `anthropic` | | AWS Bedrock | `aws-bedrock` | | Azure OpenAI | `azure` | | Cerebras | `cerebras` | | Cohere | `cohere` | | DeepSeek | `deepseek` | | DeepSeek (Reasoning) | `deepseek-r` | | Fireworks | `fireworks` | | Google Gemini | `gemini` | | Gemini (OpenAI-compatible) | `gemini-oai` | | GLM | `glm` | | Groq | `groq` | | Hugging Face | `huggingface` | | Inception | `inception` | | Meta | `meta` | | MiniMaxi | `minimaxi` | | MiniMaxi (OpenAI-compatible) | `minimaxi-oai` | | Mistral | `mistral` | | Moonshot / Kimi | `moonshot-kimi` | | Ollama | `ollama` | | OpenAI | `openai` | | OpenAI Responses | `openai-responses` | | OpenRouter | `openrouter` | | Perplexity | `perplexity` | | Qwen | `qwen` | | SambaNova | `sambanova` | | Together | `together` | | xAI | `xai` | ## Custom Providers Any OpenAI-compatible API can be used by building an `LLMConfig` manually: ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; use Cognesy\Instructor\StructuredOutput; $config = new LLMConfig( apiUrl: 'https://my-provider.example.com/v1', apiKey: $_ENV['MY_PROVIDER_KEY'], model: 'my-model', driver: 'openai-compatible', maxTokens: 2048, ); $result = StructuredOutput::fromConfig($config) ->with( messages: 'Extract the data.', responseModel: MyModel::class, ) ->get(); // @doctest id="effd" ``` ================================================================================ FILE: packages/instructor/misc/philosophy.md ================================================================================ > The philosophy behind Instructor was originally formulated by > [Jason Liu](https://twitter.com/jxnlco), creator of the Python version, and adapted for this > PHP port. > "Simplicity is a great virtue, but it requires hard work to achieve it and education to > appreciate it. And to make matters worse: complexity sells better." -- Edsger Dijkstra ## Simplicity Most users only need to learn `StructuredOutput` and a response model class to get started. There is no new prompting language, no framework-specific base class to extend, and no hidden abstractions between you and the LLM. ## Transparency Instructor writes very few prompts on your behalf, and the ones it does write are visible and configurable. The library does not try to hide what it sends to the model -- you stay in control of the conversation. ## Flexibility If you already have code that calls an LLM provider directly, adopting Instructor is incremental. Add a `responseModel` to your existing call and use `get()` to receive a typed result. Any plain PHP class works as a response model -- no base class or interface is required. ## The Zen of Instructor Maintain the flexibility and power of PHP classes without unnecessary constraints. 1. Define a data schema: `class UserProfile { ... }` 2. Add validators and methods on that schema. 3. Encapsulate LLM logic in a function: `function extract($text): UserProfile` 4. Write typed computations against the result, or call methods on the object directly. It should be that simple. ## Our Goals The goal of the library -- and its documentation -- is to help you be a better PHP developer and, as a result, a better AI engineer. - The library is a result of a desire for simplicity. - It should help maintain simplicity in your codebase. - It will not try to write prompts for you. - It will not create indirections or abstractions that make debugging harder. The library is designed to be adaptable and open-ended, allowing you to extend its functionality based on your specific requirements. If you have questions or ideas, reach out on [GitHub Discussions](https://github.com/cognesy/instructor-php/discussions) or [@ddebowczyk](https://twitter.com/ddebowczyk). ================================================================================ FILE: packages/instructor/misc/contributing.md ================================================================================ For contribution workflow, quality gates, and repository conventions, use the repository root `CONTRIBUTOR_GUIDE.md`. For this package in particular: - keep docs aligned with the public API in `packages/instructor/src` - prefer short, task-oriented examples - run the package docs QA after documentation changes ================================================================================ FILE: packages/polyglot/overview.md ================================================================================ Polyglot is a PHP library that provides a unified API for interacting with various Large Language Model (LLM) providers. It serves as the low-level transport and normalization layer for InstructorPHP, but can also be used as a standalone library for direct LLM interactions. The core philosophy behind Polyglot is to create a consistent, provider-agnostic interface that abstracts away the differences between LLM APIs while staying close to provider-native request shapes. This enables developers to: - Write code once and use it with any supported LLM provider - Easily switch between providers without changing application code - Use different providers in different environments (development, testing, production) - Fall back to alternative providers if one becomes unavailable - Use local models (via Ollama) for development and cloud providers for production Polyglot has two main entrypoints: - `Cognesy\Polyglot\Inference\Inference` for model responses (chat completions) - `Cognesy\Polyglot\Embeddings\Embeddings` for vector embeddings In 2.0, Polyglot stays close to provider-native request shapes, supporting: - Plain text output by default - Native JSON output through `responseFormat` - Native JSON schema output through `responseFormat` - Tool calling through `tools` and `toolChoice` - Streaming through `withStreaming()` and `stream()` - Embeddings through the `Embeddings` facade Polyglot is a transport and normalization layer. If you need higher-level structured output workflows, fallback prompting, or schema-to-object extraction, use Instructor on top. ## Key Features ### Unified LLM API Polyglot's primary feature is its unified API that works across multiple LLM providers: - Consistent interface for making inference and embedding requests - Common message format across all providers - Standardized response handling with `InferenceResponse` and `EmbeddingsResponse` - Unified error handling and retry policies ### Framework-Agnostic Polyglot is designed to work with any PHP framework or even in plain PHP applications. It does not depend on any specific framework, making it easy to integrate into existing projects. - Compatible with Laravel, Symfony, CodeIgniter, and others - Can be used in CLI scripts or web applications - Lightweight with minimal dependencies ### Configuration Flexibility Polyglot offers a flexible configuration system built around YAML preset files: - Configure multiple providers simultaneously using named presets - Environment-based configuration with `${ENV_VAR}` interpolation in preset files - Runtime provider switching via `Inference::using('preset-name')` - Per-request customization through the fluent builder API - DSN-based configuration via `LLMConfig::fromDsn()` ## Main Concepts ### Inference The `Inference` class is the main facade for sending requests to LLM providers and receiving responses. It provides a fluent builder API for constructing requests and several convenience methods for consuming responses. Use `Inference` when you want a model response as: - Plain text via `get()` -- returns the raw content string - A full `InferenceResponse` via `response()` -- gives access to content, tool calls, usage, finish reason, and reasoning content - Decoded JSON via `asJsonData()` -- extracts and parses JSON from the response content - Tool call arguments via `asToolCallJsonData()` -- extracts arguments from tool/function calls - Streamed deltas via `stream()` -- returns an `InferenceStream` for real-time processing The `InferenceResponse` object provides rich access to the full response, including: - `content()` -- the text content of the response - `reasoningContent()` -- reasoning/thinking content (for models that support it) - `toolCalls()` -- any tool calls made by the model - `usage()` -- token usage statistics - `finishReason()` -- why the model stopped generating - `hasContent()`, `hasToolCalls()`, `hasReasoningContent()` -- presence checks ### Streaming Polyglot provides first-class streaming support through the `InferenceStream` class. When you call `stream()`, you receive a stream object that yields `PartialInferenceDelta` objects as they arrive from the provider. The stream supports several consumption patterns: - `deltas()` -- a generator that yields each visible delta as it arrives - `all()` -- collects all deltas into an array - `map(callable)` -- transforms each delta through a mapper function - `filter(callable)` -- yields only deltas matching a predicate - `reduce(callable, initial)` -- reduces the stream to a single value - `final()` -- drains the stream and returns the finalized `InferenceResponse` - `onDelta(callable)` -- registers a callback for each visible delta Streaming also dispatches events for monitoring, including `StreamFirstChunkReceived` for time-to-first-chunk measurement. ### Embeddings The `Embeddings` class is the facade for generating vector embeddings from text inputs. It follows the same fluent builder pattern as `Inference`. Use `Embeddings` when you want vectors from one or more text inputs. The `EmbeddingsResponse` gives you: - `first()` -- the first embedding vector (useful for single-input requests) - `vectors()` -- all embedding vectors as an array of `Vector` objects - `all()` -- alias for `vectors()` - `last()` -- the last embedding vector - `split(index)` -- splits vectors into two groups at a given index - `usage()` -- provider-reported token usage - `toValuesArray()` -- raw float arrays for all vectors ### Presets The usual entrypoint is `Inference::using('openai')` or `Embeddings::using('openai')`, which loads a named preset configuration. Preset files are YAML files that define the connection details for a provider. They are loaded from the following locations (searched in order): - `config/llm/presets` (or `config/embed/presets`) in your application root - `packages/polyglot/resources/config/llm/presets` within the monorepo - `vendor/cognesy/instructor-php/packages/polyglot/resources/config/llm/presets` when installed via Composer - `vendor/cognesy/instructor-polyglot/resources/config/llm/presets` for standalone installs A typical preset file looks like this: ```yaml driver: openai apiUrl: 'https://api.openai.com/v1' apiKey: '${OPENAI_API_KEY}' endpoint: /chat/completions model: gpt-4.1-nano maxTokens: 1024 contextLength: 1000000 maxOutputLength: 16384 # @doctest id="d0d4" ``` You can override any preset value at runtime using the fluent API -- for example, `withModel()` to change the model or `withMaxTokens()` to adjust the token limit. ### Providers and Drivers Each LLM provider is backed by a driver that knows how to format requests and parse responses for that provider's API. Polyglot ships with drivers for all supported providers, and you can register custom drivers when needed. The `LLMProvider` and `EmbeddingsProvider` classes act as configuration holders that pair a config with an optional explicit driver. They are typically created behind the scenes when you use `Inference::using()` or `Inference::fromConfig()`. ## What Polyglot Covers - **Provider selection** -- choose any supported provider through presets or programmatic configuration - **Request building** -- fluent API for constructing messages, setting models, tools, response formats, and options - **Request execution** -- handles HTTP communication with provider APIs - **Response normalization** -- unified `InferenceResponse` and `EmbeddingsResponse` regardless of provider - **Streaming deltas** -- real-time streaming with event-driven processing - **Retry policy** -- configurable retry behavior for transient failures via `InferenceRetryPolicy` and `EmbeddingsRetryPolicy` - **Custom drivers and runtimes** -- extensible architecture for adding new providers or custom execution logic - **Response caching** -- configurable cache policy for inference responses ## What It Does Not Try To Hide Polyglot does not invent a synthetic output mode system. You shape requests with explicit fields that match current provider APIs -- `responseFormat` for JSON output, `tools` and `toolChoice` for function calling, and `withStreaming()` for streamed responses. This keeps the abstraction thin and predictable, making it easy to understand what will be sent to the provider. ## Supported Providers ### Inference Providers Polyglot ships with drivers for the following LLM providers: - **A21** -- API access to Jamba models - **Anthropic** -- Claude family of models - **AWS Bedrock** -- Amazon Bedrock hosted models - **Microsoft Azure** -- Azure-hosted OpenAI models - **Cerebras** -- Cerebras high-performance inference - **Cohere** -- Command models (v2 API) - **Deepseek** -- Deepseek models including reasoning capabilities - **Fireworks** -- Fireworks AI hosted models - **GLM** -- GLM (ChatGLM) models - **Google Gemini** -- Google's Gemini models (native API) - **Google Gemini (OpenAI compatible)** -- Gemini via OpenAI-compatible endpoint - **Groq** -- High-performance inference platform - **Hugging Face** -- Hugging Face hosted models - **Inception** -- Inception AI models - **Meta** -- Meta AI models - **MiniMaxi** -- MiniMax models (native and OpenAI compatible) - **Mistral** -- Mistral AI models - **Moonshot** -- Kimi models - **Ollama** -- Self-hosted open source models - **OpenAI** -- GPT models family (Chat Completions API) - **OpenAI Responses** -- OpenAI Responses API - **OpenAI Compatible** -- Generic driver for any OpenAI-compatible API - **OpenRouter** -- Multi-provider routing service - **Perplexity** -- Perplexity models - **Qwen** -- Qwen (Tongyi Qianwen) models - **SambaNova** -- SambaNova hosted models - **Together** -- Together AI hosted models - **xAI** -- xAI's Grok models ### Embeddings Providers For vector embeddings generation, Polyglot supports: - **Microsoft Azure** -- Azure-hosted OpenAI embeddings - **Cohere** -- Cohere embedding models - **Google Gemini** -- Google's embedding models - **Jina** -- Jina AI embeddings - **Ollama** -- Self-hosted embedding models - **OpenAI** -- OpenAI text embedding models ## Use Cases Polyglot is a good choice for a variety of scenarios: - **Applications requiring LLM provider flexibility** -- switch between providers based on cost, performance, or feature needs without rewriting application code - **Multi-environment deployments** -- use different LLM providers in development, staging, and production through preset configuration - **Redundancy and fallback** -- implement fallback strategies when a provider is unavailable - **Hybrid approaches** -- combine different providers for different tasks based on their strengths - **Local + cloud development** -- use local models via Ollama for development and cloud providers for production - **Direct LLM access** -- when you need raw LLM responses without the higher-level extraction that Instructor provides ================================================================================ FILE: packages/polyglot/quickstart.md ================================================================================ This guide walks you through installing Polyglot and making your first LLM inference request. By the end you will have a working PHP script that sends a prompt to an LLM provider and prints the response. > Polyglot is already included in the Instructor for PHP package. If you have > Instructor installed, you do not need to install Polyglot separately. ## Installation Polyglot requires **PHP 8.3** or later. Install it with Composer: ```bash composer require cognesy/instructor-polyglot # @doctest id="6bc4" ``` ## Configure Your API Key Polyglot ships with ready-made presets for over 25 providers including OpenAI, Anthropic, Gemini, Mistral, Groq, Deepseek, and many more. Each preset reads its API key from an environment variable so credentials never appear in code. For this quickstart we will use the `openai` preset. Export your key before running any PHP code: ```bash export OPENAI_API_KEY=sk-... # @doctest id="649b" ``` Never hard-code API keys in source files. Use environment variables or a .env file to keep credentials out of version control. ## Your First Request Create a file called `test-polyglot.php` in your project directory: ```php withMessages(Messages::fromString('What is the capital of France?')) ->get(); echo "ASSISTANT: $answer\n"; // @doctest id="8ba9" ``` Run it from the terminal: ```bash php test-polyglot.php # Output: # ASSISTANT: The capital of France is Paris. # @doctest id="2481" ``` That is all it takes. The `Inference` class is the main entry point for every LLM request in Polyglot. ## Understanding the Flow Every Polyglot request follows a three-step pattern: 1. **Select a provider** -- call `Inference::using('preset')` to choose a bundled or custom preset, or create an `Inference` instance directly. 2. **Build the request** -- chain fluent methods such as `withMessages()`, `withModel()`, `withMaxTokens()`, or `withOptions()` to describe what you want. 3. **Execute** -- call a terminal method to send the request and retrieve the result. The available terminal methods are: | Method | Returns | Description | |---|---|---| | `get()` | `string` | The plain-text content of the response. | | `response()` | `InferenceResponse` | The full response object with content, usage, tool calls, and metadata. | | `asJson()` | `string` | The response content parsed as a JSON string. | | `asJsonData()` | `array` | The response content parsed into a PHP array. | | `stream()` | `InferenceStream` | A streamed response you can iterate over in real time. | ## Switching Providers Because every provider is just a preset name, switching from OpenAI to another provider is a one-line change: ```php use Cognesy\Messages\Messages; // Anthropic $text = Inference::using('anthropic') ->withMessages(Messages::fromString('Explain dependency injection in one paragraph.')) ->get(); // Google Gemini $text = Inference::using('gemini') ->withMessages(Messages::fromString('Explain dependency injection in one paragraph.')) ->get(); // Groq $text = Inference::using('groq') ->withMessages(Messages::fromString('Explain dependency injection in one paragraph.')) ->get(); // @doctest id="f975" ``` Set the corresponding environment variable for each provider you want to use (`ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, and so on). ## Overriding the Model The preset defines a default model, but you can override it per-request: ```php use Cognesy\Messages\Messages; $text = Inference::using('openai') ->withModel('gpt-4.1') ->withMessages(Messages::fromString('Summarize the theory of relativity in two sentences.')) ->get(); // @doctest id="0081" ``` ## Streaming Responses For long responses or interactive UIs, you can stream the output token by token: ```php use Cognesy\Messages\Messages; $stream = Inference::using('openai') ->withMessages(Messages::fromString('Write a short poem about PHP.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } // @doctest id="6fa6" ``` ## Next Steps Now that you have a working setup, explore the rest of the documentation to unlock the full power of Polyglot: - **[Setup](setup)** -- define your own presets and customize provider configuration. - **[Essentials](essentials/overview)** -- learn the complete request and response API. - **[Streaming](streaming/overview)** -- handle streamed responses, events, and partial updates. - **[Embeddings](embeddings/overview)** -- generate vector embeddings for semantic search and RAG. ================================================================================ FILE: packages/polyglot/setup.md ================================================================================ This guide walks you through installing Polyglot, configuring API keys, and choosing the right configuration strategy for your project. ## Installation Install Polyglot via Composer: ```bash composer require cognesy/instructor-polyglot # @doctest id="91d2" ``` > **Note:** Polyglot ships as part of the Instructor PHP monorepo. If you > already have `cognesy/instructor-php` installed, Polyglot is included > automatically -- there is no need to install it separately. ### Requirements - PHP 8.3 or higher - Composer - A valid API key for at least one supported LLM provider ## Setting Up API Keys Polyglot authenticates with LLM providers through API keys. The simplest approach is to export them as environment variables: ```bash export OPENAI_API_KEY=sk-your-openai-key export ANTHROPIC_API_KEY=sk-ant-your-anthropic-key export GEMINI_API_KEY=your-gemini-key # @doctest id="7005" ``` If your project uses a `.env` file, add the keys there instead and load them with a library such as `vlucas/phpdotenv`. Polyglot's bundled preset files reference these variables using `${VAR_NAME}` syntax, so the names above are the expected defaults. ## Quick Start Once an API key is available, a single call is all you need: ```php withMessages(Messages::fromString('Say hello.')) ->get(); // @doctest id="43f3" ``` If you see a friendly greeting, your installation is working correctly. The `using()` method loads a **preset** -- a small YAML file that tells Polyglot which driver, API URL, endpoint, and model to use. Polyglot ships with presets for every supported provider, so you can swap `'openai'` for `'anthropic'`, `'gemini'`, `'mistral'`, or any other supported name and it will just work (provided the matching API key is set). ## Configuration Strategies Polyglot offers three ways to configure connections, from simplest to most flexible. ### 1. Bundled Presets (Zero Configuration) Polyglot ships with ready-made presets for all supported providers. Set the appropriate environment variable and call `using()`: ```php withMessages(Messages::fromString('Explain photosynthesis in one sentence.')) ->get(); // @doctest id="bb19" ``` ```php withInputs('The quick brown fox.') ->create(); // @doctest id="3ec2" ``` Bundled presets live inside the package at `resources/config/llm/presets/` and `resources/config/embed/presets/`. You never need to edit these files -- override them with your own presets instead (see below). ### 2. Custom Presets (App-Owned YAML Files) When you need to change a model, adjust token limits, or add metadata, create your own preset files. Polyglot checks the following directories in order and uses the first match: | Priority | Path | |----------|------| | 1 | `config/llm/presets/` (or `config/embed/presets/`) | | 2 | `packages/polyglot/resources/config/llm/presets/` | | 3 | `vendor/cognesy/instructor-php/packages/polyglot/resources/config/llm/presets/` | | 4 | `vendor/cognesy/instructor-polyglot/resources/config/llm/presets/` | All paths are relative to your project root. A file placed in `config/llm/presets/` takes precedence over the bundled default. #### LLM Preset Example Create `config/llm/presets/openai.yaml`: ```yaml driver: openai apiUrl: 'https://api.openai.com/v1' apiKey: '${OPENAI_API_KEY}' endpoint: /chat/completions model: gpt-4.1-nano maxTokens: 1024 contextLength: 1000000 maxOutputLength: 16384 # @doctest id="30f8" ``` The `driver` field determines which Polyglot driver handles the request. The `apiKey` value supports `${ENV_VAR}` interpolation so secrets never appear in plain text. #### Embeddings Preset Example Create `config/embed/presets/openai.yaml`: ```yaml driver: openai apiUrl: 'https://api.openai.com/v1' apiKey: '${OPENAI_API_KEY}' endpoint: /embeddings model: text-embedding-3-small dimensions: 1536 maxInputs: 2048 # @doctest id="c933" ``` Once the file exists, `Inference::using('openai')` or `Embeddings::using('openai')` will resolve it automatically. You can also create entirely new presets for custom deployments. For example, to add a preset for a local vLLM server, create `config/llm/presets/local-vllm.yaml`: ```yaml driver: openai-compatible apiUrl: 'http://localhost:8000/v1' apiKey: 'not-needed' endpoint: /chat/completions model: meta-llama/Llama-3-8b maxTokens: 2048 # @doctest id="1cde" ``` Then use it like any other preset: ```php $text = Inference::using('local-vllm') ->withMessages(Messages::fromString('Say hello.')) ->get(); // @doctest id="d09d" ``` #### EmbeddingsConfig Reference | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `driver` | string | `'openai'` | Driver identifier | | `apiUrl` | string | `''` | Base URL of the provider API | | `apiKey` | string | `''` | Authentication key | | `endpoint` | string | `''` | API endpoint path (e.g., `/embeddings`) | | `model` | string | `''` | Embedding model identifier | | `dimensions` | int | `0` | Output vector dimensions | | `maxInputs` | int | `0` | Maximum number of inputs per request | | `metadata` | array | `[]` | Provider-specific metadata | ### 3. Runtime Configuration (Programmatic) When connection details come from a database, user input, or any other dynamic source, build the config object directly in PHP: ```php withMessages(Messages::fromString('What is the capital of France?')) ->get(); // @doctest id="4f5c" ``` The same approach works for embeddings: ```php withOverrides([ 'model' => 'gpt-4.1', 'maxTokens' => 4096, ]); $inference = Inference::fromConfig($config); // @doctest id="0c93" ``` This is useful when you want to keep all the defaults from a preset but need to swap the model or adjust limits for a specific use case. ## DSN Strings For compact, inline configuration you can use a DSN (Data Source Name) string. This is useful when storing connection info in a single environment variable or database column: ```php .yaml`. **API key not found.** Make sure the environment variable is exported in the same shell session that runs your PHP script. You can verify with `echo $OPENAI_API_KEY` before launching PHP. **Wrong model or endpoint.** Create a custom preset (see above) to override the bundled defaults with the model and endpoint you need. ================================================================================ FILE: packages/polyglot/testing-doubles.md ================================================================================ ## Overview Polyglot supports deterministic tests at two main seams. - use fake drivers when you want to bypass HTTP and drive the runtime directly - use `MockHttpDriver` when you want to keep transport and provider adapter behavior in play Pick the shallowest seam that still exercises the behavior you care about. ## `FakeInferenceDriver` `FakeInferenceDriver` lives in `packages/polyglot/tests/Support`. Use it when you want to test: - `Inference` request execution without real HTTP - retry and event behavior around raw inference responses - streaming assembly from queued `PartialInferenceDelta` batches It supports: - queued `InferenceResponse` objects - queued streaming delta batches - callback-driven sync or streaming behavior when the test needs custom logic ## `FakeEmbeddingsDriver` `FakeEmbeddingsDriver` also lives in `packages/polyglot/tests/Support`. Use it when you want to test: - `Embeddings` and `PendingEmbeddings` behavior without real HTTP - memoization and runtime delegation - event dispatch around completed embeddings responses It supports: - queued `EmbeddingsResponse` objects - callback-driven response generation from `EmbeddingsRequest` - request recording through `handleCalls` and `requests` Minimal example: ```php use Cognesy\Polyglot\Embeddings\Data\EmbeddingsResponse; use Cognesy\Polyglot\Embeddings\Data\Vector; use Cognesy\Polyglot\Tests\Support\FakeEmbeddingsDriver; $driver = new FakeEmbeddingsDriver([ new EmbeddingsResponse([new Vector(values: [0.1, 0.2], id: 0)]), ]); // @doctest id="5478" ``` ## `MockHttpDriver` Use `MockHttpDriver` when the HTTP layer still matters. This is the right seam for: - provider adapter tests - request body and header assertions - golden tests around provider-specific payload shapes - error-path coverage that depends on real HTTP response objects If the test is really about transport or adapter behavior, keep the mock HTTP path. If it is about runtime behavior above transport, prefer the fake drivers. ## Which One To Use Use this rule of thumb: - `FakeInferenceDriver` for most deterministic inference tests - `FakeEmbeddingsDriver` for most deterministic embeddings tests - `MockHttpDriver` for transport and provider adapter coverage ================================================================================ FILE: packages/polyglot/upgrade.md ================================================================================ Polyglot 2.0 is centered around explicit request fields. The main migration points are: - remove old output mode usage - set `responseFormat` for native JSON or JSON schema - set `tools` and `toolChoice` for tool calling - use `stream()->deltas()` for streaming ## Response Model Polyglot is now explicitly the raw inference layer. - `InferenceResponse` is the final raw provider response - streaming yields `PartialInferenceDelta` - structured value ownership belongs to higher-level packages such as Instructor If older code assumed that Polyglot streaming yielded accumulated partial response snapshots, update that code to work from deltas instead. ## Before ```php with( messages: 'Return JSON.', mode: $oldMode, ) ->asJsonData(); // @doctest id="5108" ``` ## After ```php withMessages(Messages::fromString('Return JSON.')) ->withResponseFormat(new ResponseFormat(type: 'json_object')) ->asJsonData(); // @doctest id="3151" ``` Markdown-JSON fallback is no longer a Polyglot concern. Use Instructor when you need higher-level structured output strategies. ## Streaming Migration Update old streaming code like this: - replace partial-response iteration with `stream()->deltas()` - assemble final raw output with `final()` - move partial structured parsing to Instructor or your own delta accumulator ================================================================================ FILE: packages/polyglot/essentials/overview.md ================================================================================ The `Inference` class is the main facade for interacting with LLM APIs. It provides a clean, immutable interface for chat completions, tool calling, JSON output generation, and streaming -- all through a consistent API regardless of the underlying provider. ## Quick Start The simplest way to generate text is with a single chained call: ```php withMessages(Messages::fromString('What is the capital of France?')) ->get(); // @doctest id="ae41" ``` The `using()` static method resolves a named preset from your configuration, while `withMessages()` accepts a `Messages` object. Use `Messages::fromString()` to wrap a plain text prompt, or `Messages::fromArray()` to convert an array of role/content pairs. The `get()` method executes the request and returns the response content as a string. ## Creating an Inference Instance For more control over the lifecycle, create an instance directly. Without arguments, Inference uses a sensible default configuration: ```php withMessages(Messages::fromArray([['role' => 'user', 'content' => 'Explain event sourcing briefly.']])) ->get(); // @doctest id="7900" ``` You may also use the `with()` method, which accepts all request parameters at once: ```php with( messages: Messages::fromString('What is the capital of France?'), )->get(); // @doctest id="3751" ``` ## Core Request Fields The `with()` method and its individual `with...()` counterparts allow you to set every aspect of the inference request: | Field | Method | Description | |-------|--------|-------------| | `messages` | `withMessages()` | The conversation messages | | `model` | `withModel()` | Override the model defined in the preset | | `tools` | `withTools()` | Tool/function definitions (`ToolDefinitions`) for the model to call | | `toolChoice` | `withToolChoice()` | Control which tool the model should use (`ToolChoice`) | | `responseFormat` | `withResponseFormat()` | Request structured output (`ResponseFormat`) | | `options` | `withOptions()` | Provider-specific parameters (`temperature`, `max_tokens`, etc.) | | `maxTokens` | `withMaxTokens()` | Shorthand for setting the maximum output token count | ## Execution Paths Once you have configured a request, choose how to execute it: | Method | Returns | Use case | |--------|---------|----------| | `get()` | `string` | Quick text extraction | | `response()` | `InferenceResponse` | Full response with metadata, usage stats, and tool calls | | `asJson()` | `string` | Extract JSON from the response content | | `asJsonData()` | `array` | Decode JSON from the response into a PHP array | | `asToolCallJson()` | `string` | Extract tool call arguments as a JSON string | | `asToolCallJsonData()` | `array` | Decode tool call arguments into a PHP array | | `stream()` | `InferenceStream` | Stream partial deltas as they arrive | ## Multi-Turn Conversations For multi-turn conversations, pass an array of messages with role annotations: ```php 'user', 'content' => 'Can you help me with a math problem?'], ['role' => 'assistant', 'content' => 'Of course! What would you like to solve?'], ['role' => 'user', 'content' => 'What is the square root of 144?'], ]); $answer = Inference::using('openai') ->withMessages($messages) ->get(); // @doctest id="f312" ``` ## Customizing Request Options Provider-specific parameters such as `temperature`, `max_tokens`, or `top_p` are passed through the `options` array. Most providers follow the OpenAI-compatible parameter conventions: ```php withMessages(Messages::fromString('Write a short poem about coding.')) ->withModel('gpt-4o') ->withOptions(['temperature' => 0.7, 'max_tokens' => 200]) ->get(); // @doctest id="a180" ``` You can also set all parameters at once via the `with()` convenience method: ```php with( messages: Messages::fromString('Write a haiku about PHP.'), model: 'gpt-4o', options: ['temperature' => 0.9, 'max_tokens' => 100], )->get(); // @doctest id="020d" ``` ## Streaming Responses Streaming lets you display partial output as it arrives from the model, creating a more responsive user experience. Call `stream()` to get an `InferenceStream`, then iterate over deltas: ```php withMessages(Messages::fromString('Describe the capital of Brasil.')) ->withMaxTokens(512) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } // @doctest id="fe11" ``` Each `PartialInferenceDelta` exposes the `contentDelta` string for the incremental text fragment. The stream also provides functional-style helpers -- `map()`, `filter()`, and `reduce()` -- for processing deltas inline. You can also register a callback to handle each delta as it arrives: ```php withMessages(Messages::fromString('Tell me a story.')) ->stream(); $stream->onDelta(fn($delta) => print($delta->contentDelta)); // Drain the stream to trigger callbacks $stream->all(); // @doctest id="084c" ``` After the stream completes, call `final()` to retrieve the assembled `InferenceResponse` with full content and usage statistics. ## Working with the Full Response When you need more than just text, use `response()` to access the complete `InferenceResponse` object: ```php withMessages(Messages::fromString('What is quantum computing?')) ->response(); $text = $response->content(); $usage = $response->usage(); $finishReason = $response->finishReason(); // @doctest id="263d" ``` The response object provides access to content, reasoning content (for models that support chain-of-thought), tool calls, token usage statistics, and the raw HTTP response data. ## Switching Between Providers Polyglot ships with YAML-based presets for many providers. Switching between them is a single method call: ```php withMessages($question)->get(); $anthropic = Inference::using('anthropic')->withMessages($question)->get(); $gemini = Inference::using('gemini')->withMessages($question)->get(); // @doctest id="d6d3" ``` Available presets include `openai`, `anthropic`, `gemini`, `mistral`, `groq`, `ollama`, `fireworks`, `together`, `openrouter`, `cohere`, `deepseek`, `xai`, `azure`, `perplexity`, `sambanova`, and others. Each preset is defined in a YAML file under `resources/config/llm/presets/`. ## Configuring Presets Each preset is a YAML file that defines the connection parameters for a provider. For example, the OpenAI preset: ```yaml driver: openai apiUrl: 'https://api.openai.com/v1' apiKey: '${OPENAI_API_KEY}' endpoint: /chat/completions model: gpt-4.1-nano maxTokens: 1024 contextLength: 1000000 maxOutputLength: 16384 # @doctest id="a8d0" ``` Polyglot resolves presets from several locations, searched in order: 1. `config/llm/presets/` (your project root) 2. `packages/polyglot/resources/config/llm/presets/` (monorepo) 3. `vendor/cognesy/instructor-php/packages/polyglot/resources/config/llm/presets/` 4. `vendor/cognesy/instructor-polyglot/resources/config/llm/presets/` To customize a provider, copy the relevant YAML file into `config/llm/presets/` at your project root and modify it as needed. Environment variables are referenced with the `${VAR_NAME}` syntax. ## Selecting a Model Each preset defines a default model, but you can override it per-request: ```php withMessages(Messages::fromString('Explain machine learning in one sentence.')) ->withModel('gpt-4o') ->get(); // @doctest id="5717" ``` ## Immutability `Inference` is immutable from the caller's perspective. Every `with...()` method returns a new instance, leaving the original unchanged. This makes it safe to build a base configuration and derive specialized variants from it: ```php withOptions(['temperature' => 0.3]); $precise = $base->withModel('gpt-4o'); $fast = $base->withModel('gpt-4.1-mini'); // @doctest id="051a" ``` Both `$precise` and `$fast` inherit the temperature setting without affecting each other or the `$base` instance. ================================================================================ FILE: packages/polyglot/essentials/inference-class.md ================================================================================ The `Inference` class is a thin, immutable facade over `InferenceRuntime`. It provides the unified entry point for configuring providers, building requests, and retrieving responses from any supported LLM. ## Creating an Instance Choose the factory method that matches your level of control: ```php withMessages(Messages::fromString('Explain dependency injection in one paragraph.')) ->withModel('gpt-4.1-nano'); // @doctest id="c25b" ``` ### Tools and Response Format ```php use Cognesy\Polyglot\Inference\Data\ToolChoice; use Cognesy\Polyglot\Inference\Data\ResponseFormat; $inference = Inference::using('openai') ->withTools($toolDefinitions) ->withToolChoice(ToolChoice::auto()) ->withResponseFormat(ResponseFormat::jsonObject()); // @doctest id="eed1" ``` ### Streaming and Token Limits ```php $inference = Inference::using('openai') ->withStreaming(true) ->withMaxTokens(256); // @doctest id="2097" ``` ### Provider-Specific Options ```php $inference = Inference::using('openai') ->withOptions(['temperature' => 0.5, 'top_p' => 0.9]); // @doctest id="766f" ``` ### The Combined `with()` Method When you prefer a single call, use `with()` to set multiple fields at once: ```php use Cognesy\Messages\Messages; use Cognesy\Polyglot\Inference\Data\ToolChoice; use Cognesy\Polyglot\Inference\Data\ResponseFormat; $inference = Inference::using('openai')->with( messages: Messages::fromString('Hello'), model: 'gpt-4.1-nano', toolChoice: ToolChoice::auto(), responseFormat: ResponseFormat::text(), options: ['temperature' => 0.7], ); // @doctest id="ac7e" ``` ### Full Method Reference | Method | Purpose | |-------------------------------|-----------------------------------------------| | `withMessages(...)` | Set conversation messages | | `withModel(...)` | Override the model | | `withTools(...)` | Attach tool/function definitions | | `withToolChoice(...)` | Control tool selection strategy | | `withResponseFormat(...)` | Specify the response format | | `withOptions(...)` | Set provider-specific options | | `withStreaming(...)` | Enable or disable streaming | | `withMaxTokens(...)` | Set maximum token count | | `withCachedContext(...)` | Attach reusable cached context | | `withRetryPolicy(...)` | Configure retry behavior | | `withResponseCachePolicy(...)` | Configure response caching | | `withRequest(...)` | Load all fields from an `InferenceRequest` | | `withRuntime(...)` | Replace the underlying runtime | ## Executing Requests ### Response Shortcuts These methods build the request, execute it, and return the result in a single step: ```php withMessages(Messages::fromString('What is PHP?')) ->withModel('gpt-4.1-nano'); // Plain text content $text = $inference->get(); // Full InferenceResponse object (with usage, finish reason, etc.) $response = $inference->response(); // JSON string extracted from the response $json = $inference->asJson(); // Parsed JSON as an associative array $data = $inference->asJsonData(); // JSON from a tool call response $toolJson = $inference->asToolCallJson(); // Parsed tool call JSON as an array $toolData = $inference->asToolCallJsonData(); // @doctest id="5d4b" ``` ### Streaming To receive partial results as they arrive from the provider: ```php $stream = Inference::using('openai') ->withMessages(Messages::fromString('Write a short story about a robot.')) ->stream(); foreach ($stream->deltas() as $partial) { echo $partial->contentDelta; } // @doctest id="1cfa" ``` ### The Lazy Handle: `PendingInference` If you need to defer execution or pass the handle to another part of your system, call `create()` to get a `PendingInference` instance. Execution happens only when you call a response method on it: ```php $pending = Inference::using('openai') ->withMessages(Messages::fromString('Hello')) ->create(); // Nothing has been sent to the provider yet. // Execution happens here: $text = $pending->get(); // @doctest id="7557" ``` `PendingInference` exposes the same response methods as `Inference`: `get()`, `response()`, `asJson()`, `asJsonData()`, `asToolCallJson()`, `asToolCallJsonData()`, and `stream()`. ## Custom Drivers To use a custom driver, implement the `CanProvideInferenceDrivers` contract and pass it to `Inference::using()` or `Inference::fromConfig()` via the `drivers` parameter: ```php use Cognesy\Polyglot\Inference\Inference; $response = Inference::using('custom-provider', drivers: $myDriverRegistry) ->withMessages(Messages::fromString('Hello from a custom driver.')) ->get(); // @doctest id="a670" ``` ================================================================================ FILE: packages/polyglot/essentials/creating-requests.md ================================================================================ Polyglot provides a clean, fluent API for building inference requests. You can configure messages, models, tools, response formats, and provider-specific options -- all through a consistent interface that works across every supported LLM provider. ## Basic Request The simplest way to get a response is to pass a string message directly: ```php withMessages(Messages::fromString('What is the capital of France?')) ->get(); echo $response; // "Paris." // @doctest id="577d" ``` `Messages::fromString()` wraps a plain string as a user message. You can also use `Messages::fromArray()` to pass an array of role/content pairs. ## The `with()` Method When you need to configure multiple request fields at once, use the combined `with()` method. It accepts all core request parameters in a single call: ```php with( messages: Messages::fromArray([ ['role' => 'system', 'content' => 'Answer briefly.'], ['role' => 'user', 'content' => 'What is CQRS?'], ]), model: 'gpt-4.1-nano', options: ['temperature' => 0.2], ) ->get(); // @doctest id="8d88" ``` All parameters on `with()` are optional -- pass only what you need: | Parameter | Type | Description | |------------------|-------------------------|--------------------------------------------| | `messages` | `?Messages` | The messages to send to the LLM | | `model` | `?string` | Model identifier (overrides preset default)| | `tools` | `?ToolDefinitions` | Tool/function definitions for the model | | `toolChoice` | `?ToolChoice` | Tool selection preference | | `responseFormat` | `?ResponseFormat` | Response format specification | | `options` | `?array` | Provider-specific request options | ## Focused Helper Methods For better readability, use the dedicated fluent helpers instead of packing everything into a single `with()` call: ```php withModel('claude-sonnet-4-20250514') ->withMessages(Messages::fromArray([ ['role' => 'system', 'content' => 'You are a helpful assistant who provides concise answers.'], ['role' => 'user', 'content' => 'What is the capital of France?'], ['role' => 'assistant', 'content' => 'Paris.'], ['role' => 'user', 'content' => 'And what about Germany?'], ])) ->withOptions(['temperature' => 0.5]) ->get(); // @doctest id="58b0" ``` Each helper returns a new immutable instance, so you can safely branch from a shared base: ```php $base = Inference::using('openai')->withModel('gpt-4.1-nano'); $creative = $base->withOptions(['temperature' => 0.9]); $precise = $base->withOptions(['temperature' => 0.0]); // @doctest id="5efc" ``` The full list of fluent helpers: - `withMessages(...)` -- set the conversation messages - `withModel(...)` -- override the model - `withTools(...)` -- attach tool/function definitions - `withToolChoice(...)` -- control tool selection - `withResponseFormat(...)` -- specify the response format - `withOptions(...)` -- set provider-specific options - `withStreaming(...)` -- enable or disable streaming - `withMaxTokens(...)` -- set the maximum token count - `withCachedContext(...)` -- attach reusable cached context - `withRetryPolicy(...)` -- configure retry behavior - `withResponseCachePolicy(...)` -- configure response caching ## Using the `Messages` Class For complex conversations, use the `Messages` class to build message sequences with a more expressive API: ```php asSystem('You are a senior PHP8 backend developer.') ->asDeveloper('Be concise and use modern PHP8.2+ features.') ->asUser([ 'What is the best way to handle errors in PHP8?', 'Provide a code example.', ]); $response = Inference::using('openai') ->withModel('gpt-4.1-nano') ->withMessages($messages) ->get(); // @doctest id="46bf" ``` > The `asDeveloper()` method maps to OpenAI's developer role and is automatically > normalized for providers that do not support it. ## Message Formats The `withMessages()` method requires a `Messages` object. You can create one from different input formats using the factory methods on `Messages`: - **`Messages::fromString($text)`** -- wraps a plain string as a single user message - **`Messages::fromArray($array)`** -- converts an array of role/content pairs - **`Messages` fluent API** -- build messages with `asSystem()`, `asUser()`, `asDeveloper()`, `asAssistant()` ### Multimodal Content For providers that support vision, you can include images in your messages: ```php 'user', 'content' => [ [ 'type' => 'text', 'text' => 'What\'s in this image?', ], [ 'type' => 'image_url', 'image_url' => [ 'url' => "data:image/jpeg;base64,$imageData", ], ], ], ], ]); $response = Inference::using('openai') ->withModel('gpt-4o') ->withMessages($messages) ->get(); // @doctest id="0678" ``` ## Using `InferenceRequest` Directly If your application already constructs request objects -- for example, when deserializing stored requests or building them in a pipeline -- you can pass them in directly: ```php 0], ); $text = Inference::using('openai') ->withRequest($request) ->get(); // @doctest id="6fe1" ``` `InferenceRequest` objects are immutable value objects. Use `with()` or the dedicated mutators (`withMessages()`, `withModel()`, etc.) to derive modified copies. Note that `InferenceRequest::with()` accepts typed objects (`Messages`, `ToolDefinitions`, `ToolChoice`, `ResponseFormat`) rather than primitive arrays or strings: ```php $updated = $request->with( model: 'gpt-4.1', options: ['temperature' => 0.7], ); // @doctest id="c690" ``` ================================================================================ FILE: packages/polyglot/essentials/request-options.md ================================================================================ The `options` array is the escape hatch for provider-specific request fields that fall outside Polyglot's unified API. Anything you place in `options` is passed through to the underlying provider driver. > **Note:** Except for `max_tokens` and `stream`, all option keys are provider-specific > and may not be available or behave identically across providers. Always consult the > provider's API documentation for details. ## Setting Options Pass options through `withOptions()` or the `options` parameter on `with()`: ```php withMessages(Messages::fromString('Write one short sentence about PHP.')) ->withOptions([ 'temperature' => 0.2, 'top_p' => 0.9, ]) ->get(); // @doctest id="dbac" ``` Options are merged additively -- calling `withOptions()` multiple times will merge the new keys into the existing set rather than replacing it. ## Common Options These options are widely supported across most providers: ```php $options = [ 'temperature' => 0.7, // Controls randomness (0.0 to 1.0) 'max_tokens' => 1000, // Maximum tokens to generate 'top_p' => 0.95, // Nucleus sampling parameter 'frequency_penalty' => 0.0, // Penalize repeated tokens 'presence_penalty' => 0.0, // Penalize repeated topics 'stop' => ["\n\n", "User:"], // Stop sequences ]; // @doctest id="26ac" ``` ## Dedicated Helpers Over Raw Options For common behaviors, prefer the dedicated fluent helpers instead of manually placing values in the `options` array. The helpers ensure correct handling across all providers: | Instead of this... | Use this... | |--------------------------------------------|-------------------------------| | `withOptions(['stream' => true])` | `withStreaming(true)` | | `withOptions(['max_tokens' => 256])` | `withMaxTokens(256)` | | `withOptions(['retryPolicy' => [...]])` | `withRetryPolicy($policy)` | These helpers set values that the request builder manages separately from the raw options array, ensuring they are applied correctly regardless of the provider. ## Provider-Specific Options Different providers accept different option keys. Here are a few examples: ### OpenAI ```php $response = Inference::using('openai') ->withMessages(Messages::fromString('Write a poem about programming.')) ->withOptions([ 'temperature' => 0.7, 'top_p' => 0.9, 'frequency_penalty' => 0.5, 'presence_penalty' => 0.3, ]) ->get(); // @doctest id="d9be" ``` ### Anthropic ```php $response = Inference::using('anthropic') ->withMessages(Messages::fromString('Write a poem about programming.')) ->withOptions([ 'temperature' => 0.7, 'top_p' => 0.9, 'top_k' => 40, ]) ->get(); // @doctest id="f249" ``` ## Retry Policy Retry behavior is configured explicitly through `withRetryPolicy()` -- never place it inside the `options` array. Polyglot will throw an `InvalidArgumentException` if you do. ```php withMessages(Messages::fromString('Summarize this article.')) ->withRetryPolicy($retryPolicy) ->get(); // @doctest id="8c05" ``` The retry policy supports exponential backoff with configurable jitter and can also recover from truncated responses: | Parameter | Default | Description | |------------------------|---------------------|-------------------------------------------------| | `maxAttempts` | `1` | Total attempts (1 = no retry) | | `baseDelayMs` | `250` | Base delay in milliseconds | | `maxDelayMs` | `8000` | Maximum delay cap | | `jitter` | `'full'` | Jitter strategy: `none`, `full`, or `equal` | | `retryOnStatus` | `[408,429,500,...]` | HTTP status codes that trigger a retry | | `retryOnExceptions` | Timeout, Network | Exception classes that trigger a retry | | `lengthRecovery` | `'none'` | Recovery mode: `none`, `continue`, `increase_max_tokens` | | `lengthMaxAttempts` | `1` | Max attempts for length recovery | | `lengthContinuePrompt` | `'Continue.'` | Prompt used for `continue` recovery mode | | `maxTokensIncrement` | `512` | Token increment for `increase_max_tokens` mode | ## Response Cache Policy Control whether responses are cached in memory for reuse: ```php withMessages(Messages::fromString('What is 2 + 2?')) ->withResponseCachePolicy(ResponseCachePolicy::Memory) ->get(); // @doctest id="7365" ``` Available policies: - `ResponseCachePolicy::None` -- no caching (default) - `ResponseCachePolicy::Memory` -- cache responses in memory for the current process ## Cached Context Use `withCachedContext()` to attach stable, reusable context that should be prepended to every request. This is useful when you have shared system instructions, tool definitions, or response formats that remain constant across multiple calls: ```php withCachedContext( messages: Messages::fromArray([ ['role' => 'system', 'content' => 'You are an expert PHP developer.'], ]), tools: $sharedToolDefinitions, toolChoice: ToolChoice::auto(), responseFormat: ResponseFormat::jsonObject(), ); // Each call inherits the cached context automatically $response1 = $base->withMessages(Messages::fromString('Explain SOLID principles.'))->get(); $response2 = $base->withMessages(Messages::fromString('What is the Repository pattern?'))->get(); // @doctest id="d3e3" ``` When the request is executed, cached context is merged with the request-level fields: cached messages are prepended to the request messages, and cached tools, tool choice, and response format are used as defaults when the request does not specify its own. Drivers can map cached context to provider-native caching features (such as Anthropic's prompt caching) when available. ================================================================================ FILE: packages/polyglot/essentials/response-handling.md ================================================================================ Polyglot's `PendingInference` class represents a pending inference execution. It is returned by the `Inference` class when you call the `create()` method. The request is **not sent** to the underlying LLM until you actually access the response data, making the object a lazy handle over a single inference operation. ## Retrieving Text Content The simplest way to get the model's response is the `get()` method, which returns the response content as a plain string: ```php withMessages(Messages::fromString('What is the capital of France?')) ->create(); // Get the response as plain text $text = $pending->get(); echo $text; // "The capital of France is Paris." // @doctest id="3d98" ``` ## Retrieving JSON Data When you request a JSON response format, use `asJsonData()` to decode the content directly into an associative array, or `asJson()` to get the raw JSON string: ```php withMessages(Messages::fromString('Return JSON with a single "status" field.')) ->withResponseFormat(ResponseFormat::jsonObject()) ->create(); // Decode response content as a PHP array $data = $pending->asJsonData(); echo $data['status']; // Or get the raw JSON string $json = $pending->asJson(); // @doctest id="539f" ``` ## Working with `InferenceResponse` For full access to every detail of the model's reply, call `response()` to get the normalized `InferenceResponse` object: ```php withMessages(Messages::fromString('What is the capital of France?')) ->create(); $response = $pending->response(); // Content and reasoning echo "Content: " . $response->content() . "\n"; echo "Reasoning: " . $response->reasoningContent() . "\n"; // Finish reason (returns InferenceFinishReason enum) echo "Finish reason: " . $response->finishReason()->value . "\n"; // Token usage $usage = $response->usage(); echo "Input tokens: " . $usage->input() . "\n"; echo "Output tokens: " . $usage->output() . "\n"; echo "Total tokens: " . $usage->total() . "\n"; echo "Cache tokens: " . $usage->cache() . "\n"; // Raw HTTP response data $httpResponse = $response->responseData(); // @doctest id="3f58" ``` ### Available `InferenceResponse` Methods | Method | Returns | Description | |--------|---------|-------------| | `content()` | `string` | The model's text output | | `reasoningContent()` | `string` | Chain-of-thought / thinking content (if supported) | | `toolCalls()` | `ToolCalls` | Collection of tool calls made by the model | | `usage()` | `InferenceUsage` | Token counts for the request | | `finishReason()` | `InferenceFinishReason` | Why the model stopped generating | | `responseData()` | `HttpResponse` | The underlying raw HTTP response | | `hasContent()` | `bool` | Whether the response contains text content | | `hasToolCalls()` | `bool` | Whether the model made any tool calls | | `hasReasoningContent()` | `bool` | Whether reasoning / thinking content is present | | `isPartial()` | `bool` | Whether this is a partial (streaming) response | ### Finish Reasons The `finishReason()` method returns an `InferenceFinishReason` enum. Polyglot normalizes the many vendor-specific strings into a consistent set of values: | Value | Meaning | |-------|---------| | `Stop` | The model finished naturally | | `Length` | Output was truncated due to token limits | | `ToolCalls` | The model wants to invoke a tool | | `ContentFilter` | Content was blocked by safety filters | | `Error` | An error occurred during generation | | `Other` | An unrecognized finish reason | ### Token Usage The `InferenceUsage` object provides detailed token breakdowns including cache and reasoning tokens: ```php usage(); $usage->inputTokens; // Input / prompt tokens $usage->outputTokens; // Output / completion tokens $usage->cacheWriteTokens; // Tokens written to cache $usage->cacheReadTokens; // Tokens read from cache $usage->reasoningTokens; // Reasoning / thinking tokens // Convenience accessors $usage->input(); // Same as inputTokens $usage->output(); // outputTokens + reasoningTokens $usage->cache(); // cacheWriteTokens + cacheReadTokens $usage->total(); // Sum of all token counts // @doctest id="b15b" ``` ## Handling Tool Calls When the model decides to invoke a tool, you can extract the tool call data using `asToolCallJsonData()` on `PendingInference`, or inspect the `ToolCalls` collection on the response object: ```php 'function', 'function' => [ 'name' => 'get_weather', 'description' => 'Get the current weather in a location', 'parameters' => [ 'type' => 'object', 'properties' => [ 'location' => [ 'type' => 'string', 'description' => 'The city and state, e.g. San Francisco, CA', ], 'unit' => [ 'type' => 'string', 'enum' => ['celsius', 'fahrenheit'], ], ], 'required' => ['location'], ], ], ], ]); $response = Inference::using('openai') ->with( messages: Messages::fromString('What is the weather in Paris?'), tools: $tools, toolChoice: ToolChoice::auto(), ) ->response(); if ($response->hasToolCalls()) { $toolCalls = $response->toolCalls(); foreach ($toolCalls->all() as $call) { echo "Tool: " . $call->name() . "\n"; echo "Args: " . $call->argsAsJson() . "\n"; // Access individual argument values $location = $call->value('location'); $unit = $call->value('unit', 'celsius'); } } // @doctest id="c92e" ``` ### Quick JSON Extraction from Tool Calls If you just need the arguments as a PHP array without inspecting the full response, use the shorthand on `PendingInference`: ```php asToolCallJsonData(); // Or as a JSON string $json = $pending->asToolCallJson(); // @doctest id="b235" ``` > **Note:** When a single tool call is present, `asToolCallJsonData()` returns that > call's arguments as an array. When multiple tool calls are present, it returns > an array of all tool call data. ## Streaming Responses For long-running completions, streaming lets you display output as it arrives. Call `stream()` to get an `InferenceStream` and consume deltas: ```php withMessages(Messages::fromString('Write a short story about a robot.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } // After iteration, get the finalized response $finalResponse = $stream->final(); echo "\n\nTokens used: " . $finalResponse->usage()->total(); // @doctest id="beb3" ``` ### The `PartialInferenceDelta` Object Each delta yielded during streaming is a `PartialInferenceDelta` with the following public properties: | Property | Type | Description | |----------|------|-------------| | `contentDelta` | `string` | New text content in this chunk | | `reasoningContentDelta` | `string` | New reasoning content in this chunk | | `toolId` | `ToolCallId\|string\|null` | Tool call ID | | `toolName` | `string` | Tool name (when streaming tool calls) | | `toolArgs` | `string` | Partial tool arguments JSON | | `finishReason` | `string` | Set on the final delta | | `usage` | `?InferenceUsage` | Token usage (typically on the final delta) | | `usageIsCumulative` | `bool` | Whether usage counts are cumulative | ### Stream Methods The `InferenceStream` class provides several ways to consume and transform the delta stream: ```php deltas() as $delta) { /* ... */ } // Transform each delta foreach ($stream->map(fn($d) => strtoupper($d->contentDelta)) as $text) { echo $text; } // Reduce to a single value $fullText = $stream->reduce( fn(string $carry, $delta) => $carry . $delta->contentDelta, '' ); // Filter deltas foreach ($stream->filter(fn($d) => $d->contentDelta !== '') as $delta) { echo $delta->contentDelta; } // Collect all deltas into an array $allDeltas = $stream->all(); // Get the finalized response (drains the stream if needed) $response = $stream->final(); // @doctest id="9774" ``` ### Using the `onDelta` Callback Instead of iterating manually, you can register a callback that fires for each visible delta: ```php withMessages(Messages::fromString('Explain queues in simple terms.')) ->stream(); $stream->onDelta(function ($delta) { echo $delta->contentDelta; // Flush output for real-time display if (ob_get_level() > 0) { ob_flush(); flush(); } }); // Drain the stream to trigger all callbacks $response = $stream->final(); // @doctest id="6676" ``` ### Stream Lifecycle The stream is **one-shot**: once `deltas()` has been fully iterated, calling it again throws a `LogicException`. If you need to replay the response, work with the finalized `InferenceResponse` returned by `$stream->final()`. Calling `final()` before the stream is exhausted will automatically drain all remaining deltas, ensuring the finalized response is complete. ## Checking for Streaming Mode If you need to branch your code based on whether a request was configured for streaming, use the `isStreamed()` method on `PendingInference`: ```php withMessages(Messages::fromString('Hello!')) ->withStreaming() ->create(); if ($pending->isStreamed()) { foreach ($pending->stream()->deltas() as $delta) { echo $delta->contentDelta; } } else { echo $pending->get(); } // @doctest id="e3ca" ``` ================================================================================ FILE: packages/polyglot/modes/overview.md ================================================================================ One of Polyglot's key strengths is its ability to support various output formats from LLM providers. This flexibility allows you to structure responses in the format that best suits your application, whether you need plain text, structured JSON data, or function and tool calls. ## How Response Shaping Works Polyglot 2.0 does not use output mode enums. Instead, response shape is controlled by the same request fields that providers already expect: - **Plain text** -- the default behavior when no format is specified - **JSON object** -- set `responseFormat` with type `json_object` - **JSON schema** -- set `responseFormat` with type `json_schema` and a schema definition - **Tool calls** -- provide `tools` and `toolChoice` definitions This approach keeps Polyglot close to the underlying APIs and makes requests easier to reason about. You work with the same concepts you would find in the provider's own documentation, without an additional abstraction layer in between. ## Response Shapes at a Glance | Shape | Request Fields | Best For | |---|---|---| | Plain text | _(none)_ | Simple text generation, conversations, summaries | | JSON object | `responseFormat: ResponseFormat::jsonObject()` | Structured data extraction without a strict schema | | JSON schema | `responseFormat: ResponseFormat::jsonSchema(...)` | Strictly typed, schema-validated data | | Tool calls | `tools: ToolDefinitions`, `toolChoice: ToolChoice` | Function calling, external actions, agent workflows | ## Choosing the Right Shape Consider these factors when selecting a response shape: 1. **Data complexity.** More complex or nested data structures benefit from JSON Schema, which enforces the exact shape you need. 2. **Provider support.** Not every provider supports every shape natively. JSON object mode is widely supported; JSON Schema is currently best supported by OpenAI. Check your provider's capabilities before relying on a specific format. 3. **Consistency requirements.** When you need guaranteed structure across many requests, prefer JSON Schema or tool calls over plain JSON object mode. 4. **Application needs.** If the response will be parsed by downstream code, structured formats save you from fragile string parsing. ## Convenience Accessors Polyglot provides several shortcut methods on the `Inference` facade for reading responses in different formats: | Method | Returns | Use When | |---|---|---| | `get()` | `string` | You want the raw text content | | `asJson()` | `string` | You want the JSON string from the response | | `asJsonData()` | `array` | You want decoded JSON as a PHP array | | `asToolCallJson()` | `string` | You want tool call arguments as a JSON string | | `asToolCallJsonData()` | `array` | You want tool call arguments as a PHP array | | `response()` | `InferenceResponse` | You need the full response object with metadata | | `stream()` | `InferenceStream` | You want to stream partial responses in real time | ## Tips for Reliable Structured Output For best results when requesting structured responses: 1. **Be explicit in prompts.** Clearly describe the expected format, including field names and types. 2. **Provide examples.** Show what a correct response looks like directly in your prompt. 3. **Use constraints.** Specify limits, required fields, and allowed values. 4. **Test across providers.** If your application supports multiple providers, verify that your chosen format works with each one. 5. **Implement fallbacks.** Have backup strategies for when a provider does not support your preferred format natively. ================================================================================ FILE: packages/polyglot/modes/tools.md ================================================================================ Tool calling enables the model to request specific actions from your application. Instead of generating a text response, the model returns structured function calls with arguments, which your code can execute and optionally feed back into the conversation. This is the foundation for building agents, assistants, and any application that needs to interact with external systems. ## Defining Tools Tools are defined as arrays following the OpenAI function calling format. Each tool describes a function's name, purpose, and expected parameters: ```php 'function', 'function' => [ 'name' => 'get_weather', 'description' => 'Get the current weather for a location', 'parameters' => [ 'type' => 'object', 'properties' => [ 'location' => [ 'type' => 'string', 'description' => 'The city and country (e.g., "Paris, France")', ], 'unit' => [ 'type' => 'string', 'enum' => ['celsius', 'fahrenheit'], 'description' => 'The temperature unit to use', ], ], 'required' => ['location'], ], ], ]; // @doctest id="8352" ``` ## Making a Tool Call Request Pass your tool definitions via the `tools` parameter and control how the model selects tools with `toolChoice`: ```php with( messages: Messages::fromString('What is the weather like in Paris?'), tools: ToolDefinitions::fromArray([$weatherTool]), toolChoice: ToolChoice::auto(), ) ->response(); // @doctest id="f21c" ``` ## Processing Tool Call Results The response object provides methods for inspecting whether the model made tool calls and extracting their details: ```php hasToolCalls()) { $toolCalls = $response->toolCalls(); foreach ($toolCalls->all() as $call) { $name = $call->name(); // e.g. 'get_weather' $args = $call->args(); // e.g. ['location' => 'Paris, France'] $id = $call->idString(); // unique call ID string for multi-turn conversations // Execute the function and use the result... } } else { // The model responded with text instead echo $response->content(); } // @doctest id="8939" ``` ## Convenience Accessors For simple cases where you just need the tool call arguments as data, Polyglot provides shortcut methods: ```php with( messages: Messages::fromString('Get the weather for Paris.'), tools: ToolDefinitions::fromArray([$weatherTool]), toolChoice: ToolChoice::auto(), ) ->asToolCallJson(); // Or as a decoded PHP array $data = Inference::using('openai') ->with( messages: Messages::fromString('Get the weather for Paris.'), tools: ToolDefinitions::fromArray([$weatherTool]), toolChoice: ToolChoice::auto(), ) ->asToolCallJsonData(); // @doctest id="34c2" ``` When the model returns a single tool call, `asToolCallJsonData()` returns that call's arguments as an array. When multiple tool calls are returned, it returns an array of all calls. ## Controlling Tool Selection The `toolChoice` parameter controls how the model decides whether to use tools: ```php with( messages: Messages::fromString('What is the weather like in Paris?'), tools: $toolDefs, toolChoice: ToolChoice::auto(), ) ->response(); // Force the model to call a specific tool $response = Inference::using('openai') ->with( messages: Messages::fromString('What is the weather like in Paris?'), tools: $toolDefs, toolChoice: ToolChoice::specific('get_weather'), ) ->response(); // Prevent tool usage entirely (model responds with text) $response = Inference::using('openai') ->with( messages: Messages::fromString('What is the weather like in Paris?'), tools: $toolDefs, toolChoice: ToolChoice::none(), ) ->response(); // @doctest id="d212" ``` ## Multiple Tools You can provide multiple tool definitions in a single request. The model will select the most appropriate one based on the user's message: ```php 'function', 'function' => [ 'name' => 'get_weather', 'description' => 'Get the current weather for a location', 'parameters' => [ 'type' => 'object', 'properties' => [ 'location' => ['type' => 'string'], ], 'required' => ['location'], ], ], ], [ 'type' => 'function', 'function' => [ 'name' => 'get_flight_info', 'description' => 'Get information about a flight', 'parameters' => [ 'type' => 'object', 'properties' => [ 'flight_number' => ['type' => 'string'], 'date' => ['type' => 'string'], ], 'required' => ['flight_number'], ], ], ], ]; $response = Inference::using('openai') ->with( messages: Messages::fromString('What is the status of flight AA123?'), tools: ToolDefinitions::fromArray($tools), toolChoice: ToolChoice::auto(), ) ->response(); // @doctest id="14a1" ``` ## Using the Fluent API The fluent builder methods `withTools()` and `withToolChoice()` offer an alternative to passing everything through `with()`: ```php withMessages(Messages::fromString('Get the weather for Paris.')) ->withTools(ToolDefinitions::fromArray([$weatherTool])) ->withToolChoice(ToolChoice::auto()) ->response(); // @doctest id="1a61" ``` ## Provider Support Tool calling support varies across providers: | Provider | Tool Calling | Tool Choice | |---|---|---| | OpenAI | Yes | Yes (auto, none, specific function) | | Anthropic | Yes | Yes | | Groq | Yes | Yes | | Gemini | Yes | Varies by model | | Other providers | Varies | Varies | You can query tool support programmatically through `DriverCapabilities::supportsToolCalling()` and `DriverCapabilities::supportsToolChoice()`. ## When to Use Tool Calling Tool calling is ideal for: - Building agents that interact with external APIs and services - Creating assistants that retrieve real-time information - Implementing multi-step workflows where the model orchestrates actions - Extracting structured data using function schemas (an alternative to JSON Schema mode) - Giving the model access to specific capabilities like calculations, database queries, or file operations ================================================================================ FILE: packages/polyglot/modes/json-schema.md ================================================================================ JSON Schema mode takes structured output a step further by validating the response against a predefined schema. When the provider supports it natively, the schema is enforced at the API level, guaranteeing that the response matches the exact structure you defined. ## Basic Usage Use `ResponseFormat::jsonSchema()` to create a response format with a schema definition. Polyglot forwards the schema directly to the provider: ```php with( messages: Messages::fromString('Return a city record as JSON.'), responseFormat: ResponseFormat::jsonSchema( schema: [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string'], 'country' => ['type' => 'string'], ], 'required' => ['name', 'country'], ], name: 'city_record', strict: true, ), ) ->asJsonData(); // $data is guaranteed to have 'name' and 'country' keys echo "{$data['name']}, {$data['country']}\n"; // @doctest id="8b99" ``` ## Using the Fluent API You can also set the response format with the `withResponseFormat()` method, using the `ResponseFormat::jsonSchema()` factory: ```php 'object', 'properties' => [ 'title' => ['type' => 'string'], 'author' => ['type' => 'string'], 'year' => ['type' => 'integer'], ], 'required' => ['title', 'author', 'year'], ]; $data = Inference::using('openai') ->withMessages(Messages::fromString('Return a book record for "1984" by George Orwell.')) ->withResponseFormat(ResponseFormat::jsonSchema( schema: $schema, name: 'book_record', strict: true, )) ->asJsonData(); // @doctest id="c412" ``` ## Complex Nested Schemas JSON Schema mode shines when you need complex, nested data structures. The provider will enforce every level of the schema: ```php 'object', 'properties' => [ 'location' => [ 'type' => 'string', 'description' => 'The city and country', ], 'current_temperature' => [ 'type' => 'number', 'description' => 'Current temperature in Celsius', ], 'conditions' => [ 'type' => 'string', 'description' => 'Current weather conditions', ], 'forecast' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'day' => ['type' => 'string'], 'high' => ['type' => 'number'], 'low' => ['type' => 'number'], 'conditions' => ['type' => 'string'], ], 'required' => ['day', 'high', 'low', 'conditions'], ], ], ], 'required' => ['location', 'current_temperature', 'conditions', 'forecast'], ]; $data = Inference::using('openai') ->with( messages: Messages::fromString('Provide a weather report for Paris, France.'), responseFormat: ResponseFormat::jsonSchema( schema: $schema, name: 'weather_report', strict: true, ), ) ->asJsonData(); echo "Weather in {$data['location']}: {$data['conditions']}, {$data['current_temperature']}C\n"; foreach ($data['forecast'] as $day) { echo " {$day['day']}: {$day['low']}C - {$day['high']}C, {$day['conditions']}\n"; } // @doctest id="79bb" ``` ## How Schema Validation Works With JSON Schema mode, the validation pipeline depends on the provider: 1. The schema is sent to the provider as part of the API request. 2. The model structures its response to match the schema. 3. For providers with native support (like OpenAI), validation happens at the API level before the response is returned. 4. Polyglot forwards the native schema request. It does not emulate schema enforcement for providers that lack it. When `strict` is set to `true`, the provider will reject any response that does not conform to the schema and retry internally. This gives you strong guarantees about the output structure. ## Provider Support Provider support for JSON Schema varies significantly: | Provider | JSON Schema Support | |---|---| | OpenAI (GPT-4 and newer) | Full native support with strict mode | | Groq, Fireworks, and others | Varies -- check `DriverCapabilities` | | Anthropic | Not supported natively | You can query support programmatically: ```php // DriverCapabilities::supportsResponseFormatJsonSchema() // @doctest id="b89a" ``` For providers without native JSON Schema support, consider using [JSON object mode](/modes/json) with detailed prompts, or use the Instructor layer above Polyglot for automatic fallback strategies. ## When to Use JSON Schema Mode JSON Schema mode is ideal for: - Applications requiring strictly typed data with guaranteed structure - Integration with databases or APIs that expect specific field names and types - Data extraction with complex, nested structures - Ensuring consistent response formats across many requests - Any situation where a malformed response would cause downstream failures ================================================================================ FILE: packages/polyglot/modes/json.md ================================================================================ JSON object mode instructs the model to return its response as a valid JSON object. This is useful when you need structured data that can be easily processed by your application, without defining a full schema. ## Basic Usage Use `ResponseFormat::jsonObject()` to request JSON output. The `asJsonData()` convenience method decodes the response into a PHP array: ```php withMessages(Messages::fromString('Return JSON with keys "name" and "role".')) ->withResponseFormat(ResponseFormat::jsonObject()) ->asJsonData(); // $data is now a PHP array, e.g. ['name' => 'Alice', 'role' => 'Engineer'] // @doctest id="7918" ``` The `asJsonData()` method only decodes the returned content. Validation and structure depend on the provider and your prompt -- there are no schema guarantees with this mode. ## Guiding the JSON Structure For best results, include clear instructions about the expected JSON structure directly in your prompt. The model will follow your guidance, but without a schema there is no enforcement: ```php with( messages: Messages::fromString($prompt), responseFormat: ResponseFormat::jsonObject(), ) ->asJsonData(); foreach ($data['cities'] as $city) { echo "{$city['name']}, {$city['country']}: {$city['population']} million\n"; } // @doctest id="e623" ``` ## Using the Fluent API You can also set the response format with the dedicated `withResponseFormat()` method: ```php withMessages(Messages::fromString('List three programming languages as JSON with name and year fields.')) ->withResponseFormat(ResponseFormat::jsonObject()) ->asJsonData(); // @doctest id="6b18" ``` ## Getting JSON as a String If you need the raw JSON string instead of a decoded array, use `asJson()`: ```php withMessages(Messages::fromString('Return a JSON object with a greeting.')) ->withResponseFormat(ResponseFormat::jsonObject()) ->asJson(); // $json is a string like '{"greeting": "Hello, world!"}' // @doctest id="42e5" ``` ## Provider Support Most major providers support native JSON object mode, including OpenAI, Groq, Fireworks, and others. Some providers (such as Anthropic) do not support `responseFormat` natively -- for those, consider using tool calls to extract structured data, or use the Instructor layer above Polyglot for prompt-based fallback strategies. You can query a driver's capabilities programmatically through `DriverCapabilities::supportsResponseFormatJsonObject()`. ## When to Use JSON Object Mode JSON object mode is ideal for: - Extracting structured data (lists, records, key-value pairs) - API responses that need to be machine-readable - Generating datasets or feeding data into downstream processing - Cases where you want structured output but do not need strict schema validation If you need guaranteed field names, types, and required properties, consider [JSON Schema mode](/modes/json-schema) instead. ================================================================================ FILE: packages/polyglot/modes/md-json.md ================================================================================ Polyglot 2.0 does not expose a markdown-JSON response mode. In previous versions, `OutputMode::MdJson` instructed the model to wrap its JSON response in a Markdown code block (` ```json ... ``` `), and Polyglot would extract the JSON content automatically. This was useful as a compatibility fallback for providers that lacked native JSON output support. ## Why It Was Removed Polyglot 2.0 is designed to model only native provider request fields. Since markdown-wrapped JSON is a prompt-based convention rather than a native API feature, it does not belong in Polyglot's abstraction layer. The response shape in Polyglot 2.0 is controlled entirely by standard request parameters: - `responseFormat` for JSON object and JSON schema modes - `tools` and `toolChoice` for tool calling There is no `responseFormat` type for "respond with JSON inside a Markdown code block" because no provider API supports that natively. ## What to Use Instead If you need prompt-based JSON fallback strategies -- for example, when working with providers that do not support native JSON output -- use the **Instructor** layer above Polyglot. Instructor handles response format negotiation, prompt engineering for structured output, and extraction of JSON from various response formats, including Markdown-wrapped JSON. For providers that do support native JSON output, use [JSON object mode](/modes/json) or [JSON Schema mode](/modes/json-schema) directly through Polyglot's `responseFormat` field. ## Migration from 1.x If your application previously used `OutputMode::MdJson`, you have two migration paths: 1. **Switch to native JSON.** If your provider supports it, use `responseFormat: ['type' => 'json_object']` for the same structured output with better reliability. 2. **Use Instructor.** If you need the Markdown JSON fallback for providers without native JSON support, move that logic to the Instructor layer, which provides automatic format negotiation and extraction. ================================================================================ FILE: packages/polyglot/modes/text.md ================================================================================ Text is the simplest and most portable output format. When you do not set `responseFormat`, Polyglot asks the provider for a normal text response. Every provider supports this shape, making it the safest default for any use case. ## Basic Usage A minimal text request requires nothing more than a message: ```php withMessages(Messages::fromString('What is the single responsibility principle?')) ->get(); // @doctest id="2210" ``` The `get()` method returns the raw string content from the model's response. There is no JSON parsing, no schema validation -- just the text the model produced. ## When to Use Text Mode Plain text is ideal for: - Simple question answering - Creative content generation (stories, poems, copy) - Conversational interactions and chat - Summaries and paraphrasing - Any use case where structured data is not required ## Working Across Providers Text mode works consistently across all providers, making it the most portable option. You can swap providers without changing anything else about your request: ```php withMessages(Messages::fromString('Write a short poem about the ocean.')) ->get(); // Using Anthropic -- same API, same result shape $response = Inference::using('anthropic') ->withMessages(Messages::fromString('Write a short poem about the ocean.')) ->get(); // @doctest id="c256" ``` ## Using the `with()` Method You can also use the `with()` method to pass messages alongside other parameters. Since text is the default, there is no need to specify a response format: ```php with( messages: Messages::fromString('Explain the SOLID principles in one paragraph.'), options: ['temperature' => 0.3], ) ->get(); // @doctest id="cf5a" ``` ## Streaming Text Responses For long-form content, you may want to stream the response so your application can display output as it arrives: ```php withMessages(Messages::fromString('Write a short essay about renewable energy.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } // @doctest id="8cd7" ``` Each delta contains a `contentDelta` with the next chunk of text from the model. Streaming works with all providers that support it. ## Accessing the Full Response If you need metadata beyond the raw text -- such as token usage or the finish reason -- use the `response()` method instead of `get()`: ```php withMessages(Messages::fromString('What is photosynthesis?')) ->response(); $text = $response->content(); $usage = $response->usage(); $reason = $response->finishReason(); // @doctest id="7119" ``` ================================================================================ FILE: packages/polyglot/streaming/overview.md ================================================================================ Streaming LLM responses allows your application to display content as it is generated, rather than waiting for the entire response to complete. This creates a more responsive experience for users and enables progressive processing of long outputs. Polyglot provides a consistent streaming API that works identically across all supported providers. ## Why Stream? Streaming offers several practical advantages over waiting for a complete response: - **Lower perceived latency.** Users see the first tokens almost immediately instead of staring at a blank screen. - **Progressive processing.** You can begin acting on early output while later parts are still generating. - **Efficient handling of long outputs.** Content is processed incrementally, avoiding large memory allocations and timeout risks. - **Early termination.** You can break out of the stream when you have enough data, saving time and cost. ## Enabling Streaming To stream a response, call `withStreaming()` on the `Inference` builder, then call `stream()` to obtain an `InferenceStream`. The `stream()` shortcut enables streaming automatically, so you may omit `withStreaming()` when using it: ```php withMessages(Messages::fromString('Explain event buses in simple language.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } // @doctest id="bf68" ``` The `deltas()` method returns a PHP `Generator` that yields `PartialInferenceDelta` objects one at a time. Each delta represents a single chunk received from the provider. ## What a Delta Contains Every `PartialInferenceDelta` carries the incremental data from a single streaming event: | Property | Description | |---|---| | `contentDelta` | New text content received in this chunk. | | `reasoningContentDelta` | New reasoning / chain-of-thought content (for models that support it). | | `toolName`, `toolArgs`, `toolId` | Tool call fragments streamed incrementally. | | `finishReason` | Empty until the final chunk, then contains the stop reason (e.g. `stop`, `tool_calls`). | | `usage` | Token usage statistics, when provided by the provider. | | `value` | An optional arbitrary value attached to the delta by higher-level layers. | Polyglot's `VisibilityTracker` automatically filters out invisible deltas (chunks that carry no meaningful change), so you only receive deltas that contain new content, tool data, or status changes. ## Retrieving the Final Response After iterating through all deltas, you can obtain the complete `InferenceResponse` assembled from the stream: ```php withMessages(Messages::fromString('Write a haiku about PHP.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } $response = $stream->final(); echo $response->content(); // full accumulated text echo $response->usage(); // token usage for the request // @doctest id="3a65" ``` If you call `final()` before consuming the stream, it will drain all remaining deltas internally so that the response is fully assembled. This means you can skip the `foreach` loop entirely when you only need the final result, though in that case a non-streaming request would be more appropriate. ## Using Callbacks You can register a callback with `onDelta()` instead of (or in addition to) iterating manually. The callback fires for every visible delta: ```php withMessages(Messages::fromString('Write a short poem about queues.')) ->stream() ->onDelta(fn($delta) => print($delta->contentDelta)); // Drain the stream to trigger the callbacks $stream->final(); // @doctest id="345d" ``` This pattern is convenient when the delta processing logic is simple and you want the final response at the end. ## Early Termination You can break out of the delta loop at any time. This is useful when you have received enough content and want to stop processing: ```php withMessages(Messages::fromString('Write a long story about space exploration.')) ->stream(); $wordCount = 0; foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; $wordCount += str_word_count($delta->contentDelta); if ($wordCount >= 100) { echo "\n[Stopped after ~100 words]\n"; break; } } // @doctest id="8fe8" ``` When you break out of the loop, the underlying HTTP connection to the provider continues in the background, but your application stops processing further chunks. ## Replay and Caching The `deltas()` generator is one-shot by default -- once consumed, it cannot be iterated again. Attempting to call `deltas()` a second time will throw a `LogicException`. If you need to replay the stream (for example, during testing or when multiple consumers need the same data), enable in-memory response caching before executing the request: ```php withMessages(Messages::fromString('Hello!')) ->withResponseCachePolicy(ResponseCachePolicy::Memory) ->stream(); // @doctest id="4d8a" ``` With `ResponseCachePolicy::Memory`, the raw response data is cached in memory, allowing the stream to be reconstructed if needed. ## Performance Considerations When working with streaming responses, keep a few things in mind: - **Memory.** If you are accumulating content yourself (e.g. building a string in a loop), be mindful of memory usage for very long responses. Consider writing chunks directly to a file or output buffer. - **Output flushing.** In CLI scripts or streaming HTTP responses, flush the output buffer after each chunk so the user sees incremental output: ```php foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; flush(); } ``` - **Timeouts.** Long-running streams may exceed default HTTP timeout settings. Adjust your timeout configuration for requests that are expected to generate large amounts of content. ================================================================================ FILE: packages/polyglot/streaming/misc.md ================================================================================ Beyond simple iteration, `InferenceStream` provides a set of functional helpers for processing deltas. These methods build on top of the `deltas()` generator, so each one consumes the stream -- you should use only one of them per stream instance. ## Reducing to a Single Value The `reduce()` method works like `array_reduce`: it folds every delta into an accumulator and returns the final value. This is useful when you need a single result derived from the entire stream: ```php withMessages(Messages::fromString('Write three short lines about queues.')) ->stream() ->reduce( fn(string $carry, $delta) => $carry . $delta->contentDelta, '', ); echo $text; // @doctest id="0284" ``` Because `reduce()` drains the entire stream before returning, it blocks until the response is complete. ## Mapping Deltas The `map()` method transforms each delta into a new value and yields the results as a generator. Use it to extract or reshape data from each chunk without consuming the stream eagerly: ```php withMessages(Messages::fromString('List five fun facts about PHP.')) ->stream(); foreach ($stream->map(fn($delta) => strtoupper($delta->contentDelta)) as $chunk) { echo $chunk; } // @doctest id="51a1" ``` ## Filtering Deltas The `filter()` method yields only the deltas that satisfy a given predicate. Deltas for which the callback returns `false` are silently skipped: ```php withMessages(Messages::fromString('Count from one to ten.')) ->stream(); // Only process deltas that contain digits foreach ($stream->filter(fn($delta) => preg_match('/\d/', $delta->contentDelta)) as $delta) { echo $delta->contentDelta; } // @doctest id="4d4f" ``` ## Collecting All Deltas The `all()` method drains the stream and returns every visible delta as an array. This is handy for inspection or testing, but keep in mind that it loads the entire stream into memory: ```php withMessages(Messages::fromString('Say hello.')) ->stream() ->all(); echo "Received " . count($deltas) . " deltas.\n"; // @doctest id="d021" ``` ## Accessing the Last Delta After the stream has been consumed (either partially or fully), you can retrieve the most recently yielded delta with `lastDelta()`: ```php $stream = Inference::using('openai') ->withMessages(Messages::fromString('What is 2 + 2?')) ->stream(); foreach ($stream->deltas() as $delta) { // process... } $last = $stream->lastDelta(); echo $last->finishReason; // e.g. "stop" // @doctest id="0ed5" ``` This is particularly useful for inspecting the finish reason or final usage data without keeping track of it manually during iteration. ## Token Usage The `usage()` method returns the accumulated `InferenceUsage` object for the stream, containing input tokens, output tokens, and any cache or reasoning token counts reported by the provider: ```php $stream = Inference::using('openai') ->withMessages(Messages::fromString('Summarize the theory of relativity.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } $usage = $stream->usage(); echo "\nTokens used: input={$usage->inputTokens}, output={$usage->outputTokens}\n"; // @doctest id="e1ba" ``` ## Execution Metadata The `execution()` method returns the underlying `InferenceExecution` object, which contains the original request, the finalized response (once the stream completes), and execution metadata such as the execution ID: ```php $stream = Inference::using('openai') ->withMessages(Messages::fromString('Hello!')) ->stream(); $stream->final(); // ensure stream is consumed $execution = $stream->execution(); echo "Execution ID: " . $execution->id->toString() . "\n"; echo "Model used: " . $execution->request()->model() . "\n"; // @doctest id="bd30" ``` ## Summary of Available Methods | Method | Returns | Consumes stream? | Description | |---|---|---|---| | `deltas()` | `Generator` | Yes | Yields visible deltas one by one. | | `map(callable)` | `iterable` | Yes | Transforms each delta via a callback. | | `filter(callable)` | `iterable` | Yes | Yields only deltas matching a predicate. | | `reduce(callable, initial)` | `mixed` | Yes (blocking) | Folds all deltas into a single value. | | `all()` | `array` | Yes (blocking) | Collects all deltas into an array. | | `onDelta(callable)` | `self` | No (registers callback) | Registers a callback fired for each visible delta. | | `final()` | `?InferenceResponse` | Drains if needed | Returns the assembled final response. | | `lastDelta()` | `?PartialInferenceDelta` | No | Returns the most recently yielded delta. | | `usage()` | `InferenceUsage` | No | Returns accumulated token usage. | | `execution()` | `InferenceExecution` | No | Returns the execution context and metadata. | ================================================================================ FILE: packages/polyglot/embeddings/overview.md ================================================================================ Embeddings are numerical representations of text that capture semantic meaning in a high-dimensional vector space. They are a foundational building block for many LLM-powered applications, enabling machines to understand relationships between words, phrases, and documents. Polyglot's `Embeddings` class provides a unified interface for generating vector embeddings across multiple providers. You write your code once, and switch between OpenAI, Cohere, Gemini, Jina, Mistral, or any other supported provider by changing a single preset name. ## Understanding Embeddings Before diving into the API, it helps to understand the core concepts: - **Vectors** -- Embeddings represent text as arrays of floating-point numbers in a high-dimensional space (typically 256 to 3072 dimensions). - **Semantic similarity** -- Texts with similar meaning produce vectors that are closer together, measurable through cosine similarity, Euclidean distance, or dot product. - **Provider models** -- Different providers offer models with varying dimension counts, language support, and performance characteristics. Common use cases for embeddings include: - **Semantic search** -- Find documents similar to a query based on meaning, not just keywords. - **Clustering** -- Group related documents together automatically. - **Classification** -- Assign categories to text based on content. - **Recommendations** -- Suggest related items based on vector proximity. - **RAG (Retrieval-Augmented Generation)** -- Retrieve relevant context for LLM prompts. ## The Embeddings Class The `Embeddings` class is a facade that combines provider configuration, request building, and result handling into a fluent, immutable API. Every method that modifies state returns a new instance, making the class safe to reuse and compose. ### Architecture Overview The class is built from several focused components: | Component | Responsibility | |---|---| | `Embeddings` | Facade with fluent API and static factory methods | | `EmbeddingsRuntime` | Orchestrates driver creation, HTTP clients, and event dispatching | | `EmbeddingsProvider` | Resolves configuration and optional explicit drivers | | `PendingEmbeddings` | Executes the request with retry logic and returns the response | | `EmbeddingsDriverRegistry` | Maps driver names to concrete driver implementations | ## Entry Points You can create an `Embeddings` instance in several ways, depending on how much control you need: ```php **Note:** Mistral and Ollama use the OpenAI-compatible driver, since their APIs follow the same format. ## Custom Driver Registration You can register your own driver for providers not bundled with Polyglot by creating a custom `EmbeddingsDriverRegistry`: ```php withDriver('custom-provider', CustomEmbeddingsDriver::class); // Or register with a factory callable $registry = BundledEmbeddingsDrivers::registry() ->withDriver('custom-provider', function ($config, $httpClient, $events) { return new CustomEmbeddingsDriver($config, $httpClient, $events); }); // @doctest id="58f9" ``` Your custom driver must implement the `CanHandleVectorization` contract. ## Events The embeddings system dispatches events at key points during execution, which you can listen to through the runtime: | Event | When | |---|---| | `EmbeddingsDriverBuilt` | After the driver is created from configuration | | `EmbeddingsRequested` | When an embeddings request is initiated | | `EmbeddingsResponseReceived` | After a successful response is received | | `EmbeddingsFailed` | When the request fails after all retry attempts | ================================================================================ FILE: packages/polyglot/embeddings/work-with-embeddings.md ================================================================================ This page covers the full workflow of generating embeddings -- from building requests to extracting and comparing vectors. ## Generating a Single Embedding The most common path is straightforward. Pass a text string, execute the request, and extract the vector: ```php withInputs('The quick brown fox jumps over the lazy dog.') ->get(); $vector = $response->first()?->values() ?? []; echo "Generated a vector with " . count($vector) . " dimensions.\n"; // @doctest id="e059" ``` ## Embedding Multiple Texts You can generate embeddings for multiple texts in a single request, which is significantly more efficient than making separate calls: ```php withInputs($documents) ->get(); // Get all vectors as Vector objects $vectors = $response->vectors(); foreach ($vectors as $index => $vector) { echo "Document " . ($index + 1) . ": " . count($vector->values()) . " dimensions\n"; } // Or get raw float arrays directly $valuesArray = $response->toValuesArray(); // $valuesArray[0] = [0.0123, -0.0456, ...], etc. // @doctest id="306e" ``` ## Using the Shorthand Method The `with()` method lets you set inputs, options, and model in a single call: ```php with( input: ['Document one', 'Document two'], options: ['dimensions' => 512], model: 'text-embedding-3-large', ) ->get(); // @doctest id="bd5d" ``` ## The EmbeddingsResponse Object The `get()` method returns an `EmbeddingsResponse` with several methods for accessing results: | Method | Returns | Description | |---|---|---| | `first()` | `?Vector` | The first vector, or `null` if the response is empty | | `last()` | `?Vector` | The last vector, or `null` if the response is empty | | `vectors()` | `Vector[]` | All vectors as an array of `Vector` objects | | `all()` | `Vector[]` | Alias for `vectors()` | | `toValuesArray()` | `float[][]` | All vectors as nested arrays of floats | | `split(int $index)` | `[Vector[], Vector[]]` | Split vectors into two groups at the given index | | `usage()` | `EmbeddingsUsage` | Token usage information for the request | ### Accessing Usage Information Every response includes token usage data: ```php withInputs('Sample text for embedding') ->get(); $usage = $response->usage(); echo "Input tokens: " . $usage->input() . "\n"; echo "Total tokens: " . $usage->total() . "\n"; // @doctest id="0613" ``` ## Working with Vector Objects Each embedding in the response is wrapped in a `Vector` object that provides methods for accessing values and comparing vectors. ### Basic Vector Operations ```php withInputs('Sample text for embedding') ->get(); $vector = $response->first(); // Get the raw float array $values = $vector->values(); echo "Dimensions: " . count($values) . "\n"; // Get the vector's index/ID in the response $id = $vector->id(); // @doctest id="a9ff" ``` ### Comparing Vectors The `Vector` class supports three distance metrics for comparing embeddings: ```php withInputs([ 'The cat sat on the mat.', 'A feline rested on the rug.', 'The stock market crashed today.', ]) ->get(); $vectors = $response->vectors(); // Cosine similarity (higher = more similar, range: -1 to 1) $similarity = $vectors[0]->compareTo($vectors[1], Vector::METRIC_COSINE); echo "Cat/Feline similarity: " . round($similarity, 4) . "\n"; $similarity = $vectors[0]->compareTo($vectors[2], Vector::METRIC_COSINE); echo "Cat/Stock similarity: " . round($similarity, 4) . "\n"; // Euclidean distance (lower = more similar) $distance = $vectors[0]->compareTo($vectors[1], Vector::METRIC_EUCLIDEAN); echo "Euclidean distance: " . round($distance, 4) . "\n"; // Dot product $dot = $vectors[0]->compareTo($vectors[1], Vector::METRIC_DOT_PRODUCT); echo "Dot product: " . round($dot, 4) . "\n"; // @doctest id="a0bd" ``` You can also use the static methods directly on float arrays: ```php withInputs($text) ->first(); echo "OpenAI dimensions: " . count($openaiVector->values()) . "\n"; // Cohere $cohereVector = Embeddings::using('cohere') ->withInputs($text) ->first(); echo "Cohere dimensions: " . count($cohereVector->values()) . "\n"; // Mistral $mistralVector = Embeddings::using('mistral') ->withInputs($text) ->first(); echo "Mistral dimensions: " . count($mistralVector->values()) . "\n"; // @doctest id="afe6" ``` ## Provider-Specific Options Different providers support additional options that you can pass through `withOptions()`: ```php withModel('text-embedding-3-large') ->withInputs('Sample text') ->withOptions([ 'encoding_format' => 'float', 'dimensions' => 512, ]) ->get(); // Cohere: specify input type and truncation behavior $response = Embeddings::using('cohere') ->withInputs('Sample text') ->withOptions([ 'input_type' => 'classification', 'truncate' => 'END', ]) ->get(); // @doctest id="a037" ``` ## Custom Configuration When you need full control over the connection parameters, create an `EmbeddingsConfig` directly: ```php withInputs('Custom configuration example') ->first(); echo "Generated embedding with " . count($vector->values()) . " dimensions.\n"; // @doctest id="54cc" ``` You can also load configuration from a DSN string: ```php withInputs([ 'Document one', 'Document two', 'Document three', ]) ->get(); $vectors = $response->toValuesArray(); // @doctest id="3c31" ``` Each provider has a maximum number of inputs per request (configured as `maxInputs` in the preset). For OpenAI this defaults to 2048; for Cohere it is 96. When processing large datasets, chunk your documents to stay within these limits. ### Processing Large Datasets When you have more documents than a single batch can handle, process them in chunks: ```php withInputs($batch)->get(); $vectors = array_merge($vectors, $response->toValuesArray()); $batchNum = (int) floor($i / $batchSize) + 1; $totalBatches = (int) ceil(count($allDocuments) / $batchSize); echo "Processed batch {$batchNum} of {$totalBatches}\n"; } catch (\Exception $e) { echo "Error processing batch: " . $e->getMessage() . "\n"; } // Small delay to avoid hitting rate limits usleep(100_000); // 100ms } echo "Processed " . count($vectors) . " embeddings in total.\n"; // @doctest id="0ba3" ``` ## Retry Policies Network failures and rate limits are inevitable in production. Polyglot provides an `EmbeddingsRetryPolicy` that implements exponential backoff with configurable jitter: ```php withInputs(['Document one']) ->withRetryPolicy(new EmbeddingsRetryPolicy( maxAttempts: 3, baseDelayMs: 250, maxDelayMs: 8000, jitter: 'full', retryOnStatus: [408, 429, 500, 502, 503, 504], )) ->get(); // @doctest id="fc47" ``` ### Retry Policy Parameters | Parameter | Default | Description | |---|---|---| | `maxAttempts` | `1` | Total number of attempts (1 = no retries) | | `baseDelayMs` | `250` | Base delay in milliseconds before the first retry | | `maxDelayMs` | `8000` | Maximum delay cap in milliseconds | | `jitter` | `'full'` | Jitter strategy: `'none'`, `'full'`, or `'equal'` | | `retryOnStatus` | `[408, 429, 500, 502, 503, 504]` | HTTP status codes that trigger a retry | | `retryOnExceptions` | `[TimeoutException, NetworkException]` | Exception classes that trigger a retry | The delay for each attempt is calculated as `baseDelayMs * 2^(attempt-1)`, capped at `maxDelayMs`, then jitter is applied: - **`none`** -- Exact calculated delay, no randomization. - **`full`** -- Random value between 0 and the calculated delay. Best for reducing thundering herd. - **`equal`** -- Half the calculated delay plus a random value up to half. A middle ground. > **Important:** Set `maxAttempts` to at least `3` in production to handle transient failures gracefully. The default of `1` means no retries. ## Caching Embeddings Embedding the same text repeatedly is wasteful. For applications that frequently re-embed identical strings (such as search queries or template documents), a caching layer pays for itself quickly: ```php */ private array $cache = []; public function __construct(?Embeddings $embeddings = null) { $this->embeddings = $embeddings ?? Embeddings::using('openai'); } /** * Get the embedding for a single text, using cache when available. * * @return float[] */ public function embed(string $text, array $options = []): array { $key = $this->cacheKey($text, $options); if (isset($this->cache[$key])) { return $this->cache[$key]; } $vector = $this->embeddings ->withInputs($text) ->withOptions($options) ->first() ->values(); $this->cache[$key] = $vector; return $vector; } /** * Embed multiple texts, fetching only uncached ones from the API. * * @param string[] $texts * @return float[][] */ public function embedMany(array $texts, array $options = []): array { $results = []; $uncachedTexts = []; $uncachedIndices = []; foreach ($texts as $i => $text) { $key = $this->cacheKey($text, $options); if (isset($this->cache[$key])) { $results[$i] = $this->cache[$key]; } else { $uncachedTexts[] = $text; $uncachedIndices[] = $i; } } if ($uncachedTexts !== []) { $response = $this->embeddings ->withInputs($uncachedTexts) ->withOptions($options) ->get(); foreach ($response->toValuesArray() as $j => $vector) { $i = $uncachedIndices[$j]; $results[$i] = $vector; $this->cache[$this->cacheKey($texts[$i], $options)] = $vector; } } ksort($results); return $results; } private function cacheKey(string $text, array $options): string { return md5($text . serialize($options)); } } // @doctest id="76a0" ``` Usage: ```php embed('What is machine learning?'); // Second call returns from cache instantly $vector = $cached->embed('What is machine learning?'); // Batch with partial cache hits $vectors = $cached->embedMany([ 'What is machine learning?', // cached 'How do neural networks work?', // API call ]); // @doctest id="d39f" ``` > **Tip:** For persistent caching across requests, replace the in-memory array with Redis, Memcached, or a database-backed store. ## Choosing the Right Model Model selection has a direct impact on both cost and quality. Here are the key trade-offs: | Factor | Smaller Models | Larger Models | |---|---|---| | **Dimensions** | Fewer (e.g., 256-1536) | More (e.g., 3072) | | **Speed** | Faster response times | Slower response times | | **Cost** | Lower per-token cost | Higher per-token cost | | **Quality** | Good for general use | Better for nuanced similarity | | **Storage** | Less memory per vector | More memory per vector | Some providers (like OpenAI's `text-embedding-3` models) support requesting a specific number of dimensions, letting you trade precision for storage efficiency: ```php withModel('text-embedding-3-large') ->withInputs('Sample text') ->first(); // Reduced-dimension embedding (256 dimensions, less storage) $compact = Embeddings::using('openai') ->withModel('text-embedding-3-large') ->withInputs('Sample text') ->withOptions(['dimensions' => 256]) ->first(); echo "Full: " . count($full->values()) . " dimensions\n"; echo "Compact: " . count($compact->values()) . " dimensions\n"; // @doctest id="7c40" ``` ## Best Practices **Batch whenever possible.** A single request with 100 texts is faster and cheaper than 100 individual requests. **Set retry policies in production.** Rate limits (HTTP 429) and transient server errors are common. Configure at least 3 attempts with jitter to handle them gracefully. **Cache aggressively.** Embeddings for the same text and model are deterministic. Cache them to avoid redundant API calls and reduce latency. **Monitor token usage.** Use the `usage()` method on responses to track consumption and detect unexpected spikes: ```php withInputs($documents) ->get(); $usage = $response->usage(); echo "Tokens used: " . $usage->total() . "\n"; // @doctest id="1f99" ``` **Match dimensions to your storage.** If you are storing millions of vectors, reducing dimensions from 3072 to 256 can cut storage costs by over 90% with only modest quality loss. ================================================================================ FILE: packages/polyglot/advanced/custom-config.md ================================================================================ Presets are the recommended way to configure Polyglot. They live in YAML files, keep secrets in environment variables, and let you switch providers without touching application code. However, there are situations where presets are not sufficient -- when configuration values are dynamic, generated at runtime, or sourced from your own application's settings. In these cases, you can build `LLMConfig` or `EmbeddingsConfig` objects directly. ## The Configuration Files Polyglot ships with YAML preset files organized in two directories: - **`config/llm/presets/`** -- one file per inference provider (e.g. `openai.yaml`, `anthropic.yaml`) - **`config/embed/presets/`** -- one file per embeddings provider A typical preset file looks like this: ```yaml # config/llm/presets/openai.yaml driver: openai apiUrl: 'https://api.openai.com/v1' apiKey: '${OPENAI_API_KEY}' endpoint: /chat/completions metadata: organization: '' project: '' model: gpt-4.1-nano maxTokens: 1024 contextLength: 1000000 maxOutputLength: 16384 # @doctest id="a024" ``` Environment variable references like `${OPENAI_API_KEY}` are resolved automatically at load time. Polyglot searches for preset files in the following directories, in order: 1. `config/llm/presets/` (your project root) 2. `packages/polyglot/resources/config/llm/presets/` (monorepo layout) 3. `vendor/cognesy/instructor-php/packages/polyglot/resources/config/llm/presets/` 4. `vendor/cognesy/instructor-polyglot/resources/config/llm/presets/` The first directory that exists wins. You can override the search path by passing a `$basePath` argument to `Inference::using()`. ## Configuration Parameters Each LLM configuration includes these parameters: | Parameter | Type | Description | |---|---|---| | `driver` | `string` | The protocol driver to use (e.g. `openai`, `anthropic`, `openai-compatible`) | | `apiUrl` | `string` | Base URL for the provider's API | | `apiKey` | `string` | API key for authentication | | `endpoint` | `string` | The API endpoint path (e.g. `/chat/completions`) | | `queryParams` | `array` | Optional query string parameters appended to the URL | | `metadata` | `array` | Provider-specific settings (organization, API version, etc.) | | `model` | `string` | Default model name | | `maxTokens` | `int` | Default maximum tokens for responses | | `contextLength` | `int` | Maximum context window supported by the model | | `maxOutputLength` | `int` | Maximum output length supported by the model | | `options` | `array` | Default request options passed to every call | | `pricing` | `array` | Optional token pricing information for cost tracking | Embeddings configurations use a similar structure with `dimensions` and `maxInputs` instead of the token-related fields. ## Runtime Configuration with LLMConfig When you need to build a configuration programmatically, use the `LLMConfig` class: ```php withMessages(Messages::fromString('Say hello.')) ->get(); // @doctest id="490b" ``` Note: `Messages` is imported from `Cognesy\Messages\Messages`. You can create messages from a string with `Messages::fromString()` or from an array of role/content pairs with `Messages::fromArray()`. This is useful when your application stores provider credentials in a database, rotates API keys at runtime, or needs to construct configurations for providers not included in the bundled presets. ### Creating Configurations from Arrays You can also create an `LLMConfig` from an associative array, which is convenient when loading configuration from a database or external source: ```php 'openai', 'apiUrl' => 'https://api.openai.com/v1', 'apiKey' => $apiKeyFromDatabase, 'endpoint' => '/chat/completions', 'model' => 'gpt-4.1-nano', 'maxTokens' => 1024, ]); // @doctest id="adeb" ``` ### Overriding Configuration Values If you need to modify an existing configuration, use `withOverrides()` to create a new instance with specific values changed: ```php withOverrides([ 'model' => 'gpt-4.1', 'maxTokens' => 4096, ]); // @doctest id="79eb" ``` ## Embeddings Configuration The embeddings equivalent follows the same pattern: ```php withMessages(Messages::fromString('Hello!')) ->get(); // @doctest id="0cfa" ``` The DSN format encodes the driver as the scheme, the host and path as the API URL, and query parameters for the remaining configuration values. ## Managing API Keys API keys should never be committed to your codebase. Polyglot preset files use environment variable references (`${OPENAI_API_KEY}`) that are resolved at load time. Store your keys in a `.env` file: ```bash OPENAI_API_KEY=sk-your-key-here ANTHROPIC_API_KEY=sk-ant-your-key-here GEMINI_API_KEY=your-key-here MISTRAL_API_KEY=your-key-here # @doctest id="2b46" ``` Load them with a package like `vlucas/phpdotenv`, or rely on your framework's built-in environment handling (Laravel loads `.env` automatically). > **Security Tip:** The `apiKey` parameter on `LLMConfig` is marked with PHP's > `#[SensitiveParameter]` attribute, which prevents it from appearing in stack traces. > Polyglot also redacts sensitive values from event payloads and debug output. ## Provider-Specific Options Different providers support unique request parameters. You can pass these through the `options` parameter on each request: ```php with( messages: Messages::fromString('Generate a creative story.'), options: [ 'temperature' => 0.8, 'top_p' => 0.95, 'frequency_penalty' => 0.5, 'presence_penalty' => 0.5, 'stop' => ["\n\n", "THE END"], ], ) ->get(); // @doctest id="98cd" ``` For Anthropic, the available options differ: ```php with( messages: Messages::fromString('Generate a creative story.'), options: [ 'temperature' => 0.7, 'top_p' => 0.9, 'top_k' => 40, ], ) ->get(); // @doctest id="8d8f" ``` Polyglot passes these options through to the provider's API without modification, so consult each provider's documentation for the full list of supported parameters. You can also set default options in the preset YAML file so they apply to every request made with that preset: ```yaml # config/llm/presets/creative-openai.yaml driver: openai apiUrl: 'https://api.openai.com/v1' apiKey: '${OPENAI_API_KEY}' endpoint: /chat/completions model: gpt-4.1 maxTokens: 2048 options: temperature: 0.9 top_p: 0.95 # @doctest id="aceb" ``` ## Environment-Based Configuration You can create custom presets for different deployment environments. For example, use a local Ollama instance in development and a cloud provider in production: ```yaml # config/llm/presets/dev-local.yaml driver: openai-compatible apiUrl: 'http://localhost:11434/v1' apiKey: '' endpoint: /chat/completions model: llama3 maxTokens: 1024 # @doctest id="78af" ``` Then select the preset based on your application's environment: ```php withMessages(Messages::fromString('Hello!')) ->get(); // @doctest id="e71e" ``` This pattern keeps your application code completely environment-agnostic. The only thing that changes between environments is which preset name is selected. ## Creating Custom Preset Files To add a new provider or a custom configuration, create a new YAML file in your project's `config/llm/presets/` directory: ```yaml # config/llm/presets/my-proxy.yaml driver: openai-compatible apiUrl: 'https://my-proxy.example.com/v1' apiKey: '${MY_PROXY_API_KEY}' endpoint: /chat/completions model: gpt-4.1-nano maxTokens: 2048 contextLength: 128000 maxOutputLength: 16384 # @doctest id="3039" ``` Then reference it by name: ```php withMessages(Messages::fromString('Hello from my proxy!')) ->get(); // @doctest id="3324" ``` Polyglot will find your custom preset file before falling back to the bundled presets, so you can override any built-in preset by creating a file with the same name in your project's configuration directory. ================================================================================ FILE: packages/polyglot/advanced/custom-http-client.md ================================================================================ Polyglot creates an HTTP client for you by default using the `HttpClientBuilder`. In most cases this is all you need. However, if your application already owns the HTTP transport concern -- for example, you need custom timeouts, middleware, or a shared client instance -- you can build your own HTTP client and inject it into the runtime. ## Injecting an HTTP Client The `InferenceRuntime::fromConfig()` method accepts an optional `httpClient` parameter. Build an HTTP client with `HttpClientBuilder`, configure it to your needs, and pass it in: ```php withConfig(new HttpClientConfig( connectTimeout: 5, requestTimeout: 60, idleTimeout: 120, failOnError: true, )) ->create(); $runtime = InferenceRuntime::fromConfig( config: new LLMConfig( driver: 'openai', apiUrl: 'https://api.openai.com/v1', apiKey: (string) getenv('OPENAI_API_KEY'), endpoint: '/chat/completions', model: 'gpt-4.1-nano', ), httpClient: $httpClient, ); $text = Inference::fromRuntime($runtime) ->withMessages(Messages::fromString('Say hello.')) ->get(); // @doctest id="ef1d" ``` When no HTTP client is provided, Polyglot creates a default one with sensible timeouts. The custom client you inject will be used for all requests made through that runtime. ## HTTP Client Configuration Options The `HttpClientConfig` class accepts these parameters: | Parameter | Default | Description | |---|---|---| | `driver` | `'curl'` | The underlying HTTP driver (`curl`, `guzzle`, `symfony`) | | `connectTimeout` | `3` | Maximum time to establish a connection (seconds) | | `requestTimeout` | `30` | Maximum total request execution time (seconds) | | `idleTimeout` | `-1` | Idle timeout for streaming connections (seconds, -1 = unlimited) | | `streamChunkSize` | `256` | Size of chunks when reading streaming responses (bytes) | | `streamHeaderTimeout` | `5` | Timeout for receiving stream headers (seconds) | | `failOnError` | `false` | Whether to throw exceptions on HTTP error status codes | ## Choosing an HTTP Driver Polyglot supports multiple HTTP drivers. The default `curl` driver works without additional dependencies. If your project already uses Guzzle or Symfony HttpClient, you can reuse them: ```php withConfig(new HttpClientConfig(driver: 'guzzle')) ->create(); // @doctest id="4a01" ``` You can also inject a pre-configured client instance from your application's service container: ```php 'http://proxy.example.com:8080', ]); $http = (new HttpClientBuilder()) ->withClientInstance('guzzle', $guzzleClient) ->create(); // @doctest id="d2c1" ``` This is particularly useful when your application requires proxy configuration, custom SSL certificates, or other transport-level settings. ## Adding Middleware The `HttpClientBuilder` supports a middleware stack for cross-cutting concerns like retries, circuit breaking, and request logging. Middleware is applied in the order it is added. ### Retry Policy Automatically retry failed requests with exponential backoff: ```php withRetryPolicy(new RetryPolicy( maxRetries: 3, baseDelayMs: 500, maxDelayMs: 8000, )) ->create(); // @doctest id="455f" ``` ### Circuit Breaker Protect your application from cascading failures by stopping requests to a failing provider: ```php withCircuitBreakerPolicy(new CircuitBreakerPolicy( failureThreshold: 5, openForSec: 30, )) ->create(); // @doctest id="a90c" ``` ### Combining Multiple Middleware Stack retry and circuit breaker policies together for robust error handling: ```php withRetryPolicy(new RetryPolicy(maxRetries: 3)) ->withCircuitBreakerPolicy(new CircuitBreakerPolicy( failureThreshold: 5, openForSec: 30, )) ->create(); // @doctest id="2293" ``` ### Custom Middleware You can also add your own middleware for logging, metrics, or request transformation: ```php withMiddleware(new MyLoggingMiddleware(), new MyMetricsMiddleware()) ->create(); // @doctest id="0f29" ``` ## Using with Embeddings The same pattern works for the embeddings runtime. Pass your custom HTTP client when building an `EmbeddingsRuntime`: ```php create(); $runtime = EmbeddingsRuntime::fromConfig( config: new EmbeddingsConfig( driver: 'openai', apiUrl: 'https://api.openai.com/v1', apiKey: (string) getenv('OPENAI_API_KEY'), endpoint: '/embeddings', model: 'text-embedding-3-small', dimensions: 1536, ), httpClient: $httpClient, ); $embeddings = Embeddings::fromRuntime($runtime); // @doctest id="a02a" ``` ## Sharing an HTTP Client Across Runtimes If your application uses both inference and embeddings, you can share a single HTTP client between them to reuse connection pools and middleware configuration: ```php withRetryPolicy(new RetryPolicy(maxRetries: 3)) ->create(); $inference = Inference::fromRuntime( InferenceRuntime::fromConfig(LLMConfig::fromPreset('openai'), httpClient: $http) ); $embeddings = Embeddings::fromRuntime( EmbeddingsRuntime::fromConfig(EmbeddingsConfig::fromPreset('openai'), httpClient: $http) ); // @doctest id="4467" ``` This ensures both services share the same retry policy, circuit breaker state, and connection pool configuration. ================================================================================ FILE: packages/polyglot/advanced/context-caching.md ================================================================================ When you are asking multiple questions against the same background material -- a system prompt, a long document, a set of tools -- resending that material with every request wastes tokens and increases latency. Polyglot's `withCachedContext()` method lets you separate the **stable parts** of a conversation from the **per-call messages**, so the provider can cache and reuse them across requests. ## How Context Caching Works Without caching, every request includes the full conversation history, system prompt, and any reference material. As conversations grow, this can lead to significant token overhead. Context caching solves this by marking a portion of the request as a reusable prefix. The provider stores this prefix server-side and references it on subsequent requests, reducing both the number of tokens processed and the time to first token. ``` Request 1: [cached context] + [new question] --> cache write Request 2: [cached context] + [new question] --> cache read (faster, cheaper) Request 3: [cached context] + [new question] --> cache read (faster, cheaper) // @doctest id="770f" ``` ## Using Cached Context The `withCachedContext()` method accepts the same kinds of data you would normally pass through `with()`, but treats them as a persistent prefix for subsequent requests: ```php withCachedContext( messages: Messages::fromArray([ ['role' => 'system', 'content' => 'You are a helpful assistant who provides concise answers.'], ['role' => 'user', 'content' => 'I want to discuss machine learning concepts.'], ['role' => 'assistant', 'content' => 'I would be happy to discuss machine learning. What aspect interests you?'], ]), ); $response1 = $inference ->withMessages(Messages::fromString('What is supervised learning?')) ->response(); echo $response1->content() . "\n"; echo 'Cache read tokens: ' . $response1->usage()->cacheReadTokens . "\n"; $response2 = $inference ->withMessages(Messages::fromString('And what about unsupervised learning?')) ->response(); echo $response2->content() . "\n"; echo 'Cache read tokens: ' . $response2->usage()->cacheReadTokens . "\n"; // @doctest id="653a" ``` The first request populates the provider's cache (you may see `cacheWriteTokens` reported). Every subsequent request that shares the same prefix benefits from a cache hit, reflected in `cacheReadTokens`. ## What Can Be Cached The cached context can include any combination of: - **messages** -- system prompts, conversation history, or reference material - **tools** -- tool/function definitions that remain constant across calls - **toolChoice** -- the tool selection strategy - **responseFormat** -- a fixed response schema ```php withCachedContext( messages: Messages::fromArray([ ['role' => 'system', 'content' => 'You are a data extraction assistant.'], ]), tools: ToolDefinitions::fromArray([ [ 'type' => 'function', 'function' => [ 'name' => 'extract_entities', 'description' => 'Extract named entities from text.', 'parameters' => [ 'type' => 'object', 'properties' => [ 'entities' => [ 'type' => 'array', 'items' => ['type' => 'string'], ], ], 'required' => ['entities'], ], ], ], ]), toolChoice: ToolChoice::auto(), ); // Each follow-up query reuses the cached system prompt and tool definitions $response = $inference->withMessages(Messages::fromString('Extract entities from: "Apple announced the new iPhone in Cupertino."'))->response(); // @doctest id="53cf" ``` ## Processing Large Documents Context caching is particularly valuable when working with large documents. Instead of resending the full document with every question, you cache it once and issue lightweight follow-up queries: ```php withCachedContext( messages: Messages::fromArray([ ['role' => 'system', 'content' => 'You will help analyze and summarize documents.'], ['role' => 'user', 'content' => "Here is the document to analyze:\n\n" . $document], ]), ); $questions = [ 'Summarize the key points in 3 bullets.', 'What are the main arguments presented?', 'Are there any contradictions in the text?', 'What conclusions can be drawn?', ]; foreach ($questions as $question) { $response = $inference->withMessages(Messages::fromString($question))->response(); echo "Q: {$question}\n"; echo "A: " . $response->content() . "\n"; echo "Cache read tokens: " . $response->usage()->cacheReadTokens . "\n\n"; } // @doctest id="e311" ``` After the first request populates the provider's cache, subsequent questions benefit from reduced input token processing, which lowers both cost and latency. ## Inspecting Cache Usage If a provider reports cache usage, you can inspect it through `response()->usage()`. The `InferenceUsage` object exposes the following cache-related fields when available: | Field | Description | |---|---| | `cacheReadTokens` | Tokens served from the cache (cache hit) | | `cacheWriteTokens` | Tokens written to the cache (cache miss / first request) | ```php withMessages(Messages::fromString('Summarize the document.'))->response(); $usage = $response->usage(); echo "Input tokens: " . $usage->inputTokens . "\n"; echo "Output tokens: " . $usage->outputTokens . "\n"; echo "Cache read tokens: " . $usage->cacheReadTokens . "\n"; echo "Cache write tokens: " . $usage->cacheWriteTokens . "\n"; // @doctest id="20c5" ``` ## Provider Support Different providers handle context caching differently: | Provider | Caching Behavior | Cache Metrics | |---|---|---| | **Anthropic** | Explicit cache markers with native support | Full reporting (`cacheReadTokens`, `cacheWriteTokens`) | | **OpenAI** | Automatic server-side prompt caching | Limited reporting; no opt-in required | | **Other providers** | No native caching | Polyglot manages conversation state correctly; no cache metrics | Polyglot sends the appropriate cache control markers to providers that support them. For providers without native caching support, `withCachedContext()` still works correctly -- the context is prepended to each request -- but you will not see cache-related usage metrics in the response. > **Tip:** To maximize cache hit rates with Anthropic, keep your cached context stable across > requests. Even small changes to the cached portion will invalidate the cache and trigger a > new cache write. ================================================================================ FILE: packages/polyglot/advanced/connection-mgmt.md ================================================================================ One of Polyglot's core design principles is that your request code should remain stable while the provider configuration changes. A **preset** is a named YAML file that bundles everything the runtime needs -- driver type, API URL, credentials, default model, and token limits -- into a single, swappable unit. When you call `Inference::using('openai')`, Polyglot loads the `openai.yaml` preset from the configuration directory and builds a fully wired runtime behind the scenes. Switching providers is a one-line change. ## Switching Providers Because presets encapsulate all provider details, the same request code works against any supported backend: ```php withMessages($prompt)->get(); $anthropic = Inference::using('anthropic')->withMessages($prompt)->get(); $gemini = Inference::using('gemini')->withMessages($prompt)->get(); // @doctest id="3ca4" ``` You can also override the model on a per-request basis without creating a new preset: ```php with( messages: Messages::fromString('What is the capital of France?'), model: 'gpt-4.1', ) ->get(); // @doctest id="77c7" ``` ## Understanding Presets vs. Driver Types It is important to distinguish between a **preset name** and a **driver type**. A preset name (e.g. `openai`, `ollama`, `custom-local`) is an arbitrary label for a YAML configuration file. A driver type (e.g. `openai`, `anthropic`, `openai-compatible`) refers to the underlying protocol implementation that Polyglot uses to communicate with the API. Multiple presets can share the same driver. For example, you might create a `local-llama` preset that uses the `openai-compatible` driver pointed at a local Ollama instance, and a `together` preset that also uses the `openai-compatible` driver pointed at the Together AI API. Polyglot ships with the following driver types: | Driver | Providers | |---|---| | `openai` | OpenAI | | `openai-responses` | OpenAI (Responses API) | | `anthropic` | Anthropic | | `gemini` | Google Gemini (native API) | | `gemini-oai` | Google Gemini (OpenAI-compatible API) | | `azure` | Azure OpenAI | | `bedrock-openai` | AWS Bedrock (OpenAI-compatible) | | `a21` | AI21 Labs | | `cerebras` | Cerebras | | `cohere` | Cohere | | `deepseek` | DeepSeek | | `fireworks` | Fireworks AI | | `glm` | GLM | | `groq` | Groq | | `huggingface` | Hugging Face | | `inception` | Inception | | `meta` | Meta | | `minimaxi` | MiniMaxi | | `mistral` | Mistral AI | | `openrouter` | OpenRouter | | `openresponses` | Open Responses | | `perplexity` | Perplexity | | `qwen` | Qwen | | `sambanova` | SambaNova | | `xai` | xAI (Grok) | | `openai-compatible` | Any OpenAI-compatible API (Ollama, Together, Moonshot, etc.) | ## Implementing Fallbacks Polyglot does not impose a fallback policy. Fallback behavior belongs in application code, where you have the context to decide which providers to try and how to handle failures: ```php withMessages($prompt) ->get(); } catch (HttpRequestException $e) { $lastException = $e; // Optionally log the failure before trying the next provider } } throw new \RuntimeException( 'All providers failed. Last error: ' . $lastException?->getMessage() ); } $response = withFallback( presets: ['openai', 'anthropic', 'gemini'], prompt: Messages::fromString('What is the capital of France?'), ); // @doctest id="04c5" ``` This pattern gives you full control over retry logic, logging, and error handling at each step of the fallback chain. ## Cost-Aware Provider Selection You can route requests to different presets based on the complexity or importance of each task. This pattern lets you reserve expensive models for critical work while using cheaper alternatives for simpler queries: ```php ['preset' => 'ollama', 'model' => 'llama3'], 'medium' => ['preset' => 'mistral', 'model' => 'mistral-small-latest'], 'high' => ['preset' => 'openai', 'model' => 'gpt-4.1'], ]; public function ask(string $question, string $tier = 'medium'): string { $provider = $this->tiers[$tier] ?? $this->tiers['medium']; return Inference::using($provider['preset']) ->with(messages: Messages::fromString($question), model: $provider['model']) ->get(); } } $router = new CostAwareRouter(); // Simple question -- use low-cost tier echo $router->ask('What is 2+2?', 'low'); // Moderate complexity -- use medium tier echo $router->ask('Explain monads in simple terms.', 'medium'); // High-stakes analysis -- use premium tier echo $router->ask('Analyze the ethical implications of AI in healthcare.', 'high'); // @doctest id="641c" ``` ## Task-Based Provider Selection Different providers may excel at different tasks. You can map task types to the most appropriate preset, routing creative writing to one model and code generation to another: ```php 'anthropic', 'factual' => 'openai', 'code' => 'gemini', 'default' => 'openai', ]; public function ask(string $question, string $taskType = 'default'): string { $preset = $this->routes[$taskType] ?? $this->routes['default']; return Inference::using($preset) ->withMessages(Messages::fromString($question)) ->get(); } } $router = new TaskRouter(); echo $router->ask('Write a short poem about the ocean.', 'creative'); echo $router->ask('What is the capital of France?', 'factual'); echo $router->ask('Write a PHP function to reverse a string.', 'code'); // @doctest id="d134" ``` > **Tip:** You can combine cost-aware and task-based routing. For example, use a cheap local > model for simple factual lookups but route complex creative tasks to a premium cloud provider. ## Reusing an Inference Instance Each call to `Inference::using()` loads the preset YAML and builds a new runtime. If you plan to issue many requests against the same provider, create the instance once and reuse it: ```php withMessages(Messages::fromString('What is PHP?'))->get(); $answer2 = $inference->withMessages(Messages::fromString('What is Laravel?'))->get(); // @doctest id="f237" ``` Because `Inference` uses immutable builder methods (each call returns a new copy), sharing a single instance across concurrent requests is safe. ================================================================================ FILE: packages/polyglot/advanced/json-schema.md ================================================================================ When you need structured output from an LLM -- a JSON object with specific fields and types -- you pass a `responseFormat` that describes the expected shape. You can build this format as a plain array, or use Polyglot's `JsonSchema` builder for a more expressive, composable approach. Native JSON Schema enforcement depends on the selected driver and model. If your provider does not support `json_schema` response format natively, consider using JSON mode or Markdown-JSON mode for best-effort output. ## Why Use JsonSchema? The `JsonSchema` builder offers several advantages over hand-crafting schema arrays: - **Type safety** -- factory methods ensure each node has the correct structure - **Composability** -- define sub-schemas once and embed them in multiple places - **Readability** -- a fluent API makes complex schemas easy to scan - **Conversion** -- convert the same schema to a response format or a tool/function definition ## Quick Start Here is a minimal example that requests structured city data from an LLM: ```php with( messages: Messages::fromArray([ ['role' => 'user', 'content' => 'What is the capital of France? Respond with JSON data.'], ]), responseFormat: ResponseFormat::jsonSchema( schema: $schema->toJsonSchema(), name: 'city_data', strict: true, ), options: ['max_tokens' => 64], ) ->asJsonData(); // @doctest id="e84a" ``` The `JsonSchema` class only helps you build the schema payload. Polyglot passes it to the provider, and the provider decides how strictly to enforce it. ## Available Types The `JsonSchema` class provides static factory methods for every JSON Schema primitive. ### String ```php $name = JsonSchema::string( name: 'full_name', description: 'The user\'s full name', ); // @doctest id="400e" ``` ### Integer and Number ```php $age = JsonSchema::integer('age', description: 'Age in years'); $price = JsonSchema::number('price', description: 'Product price'); // @doctest id="9443" ``` ### Boolean ```php $active = JsonSchema::boolean('is_active', description: 'Whether the account is active'); // @doctest id="8109" ``` ### Array Arrays require an `itemSchema` that describes the type of each element: ```php $tags = JsonSchema::array( name: 'tags', description: 'List of tags', itemSchema: JsonSchema::string(), ); // @doctest id="cb6d" ``` Arrays can also contain complex objects: ```php $hobbies = JsonSchema::array( name: 'hobbies', description: 'List of user hobbies', itemSchema: JsonSchema::object( properties: [ JsonSchema::string('name', 'Hobby name'), JsonSchema::string('description', 'Hobby description', nullable: true), JsonSchema::integer('years_experience', 'Years of experience', nullable: true), ], requiredProperties: ['name', 'description', 'years_experience'], ), ); // @doctest id="c61a" ``` ### Enum Enums restrict a field to a fixed set of string or integer values: ```php $status = JsonSchema::enum( name: 'status', description: 'Account status', enumValues: ['active', 'inactive', 'pending'], ); // @doctest id="631e" ``` ### Object Objects define nested structures with named properties: ```php $profile = JsonSchema::object( name: 'profile', description: 'User profile', properties: [ JsonSchema::string('username', 'Unique username'), JsonSchema::string('bio', 'Short biography'), JsonSchema::integer('joined_year', 'Year joined'), ], requiredProperties: ['username'], ); // @doctest id="553a" ``` ## Required and Nullable Fields **Required** and **nullable** are independent concepts: - A **required** field must be present in the output. - A **nullable** field may contain a `null` value. - A field can be both required and nullable (must be present, but may be null). - A field can be optional and non-nullable (when present, cannot be null). Required fields are specified at the object level: ```php $user = JsonSchema::object( properties: [ JsonSchema::string('email', 'Primary email'), JsonSchema::string('name', 'Full name'), JsonSchema::string('bio', 'Biography'), ], requiredProperties: ['email', 'name'], ); // @doctest id="e567" ``` Nullable fields are specified on individual properties: ```php $bio = JsonSchema::string('bio', 'Optional biography', nullable: true); // @doctest id="cb81" ``` ### OpenAI Strict Mode When working with OpenAI in strict mode, all fields must be listed as required. Use `nullable: true` to indicate fields whose values are optional: ```php $user = JsonSchema::object( properties: [ JsonSchema::string('email', 'Required email'), JsonSchema::string('bio', 'Optional biography', nullable: true), ], requiredProperties: ['email', 'bio'], // Both required, but bio can be null ); // @doctest id="0f1d" ``` ### Common Patterns ```php // Required and non-nullable (most strict) // requiredProperties: ['email'] JsonSchema::string('email', 'Primary email', nullable: false); // Required but nullable (must be present, can be null) // requiredProperties: ['bio'] JsonSchema::string('bio', 'User bio', nullable: true); // Optional and non-nullable (can be omitted, but if present cannot be null) // requiredProperties: [] (does not include 'phone') JsonSchema::string('phone', 'Phone number', nullable: false); // Optional and nullable (most permissive) // requiredProperties: [] (does not include 'website') JsonSchema::string('website', 'Personal website', nullable: true); // @doctest id="46e2" ``` ## Nested Schemas For complex structures, define child schemas first and embed them into parent schemas: ```php withItemSchema(JsonSchema::string()) ->withDescription('A list of tags') ->withNullable(true); // @doctest id="9532" ``` Available fluent methods include: | Method | Description | |---|---| | `withName(string $name)` | Set the schema name | | `withDescription(string $description)` | Set the description | | `withTitle(string $title)` | Set the title | | `withNullable(bool $nullable)` | Mark as nullable | | `withMeta(array $meta)` | Attach custom metadata | | `withEnumValues(?array $enum)` | Set enum values | | `withProperties(?array $properties)` | Set object properties | | `withItemSchema(JsonSchema $itemSchema)` | Set array item schema | | `withRequiredProperties(?array $required)` | Set required property names | | `withAdditionalProperties(?bool $additionalProperties)` | Allow or disallow additional properties | ## Converting Schemas The `JsonSchema` class provides methods to convert schemas into different output formats: ```php // Convert to a plain array $array = $schema->toArray(); // Convert to a JSON Schema document (suitable for responseFormat) $jsonSchema = $schema->toJsonSchema(); // Convert to a function/tool call definition $functionCall = $schema->toFunctionCall( functionName: 'getUserProfile', functionDescription: 'Gets the user profile information', strict: true, ); // @doctest id="e3a5" ``` ## Accessing Schema Properties You can inspect any schema programmatically: ```php $schema->type(); // Get schema type (e.g. 'object') $schema->name(); // Get schema name $schema->description(); // Get description $schema->title(); // Get title $schema->isNullable(); // Check if nullable $schema->requiredProperties(); // Get required property names $schema->properties(); // Get all property schemas $schema->property('name'); // Get a specific property schema $schema->itemSchema(); // Get item schema (for arrays) $schema->enumValues(); // Get enum values $schema->hasAdditionalProperties(); // Check if additional properties allowed $schema->meta(); // Get all meta fields $schema->meta('key'); // Get a specific meta field // @doctest id="8ae3" ``` ## Meta Fields You can attach custom meta fields to schemas. These are rendered with an `x-` prefix in the JSON Schema output: ```php $username = JsonSchema::string( name: 'username', description: 'The username', meta: [ 'min_length' => 3, 'max_length' => 50, 'pattern' => '^[a-zA-Z0-9_]+$', ], ); // @doctest id="2806" ``` In the generated schema, these become `x-min_length`, `x-max_length`, and `x-pattern`. Meta fields are useful for passing hints to post-processing validation or documentation generators. ## Using Schemas as Tool Parameters The `toFunctionCall()` method generates a tool/function definition that you can pass directly to the `tools` parameter of an inference request: ```php toFunctionCall( functionName: 'getWeather', functionDescription: 'Get current weather for a location', strict: true, ); $result = Inference::using('openai') ->with( messages: Messages::fromArray([ ['role' => 'user', 'content' => 'What is the weather like in Tokyo?'], ]), tools: ToolDefinitions::fromArray([$tool]), ) ->asToolCallJsonData(); // @doctest id="db67" ``` ## Full Example: User Profile Schema Here is a complete example that defines a rich user profile schema and uses it to extract structured data from an LLM: ```php with( messages: Messages::fromArray([ ['role' => 'user', 'content' => 'Generate a profile for John Doe who lives in New York.'], ]), responseFormat: ResponseFormat::jsonSchema( schema: $userSchema->toJsonSchema(), name: 'user_profile', strict: true, ), ) ->asJsonData(); print_r($userData); // @doctest id="35c7" ``` ## Best Practices **Write clear descriptions.** The description string guides the LLM toward correct output. Be specific about format, length, and constraints. ```php // Vague -- the LLM has little guidance JsonSchema::string('name', 'the name'); // Specific -- the LLM understands the expected format JsonSchema::string('name', 'The user\'s display name (2-50 characters)'); // @doctest id="7076" ``` **Organize nested schemas.** Define child schemas as separate variables before embedding them in a parent. This keeps your code readable and makes schemas reusable across different request types. **Be explicit about requirements.** Always specify both `requiredProperties` at the object level and `nullable` on individual fields. Leaving them implicit creates ambiguity that different providers handle differently. **Use strict mode with OpenAI.** When targeting OpenAI, set `'strict' => true` in the `json_schema` block and make all fields required. Use `nullable: true` for optional values. This gives you the strongest possible enforcement of your schema. ================================================================================ FILE: packages/polyglot/advanced/extending.md ================================================================================ Polyglot ships with drivers for over 25 LLM providers and several embeddings providers. When you need to integrate a provider that is not bundled -- or override the behavior of an existing one -- the library exposes clean extension points for both inference and embeddings. ## Custom Inference Drivers Inference drivers implement the `CanProcessInferenceRequest` interface, which defines three methods: ```php interface CanProcessInferenceRequest { public function makeResponseFor(InferenceRequest $request): InferenceResponse; /** @return iterable */ public function makeStreamDeltasFor(InferenceRequest $request): iterable; public function capabilities(?string $model = null): DriverCapabilities; } // @doctest id="60a8" ``` | Method | Purpose | |---|---| | `makeResponseFor()` | Send a synchronous request and return the complete response | | `makeStreamDeltasFor()` | Send a streaming request and yield partial deltas | | `capabilities()` | Report driver capabilities (tool calls, JSON mode, vision, etc.) | ### Registering a Driver Class The simplest approach is to provide a class string. Polyglot will instantiate it with the standard constructor signature `($config, $httpClient, $events)`: ```php withDriver('acme', AcmeInferenceDriver::class); $config = new LLMConfig( driver: 'acme', apiUrl: 'https://api.acme.com/v1', apiKey: (string) getenv('ACME_API_KEY'), endpoint: '/chat/completions', model: 'acme-large', ); $text = Inference::fromConfig($config, drivers: $drivers) ->withMessages(Messages::fromString('Hello from Acme!')) ->get(); // @doctest id="f69b" ``` ### Registering a Driver Factory For more control over instantiation, pass a callable that receives `LLMConfig`, `CanSendHttpRequests`, and `CanHandleEvents`, and returns a `CanProcessInferenceRequest`: ```php withDriver('custom', function ($config, $httpClient, $events) { // Wrap an existing driver with extra behavior return new class($config, $httpClient, $events) extends OpenAIDriver { public function makeResponseFor($request): \Cognesy\Polyglot\Inference\Data\InferenceResponse { // Add logging, metrics, request transformation, etc. return parent::makeResponseFor($request); } }; }); // @doctest id="3a8d" ``` This factory approach is particularly useful when you want to extend an existing driver with minimal code -- for example, adding request logging or custom headers to an OpenAI-compatible endpoint. ### Using the Registry with InferenceRuntime You can pass the driver registry directly when building a runtime: ```php withMessages(Messages::fromString('Hello!')) ->get(); // @doctest id="4562" ``` ### Implementing a Full Driver When building a driver from scratch, you will typically need to implement several adapter components: 1. **Request Adapter** -- transforms `InferenceRequest` into the provider's HTTP request format 2. **Body Format** -- structures the request body according to the provider's API schema 3. **Message Format** -- converts Polyglot's message format to the provider's format 4. **Response Adapter** -- parses the provider's HTTP response into `InferenceResponse` 5. **Usage Format** -- extracts token usage information from the response Most bundled drivers follow this modular adapter pattern. See the `OpenAIDriver` or `AnthropicDriver` source code for reference implementations. ## Custom Embeddings Drivers Embeddings drivers implement the `CanHandleVectorization` interface: ```php interface CanHandleVectorization { public function handle(EmbeddingsRequest $request): HttpResponse; public function fromData(array $data): ?EmbeddingsResponse; } // @doctest id="beef" ``` Register a custom embeddings driver using the `BundledEmbeddingsDrivers` registry, the same pattern used for inference drivers: ```php withDriver('acme', AcmeEmbeddingsDriver::class); $config = new EmbeddingsConfig( driver: 'acme', apiUrl: 'https://api.acme.com/v1', apiKey: (string) getenv('ACME_API_KEY'), endpoint: '/embeddings', model: 'acme-embed-v1', dimensions: 768, maxInputs: 100, ); $embeddings = Embeddings::fromRuntime( EmbeddingsRuntime::fromConfig($config, drivers: $drivers) ); // @doctest id="1dfd" ``` Like inference drivers, you can also pass a callable factory instead of a class string: ```php withDriver('acme', function ($config, $httpClient, $events) { return new AcmeEmbeddingsDriver($config, $httpClient, $events); }); // @doctest id="57dd" ``` > **Note:** The `EmbeddingsDriverRegistry` is immutable -- each mutation returns a new instance, > matching the same pattern as `InferenceDriverRegistry`. ## Removing or Replacing Bundled Drivers The `InferenceDriverRegistry` is immutable -- each mutation returns a new instance. You can remove a bundled driver or replace it entirely: ```php withoutDriver('ollama'); // Replace a driver $drivers = BundledInferenceDrivers::registry() ->withDriver('openai', MyCustomOpenAIDriver::class); // @doctest id="8a60" ``` ## Bundled Drivers For reference, Polyglot bundles the following inference drivers: | Driver Name | Class | |---|---| | `a21` | `A21Driver` | | `anthropic` | `AnthropicDriver` | | `azure` | `AzureDriver` | | `bedrock-openai` | `BedrockOpenAIDriver` | | `cerebras` | `CerebrasDriver` | | `cohere` | `CohereV2Driver` | | `deepseek` | `DeepseekDriver` | | `fireworks` | `FireworksDriver` | | `gemini` | `GeminiDriver` | | `gemini-oai` | `GeminiOAIDriver` | | `glm` | `GlmDriver` | | `groq` | `GroqDriver` | | `huggingface` | `HuggingFaceDriver` | | `inception` | `InceptionDriver` | | `meta` | `MetaDriver` | | `minimaxi` | `MinimaxiDriver` | | `mistral` | `MistralDriver` | | `openai` | `OpenAIDriver` | | `openai-responses` | `OpenAIResponsesDriver` | | `openresponses` | `OpenResponsesDriver` | | `openrouter` | `OpenRouterDriver` | | `perplexity` | `PerplexityDriver` | | `qwen` | `QwenDriver` | | `sambanova` | `SambaNovaDriver` | | `xai` | `XAiDriver` | | `moonshot` | `OpenAICompatibleDriver` | | `ollama` | `OpenAICompatibleDriver` | | `openai-compatible` | `OpenAICompatibleDriver` | | `together` | `OpenAICompatibleDriver` | The full list is defined in `BundledInferenceDrivers::registry()`. Bundled embeddings drivers include: `openai`, `azure`, `cohere`, `gemini`, `jina`, `mistral`, and `ollama`. ## Listening to Events Both `InferenceRuntime` and `EmbeddingsRuntime` dispatch events at key lifecycle points. You can listen for specific events or wiretap all of them: ```php onEvent(InferenceDriverBuilt::class, function (InferenceDriverBuilt $event) { echo "Driver built: " . $event->payload['driverClass'] . "\n"; }); // Or listen to all events for debugging $runtime->wiretap(function ($event) { error_log(get_class($event)); }); $response = Inference::fromRuntime($runtime) ->withMessages(Messages::fromString('Hello!')) ->get(); // @doctest id="ebd7" ``` ================================================================================ FILE: packages/polyglot/internals/overview.md ================================================================================ Polyglot is built on a modular, layered architecture that separates concerns and promotes extensibility. Each layer has a clear responsibility, and dependencies flow in one direction -- from the public API down to the HTTP transport. Understanding these layers will help you extend the library, contribute to its development, or build your own integrations with new LLM providers. ## The Four Layers ### Public Layer This is what application code usually touches. Two facade classes provide a unified interface for all provider interactions: - **`Inference`** -- for chat completions and text generation - **`Embeddings`** -- for generating vector embeddings These facades build request objects, delegate execution to runtimes, and return normalized responses regardless of the underlying provider. Both facades follow an immutable, fluent interface pattern -- every method that modifies state returns a new instance, so you can safely branch configurations from a shared base. ### Runtime Layer Runtimes assemble the moving parts needed for a provider call. They wire together the configuration, driver, HTTP client, and event dispatcher, and they own the execution lifecycle including retry logic and response caching. The key classes are: - **`InferenceRuntime`** -- coordinates inference execution and creates `PendingInference` handles - **`EmbeddingsRuntime`** -- coordinates embeddings execution and creates `PendingEmbeddings` handles Each runtime can be constructed from a config object, a provider, or injected directly. When no HTTP client is provided, the runtime builds a default one via `HttpClientBuilder`. Runtimes also expose `onEvent()` and `wiretap()` methods for hooking into the event system. ### Request and Response Layer Requests and responses are normalized into package data objects that are provider-agnostic: - **`InferenceRequest`** -- messages, model, tools, tool choice, response format, options, cached context, retry policy, response cache policy - **`InferenceResponse`** -- content, reasoning content, tool calls, usage, finish reason, raw HTTP response data - **`PartialInferenceDelta`** -- a single streaming event delta with content, reasoning content, tool call fragments, finish reason, and usage - **`EmbeddingsRequest`** -- input texts, model, options, retry policy - **`EmbeddingsResponse`** -- vectors and usage These objects isolate your application from provider-specific response shapes. Both request types support immutable `with*()` mutators for building modified copies. ### Driver Layer Drivers translate Polyglot requests into provider-native HTTP payloads and normalize the results back. Each driver implements `CanProcessInferenceRequest` (for inference) or `CanHandleVectorization` (for embeddings) and is composed of smaller adapter responsibilities: - **Request adapters** (`CanTranslateInferenceRequest`) -- convert `InferenceRequest` into an `HttpRequest` - **Response adapters** (`CanTranslateInferenceResponse`) -- convert raw `HttpResponse` data into `InferenceResponse` or stream of `PartialInferenceDelta` - **Message formatters** (`CanMapMessages`) -- map typed `Messages` to provider-specific structures, composing a `MessageMapper` utility for iteration - **Body formatters** (`CanMapRequestBody`) -- assemble the full request body with mode-specific adjustments - **Usage formatters** (`CanMapUsage`) -- extract token usage from provider responses Most inference drivers extend `BaseInferenceRequestDriver`, which provides the standard HTTP execution flow and stream handling. Provider-specific classes like `OpenAIDriver`, `AnthropicDriver`, and `GeminiDriver` compose the appropriate adapters and formatters for their API. ## How the Layers Connect ```text +---------------------+ +---------------------+ | Inference | | Embeddings | Public Layer +---------------------+ +---------------------+ | | +---------------------+ +---------------------+ | InferenceRuntime | | EmbeddingsRuntime | Runtime Layer +---------------------+ +---------------------+ | | +---------------------+ +---------------------+ | InferenceRequest | | EmbeddingsRequest | | PendingInference | | PendingEmbeddings | Request/Response | InferenceResponse | | EmbeddingsResponse | Layer +---------------------+ +---------------------+ | | +---------------------+ +---------------------+ | Inference Drivers | | Embeddings Drivers | Driver Layer | (OpenAI, Anthropic, | | (OpenAI, Cohere, | | Gemini, etc.) | | Gemini, etc.) | +---------------------+ +---------------------+ | | +------------------------------------------------+ | HTTP Client (shared) | Transport +------------------------------------------------+ // @doctest id="a388" ``` The public facade creates a request and hands it to the runtime. The runtime delegates to a driver, which translates the request into an HTTP call and normalizes the response. Events are dispatched at each stage for observability. The result flows back up as a normalized data object. ## Key Design Decisions **Immutability.** Both the public facades and the request/response objects are immutable. Calling `withMessages()` or `withModel()` always returns a new instance rather than modifying the original. This makes it safe to reuse a configured `Inference` or `Embeddings` instance across multiple concurrent calls. **Lazy execution.** Calling `create()` on a facade returns a `PendingInference` or `PendingEmbeddings` handle without triggering the HTTP call. Execution is deferred until the application reads from the handle via `get()`, `response()`, or `stream()`. **Driver registry.** Inference drivers are resolved through `InferenceDriverRegistry`, which maps string names (like `'openai'` or `'anthropic'`) to driver factory functions. Embeddings drivers use `EmbeddingsDriverFactory` with a similar pattern. Both support registering custom drivers at runtime. **Provider-agnostic data.** The `InferenceResponse` and `EmbeddingsResponse` objects present a uniform shape regardless of which provider produced them. Provider-specific details are accessible through `responseData()` when needed, but the primary accessors (`content()`, `toolCalls()`, `usage()`, etc.) work identically across all providers. ================================================================================ FILE: packages/polyglot/internals/lifecycle.md ================================================================================ Understanding the request lifecycle helps when debugging provider issues, implementing custom drivers, or hooking into events for observability. This page traces the complete flow for both inference and embeddings operations. ## Inference Lifecycle ### 1. Request Construction The lifecycle begins when the application builds an `InferenceRequest` through the `Inference` facade: ```php $inference = Inference::using('openai') ->withMessages(Messages::fromString('Explain PHP generics.')) ->withModel('gpt-4.1-nano') ->withMaxTokens(1024); // @doctest id="8f12" ``` At this point, no HTTP call has been made. The facade holds an `InferenceRequestBuilder` that accumulates parameters. Every `with*()` call returns a new immutable copy, so the original instance is never modified. ### 2. Creating a Pending Handle Calling `create()` (or a shortcut like `get()` or `response()`) builds the `InferenceRequest` and passes it to the runtime: ```php $pending = $inference->create(); // @doctest id="18f8" ``` The `InferenceRuntime` wraps the request in an `InferenceExecution` object and returns a `PendingInference` handle. Execution is still deferred -- no HTTP call has been sent yet. The `InferenceExecution` tracks the full lifecycle state: the original request, retry attempts, usage accumulation, and the final response. ### 3. Triggering Execution The HTTP call is triggered only when you read from the `PendingInference`: ```php $text = $pending->get(); // triggers execution, returns content string $response = $pending->response(); // triggers execution, returns InferenceResponse $stream = $pending->stream(); // triggers execution (streaming mode) // @doctest id="f36a" ``` Internally, `PendingInference` delegates to `InferenceExecutionSession`, which orchestrates the full lifecycle. ### 4. The Execution Session The `InferenceExecutionSession` is the heart of the lifecycle. It performs these steps for a non-streaming request: 1. **Dispatches `InferenceStarted`** -- signals the beginning of the operation, including the execution ID, request details, and whether streaming is enabled 2. **Dispatches `InferenceAttemptStarted`** -- signals the beginning of an attempt with the attempt number and model 3. **Calls the driver** -- `driver->makeResponseFor($request)` triggers the full request-response cycle: - The driver's request adapter converts `InferenceRequest` into an `HttpRequest` - The HTTP client sends the request to the provider - The driver's response adapter normalizes the raw `HttpResponse` into an `InferenceResponse` 4. **Checks the response** -- if the finish reason indicates a failure (error, content filter, or length limit), the session handles it according to the retry policy 5. **Dispatches success events**: - `InferenceResponseCreated` -- the response is ready - `InferenceAttemptSucceeded` -- the attempt completed, including finish reason and usage - `InferenceUsageReported` -- token usage (`InferenceUsage`) is reported with the model name - `InferenceCompleted` -- the entire operation is done, including total attempt count and timing 6. **Returns `InferenceResponse`** to the caller Cost calculation is performed externally using a `FlatRateCostCalculator` with `InferencePricing` data from the `LLMConfig`, rather than being attached to the usage object in the pipeline. ### 5. Retry Handling If the request fails with a retryable error (transient HTTP status, timeout, network error, or provider-classified retriable exception), the session: 1. Records the failure on the execution object 2. **Dispatches `InferenceAttemptFailed`** -- with the error details, HTTP status code, partial usage, and `willRetry: true` 3. Waits for the configured delay (exponential backoff with optional jitter) 4. **Dispatches a new `InferenceAttemptStarted`** and retries If all attempts are exhausted, the session dispatches `InferenceCompleted` with `isSuccess: false` and throws the terminal error. **Length-limit recovery** has special handling. When a response finishes with `Length` as the finish reason and the retry policy allows length recovery, the session can: - **`'continue'`** -- append the partial response as an assistant message, add a continuation prompt, and retry - **`'increase_max_tokens'`** -- increase the `max_tokens` option by the configured increment and retry This is independent of the regular retry count and controlled by `lengthMaxAttempts`. ### 6. Cached Context If the request includes a `CachedInferenceContext`, the driver applies it before sending. Cached context allows you to pre-configure messages, tools, tool choice, and response format that are prepended to or merged with the request's own values. This is particularly useful for system prompts or shared tool definitions that remain constant across calls. ## Streaming Lifecycle When streaming is enabled, the flow diverges after the HTTP request is sent: 1. `PendingInference::stream()` validates that streaming was requested, then creates an `InferenceStream` 2. The driver produces an iterable of `PartialInferenceDelta` objects from the SSE event stream via `driver->makeStreamDeltasFor($request)` 3. The `InferenceStream` tracks visibility state through a `VisibilityTracker` and yields only deltas with meaningful changes (filtering out empty or duplicate deltas) ```php $stream = $inference->withMessages(Messages::fromString('Hello'))->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; // incremental text } $finalResponse = $stream->final(); // assembled InferenceResponse // @doctest id="a0dd" ``` ### Stream Events The stream dispatches events as deltas arrive: - **`StreamFirstChunkReceived`** -- when the first visible delta arrives, including the request start time for TTFC measurement - **`PartialInferenceDeltaCreated`** -- for each visible delta - **`InferenceResponseCreated`** -- when the stream finishes and the final response is assembled from accumulated state ### Stream Processing The stream supports functional-style processing through `map()`, `reduce()`, and `filter()`: ```php // Map deltas to extracted values $contents = $stream->map(fn($delta) => $delta->contentDelta); // Reduce deltas into a single value $fullText = $stream->reduce(fn($carry, $delta) => $carry . $delta->contentDelta, ''); // Filter deltas $toolDeltas = $stream->filter(fn($delta) => $delta->toolName !== ''); // Collect all visible deltas $allDeltas = $stream->all(); // @doctest id="f070" ``` ### Delta Callback You can register a callback that fires for every visible delta: ```php $stream->onDelta(function (PartialInferenceDelta $delta): void { echo $delta->contentDelta; }); // @doctest id="7f7e" ``` ### Stream Finalization Calling `final()` on a stream that has not been fully consumed will drain the remaining deltas first, ensuring the final response is complete. A stream can only be consumed once -- calling `deltas()` a second time throws a `LogicException`. The final response assembled from the stream goes through the same event dispatch as a synchronous response. ## Embeddings Lifecycle The embeddings lifecycle is simpler since streaming is not involved: 1. **`Embeddings` builds an `EmbeddingsRequest`** from the configured inputs, model, and options 2. **`create()` returns `PendingEmbeddings`** -- a lazy handle that holds the request, driver, and event dispatcher 3. **`get()` triggers execution**: - The driver's `handle()` method sends the HTTP request - The response body is decoded and passed to `driver->fromData()` to build an `EmbeddingsResponse` - `EmbeddingsResponseReceived` is dispatched 4. **`EmbeddingsResponse` is returned** -- containing vectors and usage ```php $response = Embeddings::using('openai') ->withInputs(['Hello', 'World']) ->get(); $vectors = $response->vectors(); // Vector[] $first = $response->first(); // first Vector $usage = $response->usage(); // InferenceUsage // @doctest id="3661" ``` Retry logic is handled internally by `PendingEmbeddings` based on the `EmbeddingsRetryPolicy` attached to the request. The retry loop follows the same exponential backoff pattern as inference retries. ## Response Caching Both the inference and embeddings lifecycles support response caching. When `ResponseCachePolicy` is set on the request, the `InferenceExecutionSession` caches the response after the first successful execution. Subsequent calls to `response()` or `get()` on the same `PendingInference` return the cached result without making another HTTP call. ```php use Cognesy\Polyglot\Inference\Enums\ResponseCachePolicy; $pending = $inference ->withMessages(Messages::fromString('Hello')) ->withResponseCachePolicy(ResponseCachePolicy::Memory) ->create(); $first = $pending->response(); // makes HTTP call $second = $pending->response(); // returns cached response // @doctest id="9d2c" ``` For streaming, the stream itself cannot be replayed -- calling `deltas()` a second time will throw a `LogicException`. However, `final()` always returns the assembled response, which is stored in the execution object. ================================================================================ FILE: packages/polyglot/internals/configuration.md ================================================================================ Polyglot resolves two configuration types -- one for inference and one for embeddings. Both follow the same patterns: they can be loaded from YAML presets, constructed from arrays, or parsed from DSN strings. ## LLMConfig `LLMConfig` holds all the settings needed to connect to an inference provider and select a model. **Namespace:** `Cognesy\Polyglot\Inference\Config\LLMConfig` ### Fields | Field | Type | Default | Description | |---|---|---|---| | `apiUrl` | `string` | `''` | Base URL for the provider API | | `apiKey` | `string` | `''` | Authentication key (marked as `#[SensitiveParameter]`) | | `endpoint` | `string` | `''` | API endpoint path (e.g. `/chat/completions`) | | `queryParams` | `array` | `[]` | Query parameters appended to the URL | | `metadata` | `array` | `[]` | Provider-specific metadata (e.g. organization, project for OpenAI) | | `model` | `string` | `''` | Model identifier | | `maxTokens` | `int` | `1024` | Default max tokens for responses | | `contextLength` | `int` | `8000` | Model context window size | | `maxOutputLength` | `int` | `4096` | Maximum output length | | `driver` | `string` | `'openai-compatible'` | Driver name (e.g. `openai`, `anthropic`, `gemini`) | | `options` | `array` | `[]` | Additional provider-specific options | | `pricing` | `array` | `[]` | Token pricing per 1M tokens (input, output, etc.) | ### Creating a Config There are three ways to create an `LLMConfig`: ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; // From a named preset (loads from YAML files) $config = LLMConfig::fromPreset('openai'); // From an associative array $config = LLMConfig::fromArray([ 'driver' => 'openai', 'apiUrl' => 'https://api.openai.com/v1', 'apiKey' => getenv('OPENAI_API_KEY'), 'endpoint' => '/chat/completions', 'model' => 'gpt-4.1-nano', 'maxTokens' => 2048, ]); // From a DSN string $config = LLMConfig::fromDsn('openai://model=gpt-4.1-nano&maxTokens=2048'); // @doctest id="32a3" ``` ### Presets Presets are YAML files that live in well-known directories. Polyglot searches these paths in order: 1. `config/llm/presets/` (project root) 2. `packages/polyglot/resources/config/llm/presets/` (monorepo) 3. `vendor/cognesy/instructor-php/packages/polyglot/resources/config/llm/presets/` 4. `vendor/cognesy/instructor-polyglot/resources/config/llm/presets/` You may also pass a custom base path: ```php $config = LLMConfig::fromPreset('my-preset', basePath: '/path/to/presets'); // @doctest id="8900" ``` ### Overriding Values Use `withOverrides()` to create a modified copy of an existing config: ```php $base = LLMConfig::fromPreset('openai'); $custom = $base->withOverrides(['model' => 'gpt-4.1', 'maxTokens' => 4096]); // @doctest id="7524" ``` ### Pricing When pricing data is included in the config, it can be used with a cost calculator to compute costs externally. Pricing values are specified in USD per 1 million tokens: ```php use Cognesy\Polyglot\Inference\Data\InferencePricing; use Cognesy\Polyglot\Pricing\FlatRateCostCalculator; $config = LLMConfig::fromArray([ 'driver' => 'openai', 'apiUrl' => 'https://api.openai.com/v1', 'apiKey' => getenv('OPENAI_API_KEY'), 'endpoint' => '/chat/completions', 'model' => 'gpt-4.1-nano', 'pricing' => [ 'inputPerMToken' => 0.10, 'outputPerMToken' => 0.40, 'cacheReadPerMToken' => 0.0, 'cacheWritePerMToken' => 0.0, 'reasoningPerMToken' => 0.0, ], ]); // Cost is calculated externally using a calculator $pricing = InferencePricing::fromArray($config->pricing); $calculator = new FlatRateCostCalculator(); $cost = $calculator->calculate($usage, $pricing); // @doctest id="8b4a" ``` ### Type Coercion Both config classes automatically coerce numeric string values to integers for fields that expect `int` types. This is useful when loading values from YAML files or environment variables where values may arrive as strings. For `LLMConfig`, the coerced fields are `maxTokens`, `contextLength`, and `maxOutputLength`. ## EmbeddingsConfig `EmbeddingsConfig` holds the settings for connecting to an embeddings provider. **Namespace:** `Cognesy\Polyglot\Embeddings\Config\EmbeddingsConfig` ### Fields | Field | Type | Default | Description | |---|---|---|---| | `apiUrl` | `string` | `''` | Base URL for the provider API | | `apiKey` | `string` | `''` | Authentication key | | `endpoint` | `string` | `''` | API endpoint path | | `model` | `string` | `''` | Model identifier | | `dimensions` | `int` | `0` | Embedding dimensions (0 = provider default) | | `maxInputs` | `int` | `0` | Maximum number of inputs per request | | `metadata` | `array` | `[]` | Provider-specific metadata | | `driver` | `string` | `'openai'` | Driver name | ### Creating a Config ```php use Cognesy\Polyglot\Embeddings\Config\EmbeddingsConfig; // From a named preset $config = EmbeddingsConfig::fromPreset('openai'); // From an array $config = EmbeddingsConfig::fromArray([ 'driver' => 'openai', 'apiUrl' => 'https://api.openai.com/v1', 'apiKey' => getenv('OPENAI_API_KEY'), 'endpoint' => '/embeddings', 'model' => 'text-embedding-3-small', 'dimensions' => 1536, ]); // From a DSN string $config = EmbeddingsConfig::fromDsn('openai://model=text-embedding-3-small'); // @doctest id="2f0a" ``` Presets for embeddings are resolved from similar paths, under the `embed` config group: 1. `config/embed/presets/` 2. `packages/polyglot/resources/config/embed/presets/` 3. `vendor/cognesy/instructor-php/packages/polyglot/resources/config/embed/presets/` 4. `vendor/cognesy/instructor-polyglot/resources/config/embed/presets/` ### Overriding Values ```php $modified = $config->withOverrides([ 'model' => 'text-embedding-3-large', 'dimensions' => 1024, ]); // @doctest id="bbb8" ``` For `EmbeddingsConfig`, type coercion applies to the `dimensions` and `maxInputs` fields. > **Note:** The legacy field name `defaultDimensions` is automatically normalized to `dimensions` during config loading. ## Retry Policies Retry behavior is configured separately from the provider config, via dedicated policy objects. Retry policies must not be placed inside the `options` array -- Polyglot will throw an `InvalidArgumentException` if you attempt this. ### InferenceRetryPolicy The `InferenceRetryPolicy` provides fine-grained control over retry behavior for inference requests: ```php use Cognesy\Polyglot\Inference\Config\InferenceRetryPolicy; $policy = new InferenceRetryPolicy( maxAttempts: 3, // Total attempts (including the first) baseDelayMs: 250, // Base delay between retries maxDelayMs: 8000, // Maximum delay cap jitter: 'full', // Jitter strategy: 'none', 'full', or 'equal' retryOnStatus: [408, 429, 500, 502, 503, 504], lengthRecovery: 'continue', // 'none', 'continue', or 'increase_max_tokens' lengthMaxAttempts: 1, // Max recovery attempts for length issues lengthContinuePrompt: 'Continue.', maxTokensIncrement: 512, // Increment when using 'increase_max_tokens' ); $inference->withRetryPolicy($policy); // @doctest id="c343" ``` The retry delay uses exponential backoff: `baseDelayMs * 2^(attempt-1)`, capped at `maxDelayMs`. The `jitter` strategy adds randomness to avoid thundering herd problems: - `'none'` -- exact exponential backoff - `'full'` -- random value between 0 and the calculated delay - `'equal'` -- half the delay plus a random amount up to half the delay Length recovery allows automatic continuation when a response is cut short by the provider's token limit. Two strategies are available: `'continue'` appends the partial response and sends a continuation prompt, while `'increase_max_tokens'` retries with a higher `max_tokens` value. The policy also retries on specific exceptions by default: `TimeoutException` and `NetworkException`. Provider-specific errors classified as retriable (rate limits, quota exceeded, transient errors) are also retried automatically. ### EmbeddingsRetryPolicy For embeddings, use `EmbeddingsRetryPolicy`: ```php use Cognesy\Polyglot\Embeddings\Config\EmbeddingsRetryPolicy; $embeddings->withRetryPolicy(new EmbeddingsRetryPolicy( maxAttempts: 3, )); // @doctest id="8d59" ``` ================================================================================ FILE: packages/polyglot/internals/providers.md ================================================================================ Provider objects sit between configuration and runtime assembly. They resolve config values from presets, arrays, or explicit objects, and optionally carry an explicit driver instance. Runtimes use providers to determine which driver to build and how to configure it. ## LLMProvider `LLMProvider` is a builder that wraps an `LLMConfig` and an optional explicit driver. It implements `CanResolveLLMConfig` and `HasExplicitInferenceDriver`, which the runtime uses during assembly. **Namespace:** `Cognesy\Polyglot\Inference\LLMProvider` ### Creating a Provider ```php use Cognesy\Polyglot\Inference\LLMProvider; use Cognesy\Polyglot\Inference\Config\LLMConfig; // From a named preset $provider = LLMProvider::using('openai'); // With a custom base path for presets $provider = LLMProvider::using('openai', basePath: '/path/to/presets'); // From an explicit config $provider = LLMProvider::fromLLMConfig($config); // From an array $provider = LLMProvider::fromArray([ 'driver' => 'anthropic', 'apiUrl' => 'https://api.anthropic.com/v1', 'apiKey' => getenv('ANTHROPIC_API_KEY'), 'endpoint' => '/messages', 'model' => 'claude-sonnet-4-20250514', ]); // Default (OpenAI with gpt-4.1-nano) $provider = LLMProvider::new(); // @doctest id="fe82" ``` ### Customizing a Provider All mutators return a new immutable instance: ```php // Override specific config values $provider = LLMProvider::using('openai') ->withModel('gpt-4.1') ->withConfigOverrides(['maxTokens' => 4096]); // Replace the entire config $provider = $provider->withLLMConfig($newConfig); // Inject an explicit driver (bypasses the driver factory) $provider = $provider->withDriver($customDriver); // @doctest id="2899" ``` When an explicit driver is set, the runtime uses it directly instead of building one from the config. This is useful for testing or for providers that need custom initialization. ### How the Runtime Uses It When you call `InferenceRuntime::fromProvider($provider)`, the runtime: 1. Calls `$provider->resolveConfig()` to get the `LLMConfig` 2. Checks if `$provider->explicitInferenceDriver()` returns a driver 3. If an explicit driver exists, uses it directly 4. Otherwise, looks up the driver name from the config and creates one via the `InferenceDriverRegistry` ## EmbeddingsProvider `EmbeddingsProvider` serves the same role for embeddings. It wraps an `EmbeddingsConfig` and an optional explicit driver. **Namespace:** `Cognesy\Polyglot\Embeddings\EmbeddingsProvider` ### Creating a Provider ```php use Cognesy\Polyglot\Embeddings\EmbeddingsProvider; use Cognesy\Polyglot\Embeddings\Config\EmbeddingsConfig; // Default (empty config) $provider = EmbeddingsProvider::new(); // From an explicit config $provider = EmbeddingsProvider::fromEmbeddingsConfig($config); // From an array $provider = EmbeddingsProvider::fromArray([ 'driver' => 'openai', 'apiUrl' => 'https://api.openai.com/v1', 'apiKey' => getenv('OPENAI_API_KEY'), 'endpoint' => '/embeddings', 'model' => 'text-embedding-3-small', ]); // @doctest id="1fbe" ``` Unlike `LLMProvider`, `EmbeddingsProvider` does not have a `using(...)` shortcut for presets. Use `Embeddings::using(...)` or construct the config explicitly. ### Customizing a Provider ```php $provider = EmbeddingsProvider::fromArray([...]) ->withConfigOverrides(['dimensions' => 256]) ->withDriver($customDriver); // @doctest id="bf4d" ``` ## Driver Factories ### Inference Driver Registry The `InferenceDriverRegistry` manages the mapping between driver names and their factory callables. Polyglot ships with a default set of bundled drivers via `BundledInferenceDrivers::registry()`. Supported inference drivers include: | Driver Name | Class | Notes | |---|---|---| | `a21` | `A21Driver` | A21 Labs | | `anthropic` | `AnthropicDriver` | Anthropic Messages API | | `azure` | `AzureDriver` | Azure OpenAI | | `bedrock-openai` | `BedrockOpenAIDriver` | AWS Bedrock (OpenAI-compatible) | | `cerebras` | `CerebrasDriver` | Cerebras | | `cohere` | `CohereV2Driver` | Cohere v2 | | `deepseek` | `DeepseekDriver` | DeepSeek | | `fireworks` | `FireworksDriver` | Fireworks AI | | `gemini` | `GeminiDriver` | Google Gemini native API | | `gemini-oai` | `GeminiOAIDriver` | Gemini via OpenAI-compatible endpoint | | `glm` | `GlmDriver` | GLM | | `groq` | `GroqDriver` | Groq | | `huggingface` | `HuggingFaceDriver` | Hugging Face | | `inception` | `InceptionDriver` | Inception | | `meta` | `MetaDriver` | Meta Llama API | | `minimaxi` | `MinimaxiDriver` | Minimaxi | | `mistral` | `MistralDriver` | Mistral | | `openai` | `OpenAIDriver` | OpenAI Chat Completions API | | `openai-responses` | `OpenAIResponsesDriver` | OpenAI Responses API | | `openresponses` | `OpenResponsesDriver` | Open Responses API | | `openrouter` | `OpenRouterDriver` | OpenRouter | | `perplexity` | `PerplexityDriver` | Perplexity | | `qwen` | `QwenDriver` | Alibaba Qwen | | `sambanova` | `SambaNovaDriver` | SambaNova | | `xai` | `XAiDriver` | xAI (Grok) | | `moonshot` | `OpenAICompatibleDriver` | Moonshot (via OpenAI-compatible) | | `ollama` | `OpenAICompatibleDriver` | Ollama (via OpenAI-compatible) | | `openai-compatible` | `OpenAICompatibleDriver` | Generic OpenAI-compatible APIs | | `together` | `OpenAICompatibleDriver` | Together AI (via OpenAI-compatible) | You can extend the registry with custom drivers: ```php use Cognesy\Polyglot\Inference\Creation\InferenceDriverRegistry; use Cognesy\Polyglot\Inference\Creation\BundledInferenceDrivers; $registry = BundledInferenceDrivers::registry() ->withDriver('my-provider', MyCustomDriver::class); $runtime = InferenceRuntime::fromConfig($config, drivers: $registry); // @doctest id="574a" ``` A custom driver can be registered as a class name (must accept `LLMConfig`, `CanSendHttpRequests`, and `CanHandleEvents` in its constructor) or as a callable factory: ```php $registry = $registry->withDriver('my-provider', function ($config, $httpClient, $events) { return new MyCustomDriver($config, $httpClient, $events); }); // @doctest id="9534" ``` You can also remove drivers from the registry: ```php $registry = $registry->withoutDriver('openai-compatible'); // @doctest id="50df" ``` ### Embeddings Driver Registry The `EmbeddingsDriverRegistry` follows the same immutable instance-based pattern as `InferenceDriverRegistry`. Bundled embeddings drivers are provided via `BundledEmbeddingsDrivers::registry()` and include: `openai`, `azure`, `cohere`, `gemini`, `jina`, `mistral`, and `ollama`. Custom embeddings drivers can be registered through the registry: ```php use Cognesy\Polyglot\Embeddings\Creation\BundledEmbeddingsDrivers; $registry = BundledEmbeddingsDrivers::registry() ->withDriver('my-provider', MyEmbeddingsDriver::class); $runtime = EmbeddingsRuntime::fromConfig($config, drivers: $registry); // @doctest id="f4a0" ``` Or with a factory callable: ```php $registry = $registry->withDriver('my-provider', function ($config, $httpClient, $events) { return new MyEmbeddingsDriver($config, $httpClient, $events); }); // @doctest id="94eb" ``` Both `InferenceDriverRegistry` and `EmbeddingsDriverRegistry` use immutable instance-based registration, so driver registrations can vary per runtime. ## Key Contracts The provider system is built on a small set of interfaces: ### Provider Contracts | Interface | Purpose | |---|---| | `CanResolveLLMConfig` | Returns an `LLMConfig` from a provider | | `HasExplicitInferenceDriver` | Optionally returns a pre-built inference driver | | `CanAcceptLLMConfig` | Allows setting an `LLMConfig` on a provider | | `CanResolveEmbeddingsConfig` | Returns an `EmbeddingsConfig` from a provider | | `HasExplicitEmbeddingsDriver` | Optionally returns a pre-built embeddings driver | ### Driver Contracts | Interface | Purpose | |---|---| | `CanProcessInferenceRequest` | Main inference driver contract (make responses, stream deltas, report capabilities) | | `CanHandleVectorization` | Main embeddings driver contract (handle requests, parse responses) | | `CanProvideInferenceDrivers` | Registry that creates inference drivers by name | ### Adapter Contracts | Interface | Purpose | |---|---| | `CanTranslateInferenceRequest` | Converts `InferenceRequest` to `HttpRequest` | | `CanTranslateInferenceResponse` | Converts `HttpResponse` to `InferenceResponse` or stream deltas | | `CanMapMessages` | Maps typed `Messages` to provider format | | `CanMapRequestBody` | Assembles the request body | | `CanMapUsage` | Extracts token usage from response data | The driver contract `CanProcessInferenceRequest` also includes a `capabilities()` method that reports what features a driver supports (e.g., streaming, tool calls, structured output). This can be used to make runtime decisions about which features to use with a given provider: ```php $driver->capabilities()->supportsStreaming; $driver->capabilities('deepseek-reasoner')->supportsToolCalls; // @doctest id="2352" ``` ================================================================================ FILE: packages/polyglot/internals/adapters.md ================================================================================ Polyglot drivers are composed from small, focused adapter classes. Each adapter handles one aspect of the translation between Polyglot's unified data model and a provider's native HTTP format. This composition makes it straightforward to add new providers -- most of the logic is shared, and only the provider-specific differences need new code. ## Adapter Responsibilities Every inference driver is built from two main translators, each of which may use additional formatters internally: ### Request Translation The request adapter converts a Polyglot `InferenceRequest` into an `HttpRequest`. It is responsible for: - **Message formatting** -- mapping Polyglot's typed `Messages` (with roles, content parts, tool calls, and tool results) into the provider's expected structure - **Body formatting** -- assembling the full request body including model, tools, response format, and mode-specific adjustments - **HTTP request assembly** -- setting the URL, headers (including authentication), and body These responsibilities are typically split across three classes: | Class Pattern | Contract | Purpose | |---|---|---| | `*MessageFormat` | `CanMapMessages` | Maps `Messages` to provider format | | `*BodyFormat` | `CanMapRequestBody` | Assembles the full request body | | `*RequestAdapter` | `CanTranslateInferenceRequest` | Builds the final `HttpRequest` | ### Response Translation The response adapter converts raw HTTP responses back into Polyglot data objects: | Class Pattern | Contract | Purpose | |---|---|---| | `*ResponseAdapter` | `CanTranslateInferenceResponse` | Parses responses and stream deltas | | `*UsageFormat` | `CanMapUsage` | Extracts token usage from response data | ## How They Compose Each driver wires its adapters together in its constructor. Here is the OpenAI driver as an example: ```php class OpenAIDriver extends BaseInferenceRequestDriver { public function __construct( LLMConfig $config, CanSendHttpRequests $httpClient, EventDispatcherInterface $events, ) { parent::__construct( config: $config, httpClient: $httpClient, events: $events, requestTranslator: new OpenAIRequestAdapter( $config, new OpenAIBodyFormat($config, new OpenAIMessageFormat()), ), responseTranslator: new OpenAIResponseAdapter( new OpenAIUsageFormat(), ), ); } } // @doctest id="2842" ``` The `BaseInferenceRequestDriver` handles the shared execution logic -- sending HTTP requests, reading responses, and parsing event streams. The adapters only need to handle format translation. ## The Contracts ### Request Side The `CanTranslateInferenceRequest` contract defines a single method: ```php interface CanTranslateInferenceRequest { public function toHttpRequest(InferenceRequest $request): HttpRequest; } // @doctest id="5956" ``` Request adapters typically delegate body construction to a `CanMapRequestBody` implementation: ```php interface CanMapRequestBody { public function toRequestBody(InferenceRequest $request): array; } // @doctest id="78af" ``` Message formatting is handled by `CanMapMessages`, which receives typed `Messages` and returns a provider-native array. Implementations compose a `MessageMapper` utility for typed iteration instead of duplicating the loop: ```php interface CanMapMessages { public function map(Messages $messages): array; } // @doctest id="b6e7" ``` A typical request adapter composes these together. For example, `OpenAIRequestAdapter` receives a `CanMapRequestBody` (which itself wraps a `CanMapMessages`), then builds the final HTTP request with URL, headers, and the formatted body: ```php class OpenAIRequestAdapter implements CanTranslateInferenceRequest { public function __construct( protected LLMConfig $config, protected CanMapRequestBody $bodyFormat, ) {} public function toHttpRequest(InferenceRequest $request): HttpRequest { return new HttpRequest( url: "{$this->config->apiUrl}{$this->config->endpoint}", method: 'POST', headers: [ 'Authorization' => "Bearer {$this->config->apiKey}", 'Content-Type' => 'application/json; charset=utf-8', 'Accept' => 'application/json', ], body: $this->bodyFormat->toRequestBody($request), options: ['stream' => $request->isStreamed()], ); } } // @doctest id="8ec1" ``` ### Response Side The `CanTranslateInferenceResponse` contract handles both synchronous and streaming responses: ```php interface CanTranslateInferenceResponse { public function fromResponse(HttpResponse $response): ?InferenceResponse; /** @return iterable */ public function fromStreamDeltas( iterable $eventBodies, ?HttpResponse $responseData = null, ): iterable; public function toEventBody(string $data): string|bool; } // @doctest id="7bbc" ``` The `toEventBody()` method extracts the payload from an SSE line (stripping the `data:` prefix, detecting `[DONE]` markers). The `fromStreamDeltas()` method parses a sequence of those payloads into `PartialInferenceDelta` objects carrying incremental content, tool call fragments, and usage snapshots. Usage extraction is handled by `CanMapUsage`: ```php interface CanMapUsage { public function fromData(array $data): InferenceUsage; } // @doctest id="2c37" ``` Different providers report token usage under different keys and with different granularity. Some include cache tokens or reasoning tokens, others do not. Each provider's usage formatter encapsulates these differences into the normalized `InferenceUsage` object. ## Embeddings Adapters Embeddings drivers follow the same pattern with their own set of contracts: | Contract | Purpose | |---|---| | `EmbedRequestAdapter` | Converts `EmbeddingsRequest` to `HttpRequest` | | `EmbedResponseAdapter` | Converts `HttpResponse` to `EmbeddingsResponse` | | `CanMapRequestBody` | Assembles the embeddings request body | | `CanMapUsage` | Extracts usage from embeddings response data | ## Adding a New Provider To add support for a new provider, you typically need to create: 1. A **message format** class if the provider uses a non-OpenAI message structure 2. A **body format** class to assemble requests with any provider-specific fields 3. A **request adapter** to set the URL, headers, and authentication scheme 4. A **response adapter** to parse responses and streaming events 5. A **usage format** class if token usage is reported differently 6. A **driver** class that wires these adapters together and extends `BaseInferenceRequestDriver` Many providers use OpenAI-compatible formats. In those cases, you can often reuse the OpenAI adapters directly or extend them with minimal overrides. The `OpenAICompatibleDriver` is designed exactly for this purpose -- drivers like `ollama`, `together`, and `moonshot` all map to it in the bundled driver registry. ================================================================================ FILE: packages/polyglot/internals/http-client.md ================================================================================ Polyglot does not implement its own HTTP transport. It builds on the shared HTTP package, keeping transport concerns separate from request and response normalization. This separation means you can swap HTTP implementations, add middleware, or inject test doubles without touching the driver layer. ## The Transport Contract All HTTP communication flows through a single contract: ```php interface CanSendHttpRequests { public function send(HttpRequest $request): PendingHttpResponse; } // @doctest id="07ab" ``` Every inference and embeddings driver receives a `CanSendHttpRequests` implementation. The driver translates its `InferenceRequest` into an `HttpRequest`, sends it via the client's `send()` method (which returns a `PendingHttpResponse`), calls `get()` on the pending response to obtain the `HttpResponse`, and translates that back. ## Default Client When you call `InferenceRuntime::fromConfig(...)` or `EmbeddingsRuntime::fromConfig(...)` without providing an HTTP client, Polyglot creates a default one using `HttpClientBuilder`: ```php $httpClient = (new HttpClientBuilder(events: $events))->create(); // @doctest id="ab90" ``` The builder selects an appropriate underlying HTTP library (Guzzle, Symfony HttpClient, or Laravel's HTTP client) based on what is available in your project. You can inject your own client when you need specific configuration: ```php use Cognesy\Http\Creation\HttpClientBuilder; $httpClient = (new HttpClientBuilder()) ->withMiddleware(new MyLoggingMiddleware()) ->create(); $runtime = InferenceRuntime::fromConfig($config, httpClient: $httpClient); // @doctest id="7421" ``` ## HttpRequest and HttpResponse These data objects represent the HTTP layer's request and response. Drivers create `HttpRequest` objects through their request adapters and read `HttpResponse` objects through their response adapters. ### HttpRequest ```php $request = new HttpRequest( url: 'https://api.openai.com/v1/chat/completions', method: 'POST', headers: ['Authorization' => 'Bearer ...', 'Content-Type' => 'application/json'], body: ['model' => 'gpt-4.1-nano', 'messages' => [...]], options: ['stream' => true], ); $request->url(); // string $request->method(); // string $request->headers(); // array $request->body(); // HttpRequestBody $request->options(); // array $request->isStreamed(); // bool // Create a copy with streaming toggled $streamRequest = $request->withStreaming(true); // @doctest id="4103" ``` ### HttpResponse The `HttpResponse` interface provides access to the response data: ```php $response->statusCode(); // int $response->headers(); // array $response->body(); // string -- the full response body $response->stream(); // Generator -- for streaming responses $response->original(); // mixed -- the underlying library's native response // @doctest id="fa88" ``` For streaming, the `stream()` method returns a `Generator` that yields chunks as they arrive from the provider. The driver's response adapter parses these chunks into SSE events and then into `PartialInferenceDelta` objects. ## Middleware The HTTP client supports a middleware stack for cross-cutting concerns like logging, retries, caching, and authentication. Middleware implements the `HttpMiddleware` interface: ```php interface HttpMiddleware { public function handle( HttpRequest $request, CanHandleHttpRequest $next, ): HttpResponse; } // @doctest id="7a72" ``` The `BaseMiddleware` abstract class provides convenient hooks so you do not need to manage the chain manually: ```php abstract class BaseMiddleware implements HttpMiddleware { // Called before the request is sent protected function beforeRequest(HttpRequest $request): void {} // Called after the response is received protected function afterRequest( HttpRequest $request, HttpResponse $response, ): HttpResponse { return $response; } // Determines whether to wrap the response protected function shouldDecorateResponse( HttpRequest $request, HttpResponse $response, ): bool { return false; } // Wraps the response if shouldDecorateResponse returns true protected function toResponse( HttpRequest $request, HttpResponse $response, ): HttpResponse { return $response; } } // @doctest id="78c1" ``` ### Managing the Middleware Stack The `MiddlewareStack` supports named middleware for easy manipulation: ```php $client->middleware()->append($middleware, name: 'logging'); $client->middleware()->prepend($middleware, name: 'auth'); $client->middleware()->replace('logging', $newMiddleware); $client->middleware()->remove('logging'); $client->middleware()->has('logging'); // bool $client->middleware()->get('logging'); // ?HttpMiddleware $client->middleware()->all(); // array $client->middleware()->clear(); // self // @doctest id="db23" ``` Middleware runs in stack order: middleware added with `prepend()` runs before middleware added with `append()`. The `name` parameter is optional but recommended -- it allows you to replace or remove middleware later without tracking references. ## Stream Cache Manager For advanced use cases, Polyglot supports stream caching through the `CanManageStreamCache` contract. When provided, the stream cache manager can record and replay streaming responses, which is useful for testing and development: ```php $runtime = InferenceRuntime::fromConfig( $config, streamCacheManager: $cacheManager, ); // @doctest id="d369" ``` The cache behavior is controlled per-request through the `ResponseCachePolicy` enum on the `InferenceRequest`. You can set this through the facade: ```php use Cognesy\Polyglot\Inference\Enums\ResponseCachePolicy; $inference->withResponseCachePolicy(ResponseCachePolicy::Memory); // @doctest id="c856" ``` ## Shared Event Dispatcher The HTTP client shares the same event dispatcher as the runtime that created it. This means HTTP-level events (connection errors, timeouts, etc.) flow through the same event system as inference events, providing a unified observability pipeline. ================================================================================ FILE: packages/polyglot/internals/request-response.md ================================================================================ Polyglot normalizes all provider interactions into a small set of data objects. These objects are immutable -- every mutation returns a new instance, making them safe to pass around and branch from. ## InferenceRequest `InferenceRequest` encapsulates everything needed for an LLM call. It stores the conversation messages, model selection, tools, response format, options, and caching/retry configuration. **Namespace:** `Cognesy\Polyglot\Inference\Data\InferenceRequest` ### Key Properties | Property | Type | Description | |---|---|---| | `id` | `InferenceRequestId` | Unique identifier, auto-generated | | `createdAt` | `DateTimeImmutable` | Timestamp of creation | | `updatedAt` | `DateTimeImmutable` | Timestamp of last mutation | | `messages` | `Messages` | The conversation messages | | `model` | `string` | Model identifier | | `tools` | `ToolDefinitions` | Tool/function definitions | | `toolChoice` | `ToolChoice` | Tool selection strategy | | `responseFormat` | `ResponseFormat` | Structured output format | | `options` | `array` | Additional options (e.g. `stream`, `max_tokens`, `temperature`) | | `cachedContext` | `CachedInferenceContext` | Shared context for prompt caching | | `responseCachePolicy` | `ResponseCachePolicy` | Controls response caching behavior | | `retryPolicy` | `?InferenceRetryPolicy` | Retry configuration | ### Reading Values ```php $request->messages(); // Messages -- the message list $request->model(); // string $request->isStreamed(); // bool -- checks options['stream'] $request->tools(); // ToolDefinitions $request->toolChoice(); // ToolChoice $request->responseFormat(); // ResponseFormat $request->options(); // array $request->cachedContext(); // ?CachedInferenceContext $request->responseCachePolicy(); // ResponseCachePolicy $request->retryPolicy(); // ?InferenceRetryPolicy $request->id(); // InferenceRequestId // @doctest id="e046" ``` Predicate methods are also available: `hasMessages()`, `hasModel()`, `hasTools()`, `hasToolChoice()`, `hasResponseFormat()`, `hasNonTextResponseFormat()`, `hasTextResponseFormat()`, `hasOptions()`. ### Modifying a Request All mutators return a new instance, preserving the original request ID and creation timestamp: ```php $updated = $request ->withMessages(Messages::fromString('New prompt')) ->withModel('gpt-4.1') ->withStreaming(true) ->withOptions(['temperature' => 0.7]) ->withTools($toolDefinitions) ->withToolChoice('auto') ->withResponseFormat(['type' => 'json_object']) ->withRetryPolicy(new InferenceRetryPolicy(maxAttempts: 3)) ->withResponseCachePolicy(ResponseCachePolicy::Memory); // @doctest id="f97e" ``` The `with(...)` method allows setting multiple fields in a single call: ```php $updated = $request->with( messages: Messages::fromString('New prompt'), model: 'gpt-4.1', options: ['temperature' => 0.7], ); // @doctest id="aa98" ``` ### Cached Context The cached context mechanism allows you to separate stable parts of a prompt (system messages, tool definitions, response format) from the dynamic parts (user messages). When `withCacheApplied()` is called, the cached context is merged into the request: ```php $request = new InferenceRequest( messages: Messages::fromString('What is 2+2?'), cachedContext: new CachedInferenceContext( messages: [['role' => 'system', 'content' => 'You are a math tutor.']], tools: $toolDefinitions, responseFormat: ['type' => 'json_object'], ), ); // Merges cached messages before request messages, // cached tools/format used if request has none $merged = $request->withCacheApplied(); // @doctest id="133c" ``` After applying, the merged request has an empty cached context to prevent double-application. ### Serialization Requests can be serialized to and from arrays for storage or transport: ```php $array = $request->toArray(); $restored = InferenceRequest::fromArray($array); // @doctest id="80bc" ``` ## PendingInference `PendingInference` is a lazy handle for a single inference operation. It does not execute the request until you access the results. This enables the fluent `Inference` API to defer execution to the moment of consumption. **Namespace:** `Cognesy\Polyglot\Inference\PendingInference` ### Consuming Results ```php // Get plain text content $text = $pending->get(); // Get the full response object $response = $pending->response(); // Stream the response (requires streaming to be enabled) $stream = $pending->stream(); // Extract JSON from the response content $json = $pending->asJson(); // string $data = $pending->asJsonData(); // array // Extract tool call arguments as JSON $json = $pending->asToolCallJson(); // string $data = $pending->asToolCallJsonData(); // array // Check if streaming is enabled for this request $isStreamed = $pending->isStreamed(); // @doctest id="c0e1" ``` The underlying `InferenceExecutionSession` handles retry logic, event dispatching, and response caching. Once execution completes, the response is cached for the lifetime of the `PendingInference` instance. > **Important:** Calling `stream()` on a non-streaming request will throw an `InvalidArgumentException`. Enable streaming via `withStreaming(true)` on the facade before calling `create()`. ## InferenceResponse `InferenceResponse` is a `final readonly` value object that normalizes the provider's result into a consistent shape. **Namespace:** `Cognesy\Polyglot\Inference\Data\InferenceResponse` ### Reading the Response ```php $response->content(); // string -- the generated text $response->reasoningContent(); // string -- chain-of-thought (if available) $response->toolCalls(); // ToolCalls collection $response->usage(); // InferenceUsage object with token counts $response->finishReason(); // InferenceFinishReason enum $response->responseData(); // HttpResponse -- the raw HTTP response $response->isPartial(); // bool -- true for intermediate streaming results // @doctest id="a411" ``` Predicate methods: `hasContent()`, `hasReasoningContent()`, `hasToolCalls()`, `hasFinishReason()`. ### JSON Extraction The response provides convenience methods for extracting structured data: ```php // Find JSON in the response content $json = $response->findJsonData(); // Json object $data = $response->findJsonData()->toArray(); // array $str = $response->findJsonData()->toString(); // string // Extract tool call arguments $json = $response->findToolCallJsonData(); // Json object // @doctest id="550c" ``` When a response has a single tool call, `findToolCallJsonData()` returns the arguments of that call. When there are multiple tool calls, it returns an array of all tool call data. ### Reasoning Content Fallback Some providers embed reasoning in `` tags within the content rather than in a dedicated field. The `withReasoningContentFallbackFromContent()` method handles this: ```php $response = $response->withReasoningContentFallbackFromContent(); // Now $response->reasoningContent() contains the extracted reasoning // And $response->content() has the tags removed // @doctest id="f5fa" ``` This is a no-op if the response already has dedicated reasoning content or if no `` tags are present. ### Finish Reason The `finishReason()` method returns an `InferenceFinishReason` enum. The `hasFinishedWithFailure()` method checks whether the response ended with an error, content filter, or length limit: ```php if ($response->hasFinishedWithFailure()) { // Handle error, content_filter, or length finish reasons } // @doctest id="1053" ``` ### Serialization Responses support round-trip serialization: ```php $array = $response->toArray(); $restored = InferenceResponse::fromArray($array); // @doctest id="739b" ``` ## PartialInferenceDelta During streaming, the driver emits `PartialInferenceDelta` objects for each SSE event. Each delta carries only the incremental change from that event. **Namespace:** `Cognesy\Polyglot\Inference\Data\PartialInferenceDelta` ### Fields | Field | Type | Description | |---|---|---| | `contentDelta` | `string` | Incremental text content | | `reasoningContentDelta` | `string` | Incremental reasoning content | | `toolId` | `ToolCallId\|string\|null` | Tool call identifier | | `toolName` | `string` | Tool name (first delta of a tool call) | | `toolArgs` | `string` | Incremental tool call arguments | | `finishReason` | `string` | Set on the final delta | | `usage` | `?InferenceUsage` | Token usage (typically on the last delta) | | `usageIsCumulative` | `bool` | Whether usage represents total (true) or incremental (false) | | `responseData` | `?HttpResponse` | Raw response data for this event | | `value` | `mixed` | Optional provider-specific value | The `InferenceStream` accumulates these deltas internally using `InferenceStreamState` and assembles the final `InferenceResponse` when the stream completes. A `VisibilityTracker` ensures that only deltas with meaningful content changes are yielded to the caller. ## InferenceUsage The `InferenceUsage` object tracks token consumption across several categories: **Namespace:** `Cognesy\Polyglot\Inference\Data\InferenceUsage` ```php $usage = $response->usage(); $usage->inputTokens; // int -- prompt tokens $usage->outputTokens; // int -- completion tokens $usage->cacheWriteTokens; // int -- tokens written to cache $usage->cacheReadTokens; // int -- tokens read from cache $usage->reasoningTokens; // int -- tokens used for reasoning // Aggregate accessors $usage->total(); // sum of all token categories $usage->input(); // input tokens only $usage->output(); // output + reasoning tokens $usage->cache(); // cache write + cache read tokens // String representation $usage->toString(); // "Tokens: 150 (i:100 o:40 c:0 r:10)" // @doctest id="83cf" ``` ### Cost Calculation Cost is calculated externally using a calculator rather than through methods on the usage object. Pricing is specified in USD per 1 million tokens: ```php use Cognesy\Polyglot\Inference\Data\InferencePricing; use Cognesy\Polyglot\Pricing\FlatRateCostCalculator; $calculator = new FlatRateCostCalculator(); $cost = $calculator->calculate($usage, new InferencePricing( inputPerMToken: 0.15, outputPerMToken: 0.60, )); // The Cost value object $cost->total; // float -- total cost in USD $cost->breakdown; // array -- per-category breakdown $cost->toString(); // string representation $cost->toArray(); // array representation // @doctest id="3731" ``` ### Accumulation Usage and cost can be accumulated across multiple requests: ```php $total = $usage1->withAccumulated($usage2); $totalCost = $cost1->withAccumulated($cost2); // @doctest id="4117" ``` ## Embeddings Data Objects ### EmbeddingsRequest Holds the input texts, model, options, and retry policy for an embeddings call: ```php $request = new EmbeddingsRequest( input: ['Hello world', 'Another text'], model: 'text-embedding-3-small', options: ['dimensions' => 256], ); $request->inputs(); // array of strings $request->model(); // string $request->options(); // array $request->hasInputs(); // bool $request->retryPolicy(); // ?EmbeddingsRetryPolicy // Immutable mutations $updated = $request->withInputs('New text'); $updated = $request->withModel('text-embedding-3-large'); $updated = $request->withOptions(['dimensions' => 1024]); // @doctest id="c3a5" ``` ### EmbeddingsResponse Normalizes the provider's embeddings result: ```php $response->vectors(); // Vector[] -- all embedding vectors $response->first(); // ?Vector -- first vector $response->last(); // ?Vector -- last vector $response->all(); // Vector[] -- alias for vectors() $response->usage(); // InferenceUsage $response->toValuesArray(); // array of float arrays $response->split($index); // [Vector[], Vector[]] -- split at index // @doctest id="3eaa" ``` ### PendingEmbeddings A lazy handle similar to `PendingInference`. Calling `get()` triggers the HTTP request and returns an `EmbeddingsResponse`. The response is cached after the first call. Retry logic is handled internally based on the `EmbeddingsRetryPolicy` attached to the request, using the same exponential backoff pattern as inference retries. ```php $pending = $embeddings->withInputs('Hello world')->create(); $response = $pending->get(); // triggers HTTP call $request = $pending->request(); // access the original request // @doctest id="4a74" ``` ================================================================================ FILE: packages/polyglot/internals/events.md ================================================================================ Polyglot uses an event system to provide observability into the internal execution pipeline. Events are dispatched at each stage of the lifecycle, making it straightforward to implement logging, metrics, debugging, and monitoring without modifying the core library. ## Listening to Events Both inference and embeddings runtimes expose two ways to listen to events: ### Targeted Listeners Use `onEvent()` to listen for a specific event class: ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; use Cognesy\Polyglot\Inference\Events\InferenceResponseCreated; use Cognesy\Polyglot\Inference\InferenceRuntime; $runtime = InferenceRuntime::fromConfig( new LLMConfig( driver: 'openai', apiUrl: 'https://api.openai.com/v1', apiKey: getenv('OPENAI_API_KEY'), endpoint: '/chat/completions', model: 'gpt-4.1-nano', ), )->onEvent(InferenceResponseCreated::class, function ($event): void { // Log or inspect the response }); // @doctest id="9164" ``` You can register multiple listeners for the same event class. An optional priority parameter controls the order (higher values run first): ```php $runtime->onEvent(InferenceStarted::class, $highPriorityListener, priority: 10); $runtime->onEvent(InferenceStarted::class, $lowPriorityListener, priority: 0); // @doctest id="5694" ``` ### Wiretap Use `wiretap()` to receive all events regardless of type. This is useful for debugging and general-purpose logging: ```php $runtime->wiretap(function ($event): void { echo get_class($event) . "\n"; }); // @doctest id="409f" ``` ## Inference Events The inference lifecycle dispatches events in this order: ### Execution-Level Events | Event | When Dispatched | Key Data | |---|---|---| | `InferenceStarted` | Beginning of execution | `data['executionId']`, `data['requestId']`, `data['isStreamed']`, `data['model']`, `data['messageCount']` | | `InferenceCompleted` | End of execution (success or failure) | `data['executionId']`, `data['isSuccess']`, `data['finishReason']`, `data['attemptCount']`, `data['durationMs']`, token-count fields | These events bracket the entire inference operation, including any retry attempts. `InferenceCompleted` is dispatched exactly once per execution, whether it succeeded or failed. ### Attempt-Level Events Each retry attempt dispatches its own events: | Event | When Dispatched | Key Data | |---|---|---| | `InferenceAttemptStarted` | Beginning of an attempt | execution ID, attempt ID, attempt number, model | | `InferenceAttemptSucceeded` | Attempt completed successfully | `data['executionId']`, `data['attemptId']`, `data['attemptNumber']`, `data['finishReason']`, `data['durationMs']`, token-count fields | | `InferenceAttemptFailed` | Attempt failed | `data['executionId']`, `data['attemptId']`, `data['attemptNumber']`, `data['errorMessage']`, `data['errorType']`, `data['willRetry']`, `data['httpStatusCode']`, partial token-count fields, `data['durationMs']` | | `InferenceUsageReported` | After a successful attempt | `data['executionId']`, `data['model']`, `data['isFinal']`, token-count fields | When retries are configured, you may see multiple `InferenceAttemptStarted`/`InferenceAttemptFailed` pairs before a final `InferenceAttemptSucceeded` event. The `attemptNumber` field tracks which attempt is running. ### Response Events | Event | When Dispatched | Key Data | |---|---|---| | `InferenceRequested` | Before sending the HTTP request | request data | | `InferenceResponseCreated` | After receiving and parsing the response | `data['executionId']`, `data['requestId']`, `data['responseId']`, `data['finishReason']`, content-length fields, tool-call summary, `data['usage']` | | `InferenceFailed` | On unrecoverable failure | error details | ### Streaming Events | Event | When Dispatched | Key Data | |---|---|---| | `StreamFirstChunkReceived` | First visible delta arrives | execution ID, timeToFirstChunkMs, receivedAt, model, initial content | | `PartialInferenceDeltaCreated` | Each visible delta | `data['executionId']`, `data['contentDelta']` | | `StreamEventReceived` | Raw SSE event received | raw event data | | `StreamEventParsed` | SSE event parsed into a delta | parsed event data | The `StreamFirstChunkReceived` event is particularly useful for measuring time-to-first-chunk (TTFC), as it includes the `requestStartedAt` timestamp. ### Driver Events | Event | When Dispatched | Key Data | |---|---|---| | `InferenceDriverBuilt` | After the driver is created by the factory | driver class, redacted config, HTTP client class | Sensitive configuration values (API keys, tokens, secrets) are automatically redacted in the `InferenceDriverBuilt` event payload. ## Embeddings Events The embeddings lifecycle dispatches a smaller set of events: | Event | When Dispatched | Key Data | |---|---|---| | `EmbeddingsDriverBuilt` | After the embeddings driver is created | driver class, config, HTTP client class | | `EmbeddingsRequested` | Before sending the embeddings request | request data | | `EmbeddingsResponseReceived` | After receiving the response | `data['model']`, `data['inputCount']`, `data['vectorCount']`, `data['dimensions']`, `data['usage']` | | `EmbeddingsFailed` | On failure | error details | ## Practical Examples ### Logging Token Usage ```php use Cognesy\Polyglot\Inference\Events\InferenceUsageReported; $runtime->onEvent(InferenceUsageReported::class, function ($event): void { logger()->info('Token usage', [ 'model' => $event->data['model'] ?? null, 'inputTokens' => $event->data['inputTokens'] ?? 0, 'outputTokens' => $event->data['outputTokens'] ?? 0, 'totalTokens' => $event->data['totalTokens'] ?? 0, ]); }); // @doctest id="617d" ``` ### Measuring Time-to-First-Chunk ```php use Cognesy\Polyglot\Inference\Events\StreamFirstChunkReceived; $runtime->onEvent(StreamFirstChunkReceived::class, function (StreamFirstChunkReceived $event): void { logger()->info("TTFC: {$event->timeToFirstChunkMs}ms for model {$event->model}"); }); // @doctest id="ba28" ``` ### Tracking Retry Attempts ```php use Cognesy\Polyglot\Inference\Events\InferenceAttemptFailed; $runtime->onEvent(InferenceAttemptFailed::class, function (InferenceAttemptFailed $event): void { logger()->warning('Attempt failed', [ 'attemptNumber' => $event->data['attemptNumber'] ?? null, 'errorMessage' => $event->data['errorMessage'] ?? null, 'errorType' => $event->data['errorType'] ?? null, 'willRetry' => $event->data['willRetry'] ?? false, 'httpStatus' => $event->data['httpStatusCode'] ?? null, ]); }); // @doctest id="2c9c" ``` ### Monitoring Execution Outcomes ```php use Cognesy\Polyglot\Inference\Events\InferenceCompleted; $runtime->onEvent(InferenceCompleted::class, function (InferenceCompleted $event): void { logger()->info('Inference completed', [ 'success' => $event->data['isSuccess'] ?? false, 'finishReason' => $event->data['finishReason'] ?? null, 'attempts' => $event->data['attemptCount'] ?? 0, 'totalTokens' => $event->data['totalTokens'] ?? 0, 'durationMs' => $event->data['durationMs'] ?? 0, ]); }); // @doctest id="aa5d" ``` ## Event Dispatcher Events are dispatched through an `EventDispatcher` that implements `CanHandleEvents` (which extends `Psr\EventDispatcher\EventDispatcherInterface`). When a runtime is created without an explicit event dispatcher, it creates a default one named `'polyglot.inference.runtime'` or `'polyglot.embeddings.runtime'`. You can inject a shared event dispatcher to correlate events across multiple runtimes or integrate with your application's existing event system: ```php use Cognesy\Events\Dispatchers\EventDispatcher; $events = new EventDispatcher(name: 'my-app'); $runtime = InferenceRuntime::fromConfig($config, events: $events); // @doctest id="bacc" ``` The same event dispatcher instance can be shared between inference and embeddings runtimes, allowing a single wiretap listener to observe all Polyglot activity. ================================================================================ FILE: packages/polyglot/internals/public-api.md ================================================================================ Most applications only need a small part of the package. The `Inference` and `Embeddings` facades provide a fluent, immutable interface that handles provider differences behind the scenes. ## Inference The `Inference` class is the main entry point for LLM interactions. It encapsulates provider complexities behind a unified, fluent interface. **Namespace:** `Cognesy\Polyglot\Inference\Inference` ### Creating an Instance Polyglot offers several ways to create an `Inference` instance depending on how much control you need: ```php use Cognesy\Polyglot\Inference\Inference; // From a named preset (resolves config from YAML files) $inference = Inference::using('openai'); // From an explicit config object $inference = Inference::fromConfig($llmConfig); // From a provider object (useful for overrides) $inference = Inference::fromProvider($provider); // From an already-built runtime $inference = Inference::fromRuntime($runtime); // @doctest id="70cf" ``` You can also pass a custom driver registry to `using()` or `fromConfig()` if you have registered custom drivers: ```php $inference = Inference::using('my-provider', drivers: $customRegistry); // @doctest id="1835" ``` ### Building a Request All request methods return a new immutable instance, so you can safely branch configurations: ```php $base = Inference::using('openai') ->withModel('gpt-4.1-nano') ->withMaxTokens(1024); // Branch into two different requests from the same base $response1 = $base->withMessages(Messages::fromString('Explain PHP traits.'))->get(); $response2 = $base->withMessages(Messages::fromString('Explain PHP enums.'))->get(); // @doctest id="c0d3" ``` Available request methods: | Method | Purpose | |---|---| | `with(...)` | Set multiple parameters at once (messages, model, tools, toolChoice, responseFormat, options) | | `withMessages(...)` | Set the conversation messages | | `withModel(...)` | Override the model | | `withMaxTokens(...)` | Set maximum output tokens | | `withTools(...)` | Provide tool/function definitions | | `withToolChoice(...)` | Control tool selection behavior | | `withResponseFormat(...)` | Request structured output format | | `withOptions(...)` | Pass additional provider options (merged with existing) | | `withStreaming(...)` | Enable or disable streaming | | `withCachedContext(...)` | Set cached context (messages, tools, toolChoice, responseFormat) | | `withRetryPolicy(...)` | Configure retry behavior via `InferenceRetryPolicy` | | `withResponseCachePolicy(...)` | Control response caching via `ResponseCachePolicy` | | `withRequest(...)` | Set all parameters from an existing `InferenceRequest` | | `withRuntime(...)` | Swap the underlying runtime | ### Executing and Reading Results Shortcuts execute the request and return results directly: ```php // Get the text content $text = $inference->withMessages(Messages::fromString('Hello'))->get(); // Get the full response object $response = $inference->withMessages(Messages::fromString('Hello'))->response(); // Parse JSON from the response content $data = $inference->withMessages(Messages::fromString('Return JSON'))->asJsonData(); // Get JSON as a string $json = $inference->withMessages(Messages::fromString('Return JSON'))->asJson(); // Parse tool call arguments as JSON array $args = $inference->withMessages(Messages::fromString('Call a tool'))->asToolCallJsonData(); // Get tool call arguments as JSON string $json = $inference->withMessages(Messages::fromString('Call a tool'))->asToolCallJson(); // Stream the response $stream = $inference->withMessages(Messages::fromString('Hello'))->stream(); // @doctest id="854c" ``` For lower-level control, `create()` returns a `PendingInference` without triggering execution: ```php $pending = $inference->withMessages(Messages::fromString('Hello'))->create(); // Then choose how to consume it $text = $pending->get(); $response = $pending->response(); $stream = $pending->stream(); // @doctest id="b2c8" ``` ### Working with Responses The `InferenceResponse` object provides access to all parts of the provider's response: ```php $response = $inference->withMessages(Messages::fromString('Hello'))->response(); $response->content(); // string -- the text content $response->reasoningContent(); // string -- reasoning/thinking content (if supported) $response->toolCalls(); // ToolCalls -- tool call collection $response->usage(); // InferenceUsage -- token counts $response->finishReason(); // InferenceFinishReason enum $response->responseData(); // HttpResponse -- raw provider response $response->isPartial(); // bool -- true for partial streaming responses // Convenience checks $response->hasContent(); $response->hasReasoningContent(); $response->hasToolCalls(); $response->hasFinishReason(); // JSON extraction $response->findJsonData(); // Json object from content $response->findToolCallJsonData(); // Json object from tool call args // @doctest id="1254" ``` ### Working with Streams The `InferenceStream` provides several ways to consume streaming data: ```php $stream = $inference->withMessages(Messages::fromString('Hello'))->stream(); // Iterate over visible deltas foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; // incremental text echo $delta->reasoningContentDelta; // incremental reasoning (if any) echo $delta->toolName; // tool name (if starting a tool call) echo $delta->toolArgs; // tool arguments fragment } // Get the final assembled response $finalResponse = $stream->final(); // Register a callback for each delta $stream->onDelta(function (PartialInferenceDelta $delta): void { echo $delta->contentDelta; }); // Functional-style processing $texts = $stream->map(fn($d) => $d->contentDelta); $full = $stream->reduce(fn($carry, $d) => $carry . $d->contentDelta, ''); $toolOnly = $stream->filter(fn($d) => $d->toolName !== ''); // Collect all deltas at once $allDeltas = $stream->all(); // @doctest id="8e82" ``` ## Embeddings The `Embeddings` class provides a unified interface for generating vector embeddings across providers. **Namespace:** `Cognesy\Polyglot\Embeddings\Embeddings` ### Creating an Instance ```php use Cognesy\Polyglot\Embeddings\Embeddings; // From a named preset $embeddings = Embeddings::using('openai'); // From an explicit config $embeddings = Embeddings::fromConfig($embeddingsConfig); // From a provider object $embeddings = Embeddings::fromProvider($provider); // From a runtime $embeddings = Embeddings::fromRuntime($runtime); // @doctest id="110b" ``` ### Building a Request ```php $embeddings = Embeddings::using('openai') ->withInputs('The quick brown fox') ->withModel('text-embedding-3-small') ->withOptions(['dimensions' => 256]); // @doctest id="48f7" ``` Available request methods: | Method | Purpose | |---|---| | `with(...)` | Set input, options, and model at once | | `withInputs(...)` | Set input text(s) to embed (string or array of strings) | | `withModel(...)` | Override the model | | `withOptions(...)` | Pass additional provider options | | `withRetryPolicy(...)` | Configure retry behavior via `EmbeddingsRetryPolicy` | | `withRequest(...)` | Set all parameters from an existing `EmbeddingsRequest` | | `withRuntime(...)` | Swap the underlying runtime | ### Executing and Reading Results ```php // Get the full response $response = $embeddings->withInputs('Hello world')->get(); // Get just the vector objects $vectors = $embeddings->withInputs(['text one', 'text two'])->vectors(); // Get the first vector $vector = $embeddings->withInputs('Hello world')->first(); // @doctest id="0ddc" ``` For lower-level control, `create()` returns a `PendingEmbeddings`: ```php $pending = $embeddings->withInputs('Hello world')->create(); $response = $pending->get(); // @doctest id="bb80" ``` ### Working with Responses The `EmbeddingsResponse` object provides access to the embedding vectors: ```php $response = $embeddings->withInputs(['Hello', 'World'])->get(); $response->vectors(); // Vector[] -- all embedding vectors $response->all(); // Vector[] -- alias for vectors() $response->first(); // ?Vector -- first vector $response->last(); // ?Vector -- last vector $response->usage(); // EmbeddingsUsage -- token counts $response->toValuesArray(); // array -- raw float arrays // Split vectors at a given index [$before, $after] = $response->split(1); // @doctest id="50ac" ``` ## Registering Custom Drivers ### Inference Drivers Custom inference drivers are registered through the `InferenceDriverRegistry` and passed to the runtime: ```php use Cognesy\Polyglot\Inference\Creation\BundledInferenceDrivers; $registry = BundledInferenceDrivers::registry() ->withDriver('my-provider', MyCustomDriver::class); $inference = Inference::using('my-provider', drivers: $registry); // @doctest id="0daf" ``` ### Embeddings Drivers Custom embeddings drivers are registered through the `EmbeddingsDriverRegistry` and passed to the runtime: ```php use Cognesy\Polyglot\Embeddings\Creation\BundledEmbeddingsDrivers; $registry = BundledEmbeddingsDrivers::registry() ->withDriver('my-provider', MyCustomDriver::class); $runtime = EmbeddingsRuntime::fromConfig($config, drivers: $registry); $embeddings = Embeddings::fromRuntime($runtime); // @doctest id="5e39" ``` See the [Providers](/internals/providers) page for details on driver registration and factory patterns. ================================================================================ FILE: packages/polyglot/troubleshooting/overview.md ================================================================================ This section covers the most common issues you may encounter when working with Polyglot, along with practical guidance for resolving them quickly. ## Common Categories Most problems fall into one of these categories: 1. **[Authentication Issues](/troubleshooting/issues-authentication)** -- Missing or invalid API keys, wrong environment variables, or incorrect key formats for a given provider. 2. **[Configuration Issues](/troubleshooting/issues-configuration)** -- Preset files that cannot be found, missing required fields, or type mismatches in configuration values. 3. **[Connection Issues](/troubleshooting/issues-connection)** -- Network failures, incorrect API URLs, proxy or firewall rules blocking outbound requests, and DNS resolution problems. 4. **[Rate Limits](/troubleshooting/issues-rate-limits)** -- HTTP 429 responses from providers, quota exhaustion, and strategies for retry and throttling. 5. **[Model-Specific Issues](/troubleshooting/issues-model-specific)** -- Capability differences between models such as tool support, JSON schema output, context length limits, and vision features. 6. **[Provider-Specific Issues](/troubleshooting/issues-provider-specific)** -- Quirks unique to individual providers like Anthropic's message format, Gemini's native API, OpenAI organization IDs, and local Ollama setup. 7. **[Streaming Issues](/troubleshooting/issues-streaming)** -- Premature stream termination, stream reuse errors, output buffering, and connection timeouts during long-running streams. 8. **[Debugging](/troubleshooting/debugging)** -- Using events, wiretapping, and HTTP-level inspection to understand what Polyglot sends and receives. ## Quick Diagnosis Checklist Before diving into specific pages, run through this checklist: - **Is the API key set?** Check the environment variable referenced in your preset (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`). - **Is the preset name correct?** The name passed to `Inference::using('...')` must match a YAML file in the preset directory. - **Does the model support the feature you are requesting?** Not all models support tools, JSON schema output, or streaming. Try a plain text request first. - **Are provider-specific options bleeding across providers?** Remove custom `options` entries and add them back one at a time. - **Is the stream being consumed only once?** Calling `deltas()` a second time throws a `LogicException`. When in doubt, attach a `wiretap()` listener to the runtime to see every event Polyglot dispatches during the request lifecycle. See the [Debugging](/troubleshooting/debugging) page for details. ================================================================================ FILE: packages/polyglot/troubleshooting/debugging.md ================================================================================ Debugging LLM interactions is essential for troubleshooting and optimizing your applications. Polyglot provides several layers of observability, from high-level event listeners to raw HTTP request inspection. ## Wiretapping the Runtime The simplest debugging path is to attach a wiretap listener to the `InferenceRuntime`. The wiretap receives every event dispatched during the request lifecycle, including request construction, driver selection, streaming deltas, and the final response. ```php wiretap(function ($event): void { echo get_class($event) . PHP_EOL; }); $text = Inference::fromRuntime($runtime) ->withMessages(Messages::fromString('Say hello.')) ->get(); // @doctest id="c06f" ``` This prints every event class name as it fires, giving you an immediate view of the request flow without modifying your application code. ## Listening for Specific Events When you only care about certain events, use `onEvent()` to register targeted listeners instead of a wiretap. This avoids noise from events you do not need. ```php onEvent( InferenceRequested::class, function (InferenceRequested $event): void { echo "Request sent to model\n"; }, ); $runtime->onEvent( InferenceResponseCreated::class, function (InferenceResponseCreated $event): void { echo "Response received\n"; }, ); $text = Inference::fromRuntime($runtime) ->withMessages(Messages::fromString('What is the capital of France?')) ->get(); // @doctest id="f676" ``` ### Available Events Polyglot dispatches events at each stage of the inference lifecycle: | Event | When it fires | |---|---| | `InferenceStarted` | Before the first attempt begins | | `InferenceRequested` | When a request is about to be sent | | `InferenceAttemptStarted` | At the start of each retry attempt | | `InferenceAttemptSucceeded` | When an attempt receives a successful response | | `InferenceAttemptFailed` | When an attempt fails (before retry) | | `StreamEventReceived` | When a raw SSE event arrives during streaming | | `StreamEventParsed` | After a stream event is parsed into a delta | | `StreamFirstChunkReceived` | When the first visible delta arrives (useful for TTFC) | | `PartialInferenceDeltaCreated` | For each visible streaming delta | | `InferenceResponseCreated` | When the final response is assembled | | `InferenceCompleted` | After the entire inference flow finishes | | `InferenceFailed` | When all retry attempts are exhausted | | `InferenceUsageReported` | When token usage data is available | | `InferenceDriverBuilt` | When the driver is constructed (includes redacted config) | ## Logging to Files For persistent debugging, write event data to a log file: ```php onEvent( InferenceRequested::class, function (InferenceRequested $event): void { logToFile("REQUEST: " . json_encode($event->data)); }, ); $runtime->onEvent( InferenceResponseCreated::class, function (InferenceResponseCreated $event): void { logToFile("RESPONSE: " . json_encode($event->data)); }, ); $text = Inference::fromRuntime($runtime) ->withMessages(Messages::fromString('What is artificial intelligence?')) ->get(); // @doctest id="1aaf" ``` ## HTTP-Level Inspection If you need to see the raw HTTP request and response bodies, inject a custom HTTP client with middleware. This is useful when you suspect Polyglot is sending an unexpected payload, or when the provider returns an error body that higher-level events do not surface. ```php withMessages(Messages::fromString('Test message')) ->get(); // @doctest id="bead" ``` You can add custom middleware to the HTTP client using `withMiddleware()` to log, transform, or inspect requests and responses at the transport layer. This is especially helpful when working behind proxies, or when provider error messages are only visible in the raw HTTP body. ## Tips for Effective Debugging - **Start with wiretap.** It gives a complete picture with no configuration. - **Narrow to specific events** once you know which stage of the flow is failing. - **Check the `InferenceDriverBuilt` event** to confirm the correct driver and configuration were resolved. The config is automatically redacted to hide API keys. - **Use file logging in production** rather than `echo`, so you can review logs after the fact. - **For streaming issues**, listen for `StreamFirstChunkReceived` to measure time-to-first-chunk, and `PartialInferenceDeltaCreated` to verify deltas are arriving. ================================================================================ FILE: packages/polyglot/troubleshooting/issues-connection.md ================================================================================ Network connectivity problems prevent Polyglot from reaching the provider API. These issues exist below the request layer and are typically caused by incorrect URLs, firewall rules, proxy misconfiguration, or DNS failures. ## Symptoms - Error messages like "connection timeout," "failed to connect," or "network error" - Long delays before errors appear - `TimeoutException` or `NetworkException` from the HTTP client - Requests that work locally but fail in production (or vice versa) ## Verify the API URL and Endpoint The most common connection problem is an incorrect `apiUrl` or `endpoint` in your preset. Double-check both values against the provider's documentation: ```yaml # config/llm/presets/openai.yaml driver: openai apiUrl: 'https://api.openai.com/v1' endpoint: /chat/completions # @doctest id="9f00" ``` Common mistakes include trailing slashes on `apiUrl`, missing the version prefix (e.g. `/v1`), or using an endpoint path that does not match the driver. ## Check Outbound Network Access Verify that your application environment can reach the provider's API: ```bash # Test connectivity to OpenAI curl -s -o /dev/null -w "%{http_code}" https://api.openai.com/v1/models \ -H "Authorization: Bearer $OPENAI_API_KEY" # Test connectivity to Anthropic curl -s -o /dev/null -w "%{http_code}" https://api.anthropic.com/v1/messages \ -H "x-api-key: $ANTHROPIC_API_KEY" \ -H "anthropic-version: 2023-06-01" # @doctest id="7a52" ``` If the `curl` command fails, the problem is at the network layer, not in Polyglot. ## Configure Timeouts Polyglot uses the `HttpClientConfig` to control connection and request timeouts. The defaults are 3 seconds for connection and 30 seconds for the request. For slow networks or large requests, increase these values: ```php withMessages(Messages::fromString('Summarize quantum computing in 200 words.')) ->get(); // @doctest id="7116" ``` ## Proxy Configuration If your application runs behind an HTTP proxy, configure the proxy at the HTTP client level. Polyglot itself does not handle proxy settings -- it delegates all transport concerns to the HTTP client. The approach depends on your HTTP client driver. For the default cURL driver, you can set proxy options through the system environment: ```bash export HTTP_PROXY=http://proxy.example.com:8080 export HTTPS_PROXY=http://proxy.example.com:8080 # @doctest id="370f" ``` Alternatively, configure a custom HTTP client with explicit proxy settings for your chosen driver. ## Firewall and Security Groups In cloud environments, ensure that your security groups or network ACLs allow outbound HTTPS (port 443) to the provider's domain. Common provider domains to allow: - `api.openai.com` - `api.anthropic.com` - `api.mistral.ai` - `generativelanguage.googleapis.com` (Gemini) - `localhost:11434` (Ollama, local only) ## DNS Resolution If DNS is not resolving the provider's domain, you will see connection failures even though the network path is clear. Test DNS resolution independently: ```bash nslookup api.openai.com dig api.anthropic.com # @doctest id="4aa1" ``` In containerized environments, check that the container's DNS resolver is configured correctly (e.g. `/etc/resolv.conf`). ## Local Providers (Ollama) For local providers like Ollama, confirm that the service is running and listening on the expected address: ```bash # Check if Ollama is running curl http://localhost:11434/api/version # @doctest id="7eeb" ``` If Ollama is running on a different host or port, update the `apiUrl` in your preset: ```yaml driver: ollama apiUrl: 'http://192.168.1.100:11434/v1' endpoint: /chat/completions model: 'llama3' # @doctest id="eec4" ``` ## Retry Transient Failures Network issues are often transient. Use `InferenceRetryPolicy` to automatically retry on connection failures: ```php withRetryPolicy(new InferenceRetryPolicy( maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 5000, jitter: 'full', )) ->withMessages(Messages::fromString('Hello')) ->get(); // @doctest id="9958" ``` The retry policy automatically retries on `TimeoutException` and `NetworkException` by default. ================================================================================ FILE: packages/polyglot/troubleshooting/issues-authentication.md ================================================================================ Authentication failures are among the most common issues when working with LLM APIs. They typically surface as HTTP 401 or 403 responses, or as error messages containing terms like "authentication failed," "invalid API key," or "unauthorized." ## Symptoms - HTTP status code 401 (Unauthorized) or 403 (Forbidden) - Error messages mentioning "invalid API key," "authentication failed," or "access denied" - Requests that work from one machine but fail from another ## Verify the Environment Variable Each preset references an environment variable for its API key. The variable name is defined in the preset YAML file using the `${VAR_NAME}` syntax. For example, the `openai` preset uses `${OPENAI_API_KEY}` and the `anthropic` preset uses `${ANTHROPIC_API_KEY}`. Confirm that the variable is set and not empty: ```php load(); $dotenv->required(['OPENAI_API_KEY'])->notEmpty(); // @doctest id="3749" ``` ## Check the Key Format Some providers have distinctive key formats. Verifying the prefix can catch copy-paste errors early: - **OpenAI** keys typically start with `sk-` - **Anthropic** keys typically start with `sk-ant-` - **Mistral** keys are UUIDs or short alphanumeric strings ```php withMessages(Messages::fromString('Hello')) ->get(); // @doctest id="a0b9" ``` ## Test the Key Directly Use a minimal script to confirm that the key works independently of your application logic: ```php withMessages(Messages::fromString('Test')) ->withMaxTokens(5) ->get(); echo "Preset '$preset' authenticated successfully.\n"; } catch (\Exception $e) { echo "Preset '$preset' failed: " . $e->getMessage() . "\n"; } } testPreset('openai'); testPreset('anthropic'); testPreset('mistral'); // @doctest id="ad1c" ``` ## Pass the Key Programmatically If environment variables are not practical, you can supply the API key directly through `LLMConfig`: ```php withMessages(Messages::fromString('Hello'))->get(); // @doctest id="befc" ``` > **Security note:** Avoid hard-coding API keys in source files that are committed to version control. Use environment variables, secrets managers, or encrypted configuration files. ## Common Pitfalls - **Trailing whitespace or newlines** in the environment variable. Trim the value if your loading mechanism adds whitespace. - **Expired or revoked keys.** Regenerate the key in your provider's dashboard. - **Organization or project restrictions.** Some OpenAI keys require an `organization` value in the preset metadata. Check the preset YAML for a `metadata.organization` field. - **IP allowlists.** Some providers or enterprise plans restrict API access to specific IP addresses. Confirm your server's IP is permitted. ================================================================================ FILE: packages/polyglot/troubleshooting/issues-configuration.md ================================================================================ Configuration issues typically surface when Polyglot cannot find a preset file, when the file is missing required fields, or when field values have the wrong type. These problems usually produce clear error messages that point directly to the cause. ## Symptoms - `InvalidArgumentException` with "No preset directory found" or "Invalid configuration" - Unexpected driver or model being used - Type errors mentioning `maxTokens`, `dimensions`, or `maxInputs` ## Preset File Location When you call `Inference::using('openai')`, Polyglot searches for a file named `openai.yaml` in these directories (in order): 1. `config/llm/presets/` -- relative to your project root 2. `packages/polyglot/resources/config/llm/presets/` -- monorepo layout 3. `vendor/cognesy/instructor-php/packages/polyglot/resources/config/llm/presets/` -- installed via Composer as part of instructor-php 4. `vendor/cognesy/instructor-polyglot/resources/config/llm/presets/` -- installed via Composer as standalone package For embeddings, the equivalent paths use `config/embed/presets/` instead of `config/llm/presets/`. If none of these directories exist, Polyglot throws an `InvalidArgumentException`. To override the search path, pass a `basePath` argument: ```php withMessages(Messages::fromString('Hello')) ->get(); // @doctest id="452d" ``` You can also create a config from an associative array: ```php 'anthropic', 'apiUrl' => 'https://api.anthropic.com/v1', 'apiKey' => getenv('ANTHROPIC_API_KEY'), 'endpoint' => '/messages', 'model' => 'claude-haiku-4-5-20251001', 'maxTokens' => 1024, ]); // @doctest id="7455" ``` ## Overriding Preset Values To start from a preset and change specific values, use `withOverrides()`: ```php withOverrides(['model' => 'gpt-4.1', 'maxTokens' => 4096]); $text = Inference::fromConfig($config) ->withMessages(Messages::fromString('Hello')) ->get(); // @doctest id="a277" ``` ## Verify a Configuration To check that a preset loads correctly without making a request, instantiate the config and inspect it: ```php driver}\n"; echo "API URL: {$config->apiUrl}\n"; echo "Model: {$config->model}\n"; echo "Max Tokens: {$config->maxTokens}\n"; } catch (\InvalidArgumentException $e) { echo "Configuration error: " . $e->getMessage() . "\n"; } // @doctest id="b492" ``` ## Common Pitfalls - **Preset name does not match the filename.** `Inference::using('gpt4')` looks for `gpt4.yaml`, not `openai.yaml`. - **YAML indentation errors.** Malformed YAML will cause the config loader to fail silently or return unexpected values. - **Retry policy in options.** Polyglot explicitly forbids placing `retryPolicy` inside the `options` array of `LLMConfig`. Use `withRetryPolicy()` on the inference builder instead. - **Environment variable not expanded.** If the `apiKey` field contains the literal string `${OPENAI_API_KEY}` at runtime, the environment variable was not resolved. Ensure the variable is set before the preset is loaded. ================================================================================ FILE: packages/polyglot/troubleshooting/issues-rate-limits.md ================================================================================ Provider rate limits restrict the number of requests or tokens you can consume within a time window. When you exceed these limits, the provider returns an HTTP 429 response and your request fails. Polyglot provides built-in retry policies to handle these transient failures, but sustained rate limiting requires application-level strategies. ## Symptoms - HTTP status code 429 (Too Many Requests) - Error messages containing "rate limit exceeded," "too many requests," or "quota exceeded" - Requests that work in isolation but fail under load ## Use the Built-In Retry Policy Polyglot can automatically retry failed requests with exponential backoff and jitter. Retries are opt-in and explicit -- you must attach an `InferenceRetryPolicy` to the inference builder: ```php withRetryPolicy(new InferenceRetryPolicy( maxAttempts: 4, baseDelayMs: 250, maxDelayMs: 8000, jitter: 'full', )) ->withMessages(Messages::fromString('What is the capital of France?')) ->get(); // @doctest id="5287" ``` ### Retry Policy Parameters | Parameter | Default | Description | |---|---|---| | `maxAttempts` | `1` | Total number of attempts (1 means no retries) | | `baseDelayMs` | `250` | Base delay in milliseconds before the first retry | | `maxDelayMs` | `8000` | Maximum delay cap in milliseconds | | `jitter` | `'full'` | Jitter strategy: `none`, `full`, or `equal` | | `retryOnStatus` | `[408, 429, 500, 502, 503, 504]` | HTTP status codes that trigger a retry | | `retryOnExceptions` | `[TimeoutException, NetworkException]` | Exception classes that trigger a retry | The delay between retries uses exponential backoff: `baseDelayMs * 2^(attempt - 1)`, capped at `maxDelayMs`. The jitter strategy adds randomness to avoid thundering herd problems: - **`none`** -- no randomness, uses the exact computed delay - **`full`** -- random delay between 0 and the computed delay - **`equal`** -- half the computed delay plus a random value up to half the computed delay ### Length Recovery The retry policy also supports automatic recovery when a response is truncated due to token limits: ```php withRetryPolicy(new InferenceRetryPolicy( maxAttempts: 3, lengthRecovery: 'continue', // or 'increase_max_tokens' lengthMaxAttempts: 2, lengthContinuePrompt: 'Continue.', maxTokensIncrement: 512, )) ->withMessages(Messages::fromString('Write a detailed essay about climate change.')) ->get(); // @doctest id="1914" ``` ## Retry Policy for Embeddings Embeddings requests use a separate policy class with the same interface: ```php minTimeBetweenRequests = 60.0 / $requestsPerMinute; } public function waitIfNeeded(): void { $elapsed = microtime(true) - $this->lastRequestTime; if ($elapsed < $this->minTimeBetweenRequests) { usleep((int) (($this->minTimeBetweenRequests - $elapsed) * 1_000_000)); } $this->lastRequestTime = microtime(true); } } $limiter = new RateLimiter(requestsPerMinute: 30); for ($i = 0; $i < 10; $i++) { $limiter->waitIfNeeded(); $text = Inference::using('openai') ->withMessages(Messages::fromString("This is request $i")) ->get(); echo "Response $i: $text\n"; } // @doctest id="be4a" ``` ## Batch Requests to Reduce Volume Instead of making many small requests, combine related questions into a single prompt when the use case allows: ```php $q) { $batchPrompt .= ($i + 1) . ". $q\n"; } $text = Inference::using('openai') ->withMessages(Messages::fromString($batchPrompt)) ->get(); // @doctest id="6d45" ``` This reduces the number of API calls from N to 1, dramatically lowering rate limit pressure. ## Additional Strategies - **Switch providers or models.** Different providers and models have different rate limits. If one provider is heavily throttled, route some requests to another. - **Upgrade your API plan.** Most providers offer higher rate limits on paid tiers. - **Cache responses.** If the same prompts recur frequently, cache the results to avoid redundant API calls. - **Use off-peak hours.** Some providers have lower contention during off-peak hours, reducing the likelihood of rate limiting. - **Monitor usage.** Track your request volume and token consumption to anticipate rate limit issues before they affect users. ================================================================================ FILE: packages/polyglot/troubleshooting/issues-streaming.md ================================================================================ Streaming allows you to receive LLM responses incrementally as they are generated, rather than waiting for the complete response. Streaming issues typically involve premature termination, stream reuse, output buffering problems, or connection timeouts. ## Symptoms - Streams cutting off prematurely - `LogicException` with "Stream is exhausted and cannot be replayed" - Partial or incomplete responses - No output appearing during streaming (buffering issue) - Connection timeouts during long-running streams ## Enable Streaming Correctly Streaming must be explicitly enabled on the request. The simplest approach is to use the `stream()` shortcut on the inference builder, then consume the stream via `deltas()`: ```php withMessages(Messages::fromString('Write a short poem about the ocean.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } // @doctest id="16e0" ``` Alternatively, use the `withStreaming()` method followed by `create()->stream()`: ```php withMessages(Messages::fromString('Write a short poem about the ocean.')) ->withStreaming(true) ->create(); $stream = $pending->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } // @doctest id="c0d3" ``` ## Do Not Consume a Stream Twice The most common streaming mistake is attempting to iterate over `deltas()` more than once. Streams are single-pass by design. A second call to `deltas()` throws a `LogicException`. ```php deltas() as $delta) { /* first pass */ } foreach ($stream->deltas() as $delta) { /* throws! */ } // @doctest id="0bea" ``` If you need to replay the stream content, enable the memory cache policy before creating the request: ```php withResponseCachePolicy(ResponseCachePolicy::Memory) ->withMessages(Messages::fromString('Write a haiku.')) ->withStreaming(true); // @doctest id="7cc2" ``` ## Collect the Full Response After Streaming To get the complete assembled response after consuming all deltas, use the `final()` method on the stream: ```php withMessages(Messages::fromString('Explain gravity.')) ->stream(); // Consume deltas for real-time output foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; flush(); } // Get the finalized response (assembled from all deltas) $response = $stream->final(); echo "\n\nTotal tokens: " . $response->usage()->total() . "\n"; // @doctest id="4fda" ``` If you only need the final response and do not need to process deltas, call `final()` directly -- it will drain the stream internally. ## Flush Output Buffers When streaming to a browser or CLI, PHP's output buffering can delay visible output. Flush buffers explicitly after each delta: ```php deltas() as $delta) { echo $delta->contentDelta; // Flush PHP output buffer if (ob_get_level() > 0) { ob_flush(); } flush(); } // @doctest id="a788" ``` For web applications, also ensure that your web server is not buffering the response. Common server-side buffering sources: - **Nginx** -- disable proxy buffering with `proxy_buffering off;` or set the response header `X-Accel-Buffering: no` - **Apache mod_deflate / mod_gzip** -- compression modules buffer output; disable them for streaming endpoints - **PHP output buffering** -- check `output_buffering` in `php.ini` and consider calling `ob_end_flush()` before streaming begins ## Handle Connection Timeouts Streaming responses can take longer than non-streaming requests because the connection remains open while the model generates tokens. Increase the timeout settings to accommodate this: ```php withMessages(Messages::fromString('Write a long story about a space explorer.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; flush(); } // @doctest id="4103" ``` The `idleTimeout` is particularly important for streaming. It controls how long the client waits for the next chunk before giving up. If a model pauses while generating (for example, during complex reasoning), a short idle timeout will cause the stream to terminate prematurely. ## Handle Errors During Streaming Wrap the stream consumption in a try-catch to handle errors that occur mid-stream. This is important because errors can arise after some deltas have already been received: ```php withMessages(Messages::fromString('Write a detailed explanation of relativity.')) ->stream(); $content = ''; try { foreach ($stream->deltas() as $delta) { $content .= $delta->contentDelta; echo $delta->contentDelta; flush(); } } catch (\Exception $e) { echo "\nStream error: " . $e->getMessage() . "\n"; if (!empty($content)) { echo "Partial content received: " . strlen($content) . " characters\n"; } } // @doctest id="ad00" ``` ## Use the onDelta Callback Instead of iterating over `deltas()`, you can register a callback that is invoked for each visible delta: ```php withMessages(Messages::fromString('Tell me a joke.')) ->stream(); $stream->onDelta(function ($delta) { echo $delta->contentDelta; flush(); }); // Drain the stream to trigger all callbacks $stream->all(); // @doctest id="10de" ``` ## Use Functional Stream Operations The stream supports `map()`, `filter()`, and `reduce()` operations for functional-style processing: ```php withMessages(Messages::fromString('List five programming languages.')) ->stream(); // Collect only non-empty content deltas $content = $stream->reduce( fn(string $carry, $delta) => $carry . $delta->contentDelta, '', ); echo $content; // @doctest id="c916" ``` ## Fallback to Non-Streaming If streaming consistently fails for a particular model or provider, fall back to a non-streaming request: ```php withMessages(Messages::fromString($prompt)); if ($preferStreaming) { try { $content = ''; foreach ($inference->stream()->deltas() as $delta) { $content .= $delta->contentDelta; } return $content; } catch (\Exception $e) { // Fall through to non-streaming } } return $inference->get(); } // @doctest id="cbc1" ``` ## Verify Model Supports Streaming Not all models support streaming. If enabling streaming causes errors, test with a plain non-streaming request first. If the non-streaming request succeeds, the model may not support streaming, or the provider may require a different endpoint for streamed responses. ## Common Pitfalls - **Consuming `deltas()` twice.** This is the most frequent mistake. Use `ResponseCachePolicy::Memory` if you need to replay. - **Not flushing output.** Without explicit `flush()` calls, PHP buffers output and the user sees nothing until the stream completes. - **Short timeouts.** The default 30-second request timeout is too short for many streaming responses. Increase `requestTimeout` and `idleTimeout`. - **Ignoring partial content on error.** When a stream error occurs mid-way, you may have already received useful content. Always capture partial content in your error handler. - **Server-side buffering.** Even with PHP `flush()`, Nginx or Apache may buffer the response. Configure your web server to pass through responses immediately for streaming endpoints. ================================================================================ FILE: packages/polyglot/troubleshooting/issues-model-specific.md ================================================================================ Different LLM models have different capabilities and limitations, even within the same provider. A request that works perfectly with one model may fail or produce unexpected results with another. Understanding these differences is key to building reliable applications. ## Symptoms - Errors like "model not found," "parameter not supported," or "context length exceeded" - Unexpected or degraded response quality from certain models - Requests that succeed on one model but fail on another - Tool calls or JSON output that work with some models but not others ## Check Model Availability Verify that the model identifier in your preset or request matches a model that is currently available from the provider. Model names are case-sensitive and must be exact: ```php withModel('gpt-4.1-nano') ->withMessages(Messages::fromString('Hello')) ->get(); // @doctest id="86d8" ``` Models are periodically deprecated or renamed by providers. If a model that previously worked suddenly fails, check the provider's release notes for changes. ## Context Length Limits Each model has a maximum context length (measured in tokens). If your input exceeds this limit, the provider returns an error. The `contextLength` field in the preset defines this limit for reference, but the actual enforcement happens at the provider. Common context windows: | Model | Approximate Context Window | |---|---| | GPT-4.1 | 1,000,000 tokens | | GPT-4.1-nano | 1,000,000 tokens | | Claude Haiku 4.5 | 200,000 tokens | | Gemini models | varies by model | | Llama 3 (via Ollama) | 128,000 tokens | When you hit context limits, consider: - Summarizing or truncating the input - Splitting the request into smaller chunks - Switching to a model with a larger context window ## Tool and Function Calling Support Not all models support tool (function) calling. If you pass `tools` to a model that does not support them, the provider may return an error or silently ignore the tools. When debugging tool-related failures, first confirm the request works without tools: ```php withModel('gpt-4.1-nano') ->withMessages(Messages::fromString('What is 2 + 2?')) ->get(); echo $text; // Verify this works first // Step 2: Then add tools back $text = Inference::using('openai') ->withModel('gpt-4.1-nano') ->withMessages(Messages::fromString('What is 2 + 2?')) ->withTools($myTools) ->get(); // @doctest id="6d42" ``` ## JSON and Structured Output Support Models vary in their support for structured output formats: - **JSON Schema mode** -- the model is constrained to output JSON matching a specific schema. Only some models support this. - **JSON object mode** -- the model is instructed to output valid JSON, but without schema enforcement. - **Plain text** -- all models support this. If JSON schema output fails, try JSON object mode or plain text as a fallback. Polyglot's `responseFormat` option controls this, but the actual behavior depends on the model. ## Streaming Support Most modern models support streaming, but some do not. If enabling streaming causes errors, test with a non-streaming request first: ```php withModel('gpt-4.1-nano') ->withMessages(Messages::fromString('Write a haiku.')) ->get(); // Then test streaming $stream = Inference::using('openai') ->withModel('gpt-4.1-nano') ->withMessages(Messages::fromString('Write a haiku.')) ->stream(); foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } // @doctest id="d4b3" ``` ## Vision and Multimodal Capabilities Only certain models support image inputs. Sending images to a text-only model will cause an error. Check the provider's documentation to confirm which models accept multimodal input. ## Implement Model Fallbacks For production applications, implement a fallback strategy that tries alternative models when the preferred model fails: ```php withModel($model) ->withMessages(Messages::fromString($prompt)) ->get(); } catch (\Exception $e) { $lastException = $e; // Log the failure and try the next model } } throw new \RuntimeException( "All models failed. Last error: " . $lastException?->getMessage(), previous: $lastException, ); } // Try capable models first, then fall back to simpler ones $response = withFallback( ['gpt-4.1', 'gpt-4.1-mini', 'gpt-4.1-nano'], 'Explain general relativity.', ); // @doctest id="f71c" ``` ## Debugging Approach When a model-specific issue arises, use this systematic approach: 1. **Reduce to plain text.** Remove tools, response format, and streaming. If the plain text request fails, the problem is not model-capability related (check authentication, configuration, or connection). 2. **Add features one at a time.** Re-enable streaming, then response format, then tools. The first feature that causes failure identifies the unsupported capability. 3. **Check the provider's model documentation.** Verify that the specific model version supports the feature you need. 4. **Try a different model** from the same provider to confirm whether the issue is model-specific or provider-wide. ================================================================================ FILE: packages/polyglot/troubleshooting/issues-provider-specific.md ================================================================================ Each LLM provider has its own API conventions, authentication requirements, and behavioral quirks. Polyglot normalizes many of these differences through its driver system, but it cannot erase real capability gaps between providers. This page covers the most common provider-specific issues and how to address them. ## General Approach If a request works with one provider and fails with another: 1. Remove any provider-specific fields from `options`. 2. Test with plain text output (no tools, no response format, no streaming). 3. Add features back one at a time to identify which one causes the failure. Polyglot handles message format translation, endpoint routing, and authentication header differences automatically. However, custom `options` entries are passed through to the provider as-is, which can cause errors if the target provider does not recognize them. ## OpenAI ### Organization and Project IDs If you use a shared OpenAI account, you may need to set the organization ID in the preset metadata: ```yaml # config/llm/presets/openai.yaml driver: openai apiUrl: 'https://api.openai.com/v1' apiKey: '${OPENAI_API_KEY}' endpoint: /chat/completions model: gpt-4.1-nano metadata: organization: 'org-your-organization-id' project: 'proj-your-project-id' # @doctest id="453c" ``` ### API Changes OpenAI periodically updates its API. If requests that previously worked start failing, check OpenAI's changelog for breaking changes. Model deprecations are the most common cause. ### OpenAI Responses API Polyglot also supports the OpenAI Responses API through the `openai-responses` driver and preset. This uses a different endpoint (`/responses`) and message format. Make sure you use the correct preset: ```php withMessages(Messages::fromString('Hello')) ->get(); // Responses API (different driver) $text = Inference::using('openai-responses') ->withMessages(Messages::fromString('Hello')) ->get(); // @doctest id="c8ea" ``` ## Anthropic ### Message Format Anthropic uses a different message format than OpenAI. Polyglot handles this translation automatically through the `anthropic` driver. You do not need to format messages differently -- just use the standard Polyglot API. ### API Version Header The Anthropic API requires an `anthropic-version` header. This is configured in the preset metadata: ```yaml # config/llm/presets/anthropic.yaml driver: anthropic apiUrl: 'https://api.anthropic.com/v1' apiKey: '${ANTHROPIC_API_KEY}' endpoint: /messages metadata: apiVersion: '2023-06-01' beta: prompt-caching-2024-07-31 # @doctest id="0a98" ``` If you see errors about unsupported API versions, update the `apiVersion` value in your preset. ### System Messages Anthropic handles system messages differently from OpenAI -- they are sent as a separate `system` parameter rather than as a message in the conversation. Polyglot's Anthropic driver handles this automatically. ## Google Gemini Polyglot supports Gemini through two drivers: - **`gemini`** -- uses Google's native Generative AI API with its own message format - **`gemini-oai`** -- uses Google's OpenAI-compatible endpoint The native Gemini API has different request and response structures. If you experience issues with one driver, try the other: ```php withMessages(Messages::fromString('Hello')) ->get(); // OpenAI-compatible Gemini endpoint $text = Inference::using('gemini-oai') ->withMessages(Messages::fromString('Hello')) ->get(); // @doctest id="0ed4" ``` ## Mistral ### Rate Limits on Free Tier Mistral enforces strict rate limits on free-tier accounts. If you see HTTP 429 errors frequently, consider upgrading your plan or implementing more aggressive throttling in your application. ### Model Names Mistral model identifiers can change between versions. Verify that the model name in your preset matches a currently available model. ## Ollama (Local Models) ### Service Must Be Running Ollama runs as a local service. Ensure it is installed and running before making requests: ```bash # Check if Ollama is running curl http://localhost:11434/api/version # Start Ollama if not running ollama serve # @doctest id="478f" ``` ### Pull Models Before Use Models must be downloaded before they can be used: ```bash # Pull a model ollama pull llama3 # List available models ollama list # @doctest id="c3ec" ``` ### Default Endpoint The Ollama preset uses the OpenAI-compatible endpoint at `http://localhost:11434/v1/chat/completions`. If Ollama is running on a different host or port, update the `apiUrl` in your preset. ### Feature Limitations Local models through Ollama may not support all features that cloud providers offer. Tool calling, JSON schema mode, and streaming behavior can vary by model. Test each feature individually. ## Azure OpenAI Azure OpenAI uses a different URL structure and authentication mechanism. The `azure` driver handles these differences: ```yaml # config/llm/presets/azure.yaml driver: azure apiUrl: 'https://your-resource.openai.azure.com/openai' apiKey: '${AZURE_OPENAI_API_KEY}' endpoint: '/deployments/your-deployment/chat/completions' model: gpt-4 # @doctest id="0a85" ``` Azure deployments use deployment names rather than model names in the endpoint URL. Ensure the `endpoint` field includes the correct deployment name. ## AWS Bedrock Use the `bedrock-openai` driver for Bedrock's OpenAI-compatible endpoint. The current 2.0 implementation authenticates with a bearer API key. AWS SigV4 credential signing is not implemented in Polyglot yet. ```yaml # config/llm/presets/aws-bedrock.yaml driver: bedrock-openai apiUrl: 'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1' apiKey: '${AWS_BEDROCK_API_KEY}' endpoint: /chat/completions model: anthropic.claude-3-haiku-20240307-v1:0 metadata: region: '${AWS_BEDROCK_REGION:-us-east-1}' # @doctest id="2a08" ``` ## Cohere The `cohere` driver supports Cohere's v2 Chat API. Cohere uses a different message and usage format that the driver translates automatically. ## Other Providers Polyglot includes drivers for many additional providers including Deepseek, Fireworks, Groq, HuggingFace, Cerebras, Perplexity, SambaNova, Together, XAI, Qwen, GLM, Inception, and MiniMaxi. Each has a corresponding preset file in the `config/llm/presets/` directory. For providers that follow the OpenAI API format, the `openai-compatible` driver provides broad compatibility. If a provider does not have a dedicated driver, try configuring it with `driver: openai-compatible` and adjusting the `apiUrl`, `endpoint`, and `apiKey` fields. ================================================================================ FILE: packages/agents/01-introduction.md ================================================================================ # Introduction The Agents package provides a minimal, composable foundation for building LLM-powered agents in PHP. An agent, at its core, is a **loop**: send messages to a language model, receive a response that may include tool calls, execute those tools, feed the results back into the conversation, and repeat until the model produces a final answer. This simple pattern is powerful enough to drive everything from single-turn question answering to multi-step autonomous workflows. The package is designed to stay out of your way. There are no heavyweight frameworks to learn, no mandatory dependency injection containers, and no magic. You construct an execution loop, hand it some state, and get back a result. Everything in between -- tool execution, lifecycle hooks, stop conditions -- is explicit and composable. ## Key Design Principles ### Immutable State `AgentState` is a readonly value object. Every operation that modifies state -- adding a message, recording a step, signaling a stop -- returns a new instance, leaving the original untouched. This makes agent execution predictable and easy to reason about: you can always inspect any prior state without worrying that a later step has mutated it. It also makes agents inherently safe for persistence and resumption, since state can be serialized at any point without race conditions. ### Pluggable Drivers The execution engine does not know how to talk to an LLM. That responsibility belongs to a **driver**. The default `ToolCallingDriver` uses native function-calling APIs (OpenAI, Anthropic, etc.), while the `ReActDriver` implements the Thought/Action/Observation reasoning pattern using structured output. You can swap drivers without changing any of your agent code, tools, or hooks -- the `AgentLoop` treats them identically through the `CanUseTools` interface. ### Lifecycle Hooks Every phase of execution fires a lifecycle hook: before/after execution, before/after each step, before/after each tool call, on stop, and on error. Hooks can inspect and transform the agent state, inject messages, enforce resource limits, or block tool calls entirely. The built-in guard hooks (`StepsLimitHook`, `TokenUsageLimitHook`, `ExecutionTimeLimitHook`) are implemented this way, and you can add your own using the same mechanism. ### Testable by Default `FakeAgentDriver` lets you script deterministic scenarios without making any LLM calls. You define a sequence of `ScenarioStep` objects -- each specifying a response, optional tool calls, and a step type -- and the driver replays them in order. This means your agent tests are fast, deterministic, and free of API dependencies. ## Architecture Overview The package is organized into two layers, with two additional systems built on top: ### AgentLoop -- The Execution Engine `AgentLoop` is the immutable execution engine at the heart of the package. It holds a set of tools, a driver, an interceptor (hook stack), and an event handler -- all set at construction time. You pass it an `AgentState`, and it runs the step loop until a stop condition is met, returning the final state. The loop's execution cycle works as follows: 1. **Before execution** -- fire lifecycle hooks, ensure a fresh execution context 2. **Before step** -- fire hooks (guards check limits here) 3. **Use tools** -- the driver sends messages to the LLM, receives a response, and executes any requested tool calls 4. **After step** -- fire hooks (state inspection, summarization, etc.) 5. **Evaluate continuation** -- check whether the loop should stop (no tool calls, stop signal received, or continuation explicitly requested) 6. **Repeat** from step 2, or stop and fire after-execution hooks Use `AgentLoop` directly when you want full control or when your agent is simple enough that manual construction is clearer than composition. ### AgentBuilder -- The Composition Layer `AgentBuilder` assembles an `AgentLoop` from pluggable **capabilities** -- small, reusable classes that install tools, hooks, guards, drivers, and compilers. Each capability implements `CanProvideAgentCapability` and knows how to configure itself onto an agent. This keeps complex agent setups modular: you can mix and match capabilities like `UseBash`, `UseGuards`, `UseFileTools`, `UseSummarization`, and more, without any of them knowing about each other. Use `AgentBuilder` when your agent needs multiple capabilities and you want to keep the configuration declarative and composable. ### Agent Templates When agent configuration should be data-driven rather than code-driven, **Agent Templates** let you define agents as `AgentDefinition` objects and instantiate loops and states from Markdown, YAML, or JSON files. This is useful when non-developers need to configure agents, or when you want to store agent definitions alongside prompts and tool configurations in version-controlled files. ### Session Runtime When agent state must persist across HTTP requests, CLI invocations, or background processes, the **Session Runtime** provides a persistence layer. It stores `AgentSession` objects in pluggable stores (file-based or in-memory), and lets you apply typed actions (`SendMessage`, `ChangeModel`, `ForkSession`, etc.) to stored sessions. This is the foundation for building chat interfaces, long-running background agents, and multi-turn workflows. ## Quick Comparison ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Data\AgentState; use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Bash\UseBash; use Cognesy\Agents\Capability\Core\UseGuards; // Direct: construct and execute in three lines $loop = AgentLoop::default()->withTool($myTool); $state = AgentState::empty()->withUserMessage('Hello!'); $result = $loop->execute($state); // Composed: declarative capability stacking $loop = AgentBuilder::base() ->withCapability(new UseBash()) ->withCapability(new UseGuards(maxSteps: 10)) ->build(); // @doctest id="9411" ``` ## Package Structure ``` AgentLoop.php # Core execution loop CanControlAgentLoop # Loop interface (execute / iterate) Builder/ # AgentBuilder, AgentConfigurator, capability contracts Capability/ # Use* capabilities (Core, Bash, File, Subagent, etc.) Collections/ # Tools, AgentSteps, StepExecutions, ToolExecutions Context/ # AgentContext, message compilers (CanCompileMessages) Continuation/ # StopSignal, StopReason, ExecutionContinuation Data/ # AgentState, ExecutionState, AgentStep, ExecutionBudget Drivers/ # ToolCallingDriver, ReActDriver, FakeAgentDriver Enums/ # AgentStepType, ExecutionStatus Events/ # Agent event system (started, completed, failed, etc.) Exceptions/ # Domain exceptions (tool blocked, invalid args, etc.) Hook/ # HookStack, HookInterface, HookContext, built-in hooks Interception/ # CanInterceptAgentLifecycle, PassThroughInterceptor Tool/ # ToolInterface, BaseTool, FunctionTool, ToolExecutor Template/ # Agent definitions, parsers (MD/YAML/JSON), registry Session/ # AgentSession, SessionRuntime, actions, stores Broadcasting/ # AgentEventBroadcaster for SSE/WebSocket streaming // @doctest id="8d8f" ``` ## Minimal Example ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Data\AgentState; $loop = AgentLoop::default(); $state = AgentState::empty()->withUserMessage('Hello!'); $result = $loop->execute($state); echo $result->finalResponse()->toString(); // @doctest id="78fa" ``` This sends a single message to the default LLM provider, receives a text response (no tool calls), and prints it. The loop detects there are no tool calls to execute, so it stops after one step. The entire execution is captured in the returned `AgentState`, which you can inspect for usage statistics, step history, errors, and timing information. ## Recommended Learning Path 1. **[Basic Agent](02-basic-agent.md)** -- Build your first agent with `AgentLoop`, add tools, customize the driver, and understand the execution lifecycle. 2. **[AgentBuilder & Capabilities](13-agent-builder.md)** -- Compose agents from reusable capability modules when configuration becomes non-trivial. 3. **[Agent Templates](14-agent-templates.md)** -- Define agents as data when configuration should come from files rather than code. 4. **[Session Runtime](16-session-runtime.md)** -- Persist and resume agent sessions across processes for chat interfaces and long-running workflows. ================================================================================ FILE: packages/agents/02-basic-agent.md ================================================================================ # Basic Agent This guide walks you through building agents with `AgentLoop` -- the core execution engine of the Agents package. You will learn how to send messages, add tools, customize behavior, observe execution, and test agents without making LLM calls. ## Hello World The simplest possible agent sends a message to a language model and returns the response: ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Data\AgentState; $loop = AgentLoop::default(); $state = AgentState::empty()->withUserMessage('What is 2+2?'); $result = $loop->execute($state); echo $result->finalResponse()->toString(); // "2 + 2 equals 4." // @doctest id="9015" ``` Three things happen here: 1. `AgentLoop::default()` creates a loop with the default `ToolCallingDriver`, which connects to whatever LLM provider is configured in your environment (typically via `OPENAI_API_KEY` or similar). 2. `AgentState::empty()` creates a fresh, immutable state with no messages, no history, and no execution context. Calling `withUserMessage()` returns a *new* state with the message appended -- the original remains empty. 3. `$loop->execute($state)` runs the step loop. The driver sends the message to the LLM, receives a text response with no tool calls, and the loop detects there is nothing more to do. It returns the final `AgentState` containing the complete execution history. The returned state carries everything that happened: the LLM's response, token usage, step timing, finish reason, and any errors. You access the model's final text output through `finalResponse()->toString()`. ## Understanding the Execution Lifecycle Every call to `execute()` follows the same lifecycle: 1. **Prepare execution** -- The loop ensures a fresh `ExecutionState` with a unique execution ID and sets the status to `InProgress`. 2. **Before step** -- Lifecycle hooks fire. Guard hooks (step limits, token limits, time limits) check whether execution should be stopped before the next LLM call. 3. **Driver step** -- The driver compiles messages from the current state, sends them to the LLM, receives a response, and executes any requested tool calls. The result is captured as an `AgentStep`. 4. **After step** -- Lifecycle hooks fire again. Hooks can inspect the step result, transform state, or trigger summarization. 5. **Continuation check** -- The loop evaluates whether to continue. It stops when: (a) no tool calls were returned, (b) a stop signal was emitted by a hook, or (c) the execution was explicitly continued by a hook. If tool calls were present, the loop repeats from step 2. 6. **After execution** -- Final hooks fire and the execution status is set to `Completed`, `Stopped`, or `Failed`. This means a simple question-and-answer exchange completes in a single step, while tool-using agents may run for many steps as the model iterates between reasoning and acting. ## Adding a Tool Tools give the agent the ability to act on the world. You define a tool as a callable, and the LLM decides when and how to invoke it based on the function's name, parameter types, and docblock: ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Data\AgentState; use Cognesy\Agents\Tool\Tools\FunctionTool; $weather = FunctionTool::fromCallable( function (string $city): string { return "Weather in {$city}: 72F, sunny"; } ); $loop = AgentLoop::default()->withTool($weather); $state = AgentState::empty()->withUserMessage('What is the weather in Paris?'); $result = $loop->execute($state); echo $result->finalResponse()->toString(); // "The weather in Paris is 72°F and sunny." // @doctest id="36f4" ``` When the LLM receives this request, it recognizes that a weather tool is available and returns a tool call instead of a direct answer. The loop executes the tool, feeds the result back as a tool response message, and calls the LLM again. This time the model has the weather data and produces a natural language answer. The loop sees no further tool calls and stops. `FunctionTool::fromCallable()` uses reflection to automatically generate the tool's JSON schema from the callable's signature. The function name becomes the tool name, parameter types become schema properties, and any PHPDoc `@param` descriptions become property descriptions. This means well-typed, well-documented functions produce high-quality tool schemas with zero manual configuration. ### Multiple Tools You can add multiple tools to a single loop. Each call to `withTool()` returns a new `AgentLoop` instance with the additional tool registered: ```php $loop = AgentLoop::default() ->withTool($weatherTool) ->withTool($calculatorTool) ->withTool($searchTool); // @doctest id="62c5" ``` The LLM sees all available tools in each request and chooses which to call (or none) based on the user's message. ## System Prompt A system prompt establishes the agent's persona, instructions, and constraints. It is sent as a cached context prefix on every LLM request, so the model always has it in scope. Both `withSystemPrompt()` and `withUserMessage()` accept `string|\Stringable`, so you can pass xprompt `Prompt` objects or any `Stringable` directly: ```php $state = AgentState::empty() ->withSystemPrompt('You are a concise weather assistant. Always respond with temperature in Celsius.') ->withUserMessage('What is the weather in Paris?'); // @doctest id="8cf9" ``` Since `AgentState` is immutable, you can create a base state with a system prompt and reuse it across multiple conversations by calling `withUserMessage()` each time: ```php $baseState = AgentState::empty() ->withSystemPrompt('You are a helpful coding assistant.'); $result1 = $loop->execute($baseState->withUserMessage('Explain closures in PHP.')); $result2 = $loop->execute($baseState->withUserMessage('What is a generator?')); // @doctest id="dfea" ``` ## Stepping Through Execution Sometimes you need to observe or react to each step as it happens, rather than waiting for the final result. The `iterate()` method returns a generator that yields the state after every step: ```php foreach ($loop->iterate($state) as $stepState) { $step = $stepState->currentStepOrLast(); echo sprintf( "Step %d: %s (tokens: %d)\n", $stepState->stepCount(), $step->stepType()->value, $step->usage()->total(), ); } // @doctest id="d22b" ``` This is useful for progress reporting, streaming intermediate results to a UI, or implementing custom early-exit logic. The final state yielded by the generator is the same state you would get from `execute()`. ## Inspecting Results The returned `AgentState` provides rich access to everything that happened during execution: ```php $result = $loop->execute($state); // The model's final text output echo $result->finalResponse()->toString(); // Execution status: Completed, Stopped, or Failed echo $result->status()->value; // Total token usage across all steps $usage = $result->usage(); echo "Input: {$usage->inputTokens}, Output: {$usage->outputTokens}"; // Number of steps executed echo $result->stepCount(); // Total execution duration in seconds echo $result->executionDuration(); // Whether any errors occurred if ($result->hasErrors()) { echo $result->errors()->toMessagesString(); } // Why the loop stopped $stopReason = $result->stopReason(); echo $stopReason?->value; // "completed", "steps_limit", "token_limit", etc. // Debug summary (useful during development) print_r($result->debug()); // @doctest id="0679" ``` ## Observing Events The `AgentLoop` emits events at every significant point in the lifecycle. You can listen for specific event types or wiretap all events: ```php use Cognesy\Agents\Events\AgentStepCompleted; use Cognesy\Agents\Events\ToolCallCompleted; // Listen for a specific event $loop->onEvent(AgentStepCompleted::class, function (AgentStepCompleted $event) { echo "Step {$event->stepNumber} completed, tokens: {$event->usage->total()}\n"; }); // Wiretap all events (useful for debugging) $loop->wiretap(function (object $event) { echo get_class($event) . "\n"; }); $result = $loop->execute($state); // @doctest id="8fbc" ``` Events are dispatched for execution start/complete/fail, step start/complete, inference requests/responses, tool call start/complete/blocked, stop signals, and token usage reports. This makes it straightforward to build logging, monitoring, or streaming integrations without modifying agent logic. ## Customizing the Driver ### Choosing a Model By default, `AgentLoop::default()` uses whatever LLM provider and model are configured in your environment. To use a specific provider or model, create the driver explicitly: ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Drivers\ToolCalling\ToolCallingDriver; use Cognesy\Polyglot\Inference\InferenceRuntime; use Cognesy\Polyglot\Inference\LLMProvider; use Cognesy\Events\Dispatchers\EventDispatcher; $events = new EventDispatcher(); $llm = LLMProvider::using('anthropic'); $loop = AgentLoop::default()->withDriver( new ToolCallingDriver( inference: InferenceRuntime::fromProvider($llm, events: $events), llm: $llm, events: $events, ) ); // @doctest id="3d8b" ``` ### ReAct Driver The `ReActDriver` implements the Thought/Action/Observation reasoning pattern. Instead of relying on native function-calling APIs, it prompts the model to produce structured decisions about what to do next. This can be useful with models that have weaker function-calling support, or when you want the model's reasoning to be explicitly visible: ```php use Cognesy\Agents\Drivers\ReAct\ReActDriver; use Cognesy\Events\Dispatchers\EventDispatcher; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\InferenceRuntime; use Cognesy\Polyglot\Inference\LLMProvider; $events = new EventDispatcher(); $llm = LLMProvider::new(); $inference = InferenceRuntime::fromProvider($llm, events: $events); $structuredOutput = StructuredOutputRuntime::fromProvider($llm, events: $events); $loop = AgentLoop::default()->withDriver(new ReActDriver( inference: $inference, structuredOutput: $structuredOutput, model: 'gpt-4o', )); // @doctest id="0e6f" ``` ## Testing Without an LLM The `FakeAgentDriver` lets you write deterministic agent tests by scripting the exact sequence of responses the "model" will produce. No API keys, no network calls, no flaky tests: ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Data\AgentState; use Cognesy\Agents\Drivers\Testing\FakeAgentDriver; use Cognesy\Agents\Drivers\Testing\ScenarioStep; // Script a two-step scenario: tool use, then final answer $driver = FakeAgentDriver::fromSteps( ScenarioStep::toolCall('weather', ['city' => 'Paris']), ScenarioStep::final('The weather in Paris is 72F and sunny.'), ); $loop = AgentLoop::default() ->withDriver($driver) ->withTool($weatherTool); $result = $loop->execute( AgentState::empty()->withUserMessage('Weather in Paris?') ); assert($result->finalResponse()->toString() === 'The weather in Paris is 72F and sunny.'); assert($result->stepCount() === 2); // @doctest id="0329" ``` You can also create a driver that always returns the same response, which is useful for simple unit tests: ```php $driver = FakeAgentDriver::fromResponses('Hello!', 'Goodbye!'); $loop = AgentLoop::default()->withDriver($driver); // @doctest id="a399" ``` The first execution returns "Hello!", the second returns "Goodbye!", and any subsequent executions repeat "Goodbye!". ## Using AgentBuilder When your agent needs multiple capabilities -- tools, guards, a specific LLM, custom hooks -- manual construction becomes verbose. `AgentBuilder` provides a declarative composition layer: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Bash\UseBash; use Cognesy\Agents\Capability\Core\UseGuards; use Cognesy\Agents\Capability\Core\UseLLMConfig; use Cognesy\Agents\Capability\Core\UseTools; use Cognesy\Polyglot\Inference\LLMProvider; $loop = AgentBuilder::base() ->withCapability(new UseLLMConfig( llm: LLMProvider::using('anthropic'), )) ->withCapability(new UseTools($weatherTool, $searchTool)) ->withCapability(new UseBash()) ->withCapability(new UseGuards( maxSteps: 15, maxTokens: 16384, maxExecutionTime: 120.0, )) ->build(); $result = $loop->execute($state); // @doctest id="ca60" ``` Each capability is a small, focused class that knows how to install its tools, hooks, and configuration onto the agent. They compose cleanly because they operate on a shared `CanConfigureAgent` interface without needing to know about each other. The `UseGuards` capability is particularly important for production use. It installs hooks that enforce step limits, token budgets, and execution time limits, preventing runaway agents from burning through your API quota. The defaults are 20 steps, 32768 tokens, and 300 seconds. See [AgentBuilder & Capabilities](13-agent-builder.md) for the full list of built-in capabilities and how to create your own. ## Next Steps - **[AgentBuilder & Capabilities](13-agent-builder.md)** -- Learn how capabilities compose and explore the full catalog (bash, file tools, subagents, summarization, task planning, structured output, and more). - **[Agent Templates](14-agent-templates.md)** -- Define agents in Markdown, YAML, or JSON when configuration should be data-driven. - **[Session Runtime](16-session-runtime.md)** -- Persist agent sessions for multi-turn chat interfaces and long-running workflows. ================================================================================ FILE: packages/agents/03-basic-concepts.md ================================================================================ # Basic Concepts The Agents package is built around a small set of immutable value objects that represent everything about an agent's lifecycle. Understanding these concepts is essential before working with any other feature of the system. ## AgentLoop The `AgentLoop` is the orchestrator at the heart of every agent. It drives a step-based execution cycle: call the LLM, execute any requested tools, evaluate stop conditions, and repeat until the agent is finished. Each iteration of the loop follows a well-defined lifecycle: ``` BeforeExecution -> [ BeforeStep -> UseTools -> AfterStep -> ShouldStop? ] -> AfterExecution // @doctest id="00d0" ``` The loop begins with a `BeforeExecution` phase where the execution state is initialized. It then enters a repeating cycle of steps. During each step, the loop fires a `BeforeStep` hook, hands control to the driver to call the LLM and execute any tool calls, fires an `AfterStep` hook, and then evaluates whether the agent should stop. If the model responds without requesting any tool calls (a "final response"), or if a stop signal has been emitted by a hook, the loop exits. An `AfterExecution` phase finalizes the state before it is returned. You can obtain a default loop with sensible defaults using the static constructor: ```php use Cognesy\Agents\AgentLoop; $loop = AgentLoop::default(); // @doctest id="5fff" ``` This creates a loop wired with the default tool-calling driver, an event dispatcher, and a pass-through interceptor. For more control, use the `AgentBuilder` to compose a loop with specific capabilities, tools, and hooks. ## AgentState `AgentState` is an immutable value object that carries everything about an agent's session and its current execution. Every method that modifies state returns a new instance, leaving the original untouched. This immutability makes the data flow through the loop predictable and safe for inspection at any point. The state is divided into two conceptual layers: **Session data** persists across executions. It represents the agent's long-lived identity and accumulated conversation history: | Property | Description | |---|---| | `agentId` | A unique identifier for this agent instance | | `parentAgentId` | The ID of the parent agent, if this is a sub-agent | | `context` | The `AgentContext` holding messages, system prompt, metadata, and response format | | `llmConfig` | Optional LLM configuration overrides | | `executionCount` | How many times this agent has been executed | | `createdAt` / `updatedAt` | Session timing timestamps | **Execution data** is transient. It exists only while the loop is running and is `null` between executions. The `execution` property holds an `ExecutionState` instance that tracks step results, the current step, timing, continuation signals, and the execution status. Creating an initial state is straightforward: ```php use Cognesy\Agents\Data\AgentState; $state = AgentState::empty() ->withSystemPrompt('You are a helpful assistant.') ->withUserMessage('What is the capital of France?'); // @doctest id="977b" ``` Because every `with*` method returns a new instance, you can chain calls fluently. The original `$state` is never modified. `AgentState` is the runtime state for a single agent loop. If you need to persist state across HTTP requests or manage multi-turn sessions, see the [Session Runtime](16-session-runtime.md) documentation for `AgentSession` and `SessionRuntime`. ## AgentContext `AgentContext` is the container for all conversation-related data that the agent sends to the LLM. It holds four pieces of information: - **MessageStore** -- the sectioned storage for conversation messages. Messages are organized into named sections (the default section holds the main conversation). The driver's message compiler reads from this store to build the final prompt. - **System prompt** -- the instruction text prepended to every LLM call. - **Metadata** -- arbitrary key-value pairs that hooks and capabilities can use to pass information through the pipeline without modifying messages. - **Response format** -- an optional structured output format that instructs the LLM to respond in a particular schema. You interact with the context indirectly through `AgentState` methods: ```php $state = AgentState::empty() ->withSystemPrompt('You are a research assistant.') ->withUserMessage('Summarize this article.') ->withMetadata('user_id', 42); // @doctest id="5999" ``` When the loop calls the LLM, the driver's message compiler (implementing `CanCompileMessages`) transforms the current `AgentContext` into the final `Messages` collection sent to the inference API. This compilation step is where features like message filtering, trace exclusion, and context summarization are applied. ## AgentStep An `AgentStep` is an immutable snapshot of a single loop iteration. After the driver calls the LLM and executes any tool calls, the results are bundled into an `AgentStep` and attached to the state. Each step captures: | Property | Description | |---|---| | `id` | A unique `AgentStepId` for this step | | `inputMessages` | The messages that were sent to the LLM | | `outputMessages` | The assistant's response and any tool result messages | | `inferenceResponse` | The raw LLM response, including token usage and finish reason | | `toolExecutions` | A `ToolExecutions` collection with the results of each executed tool call | | `errors` | An `ErrorList` aggregating any errors from tool execution or the step itself | The step's type is derived automatically from its contents: ```php use Cognesy\Agents\Enums\AgentStepType; $step->stepType(); // AgentStepType::FinalResponse // AgentStepType::ToolExecution // AgentStepType::Error // @doctest id="f977" ``` A step is classified as `ToolExecution` when the LLM requested tool calls, `Error` when any errors occurred during the step, and `FinalResponse` when the model produced a plain text answer with no tool calls and no errors. You can inspect a step's tool calls at two levels: `requestedToolCalls()` returns the tool calls the model asked for, while `executedToolCalls()` returns only those that were actually run (a tool call can be blocked by a hook before execution). ## StepExecution `StepExecution` wraps an `AgentStep` with timing and continuation metadata. When a step completes, the loop bundles the step together with its start and end timestamps and the continuation state at that point, then appends this `StepExecution` to the completed steps list. This separation keeps the `AgentStep` focused on what happened during the step (messages, tool calls, errors), while `StepExecution` records when it happened and whether a stop signal was active: ```php $stepExecution = $state->lastStepExecution(); $stepExecution->step(); // The underlying AgentStep $stepExecution->startedAt(); // DateTimeImmutable $stepExecution->completedAt(); // DateTimeImmutable $stepExecution->duration(); // float (seconds) $stepExecution->continuation(); // ExecutionContinuation snapshot $stepExecution->usage(); // Token usage for this step // @doctest id="a45c" ``` ## ExecutionState `ExecutionState` tracks the transient state of a single execution run. It is created fresh when the loop starts and is set to `null` on the `AgentState` once the execution completes. The execution state manages: - **Status** -- one of `Pending`, `InProgress`, `Completed`, `Stopped`, or `Failed` (see `ExecutionStatus` enum). - **Step history** -- a `StepExecutions` collection of all completed steps. - **Current step** -- the in-progress `AgentStep` before it is finalized. - **Continuation** -- an `ExecutionContinuation` object that collects stop signals and tracks whether a continuation has been requested. - **Timing** -- execution start and completion timestamps. You typically access execution data through convenience methods on `AgentState` rather than working with `ExecutionState` directly: ```php $state->status(); // ExecutionStatus::InProgress $state->stepCount(); // 3 $state->usage(); // Accumulated token usage across all steps $state->executionDuration();// Total wall-clock time in seconds $state->shouldStop(); // Whether the loop should terminate // @doctest id="28f9" ``` The `shouldStop()` logic follows a clear priority chain. If a stop signal has been emitted (by a hook such as `StepsLimitHook` or `FinishReasonHook`) and no continuation has been explicitly requested, the execution stops. If no stop signal exists but the current step has tool calls, the execution continues to process them. If neither condition applies -- meaning the model produced a final response with no tool calls -- the execution stops naturally. ## ToolExecution A `ToolExecution` is an immutable record of a single tool call's outcome. It captures the tool call that was requested, the result (success or failure), and precise timing: ```php $toolExec = $state->lastToolExecution(); $toolExec->name(); // 'search_web' $toolExec->args(); // ['query' => 'PHP 8.4 features'] $toolExec->hasError(); // false $toolExec->value(); // The successful return value $toolExec->error(); // null (or a Throwable on failure) $toolExec->wasBlocked(); // true if a hook blocked execution $toolExec->startedAt(); // DateTimeImmutable $toolExec->completedAt(); // DateTimeImmutable // @doctest id="7044" ``` Tool executions are collected within each `AgentStep` via its `toolExecutions()` method. When a tool call is blocked by a pre-execution hook, the `ToolExecution` is still recorded, but with a `Failure` result containing a `ToolExecutionBlockedException`. ================================================================================ FILE: packages/agents/04-controlling-the-loop.md ================================================================================ # Controlling the Loop The `AgentLoop` exposes two methods for running an agent: `execute()` for simple run-to-completion workflows, and `iterate()` for step-by-step observation. Both operate on an immutable `AgentState` and return the resulting state after the agent finishes. ## execute() vs iterate() ### Running to Completion The `execute()` method runs the full loop and returns the final state in a single call. This is the right choice for most application code where you simply need the agent's answer: ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Data\AgentState; $loop = AgentLoop::default(); $state = AgentState::empty() ->withSystemPrompt('You are a helpful assistant.') ->withUserMessage('What are the three laws of thermodynamics?'); $finalState = $loop->execute($state); echo $finalState->finalResponse()->toString(); // @doctest id="fcfc" ``` Internally, `execute()` is a thin wrapper around `iterate()` -- it simply consumes the iterator and returns the last yielded state. ### Stepping Through Execution The `iterate()` method returns a generator that yields the state after each completed step. This gives you the opportunity to observe progress, log intermediate results, update a UI, or apply custom logic between steps: ```php foreach ($loop->iterate($state) as $stepState) { $step = $stepState->currentStepOrLast(); $type = $step?->stepType(); echo sprintf( "Step %d: %s (%d tokens)\n", $stepState->stepCount(), $type?->value ?? 'unknown', $step?->usage()->total() ?? 0, ); } // @doctest id="ce2e" ``` Each yielded `$stepState` is a complete `AgentState` snapshot. You can inspect messages, tool executions, errors, and token usage at every point in the agent's run. The final yield includes the post-execution state after the `AfterExecution` hooks have fired. Use `execute()` for straightforward application logic. Use `iterate()` when you need progress updates, streaming indicators, step-level logging, or any form of real-time observation. ## Inspecting State After Execution Once the loop finishes, the returned `AgentState` provides a comprehensive set of accessors to understand what happened during the run. ### Execution Summary ```php $state->status(); // ExecutionStatus::Completed $state->stepCount(); // Number of completed steps $state->executionDuration(); // Total wall-clock time (seconds) $state->usage(); // Accumulated token usage across all steps $state->executionCount(); // How many times this agent has been executed // @doctest id="da39" ``` ### Step History Every completed step is recorded as a `StepExecution` in the execution's step history. You can iterate over all steps to review the full trace of the agent's reasoning: ```php foreach ($state->stepExecutions()->all() as $stepExecution) { $step = $stepExecution->step(); echo sprintf( "Step [%s]: %s (%.2fs)\n", $step->stepType()->value, $step->outputMessages()->toString(), $stepExecution->duration(), ); } // @doctest id="51c7" ``` For quick access to the most recent step: ```php $state->lastStep(); // The last completed AgentStep $state->lastStepType(); // AgentStepType enum value $state->lastStepUsage(); // Token usage for the last step $state->lastStepDuration(); // Duration of the last step (seconds) $state->lastStepErrors(); // ErrorList from the last step // @doctest id="9fad" ``` ### Tool Execution Details When the agent used tools during its run, you can drill into the execution details of each tool call: ```php $toolExec = $state->lastToolExecution(); if ($toolExec !== null) { echo $toolExec->name(); // Tool name, e.g. 'search_web' echo $toolExec->hasError(); // Whether the tool call failed echo $toolExec->value(); // The return value on success } // @doctest id="3a7a" ``` To see all tool executions from the last step: ```php foreach ($state->lastStepToolExecutions()->all() as $toolExec) { echo sprintf( "%s(%s) -> %s\n", $toolExec->name(), json_encode($toolExec->args()), $toolExec->hasError() ? 'ERROR: ' . $toolExec->errorMessage() : 'OK', ); } // @doctest id="6b5b" ``` ### Stop Reason Every execution ends for a reason. The stop reason tells you whether the agent completed naturally, hit a limit, encountered an error, or was stopped by an external request: ```php use Cognesy\Agents\Continuation\StopReason; $reason = $state->stopReason(); // StopReason enum match ($reason) { StopReason::Completed => 'Agent finished naturally', StopReason::FinishReasonReceived=> 'LLM signaled completion', StopReason::StepsLimitReached => 'Hit the maximum step count', StopReason::TokenLimitReached => 'Exceeded token budget', StopReason::TimeLimitReached => 'Exceeded time limit', StopReason::RetryLimitReached => 'Hit the maximum retry count', StopReason::StopRequested => 'A hook requested a stop', StopReason::ErrorForbade => 'An error prevented continuation', StopReason::UserRequested => 'The user requested a stop', default => 'Unknown reason', }; // @doctest id="a432" ``` You can also retrieve the full stop signal for additional context: ```php $signal = $state->stopSignal(); $signal->reason; // StopReason enum $signal->message; // Human-readable explanation $signal->context; // Array of contextual data $signal->source; // The class that emitted the signal // @doctest id="865e" ``` ## Reading the Response `AgentState` provides two convenience methods for extracting the agent's output, each suited to different situations. ### finalResponse() Returns the output messages from the last step, but only if that step was a `FinalResponse` (the model answered without requesting tool calls). If the execution ended mid-tool-use or with an error, this returns an empty `Messages` collection: ```php if ($state->hasFinalResponse()) { echo $state->finalResponse()->toString(); } // @doctest id="f9f7" ``` ### currentResponse() Returns the most recent visible output regardless of step type. It first checks for a final response; if none exists, it falls back to the output of the current or last step. This is useful during `iterate()` loops where you want to show the latest output even if the agent has not finished: ```php echo $state->currentResponse()->toString(); // @doctest id="73c6" ``` A typical pattern after execution combines both: ```php $text = $state->hasFinalResponse() ? $state->finalResponse()->toString() : $state->currentResponse()->toString(); // @doctest id="6988" ``` ## Listening to Events The `AgentLoop` dispatches events at every significant point in the execution lifecycle. You can subscribe to specific event types or listen to all events with a wiretap. ### Subscribing to Specific Events Use `onEvent()` to register a listener for a particular event class. The listener receives the fully-typed event object: ```php use Cognesy\Agents\Events\AgentStepCompleted; $loop->onEvent(AgentStepCompleted::class, function (AgentStepCompleted $event) { echo sprintf( "Step %d: %d tokens, finish=%s (%.2fms)\n", $event->stepNumber, $event->usage->total(), $event->finishReason?->value ?? 'n/a', $event->durationMs, ); }); // @doctest id="a865" ``` ### Wiretap (All Events) Use `wiretap()` to observe every event the loop dispatches. This is invaluable for debugging and logging: ```php $loop->wiretap(function (object $event) { echo get_class($event) . "\n"; }); // @doctest id="5879" ``` ### Available Events The loop emits the following events during execution: | Event | When | |---|---| | `AgentExecutionStarted` | The loop begins a new execution | | `AgentStepStarted` | A new step is about to begin | | `InferenceRequestStarted` | An LLM request is being sent | | `InferenceResponseReceived` | An LLM response has arrived | | `ToolCallStarted` | A tool call is about to execute | | `ToolCallCompleted` | A tool call has finished | | `ToolCallBlocked` | A hook blocked a tool call | | `AgentStepCompleted` | A step has finished (includes usage and timing) | | `ContinuationEvaluated` | The loop evaluated whether to continue | | `StopSignalReceived` | A stop signal was emitted | | `TokenUsageReported` | Token usage was recorded for a step | | `AgentExecutionStopped` | The loop is stopping (includes stop reason) | | `AgentExecutionCompleted` | The execution has fully finished | | `AgentExecutionFailed` | The execution ended with an error | Events are dispatched through the loop's `EventDispatcher`. If you are using the `AgentBuilder`, the builder can wire a parent event handler so that events propagate up to your application's event system. ## Debugging Execution For quick diagnostics, `AgentState` provides a `debug()` method that returns an array summarizing the execution: ```php $info = $state->debug(); // [ // 'status' => ExecutionStatus::Completed, // 'executionCount' => 1, // 'hasExecution' => true, // 'executionId' => '550e8400-e29b-41d4-a716-446655440000', // 'steps' => 3, // 'continuation' => 'No Stop Signals; Continuation Requested: No', // 'hasErrors' => false, // 'errors' => ErrorList(...), // 'usage' => ['input' => 150, 'output' => 42], // ] // @doctest id="f1e4" ``` This is particularly useful when logging or when you need a quick overview of what happened without drilling into individual steps. ================================================================================ FILE: packages/agents/05-tools.md ================================================================================ # Tools Tools are the primary mechanism through which an agent interacts with the outside world. When you give an agent a set of tools, the LLM decides which tool to call, with what arguments, and when. The agent loop orchestrates this cycle automatically: the LLM requests a tool call, the framework executes it, feeds the result back, and the LLM continues reasoning until it produces a final response. This page covers the full tool API -- from creating and registering tools, through the contracts that govern them, to the execution lifecycle and error handling. For practical step-by-step guidance on building your own tools, see [Building Tools](06-building-tools.md). ## Creating Tools With FunctionTool The fastest way to create a tool is to wrap any PHP callable with `FunctionTool::fromCallable()`. The tool name, description, and parameter schema are all generated automatically from the function signature using reflection: ```php use Cognesy\Agents\Tool\Tools\FunctionTool; use Cognesy\Schema\Attributes\Description; #[Description('Look up the current weather for a given city')] function get_weather( #[Description('City name, e.g. "Paris"')] string $city, ): string { return "Weather in {$city}: 72F, sunny"; } $tool = FunctionTool::fromCallable(get_weather(...)); // @doctest id="c8c4" ``` The `#[Description]` attribute on the function provides the tool description that the LLM sees. The same attribute on parameters documents individual arguments in the generated JSON schema. Named functions produce meaningful tool names; closures work too, but you should prefer named functions for clarity. > **Tip:** `FunctionTool` is the recommended starting point for most projects. It handles schema generation, argument passing, and result wrapping with zero boilerplate. ## The Tools Collection Tools are collected in the immutable `Tools` value object. Pass any number of `ToolInterface` implementations to its constructor, and the collection indexes them by name: ```php use Cognesy\Agents\Collections\Tools; use Cognesy\Agents\Tool\Tools\FunctionTool; function get_weather(string $city): string { return "Weather in {$city}: 72F, sunny"; } $tools = new Tools( FunctionTool::fromCallable(get_weather(...)), ); // @doctest id="d17f" ``` ### Querying the Collection The `Tools` collection provides a rich query API for inspecting registered tools at runtime: ```php $tools->has('get_weather'); // bool -- check if a tool is registered $tools->get('get_weather'); // ToolInterface -- retrieve by name (throws if missing) $tools->names(); // ['get_weather', ...] -- all registered names $tools->count(); // int -- number of tools $tools->isEmpty(); // bool -- true when collection is empty $tools->all(); // array -- keyed by name $tools->descriptions(); // [['name' => ..., 'description' => ...], ...] $tools->toToolSchema(); // ToolDefinitions -- schema collection sent to the LLM // @doctest id="e312" ``` The `descriptions()` method returns an array of compact summaries (name and description) for each tool. The `toToolSchema()` method returns the full OpenAI-compatible function-calling schema array that gets sent to the LLM as part of the inference request. ### Immutable Mutators The `Tools` collection is immutable. Every mutation returns a new instance, leaving the original unchanged: ```php // Add a single tool $tools = $tools->withTool($anotherTool); // Add multiple tools at once $tools = $tools->withTools($toolA, $toolB, $toolC); // Remove a tool by name $tools = $tools->withToolRemoved('get_weather'); // Merge two collections (tools from $other override same-named tools) $tools = $tools->merge($otherToolsCollection); // @doctest id="dc0a" ``` ## Registering Multiple Tools Pass multiple tools to the `Tools` constructor. The LLM chooses which tool to call on each turn: ```php use Cognesy\Agents\Tool\Tools\FunctionTool; use Cognesy\Agents\Collections\Tools; use Cognesy\Schema\Attributes\Description; #[Description('Get the current weather for a city')] function get_weather( #[Description('City name')] string $city, ): string { return "Weather in {$city}: 72F, sunny"; } #[Description('Evaluate a math expression')] function calculate( #[Description('Math expression to evaluate')] string $expression, ): string { return (string) eval("return {$expression};"); } $tools = new Tools( FunctionTool::fromCallable(get_weather(...)), FunctionTool::fromCallable(calculate(...)), ); // @doctest id="8009" ``` ## Attaching Tools to an Agent There are two ways to give tools to an agent: directly on the `AgentLoop`, or through the `AgentBuilder` capability system. ### Direct Assignment The `AgentLoop` provides `withTools()` (replacing the entire collection) and `withTool()` (appending a single tool) methods: ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Data\AgentState; $loop = AgentLoop::default()->withTools($tools); $state = AgentState::empty()->withUserMessage('What is the weather in Paris?'); $result = $loop->execute($state); echo $result->finalResponse()->toString(); // @doctest id="e7dc" ``` You can also add tools one at a time: ```php $loop = AgentLoop::default() ->withTool(FunctionTool::fromCallable(get_weather(...))) ->withTool(FunctionTool::fromCallable(calculate(...))); // @doctest id="1a6f" ``` ### Via the AgentBuilder The `UseTools` capability integrates tools through the builder's composition layer. This is the preferred approach when assembling agents from reusable capabilities: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Core\UseTools; $loop = AgentBuilder::base() ->withCapability(new UseTools( FunctionTool::fromCallable(get_weather(...)), FunctionTool::fromCallable(calculate(...)), )) ->build(); // @doctest id="f2de" ``` `UseTools` merges the provided tools into any tools already registered on the builder, so you can combine multiple `UseTools` capabilities without overwriting earlier registrations. ## Tool Contracts The tool system is built on a small set of interfaces. Understanding them helps when you need to go beyond the basics and build custom tool implementations. ### ToolInterface Every tool implements `ToolInterface`, which defines the three things the framework needs from a tool: ```php interface ToolInterface { public function use(mixed ...$args): Result; // Execute the tool public function toToolSchema(): ToolDefinition; // Schema sent to the LLM public function descriptor(): CanDescribeTool; // Metadata accessor } // @doctest id="32d7" ``` The `use()` method receives the arguments that the LLM provided and returns a `Result` object wrapping either a success value or a failure. The `toToolSchema()` method returns a `ToolDefinition` value object describing the tool's name, description, and parameters. The `descriptor()` method returns the tool's identity and documentation. ### CanDescribeTool The descriptor interface provides identity and documentation at two levels of detail: ```php interface CanDescribeTool { public function name(): string; // Tool name (e.g., 'read_file') public function description(): string; // What the tool does public function metadata(): array; // Lightweight info for browsing/discovery public function instructions(): array; // Full specification with parameters } // @doctest id="6146" ``` **`metadata()`** returns a compact summary suitable for listing tools. The default implementation includes `name` and `summary` keys, with an optional `namespace` key for namespaced tool names (e.g., `file.read` yields namespace `file`). **`instructions()`** returns the complete specification including parameter definitions and return type. This two-level design supports tool registries where an agent can browse available tools before loading their full documentation. ### CanAccessAgentState Tools that need to read the current agent execution state implement `CanAccessAgentState`. The framework calls `withAgentState()` before each invocation, passing in the current `AgentState`. The method returns a new (cloned) instance with the state injected: ```php interface CanAccessAgentState { public function withAgentState(AgentState $state): static; } // @doctest id="8212" ``` State is read-only from the tool's perspective. The `withAgentState()` method clones the tool and injects the state, ensuring that tool instances remain safe to reuse across invocations. Modifications to agent state should be handled by the agent's state processors, not by tools directly. ### CanAccessToolCall Tools that need access to their invocation context (the raw `ToolCall` object with its ID and arguments) implement `CanAccessToolCall`. This is useful for correlation, tracing, logging, and subagent tools that emit events: ```php interface CanAccessToolCall { public function withToolCall(ToolCall $toolCall): static; } // @doctest id="272e" ``` Like `CanAccessAgentState`, this method clones the tool and injects the `ToolCall`, preserving immutability. ### CanManageTools The `CanManageTools` interface defines the contract for mutable tool registries that support lazy instantiation through factories: ```php interface CanManageTools { public function register(ToolInterface $tool): void; public function registerFactory(string $name, callable $factory): void; public function has(string $name): bool; public function get(string $name): ToolInterface; public function all(): array; public function names(): array; public function count(): int; } // @doctest id="ff75" ``` The `ToolRegistry` class implements this interface and is used internally by the `ToolsTool` capability for dynamic tool discovery. The `registerFactory()` method accepts a `callable(): ToolInterface` that is only invoked when the tool is first requested, enabling lazy loading of expensive tools. ### CanExecuteToolCalls The `CanExecuteToolCalls` interface defines the contract for executing a batch of tool calls against a given agent state: ```php interface CanExecuteToolCalls { public function executeTools(ToolCalls $toolCalls, AgentState $state): ToolExecutions; } // @doctest id="63cf" ``` The `ToolExecutor` class is the default implementation, and the `AgentLoop` accepts a custom executor via `withToolExecutor()`. ## The Tool Class Hierarchy The framework provides a layered set of abstract base classes. Each layer adds a specific concern, so you can extend at the level of abstraction that fits your use case: | Class | What it adds | When to use | |---|---|---| | `SimpleTool` | Descriptor + result wrapper + `$this->arg()` helper | Full manual control over everything | | `ReflectiveSchemaTool` | Auto-generates `toToolSchema()` via reflection | When you want schema from `__invoke` signature | | `FunctionTool` | Wraps a callable with cached reflective schema | Typed callable tools (most common) | | `StateAwareTool` | `withAgentState()` / `$this->agentState` | When you need to read execution state | | `BaseTool` | State + reflective schema + default metadata/instructions | State-aware class-based tools | | `ContextAwareTool` | State + `withToolCall()` / `$this->toolCall` | When you need raw tool call context | The inheritance chain flows as follows: ``` SimpleTool # descriptor, result wrapping, arg() +-- ReflectiveSchemaTool # auto toToolSchema() from __invoke | +-- FunctionTool # wraps callable + cached schema +-- StateAwareTool # + CanAccessAgentState +-- BaseTool # + reflective schema + metadata/instructions +-- ContextAwareTool # + CanAccessToolCall // @doctest id="ab74" ``` For most projects, `FunctionTool` or `BaseTool` is all you need. See [Building Tools](06-building-tools.md) for practical guidance, and [Building Tools: Advanced Patterns](17-building-tools-advanced.md) for lower-level patterns. ## How Tool Execution Works The `ToolExecutor` manages the full lifecycle of a tool call. Understanding this flow helps when debugging or customizing tool behavior. ### 1. Schema Delivery The `Tools` collection serializes all tool schemas via `toToolSchema()` and sends them to the LLM as part of the inference request. Each schema follows the OpenAI function-calling format: ```php [ 'type' => 'function', 'function' => [ 'name' => 'get_weather', 'description' => 'Get the current weather for a city', 'parameters' => [ 'type' => 'object', 'properties' => [...], 'required' => [...], ], ], ] // @doctest id="7cca" ``` ### 2. Tool Call Parsing When the LLM responds with one or more tool calls, the framework parses them into `ToolCall` objects containing the tool name, call ID, and arguments. ### 3. Hook Interception (Before) Before executing each tool call, the `ToolExecutor` runs the `beforeToolUse` lifecycle hook via the interceptor. Hooks can: - **Modify the tool call** (e.g., rewrite arguments). - **Modify the agent state** (e.g., inject context). - **Block execution entirely** by marking the hook context as blocked. When blocked, a `ToolExecution::blocked()` result is returned without invoking the tool. If the `stopOnToolBlock` option is enabled on the `ToolExecutor`, the entire batch stops after the first blocked tool call. ### 4. Tool Preparation The executor looks up the tool by name from the `Tools` collection. If the tool implements `CanAccessAgentState`, a clone with the current `AgentState` injected is created. If it implements `CanAccessToolCall`, the raw `ToolCall` is injected the same way. This ensures tools are stateless and safe for concurrent use. ### 5. Argument Validation Required parameters declared in the tool's schema are checked against the provided arguments. Missing required parameters produce a `Result::failure()` with an `InvalidToolArgumentsException` without invoking the tool. The LLM sees the error message and can retry with corrected arguments. ### 6. Execution The tool's `use()` method is called with the LLM-provided arguments. For tools extending `SimpleTool`, this delegates to `__invoke()`, and the return value is automatically wrapped in `Result::success()`. Any exception (except `AgentStopException`) is caught and wrapped in `Result::failure()`. ### 7. Event Emission The executor dispatches `ToolCallStarted` and `ToolCallCompleted` events around each execution. These events carry timing information and success/failure status, making them useful for logging, metrics, and observability. ### 8. Hook Interception (After) The `afterToolUse` lifecycle hook runs, allowing inspection or modification of the execution result. Hooks can replace the `ToolExecution` entirely (e.g., to sanitize output or add metadata). ### 9. Result Formatting Tool execution results are formatted as messages and appended to the conversation. The LLM sees these results on its next turn. ### 10. Loop Continuation The cycle repeats until the LLM responds without requesting any tool calls, at which point the agent produces its final response. ## The ToolExecution Object Each tool invocation produces a `ToolExecution` value object that captures the complete execution record: ```php $execution->id(); // ToolExecutionId -- unique identifier $execution->toolCall(); // ToolCall -- the original call from the LLM $execution->name(); // string -- tool name shortcut $execution->args(); // array -- arguments shortcut $execution->result(); // Result -- success or failure $execution->value(); // mixed -- unwrapped success value, or null $execution->error(); // ?Throwable -- exception on failure, or null $execution->errorMessage(); // string -- error message, or empty string $execution->hasError(); // bool -- true if execution failed $execution->wasBlocked(); // bool -- true if blocked by a hook $execution->startedAt(); // DateTimeImmutable $execution->completedAt(); // DateTimeImmutable $execution->toArray(); // array -- serializable representation // @doctest id="1898" ``` The `ToolExecutions` collection aggregates multiple executions from a single step and provides batch-level queries: ```php $executions->all(); // ToolExecution[] $executions->first(); // ?ToolExecution $executions->hasExecutions(); // bool $executions->hasErrors(); // bool $executions->havingErrors(); // ToolExecution[] -- only failed ones $executions->errors(); // ErrorList $executions->toolCalls(); // ToolCalls -- extract original calls // @doctest id="2867" ``` ## Error Handling Tool failures are handled gracefully by default. If a tool throws an exception, the framework catches it, wraps it in a `Result::failure()` with a `ToolExecutionException`, and reports the error back to the LLM as a tool result. This lets the LLM retry with different arguments or fall back to an alternative approach. The `AgentStopException` is the one exception that is never caught. Throwing it from within a tool immediately stops the agent loop with the provided `StopSignal`. This is the canonical way for a tool to halt execution programmatically. ### Strict Failure Mode You can change the default behavior with the `throwOnToolFailure` option on the `ToolExecutor`. When enabled, tool exceptions propagate and halt the agent loop instead of being fed back to the LLM: ```php $executor = new ToolExecutor( tools: $tools, events: $events, interceptor: $interceptor, throwOnToolFailure: true, ); // @doctest id="1555" ``` ### Stopping on Blocked Tools The `stopOnToolBlock` option causes the executor to stop processing remaining tool calls in a batch when a hook blocks the first one: ```php $executor = new ToolExecutor( tools: $tools, events: $events, interceptor: $interceptor, stopOnToolBlock: true, ); // @doctest id="0ee3" ``` ## The Tool Registry For scenarios where tools are numerous or expensive to instantiate, the `ToolRegistry` provides a mutable, lazy-loading container that implements `CanManageTools`: ```php use Cognesy\Agents\Tool\ToolRegistry; $registry = new ToolRegistry(); // Register a tool instance directly $registry->register($myTool); // Register a factory for lazy instantiation $registry->registerFactory('expensive_tool', function () { return new ExpensiveTool(/* ... */); }); // The tool is only instantiated when first requested $tool = $registry->get('expensive_tool'); // @doctest id="602a" ``` The `ToolRegistry` is used internally by the `ToolsTool` capability, which exposes a meta-tool that lets the LLM browse, search, and inspect available tools at runtime. ## FakeTool for Testing When testing agent behavior, use `FakeTool` to create tools with predetermined responses. This avoids external dependencies and makes tests deterministic. ### Static Responses The simplest form returns the same value regardless of arguments: ```php use Cognesy\Agents\Tool\Tools\FakeTool; $tool = FakeTool::returning('search', 'Search the web', 'result text'); // @doctest id="f188" ``` ### Dynamic Responses Pass a callable handler for responses that depend on the arguments: ```php $tool = new FakeTool( name: 'search', description: 'Search the web', handler: fn(string $query) => "Results for: {$query}", ); // @doctest id="8dc5" ``` ### Full Customization `FakeTool` also accepts optional `schema`, `metadata`, and `fullSpec` arrays for complete control over how the fake tool presents itself: ```php $tool = new FakeTool( name: 'search', description: 'Search the web', handler: fn(string $query) => "Results for: {$query}", schema: [ 'type' => 'function', 'function' => [ 'name' => 'search', 'description' => 'Search the web', 'parameters' => [ 'type' => 'object', 'properties' => [ 'query' => ['type' => 'string', 'description' => 'Search query'], ], 'required' => ['query'], ], ], ], metadata: [ 'namespace' => 'web', 'tags' => ['search'], ], fullSpec: [ 'parameters' => [ 'query' => 'The search query string', ], 'returns' => 'Search results as a string', ], ); // @doctest id="6700" ``` When no custom schema is provided, `FakeTool` generates a minimal schema with an empty `properties` object, which is sufficient for most testing scenarios. ## Next Steps - [Building Tools](06-building-tools.md) -- practical guide to creating tools with `FunctionTool` and `BaseTool` - [Building Tools: Advanced Patterns](17-building-tools-advanced.md) -- `ContextAwareTool`, `SimpleTool`, descriptors, and schema strategies - [Hooks](08-hooks.md) -- intercepting tool calls with `beforeToolUse` and `afterToolUse` hooks ================================================================================ FILE: packages/agents/06-building-tools.md ================================================================================ # Building Tools This page walks through the two recommended paths for creating tools in the Agents package. Most projects only need one of these: - **`FunctionTool::fromCallable()`** -- wrap any callable and get typed parameters with auto-generated schema. - **`BaseTool`** -- extend a base class for tools that need access to agent state or custom behavior. For lower-level patterns like `ContextAwareTool`, `SimpleTool`, and custom descriptors, see [Building Tools: Advanced Patterns](17-building-tools-advanced.md). ## FunctionTool (Recommended) `FunctionTool` is the fastest path to a working tool. It uses PHP reflection to extract the tool name from the function name, the description from the `#[Description]` attribute, and the parameter schema from typed arguments. There is nothing to configure manually. ### Basic Usage ```php use Cognesy\Agents\Tool\Tools\FunctionTool; use Cognesy\Schema\Attributes\Description; #[Description('Look up the current weather for a given city')] function get_weather( #[Description('City name, e.g. "Paris"')] string $city, ): string { return "Weather in {$city}: 72F, sunny"; } $tool = FunctionTool::fromCallable(get_weather(...)); // @doctest id="b330" ``` The generated tool will have the name `get_weather`, the description from the function-level `#[Description]` attribute, and a JSON schema with a required `city` string parameter documented with its own description. ### Closures and Anonymous Functions Closures work too, though the generated tool name will be less meaningful. You can use `#[Description]` on both the closure and its parameters: ```php $tool = FunctionTool::fromCallable( #[Description('Search the web for a query')] function ( #[Description('Search query string')] string $query, #[Description('Maximum number of results')] int $limit = 10, ): string { // perform search... return "Results for: {$query}"; } ); // @doctest id="76f8" ``` ### Multiple Parameters and Types `FunctionTool` supports all common PHP types. Optional parameters (those with default values) are not marked as required in the generated schema: ```php #[Description('Create a calendar event')] function create_event( #[Description('Event title')] string $title, #[Description('Start date in YYYY-MM-DD format')] string $date, #[Description('Duration in minutes')] int $duration = 60, #[Description('Whether to send reminders')] bool $remind = true, ): string { return "Created: {$title} on {$date}"; } $tool = FunctionTool::fromCallable(create_event(...)); // @doctest id="24f6" ``` ### Using Static or Instance Methods Any callable works -- static methods, instance methods, and invokable objects: ```php use Cognesy\Schema\Attributes\Description; class WeatherService { #[Description('Get weather forecast for a city')] public function forecast( #[Description('City name')] string $city, #[Description('Number of days')] int $days = 3, ): string { return "Forecast for {$city}, next {$days} days: sunny"; } } $service = new WeatherService(); $tool = FunctionTool::fromCallable($service->forecast(...)); // @doctest id="1024" ``` ### How Schema Generation Works When you call `FunctionTool::fromCallable()`, the factory: 1. Uses `CallableSchemaFactory` to extract the function name, description, and parameter types via reflection. 2. Converts the schema to a JSON Schema array via `SchemaFactory`. 3. Caches the JSON schema on the `FunctionTool` instance so reflection only happens once. 4. Wraps the callable in a `Closure` for consistent invocation. The generated schema follows the OpenAI function-calling format and includes parameter types, descriptions, and required/optional status derived from PHP defaults. ### Accessing the Underlying Callable If you need to retrieve the original callable (for example, for testing), use the `function()` method: ```php $callback = $tool->function(); // Returns the Closure $result = $callback('Paris'); // @doctest id="527d" ``` ## BaseTool (State-Aware Class Tool) Use `BaseTool` when you need a class-based tool that can access the current `AgentState` during execution. This is the right choice when your tool needs to read conversation history, check execution metadata, or interact with other parts of the agent's runtime context. ### Basic Usage Every `BaseTool` subclass must implement `__invoke(mixed ...$args)`. Because `SimpleTool` (the root of the hierarchy) declares `__invoke` with a variadic `mixed` signature, all subclasses must keep this exact signature. Use `$this->arg()` to extract named or positional parameters from the args array. ```php use Cognesy\Agents\Tool\Tools\BaseTool; use Cognesy\Polyglot\Inference\Data\ToolDefinition; use Cognesy\Utils\JsonSchema\JsonSchema; use Cognesy\Utils\JsonSchema\ToolSchema; class WeatherTool extends BaseTool { public function __construct() { parent::__construct( name: 'weather', description: 'Get the current weather for a city', ); } public function __invoke(mixed ...$args): string { $city = (string) $this->arg($args, 'city', 0, ''); return "Weather in {$city}: 72F, sunny"; } public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') ->withProperties([ JsonSchema::string('city', 'City name'), ]) ->withRequiredProperties(['city']) )->toArray()); } } // @doctest id="09cb" ``` ### Why Override `toToolSchema()`? `BaseTool` includes reflective schema support via the `HasReflectiveSchema` trait, which can auto-generate a schema from the `__invoke` method signature. However, because `__invoke` must use the `mixed ...$args` signature, the auto-generated schema will describe a single variadic `mixed` parameter -- not useful for production prompts. You should almost always override `toToolSchema()` to declare the parameters the LLM should provide. ### Defining Parameters with JsonSchema The `JsonSchema` class provides a fluent API for building parameter schemas without writing raw arrays. It supports all JSON Schema types: ```php use Cognesy\Polyglot\Inference\Data\ToolDefinition; use Cognesy\Utils\JsonSchema\JsonSchema; use Cognesy\Utils\JsonSchema\ToolSchema; class MyTool extends BaseTool { public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') ->withProperties([ JsonSchema::string('query', 'Search query'), JsonSchema::integer('limit', 'Max results to return'), JsonSchema::boolean('verbose', 'Include detailed output'), JsonSchema::enum('format', ['json', 'text', 'csv'], 'Output format'), JsonSchema::array('tags') ->withItemSchema(JsonSchema::string()), ]) ->withRequiredProperties(['query']) )->toArray()); } } // @doctest id="1987" ``` Available `JsonSchema` factory methods include: `string()`, `integer()`, `number()`, `boolean()`, `enum()`, `array()`, `object()`, and `any()`. Each accepts a name, description, and optional configuration like nullability. ### Extracting Arguments with `$this->arg()` The `arg()` helper resolves arguments by trying three sources in order: named key, positional index, then default value. This means your tool works correctly whether the LLM passes arguments by name (the typical case) or by position in tests: ```php public function __invoke(mixed ...$args): string { $query = (string) $this->arg($args, 'query', 0, ''); $limit = (int) $this->arg($args, 'limit', 1, 10); $verbose = (bool) $this->arg($args, 'verbose', 2, false); // ... perform search } // @doctest id="c47f" ``` The lookup order is: `$args['query']` first, then `$args[0]`, then the default `''`. ### Accessing Agent State `BaseTool` extends `StateAwareTool`, so the current `AgentState` is available as `$this->agentState` during execution. The framework injects the state automatically before each invocation -- you do not need to set it yourself: ```php public function __invoke(mixed ...$args): string { // Access conversation step count, execution metadata, etc. $stepCount = $this->agentState?->stepCount() ?? 0; return "Processed after {$stepCount} steps in the conversation."; } // @doctest id="b997" ``` State is read-only from the tool's perspective. The framework clones the tool and injects the state before each call, so tools are safe to use across multiple invocations without shared mutable state. ### Constructor Defaults The `BaseTool` constructor accepts optional `name` and `description` parameters. If `name` is omitted, it defaults to the fully qualified class name. If `description` is omitted, it defaults to an empty string: ```php // Explicit naming (recommended for clear LLM prompts) parent::__construct( name: 'file.read', description: 'Read a file from disk', ); // Class-name fallback (less readable in LLM prompts) parent::__construct(); // @doctest id="6820" ``` ### Custom Metadata and Instructions `BaseTool` provides default implementations of `metadata()` and `instructions()` that derive values from the tool name and description. Override them when your tool needs richer documentation for tool registries or browsing: ```php class MyTool extends BaseTool { public function metadata(): array { return [ 'name' => $this->name(), 'summary' => 'Search across indexed documents', 'namespace' => 'search', 'tags' => ['retrieval', 'rag'], ]; } public function instructions(): array { return [ 'name' => $this->name(), 'description' => $this->description(), 'parameters' => [ 'query' => 'The search query. Supports boolean operators.', 'limit' => 'Maximum number of results. Default: 10.', ], 'returns' => 'JSON string with search results', 'notes' => ['Results are sorted by relevance score'], ]; } } // @doctest id="d585" ``` The default `metadata()` implementation supports automatic namespace extraction from dotted tool names (e.g., `file.read` extracts namespace `file`) and automatic summary extraction from the first sentence of the description. The `instructions()` method returns the full specification including the reflective parameter schema. This two-level design supports the `ToolsTool` registry pattern where agents can discover tools without loading their complete documentation. ## The `__invoke` Signature Constraint A common question is why `BaseTool` subclasses cannot declare typed parameters on `__invoke`. The answer is a PHP language constraint: `SimpleTool` (the abstract root of the hierarchy) declares `abstract public function __invoke(mixed ...$args): mixed`, and PHP does not allow child classes to narrow the parameter types of an inherited method signature. This means you cannot write: ```php // This will NOT work -- PHP fatal error public function __invoke(string $city): string { ... } // @doctest id="e3d3" ``` Instead, use `$this->arg()` to extract named or positional parameters: ```php public function __invoke(mixed ...$args): string { $city = (string) $this->arg($args, 'city', 0, ''); return "Weather in {$city}: 72F, sunny"; } // @doctest id="b8b5" ``` If you want typed parameters with compile-time safety and auto-generated schema, use `FunctionTool::fromCallable()` instead. ## Testing Your Tools ### FakeTool for Loop Testing When writing tests for agent behavior, use `FakeTool` to create tools with predetermined responses. This lets you test the agent loop without real tool implementations: ```php use Cognesy\Agents\Tool\Tools\FakeTool; // Simple static return value $tool = FakeTool::returning('search', 'Search the web', 'result text'); // Dynamic handler for input-dependent responses $tool = new FakeTool( name: 'calculator', description: 'Evaluate math expressions', handler: fn(string $expression) => (string) eval("return {$expression};"), ); // @doctest id="0005" ``` ### Testing FunctionTool Directly You can invoke a `FunctionTool` directly without the agent loop: ```php $tool = FunctionTool::fromCallable(get_weather(...)); // Via the function() accessor $result = ($tool->function())('Paris'); assert($result === 'Weather in Paris: 72F, sunny'); // Via the use() method (returns a Result object) $result = $tool->use(city: 'Paris'); assert($result->isSuccess()); assert($result->unwrap() === 'Weather in Paris: 72F, sunny'); // @doctest id="769c" ``` ### Testing BaseTool Subclasses Instantiate the tool and call it directly. If the tool reads `$this->agentState`, inject a state first: ```php $tool = new WeatherTool(); // Without state (agentState will be null) $result = $tool('Paris'); // With state injected $state = AgentState::empty()->withUserMessage('test'); $tool = $tool->withAgentState($state); $result = $tool('Paris'); // @doctest id="e868" ``` ## Which Approach Should I Use? | Approach | Use when | Schema strategy | State access | |---|---|---|---| | `FunctionTool` | You have a callable with typed parameters | Auto-generated from reflection | No | | `BaseTool` | You need agent state access or class-based organization | Override `toToolSchema()` manually | Yes (`$this->agentState`) | | `ContextAwareTool` | You need raw `ToolCall` access for tracing | Override `toToolSchema()` manually | Yes (both) | | `SimpleTool` | You want full low-level control over everything | Override `toToolSchema()` manually | No | For the vast majority of use cases, `FunctionTool` is the right choice. Reach for `BaseTool` when you need `AgentState` access, and `ContextAwareTool` only when you also need the raw `ToolCall` for correlation or tracing. ## Next Steps - [Tools](05-tools.md) -- full reference for the tool system, contracts, and execution lifecycle - [Building Tools: Advanced Patterns](17-building-tools-advanced.md) -- `ContextAwareTool`, `SimpleTool`, custom descriptors, and schema strategies ================================================================================ FILE: packages/agents/07-context-and-compilers.md ================================================================================ # Agent Context & Message Compilers ## Introduction Every agent maintains a rich context that accumulates messages, metadata, system prompts, and response format preferences throughout its lifetime. Before each LLM call, a **message compiler** decides exactly which messages from this context should be sent to the model. This separation of storage from presentation is a deliberate architectural choice. The `AgentContext` acts as the single source of truth for all conversation data, while the compiler acts as a lens -- selecting, filtering, and arranging messages for each individual inference call. You can swap compilers without touching the underlying data, and you can modify the data without worrying about how it will be presented. > **Key Insight:** Think of `AgentContext` as a database and the compiler as a query. The database stores everything; the query decides what the model actually sees. ## AgentContext `AgentContext` is the immutable container at the heart of agent state. It is declared as `final readonly`, ensuring that every modification produces a new instance rather than mutating existing data. This immutability guarantee makes agent state safe to pass through hook pipelines and across execution boundaries without risk of unintended side effects. The context holds four distinct concerns: | Concern | Type | Description | |---------|------|-------------| | **MessageStore** | `MessageStore` | A sectioned store of all conversation messages, organized by named sections (e.g., `messages`, `buffer`, `summary`) | | **Metadata** | `Metadata` | Arbitrary key-value data carried across the execution -- session IDs, user preferences, feature flags, or any application-specific state | | **System Prompt** | `string` | The system-level instruction sent to the model that defines its behavior and persona | | **ResponseFormat** | `ResponseFormat` | Optional structured output format constraints (JSON schema, etc.) that guide the model's response structure | In normal usage, you interact with context through `AgentState` rather than constructing `AgentContext` directly: ```php $state = AgentState::empty() ->withSystemPrompt('You are a helpful assistant.') ->withMetadata('session_id', 'abc'); // Read context values $state->context()->systemPrompt(); // 'You are a helpful assistant.' $state->context()->metadata(); // Metadata instance $state->context()->messages(); // Messages from the DEFAULT section $state->context()->store(); // Full MessageStore with all sections // @doctest id="e591" ``` ### Constructing AgentContext Directly While most use cases are handled through `AgentState`, you can construct an `AgentContext` directly when you need fine-grained control. The constructor accepts flexible types for convenience: ```php use Cognesy\Agents\Context\AgentContext; use Cognesy\Messages\MessageStore\MessageStore; use Cognesy\Polyglot\Inference\Data\ResponseFormat; use Cognesy\Utils\Metadata; $context = new AgentContext( store: new MessageStore(), // or null for empty store metadata: ['session_id' => 'abc'], // array, Metadata instance, or null systemPrompt: 'You are a data analyst.', responseFormat: $responseFormat, // ResponseFormat instance or null ); // @doctest id="47a0" ``` ### Mutating Context Since `AgentContext` is immutable, all "mutations" return a new instance. The `with()` method provides a convenient way to change multiple properties at once, while dedicated methods handle specific updates: ```php // Change multiple properties at once $updated = $context->with( systemPrompt: 'New prompt', metadata: new Metadata(['key' => 'value']), ); // Or use dedicated methods $updated = $context ->withSystemPrompt('New prompt') ->withMetadataKey('user_id', 42) ->withResponseFormat(new ResponseFormat(type: 'json_object')); // Message manipulation $updated = $context->withMessages($messages); // Replace all messages in DEFAULT section $updated = $context->withAppendedMessages($messages); // Append to DEFAULT section $updated = $context->withMessageStore($store); // Replace the entire store // @doctest id="b707" ``` ### Context Sections The `MessageStore` inside `AgentContext` is divided into named sections defined by the `ContextSections` class. Each section holds a distinct category of messages, allowing the system to organize conversation data by purpose: | Section | Constant | Purpose | |---------|----------|---------| | `messages` | `ContextSections::DEFAULT` | Primary conversation history -- user messages, assistant responses, and tool results | | `buffer` | `ContextSections::BUFFER` | Temporary working messages such as intermediate reasoning steps or ephemeral context | | `summary` | `ContextSections::SUMMARY` | Condensed summaries of older conversation history, typically produced by summarization capabilities | When sections are sent to the model, they follow a defined **inference order** -- summary first, then buffer, then the main conversation -- so the model receives context in a logical sequence from oldest/most general to newest/most specific: ```php use Cognesy\Agents\Context\ContextSections; ContextSections::inferenceOrder(); // Returns: ['summary', 'buffer', 'messages'] // @doctest id="d9f7" ``` This ordering matters when compilers assemble messages from multiple sections. By placing summaries before the primary conversation, the model gets a high-level understanding of past exchanges before diving into the current interaction. > **Extensibility:** While the framework defines three built-in sections, the `MessageStore` supports arbitrary section names. You can create custom sections for domain-specific needs, though you will need a custom compiler to include them in inference. ## Message Compilers Before each model call, the driver asks a `CanCompileMessages` implementation to select and arrange the messages the model should receive. The compiler reads from `AgentState` and returns a flat `Messages` collection: ```php use Cognesy\Agents\Data\AgentState; use Cognesy\Messages\Messages; interface CanCompileMessages { public function compile(AgentState $state): Messages; } // @doctest id="e8b5" ``` The compiler is the **single point** where you control what the model sees. It can filter, reorder, truncate, or inject messages -- all without modifying the underlying message store. This makes compilers the ideal place to implement context window management, message redaction, or any transformation that should only affect the model's view of the conversation. ### Built-in Compilers The framework ships with three compilers, each suited to different scenarios. Understanding when to use each one is key to building agents that manage context effectively. #### ConversationWithCurrentToolTrace (Default) The default compiler provides intelligent trace filtering for multi-step agent executions. It includes all non-trace conversation messages plus only the trace messages from the **current execution**. This prevents the model from seeing internal tool-calling traces from previous executions, keeping the context clean and focused: ```php use Cognesy\Agents\Context\Compilers\ConversationWithCurrentToolTrace; $compiler = new ConversationWithCurrentToolTrace(); // @doctest id="17df" ``` Messages are distinguished by metadata. Each message carries two metadata flags: - **`is_trace`** -- a boolean indicating whether the message is an internal trace (e.g., tool call/response pairs within a sub-execution) or a conversation message visible to the user - **`execution_id`** -- a UUID identifying which execution produced the message The compiler's logic is straightforward: include a message if it is either not a trace, or if its `execution_id` matches the current execution. When there is no active execution (between executions), all traces are excluded: ```php // Pseudocode of the filtering logic: $include = !$message->metadata()->get('is_trace') || $message->metadata()->get('execution_id') === $currentExecutionId; // @doctest id="5a01" ``` This compiler is particularly valuable when building agents that invoke sub-agents or perform multi-step tool calling, as it ensures each execution sees only its own internal state while preserving the full conversational history. #### AllSections The simplest compiler -- it sends every message from every section, with no filtering whatsoever. This is useful for debugging, testing, or when you want the model to see the complete, unedited history: ```php use Cognesy\Agents\Context\Compilers\AllSections; $compiler = new AllSections(); // @doctest id="1867" ``` > **Warning:** In production agents with long-running conversations, `AllSections` can quickly exceed the model's context window. Consider using it primarily for development and debugging. #### SelectedSections Sends messages from specific sections in a defined order. This compiler is essential when you have a summarization strategy and want to send the summary followed by only recent messages, or when you want to exclude certain sections entirely: ```php use Cognesy\Agents\Context\Compilers\SelectedSections; // Use the default inference order (summary, buffer, messages) $compiler = SelectedSections::default(); // Or specify exactly which sections to include and their order $compiler = new SelectedSections(['summary', 'messages']); // @doctest id="56ed" ``` If a named section does not exist in the store, it is silently skipped. When an empty sections array is provided, the compiler falls back to returning just the default section's messages. This compiler pairs naturally with the [Summarization capability](#context-sections) -- as older messages are condensed into summaries, the `SelectedSections` compiler can send the summary section followed by only recent conversation messages, keeping the context compact. ## Installing a Custom Compiler ### Via AgentBuilder (Recommended) The `UseContextCompiler` capability provides a clean, declarative way to replace the default compiler during agent construction: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Core\UseContextCompiler; use Cognesy\Agents\Context\Compilers\AllSections; $agent = AgentBuilder::base() ->withCapability(new UseContextCompiler(new AllSections())) ->build(); // @doctest id="9593" ``` ### Via Driver (Manual) When working directly with the loop and driver, pass the compiler at construction time. Any driver implementing the `CanAcceptMessageCompiler` interface supports this: ```php use Cognesy\Agents\Context\CanAcceptMessageCompiler; $driver = $driver->withMessageCompiler(new AllSections()); $loop = AgentLoop::default()->withDriver($driver); // @doctest id="f9f3" ``` The `CanAcceptMessageCompiler` interface requires two methods: ```php interface CanAcceptMessageCompiler { public function messageCompiler(): CanCompileMessages; public function withMessageCompiler(CanCompileMessages $compiler): static; } // @doctest id="e5c5" ``` ## Writing a Custom Compiler Implement the `CanCompileMessages` interface to build your own message selection strategy. The `compile` method receives the full `AgentState`, giving you access to the message store, metadata, execution state, and all other agent data: ```php use Cognesy\Agents\Context\CanCompileMessages; use Cognesy\Agents\Data\AgentState; use Cognesy\Messages\Messages; class RecentMessagesCompiler implements CanCompileMessages { public function __construct( private int $maxMessages = 20, ) {} public function compile(AgentState $state): Messages { $all = $state->store()->toMessages()->all(); $recent = array_slice($all, -$this->maxMessages); return new Messages(...$recent); } } // @doctest id="1e2c" ``` ### Decorating the Default Compiler Often you want to enhance the default compiler rather than replace it entirely. The `UseContextCompilerDecorator` capability wraps the existing compiler, letting you post-process its output. The decorator receives whatever compiler is currently configured and returns a new one that wraps it: ```php use Cognesy\Agents\Capability\Core\UseContextCompilerDecorator; use Cognesy\Agents\Context\CanCompileMessages; $agent = AgentBuilder::base() ->withCapability(new UseContextCompilerDecorator( fn(CanCompileMessages $inner) => new TokenLimitCompiler($inner, maxTokens: 4000) )) ->build(); // @doctest id="ad86" ``` This approach composes naturally -- multiple decorators can be stacked, and each one wraps the result of the previous. This is the recommended pattern when you want to add constraints (like token limits or message filtering) on top of an existing compilation strategy. ### Example: Token-Limited Compiler A common pattern is to limit the messages sent to the model based on an estimated token budget. This decorator wraps any inner compiler and keeps only the most recent messages that fit within the budget, working backward from the newest message: ```php use Cognesy\Agents\Context\CanCompileMessages; use Cognesy\Agents\Data\AgentState; use Cognesy\Messages\Messages; class TokenLimitCompiler implements CanCompileMessages { public function __construct( private CanCompileMessages $inner, private int $maxTokens = 8000, ) {} public function compile(AgentState $state): Messages { $messages = $this->inner->compile($state); $kept = []; $tokens = 0; // Walk backward from newest messages, accumulating until budget is exhausted foreach (array_reverse($messages->all()) as $message) { $estimate = (int) ceil(strlen($message->content()->toString()) / 4); if ($tokens + $estimate > $this->maxTokens) { break; } $tokens += $estimate; array_unshift($kept, $message); } return new Messages(...$kept); } } // @doctest id="4786" ``` > **Note:** The token estimate here uses a simple `strlen / 4` heuristic. For production use, consider integrating a proper tokenizer for your target model. ### Example: Injecting Retrieved Documents Another common pattern is injecting ephemeral context (such as RAG-retrieved documents) into the message stream without permanently storing them: ```php class RAGCompiler implements CanCompileMessages { public function __construct( private CanCompileMessages $inner, private DocumentRetriever $retriever, ) {} public function compile(AgentState $state): Messages { $messages = $this->inner->compile($state); // Get the last user message to use as a retrieval query $lastUserMessage = $messages->lastOfRole('user'); if ($lastUserMessage === null) { return $messages; } $documents = $this->retriever->search($lastUserMessage->content()->toString()); $contextMessage = Message::system("Relevant documents:\n" . $documents); // Prepend the context before the conversation return new Messages($contextMessage, ...$messages->all()); } } // @doctest id="7791" ``` ## Serialization `AgentContext` supports full serialization through `toArray()` and `fromArray()`, making it straightforward to persist and restore agent context across requests, sessions, or process boundaries: ```php // Serialize to array (e.g., for storage in a database or cache) $data = $context->toArray(); // Returns: ['metadata' => [...], 'systemPrompt' => '...', 'responseFormat' => [...], 'messageStore' => [...]] // Restore from array $restored = AgentContext::fromArray($data); // @doctest id="22c3" ``` ## Common Use Cases Compilers are the right tool when you need to: - **Trim older messages** to stay within the model's context window while preserving recent conversation flow - **Inject ephemeral context** (e.g., retrieved documents, real-time data) without permanently storing them in the message history - **Exclude internal traces** from multi-agent orchestration so child agent tool-calling details do not leak into the parent's view - **Prioritize sections** by sending summaries before raw history, giving the model a structured overview - **Redact sensitive content** before it reaches the model, such as stripping PII or credentials from tool outputs - **Implement sliding windows** that keep only the most recent N messages or N tokens of conversation - **Support hybrid strategies** by combining summarized older history with full recent messages for optimal context utilization ================================================================================ FILE: packages/agents/08-hooks.md ================================================================================ # Hooks ## Introduction Hooks let you intercept every phase of the agent's execution lifecycle. They are the primary extension mechanism for cross-cutting concerns -- logging, rate limiting, safety guards, telemetry, state transformation, and tool access control. Each hook receives a `HookContext` containing the current agent state and trigger-specific data, processes it, and returns a (potentially modified) context to continue the pipeline. Because both `HookContext` and `AgentState` are immutable, hooks compose safely -- each hook in the chain works with the output of the previous one, and no hook can accidentally corrupt shared state. > **Design Philosophy:** Hooks follow the middleware pattern common in web frameworks, but adapted for agent execution. Instead of intercepting HTTP requests, hooks intercept the agent's internal lifecycle events -- giving you the same power to observe, modify, or short-circuit execution at precisely the right moment. ## Lifecycle Events The agent loop emits eight trigger types at well-defined points during execution. Each trigger corresponds to a specific moment in the loop's lifecycle, and understanding when each fires is essential for placing your hooks correctly: | Trigger | When It Fires | Available Data | |---------|---------------|----------------| | `BeforeExecution` | Once, before the loop begins its first step | Agent state | | `BeforeStep` | Before each LLM call | Agent state | | `BeforeToolUse` | Before each individual tool execution | Agent state, `ToolCall` | | `AfterToolUse` | After each individual tool execution | Agent state, `ToolExecution` | | `AfterStep` | After each loop iteration completes | Agent state | | `OnStop` | When the loop detects a stop condition | Agent state | | `AfterExecution` | Once, after the loop ends | Agent state | | `OnError` | When an error occurs during execution | Agent state, `ErrorList` | These triggers are defined in the `HookTrigger` enum: ```php use Cognesy\Agents\Hook\Enums\HookTrigger; HookTrigger::BeforeExecution; // 'before_execution' HookTrigger::BeforeStep; // 'before_step' HookTrigger::BeforeToolUse; // 'before_tool_use' HookTrigger::AfterToolUse; // 'after_tool_use' HookTrigger::AfterStep; // 'after_step' HookTrigger::OnStop; // 'on_stop' HookTrigger::AfterExecution; // 'after_execution' HookTrigger::OnError; // 'on_error' // @doctest id="fddb" ``` The following diagram illustrates the typical flow through these triggers during a single execution: ``` BeforeExecution | +---> BeforeStep | | | +---> [LLM Call] | | | +---> BeforeToolUse ---> [Tool Execution] ---> AfterToolUse | | (repeated for each tool call in the step) | | | +---> AfterStep | | | +---> (loop back to BeforeStep if not stopping) | +---> OnStop (when stop condition detected) | +---> AfterExecution // @doctest id="47ae" ``` If an error occurs at any point, the `OnError` trigger fires with the accumulated error information. ## Implementing a Hook Create a class that implements `HookInterface`. The `handle` method receives a `HookContext` and must return one -- either the original context unchanged, or a modified copy: ```php use Cognesy\Agents\Hook\Contracts\HookInterface; use Cognesy\Agents\Hook\Data\HookContext; class LogStepsHook implements HookInterface { public function handle(HookContext $context): HookContext { $steps = $context->state()->stepCount(); echo "Step {$steps} | Trigger: {$context->triggerType()->value}\n"; return $context; } } // @doctest id="5276" ``` ### Understanding HookContext The `HookContext` object provides access to different data depending on the trigger type. It serves as both the input and output of hook processing, carrying all the information a hook needs to make decisions: | Method | Return Type | Description | Available On | |--------|-------------|-------------|--------------| | `state()` | `AgentState` | The current agent state with full access to context, messages, metadata, and execution data | All triggers | | `triggerType()` | `HookTrigger` | The enum value identifying which lifecycle event fired this hook | All triggers | | `toolCall()` | `?ToolCall` | The tool call about to be executed, including the tool name and arguments | `BeforeToolUse` | | `toolExecution()` | `?ToolExecution` | The completed tool execution result, including output and status | `AfterToolUse` | | `errorList()` | `ErrorList` | Accumulated errors from the execution | `OnError` (primarily) | | `metadata()` | `mixed` | Arbitrary metadata passed with the trigger; accepts an optional key and default value | All triggers | | `createdAt()` | `DateTimeImmutable` | When this hook context was created | All triggers | | `updatedAt()` | `DateTimeImmutable` | When this hook context was last modified by a hook | All triggers | | `hasErrors()` | `bool` | Whether the error list contains any errors | All triggers | | `isToolExecutionBlocked()` | `bool` | Whether tool execution has been blocked by a hook | `BeforeToolUse` | `HookContext` also provides convenient named constructors for each trigger type, used internally by the agent loop: ```php // These are used by the loop -- you typically don't call them directly $ctx = HookContext::beforeExecution($state); $ctx = HookContext::beforeStep($state); $ctx = HookContext::beforeToolUse($state, $toolCall); $ctx = HookContext::afterToolUse($state, $toolExecution); $ctx = HookContext::afterStep($state); $ctx = HookContext::onStop($state); $ctx = HookContext::afterExecution($state); $ctx = HookContext::onError($state, $errorList); // @doctest id="da5b" ``` ## Registering Hooks ### Via AgentBuilder (Recommended) The `UseHook` capability provides a declarative way to register hooks during agent construction. Each `UseHook` instance binds a hook implementation to one or more triggers with a specified priority: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Core\UseHook; use Cognesy\Agents\Hook\Collections\HookTriggers; $agent = AgentBuilder::base() ->withCapability(new UseHook( hook: new LogStepsHook(), triggers: HookTriggers::afterStep(), priority: 10, name: 'log_steps', )) ->build(); // @doctest id="02d5" ``` A hook can listen to multiple triggers by combining them with `HookTriggers::of()`: ```php use Cognesy\Agents\Hook\Enums\HookTrigger; $agent = AgentBuilder::base() ->withCapability(new UseHook( hook: new MyHook(), triggers: HookTriggers::of( HookTrigger::BeforeStep, HookTrigger::AfterStep, ), )) ->build(); // @doctest id="1efe" ``` The `HookTriggers` class provides convenience constructors for every trigger type, as well as the ability to combine them: ```php HookTriggers::all(); // Every trigger type HookTriggers::none(); // No triggers (useful for conditional registration) HookTriggers::beforeExecution(); // Just BeforeExecution HookTriggers::beforeStep(); // Just BeforeStep HookTriggers::beforeToolUse(); // Just BeforeToolUse HookTriggers::afterToolUse(); // Just AfterToolUse HookTriggers::afterStep(); // Just AfterStep HookTriggers::onStop(); // Just OnStop HookTriggers::afterExecution(); // Just AfterExecution HookTriggers::onError(); // Just OnError // Combine multiple triggers HookTriggers::of(HookTrigger::BeforeStep, HookTrigger::AfterStep); // @doctest id="f7c6" ``` ### Via HookStack (Manual) When composing an `AgentLoop` directly without the builder, assemble hooks into a `HookStack`. The `HookStack` wraps a `RegisteredHooks` collection and implements the `CanInterceptAgentLifecycle` interface, making it pluggable into the agent loop: ```php use Cognesy\Agents\Hook\Collections\RegisteredHooks; use Cognesy\Agents\Hook\HookStack; $stack = new HookStack(new RegisteredHooks()); $stack = $stack->with( hook: new LogStepsHook(), triggerTypes: HookTriggers::afterStep(), priority: 10, name: 'log_steps', ); $loop = AgentLoop::default()->withInterceptor($stack); // @doctest id="e2e7" ``` The `HookStack` is immutable -- each `with()` call returns a new instance with the hook added and the collection re-sorted by priority. You can chain multiple hooks fluently: ```php $stack = $stack ->with($hookA, HookTriggers::beforeStep(), priority: 100) ->with($hookB, HookTriggers::afterStep(), priority: 50) ->with($hookC, HookTriggers::onError(), priority: 0); // @doctest id="8531" ``` You can also add a pre-built `RegisteredHook` directly: ```php use Cognesy\Agents\Hook\Data\RegisteredHook; $registeredHook = new RegisteredHook( hook: new LogStepsHook(), triggers: HookTriggers::afterStep(), priority: 10, name: 'log_steps', ); $stack = $stack->withHook($registeredHook); // @doctest id="4b5e" ``` ## CallableHook For quick, one-off hooks that do not warrant a dedicated class, use `CallableHook` with a closure. This is particularly handy for prototyping or adding simple logging during development: ```php use Cognesy\Agents\Hook\Hooks\CallableHook; use Cognesy\Agents\Hook\Data\HookContext; $hook = new CallableHook(function (HookContext $ctx): HookContext { echo "Step completed.\n"; return $ctx; }); $agent = AgentBuilder::base() ->withCapability(new UseHook( hook: $hook, triggers: HookTriggers::afterStep(), )) ->build(); // @doctest id="98de" ``` `CallableHook` accepts any `callable` that takes a `HookContext` and returns a `HookContext`. It converts the callable to a `Closure` internally for type safety. ## Hook Priority When a trigger fires, hooks are executed in **descending priority order** -- higher values run first. This ordering is critical when hooks have dependencies on each other. For example, guard hooks that may emit stop signals should run before business logic hooks that assume the loop will continue. The `RegisteredHooks` collection sorts hooks automatically when they are added. The sort is stable, so hooks with the same priority retain their registration order. The built-in guard hooks use a priority of **200** (or **-200** for the finish reason guard, which runs on `AfterStep`), giving them precedence over custom hooks at the default priority of **0**. Choose your priorities according to the following guidelines: | Range | Suggested Use | Examples | |-------|---------------|----------| | 200+ | Safety guards, resource limits | Step limits, token limits, time limits | | 100-199 | Infrastructure concerns | Logging, telemetry, metrics collection | | 0-99 | Business logic, custom behavior | State enrichment, conditional branching | | Negative | Post-processing, cleanup | Finish reason detection, result formatting | > **Tip:** When in doubt, use the default priority of 0. Only assign explicit priorities when you need guaranteed ordering between hooks. ## Modifying Agent State Hooks can modify the agent's state by returning a `HookContext` with an updated `AgentState`. Since both objects are immutable, you create modified copies using the `with*` methods: ```php $hook = new CallableHook(function (HookContext $ctx): HookContext { $state = $ctx->state()->withMetadata('processed_at', time()); return $ctx->withState($state); }); // @doctest id="1192" ``` State modifications flow through the hook pipeline and back into the loop. This makes hooks suitable for: - **Injecting context** -- adding metadata that downstream hooks or the driver can read - **Adjusting system prompts** -- dynamically modifying the system prompt based on execution state - **Attaching metadata** -- tagging the state with timestamps, user IDs, or feature flags - **Modifying the message store** -- adding, removing, or transforming messages before the next LLM call ```php // Example: Dynamically adjust the system prompt based on step count $hook = new CallableHook(function (HookContext $ctx): HookContext { $state = $ctx->state(); if ($state->stepCount() > 5) { $context = $state->context()->withSystemPrompt( $state->context()->systemPrompt() . "\n\nPlease wrap up your current task." ); $state = $state->with(context: $context); } return $ctx->withState($state); }); // @doctest id="9da5" ``` ## Blocking Tool Execution In a `BeforeToolUse` hook, you can prevent a tool from executing by calling `withToolExecutionBlocked()` on the context. This is a powerful safety mechanism for restricting which tools the model can invoke at runtime: ```php class BlockDangerousTools implements HookInterface { private array $blockedTools = ['delete_all_data', 'drop_database', 'rm_rf']; public function handle(HookContext $context): HookContext { $toolName = $context->toolCall()?->name(); if ($toolName !== null && in_array($toolName, $this->blockedTools, true)) { return $context->withToolExecutionBlocked( "Tool \"{$toolName}\" is not permitted in this environment." ); } return $context; } } // @doctest id="53f8" ``` Register it on the `BeforeToolUse` trigger with a high priority to ensure it runs before other hooks: ```php $agent = AgentBuilder::base() ->withCapability(new UseHook( hook: new BlockDangerousTools(), triggers: HookTriggers::beforeToolUse(), priority: 200, name: 'block_dangerous_tools', )) ->build(); // @doctest id="2d01" ``` When a tool is blocked, several things happen internally: 1. The `HookContext` is marked with `isToolExecutionBlocked = true` 2. A `ToolExecution` with blocked status is created and attached to the context 3. A `ToolExecutionBlockedException` is recorded in the error list 4. The loop skips the actual tool execution 5. The rejection message is fed back to the model as the tool result, so it can adjust its approach You can also provide a custom message when blocking. If no message is provided, a default message is generated that includes details about the hook context for debugging: ```php // With custom message (recommended for user-facing agents) $context->withToolExecutionBlocked('This tool requires admin privileges.'); // With default message (includes HookContext details) $context->withToolExecutionBlocked(); // @doctest id="50f2" ``` ## Applying Context Configuration The built-in `ApplyContextConfigHook` sets the system prompt and response format on the agent context at the start of execution. This is how the builder internally applies system prompt and response format settings configured through `UseContextConfig`: ```php use Cognesy\Agents\Hook\Hooks\ApplyContextConfigHook; $hook = new ApplyContextConfigHook( systemPrompt: 'You are a data analysis assistant.', responseFormat: $responseFormat, ); // @doctest id="5dc6" ``` This hook runs on `BeforeExecution` and modifies the `AgentContext` inside the state, ensuring the system prompt and format are in place before the first LLM call. It only applies non-empty values -- an empty system prompt or a `null` / empty response format will leave the existing context values unchanged. ## Built-in Guard Hooks Guard hooks enforce resource limits by emitting stop signals when thresholds are exceeded. They are the primary mechanism for preventing runaway agents that might otherwise consume unlimited tokens, time, or steps. ### UseGuards Capability The `UseGuards` capability bundles all four guards with sensible defaults, providing a convenient one-liner for common resource protection: ```php use Cognesy\Agents\Capability\Core\UseGuards; $agent = AgentBuilder::base() ->withCapability(new UseGuards( maxSteps: 10, maxTokens: 5000, maxExecutionTime: 30.0, finishReasons: [], )) ->build(); // @doctest id="0422" ``` Each parameter is optional and nullable -- pass `null` to disable a specific guard. The defaults are: | Parameter | Default | Description | |-----------|---------|-------------| | `maxSteps` | `20` | Maximum number of loop iterations | | `maxTokens` | `32768` | Maximum cumulative token usage across all LLM calls | | `maxExecutionTime` | `300.0` | Maximum wall-clock seconds for the entire execution | | `finishReasons` | `[]` | LLM finish reasons that should trigger a stop (empty = disabled) | ### Individual Guard Hooks You can also register guards individually for finer control over triggers, priorities, and configuration. #### StepsLimitHook Stops the loop after a maximum number of steps. It accepts a callable `stepCounter` that extracts the current step count from the agent state, making it flexible enough to count different things (e.g., total steps, steps within the current execution): ```php use Cognesy\Agents\Hook\Hooks\StepsLimitHook; $guard = new StepsLimitHook( maxSteps: 10, stepCounter: fn($state) => $state->stepCount(), ); // @doctest id="3c0f" ``` When the limit is reached, it emits a `StopSignal` with reason `StepsLimitReached` and a descriptive message like `"Step limit reached: 10/10"`. #### TokenUsageLimitHook Stops the loop when cumulative token usage (input + output tokens across all LLM calls) exceeds a threshold. Token usage is tracked automatically by the agent state through the `usage()` accessor: ```php use Cognesy\Agents\Hook\Hooks\TokenUsageLimitHook; $guard = new TokenUsageLimitHook(maxTotalTokens: 5000); // @doctest id="484e" ``` When the limit is reached, it emits a `StopSignal` with reason `TokenLimitReached`. #### ExecutionTimeLimitHook Stops the loop after a wall-clock duration. Unlike other guards, this hook needs to listen to **two** triggers: `BeforeExecution` to record the start time, and `BeforeStep` to check elapsed time before each LLM call: ```php use Cognesy\Agents\Hook\Hooks\ExecutionTimeLimitHook; use Cognesy\Agents\Hook\Enums\HookTrigger; $guard = new ExecutionTimeLimitHook(maxSeconds: 30.0); // Must be registered on both triggers $stack = $stack->with( $guard, HookTriggers::of(HookTrigger::BeforeExecution, HookTrigger::BeforeStep), priority: 200, ); // @doctest id="f41c" ``` The hook uses microsecond-precision timestamps (`DateTimeImmutable` with `U.u` format) for accurate timing. When the limit is reached, it emits a `StopSignal` with reason `TimeLimitReached`. > **Note:** The `UseGuards` capability handles the dual-trigger registration automatically. You only need to manage it manually when registering the hook directly. #### FinishReasonHook Stops the loop when the LLM's finish reason matches a specified set. This is useful for stopping when the model indicates it has finished naturally (e.g., `stop` finish reason) rather than being cut off by a token limit. It runs on `AfterStep` since the finish reason is only available after the model responds: ```php use Cognesy\Agents\Hook\Hooks\FinishReasonHook; use Cognesy\Polyglot\Inference\Enums\InferenceFinishReason; $guard = new FinishReasonHook( stopReasons: [InferenceFinishReason::Stop], finishReasonResolver: fn($state) => $state->currentStep()?->finishReason(), ); // @doctest id="4877" ``` When registered through `UseGuards`, this hook receives a priority of **-200** (running after other `AfterStep` hooks) to ensure all post-step processing has completed before checking the finish reason. ## How Hooks Execute When a trigger fires, the `HookStack` iterates through all registered hooks sorted by priority (descending). Each hook that matches the trigger type receives the `HookContext`, processes it, and returns a (potentially modified) context. The returned context flows into the next hook in the chain: ``` Trigger fires -> Hook A (priority 200) -> modified context -> Hook B (priority 100) -> modified context -> Hook C (priority 0) -> final context -> Loop continues with final context // @doctest id="4b5e" ``` Hooks that do not match the current trigger type are silently skipped. Each successful hook execution dispatches a `HookExecuted` event containing the trigger type, hook name, and execution timestamp -- enabling external observability and performance monitoring. The `HookStack` implements `CanInterceptAgentLifecycle`, meaning it can be replaced entirely with a custom interception strategy. The `PassThroughInterceptor` is a no-op implementation that returns the context unchanged, useful for testing or when you want to disable all hooks: ```php use Cognesy\Agents\Interception\PassThroughInterceptor; $loop = AgentLoop::default()->withInterceptor(new PassThroughInterceptor()); // @doctest id="f8af" ``` ## Practical Examples ### Audit Trail Hook Record every tool invocation for compliance or debugging: ```php class AuditTrailHook implements HookInterface { private array $log = []; public function handle(HookContext $context): HookContext { if ($context->triggerType() === HookTrigger::AfterToolUse) { $execution = $context->toolExecution(); $this->log[] = [ 'tool' => $execution->name(), 'timestamp' => $context->createdAt()->format('c'), 'blocked' => $execution->wasBlocked(), ]; } return $context; } public function getLog(): array { return $this->log; } } // @doctest id="1438" ``` ### Rate Limiting Hook Throttle tool calls to prevent excessive API usage: ```php class RateLimitHook implements HookInterface { private int $callCount = 0; public function __construct( private int $maxCallsPerExecution = 50, ) {} public function handle(HookContext $context): HookContext { if ($context->triggerType() === HookTrigger::BeforeToolUse) { $this->callCount++; if ($this->callCount > $this->maxCallsPerExecution) { return $context->withToolExecutionBlocked( "Rate limit exceeded: {$this->callCount}/{$this->maxCallsPerExecution} tool calls." ); } } return $context; } } // @doctest id="c196" ``` ### Conditional Tool Access Allow or deny tools based on metadata (e.g., user role): ```php class RoleBasedAccessHook implements HookInterface { private array $adminOnlyTools = ['deploy', 'rollback', 'delete_user']; public function handle(HookContext $context): HookContext { $toolName = $context->toolCall()?->name(); if ($toolName === null || !in_array($toolName, $this->adminOnlyTools, true)) { return $context; } $role = $context->state()->context()->metadata()->get('user_role'); if ($role !== 'admin') { return $context->withToolExecutionBlocked( "Tool \"{$toolName}\" requires admin privileges." ); } return $context; } } // @doctest id="764b" ``` ================================================================================ FILE: packages/agents/09-stop-conditions.md ================================================================================ # Stop Conditions ## Introduction The agent loop runs iteratively -- calling the model, executing tools, and repeating -- until something tells it to stop. Understanding the stop condition system is essential for building predictable agents that terminate gracefully under all circumstances. Three mechanisms work together to control loop termination: **stop signals** emitted by guards or tools, **continuation overrides** that can suppress those signals, and the **AgentStopException** for immediate termination from within tool code. ## How the Loop Decides to Stop At the end of each iteration, the loop evaluates `ExecutionState::shouldStop()`. The decision follows this priority chain: ```php $shouldStop = match (true) { $continuation->shouldStop() => true, // stop signal AND no continuation override $continuation->isContinuationRequested() => false, // continuation override active $hasToolCalls => false, // model requested more tool calls default => true, // no tool calls = conversation complete }; // @doctest id="cef2" ``` In plain terms: 1. If a stop signal has been emitted **and** no continuation override is active, the loop stops immediately. 2. If a continuation override is active, the loop continues regardless of stop signals. 3. If the model returned tool calls, the loop continues to execute them. 4. If none of the above apply (the model gave a final text response with no tool calls), the loop stops -- this is the normal completion path. ## Stop Signals A `StopSignal` is an immutable value object that represents a structured request to terminate the loop. It carries a reason, a human-readable message, contextual data for debugging, and the class name of the source that created it: ```php use Cognesy\Agents\Continuation\StopReason; use Cognesy\Agents\Continuation\StopSignal; $signal = new StopSignal( reason: StopReason::StepsLimitReached, message: 'Step limit reached: 10/10', context: ['currentSteps' => 10, 'maxSteps' => 10], source: MyGuard::class, ); // @doctest id="92fe" ``` | Property | Type | Description | |----------|------|-------------| | `reason` | `StopReason` | An enum value categorizing why the stop was requested | | `message` | `string` | A human-readable description of the stop condition | | `context` | `array` | Arbitrary diagnostic data (thresholds, counters, timestamps) for debugging and logging | | `source` | `?string` | The fully-qualified class name of the hook or component that emitted the signal | Signals accumulate in a `StopSignals` collection within `ExecutionContinuation`. Multiple signals can coexist — for instance, both a step limit and a token limit might trigger in the same iteration. Use `highest()` to retrieve the most authoritative signal by priority, or `first()` for the earliest-added signal. ### Displaying and Serializing Signals Signals provide methods for display and persistence: ```php // Human-readable string $signal->toString(); // e.g., "steps_limit: Step limit reached: 10/10" // Full serialization $signal->toArray(); // ['reason' => 'steps_limit', 'message' => '...', 'context' => [...], 'source' => '...'] // Restore from serialized data $restored = StopSignal::fromArray($data); // @doctest id="a944" ``` ### Factory Methods `StopSignal` provides static factories for common signal types so you don't have to construct them manually: ```php // User-requested cancellation $signal = StopSignal::userRequested('user pressed stop', context: ['source' => 'ui'], source: self::class); // From a caught AgentStopException (used internally by the loop) $signal = StopSignal::fromStopException($exception); // @doctest id="8b29" ``` ### Creating Signals from Exceptions When an `AgentStopException` is caught by the loop, the exception is converted to a `StopSignal` using the dedicated factory method: ```php $signal = StopSignal::fromStopException($exception); // Creates a signal with reason StopRequested and the exception's message/context // @doctest id="1de5" ``` ### Emitting Stop Signals from Hooks Guard hooks are the primary source of stop signals. A hook emits a signal by modifying the agent state and returning the updated context: ```php use Cognesy\Agents\Hook\Contracts\HookInterface; use Cognesy\Agents\Hook\Data\HookContext; class CustomGuard implements HookInterface { public function handle(HookContext $context): HookContext { if ($this->shouldStop($context->state())) { $state = $context->state()->withStopSignal(new StopSignal( reason: StopReason::StepsLimitReached, message: 'Custom condition met', source: self::class, )); return $context->withState($state); } return $context; } } // @doctest id="5482" ``` The `withStopSignal()` method on `AgentState` appends the signal to the execution's `ExecutionContinuation` state. The loop checks `shouldStop()` after processing hooks at the end of each step. ### The StopSignals Collection Multiple stop signals can accumulate during execution. The `StopSignals` collection is an immutable container that manages them: ```php use Cognesy\Agents\Continuation\StopSignals; $signals = StopSignals::empty(); $signals = $signals->withSignal($stepLimitSignal); $signals = $signals->withSignal($tokenLimitSignal); $signals->hasAny(); // true $signals->first(); // Returns the first signal added (insertion order) $signals->highest(); // Returns the most authoritative signal by priority $signals->toString(); // "steps_limit: Step limit reached: 10/10 | token_limit: Token limit reached" // @doctest id="5959" ``` Each `withSignal()` call returns a new instance. The collection supports full serialization through `toArray()` and `fromArray()`. ## StopReason The `StopReason` enum categorizes every possible reason for stopping the agent loop. Each reason has a string value for serialization and a numeric priority for comparison: | Reason | Value | Priority | Description | |--------|-------|----------|-------------| | `ErrorForbade` | `error` | 0 (highest) | An error prevented continuation | | `StopRequested` | `stop_requested` | 1 | Explicit stop via `AgentStopException` | | `StepsLimitReached` | `steps_limit` | 2 | Step budget exhausted | | `TokenLimitReached` | `token_limit` | 3 | Token budget exhausted | | `TimeLimitReached` | `time_limit` | 4 | Wall-clock time budget exhausted | | `RetryLimitReached` | `retry_limit` | 5 | Maximum retries exceeded | | `FinishReasonReceived` | `finish_reason` | 6 | LLM finish reason matched a stop condition | | `UserRequested` | `user_requested` | 2 | External cancellation requested by the caller | | `Completed` | `completed` | 8 | Normal, successful completion | | `Unknown` | `unknown` | 9 (lowest) | Unclassified stop reason | ### Priority and Comparison Each `StopReason` has a numeric priority that determines its severity. Lower numbers indicate more urgent reasons -- `ErrorForbade` (0) takes precedence over `Completed` (8). This ordering is used when evaluating multiple signals: ```php $reason->priority(); // Returns the numeric priority (0-9) $reason->compare($other); // Spaceship comparison using <=> operator // @doctest id="7f4a" ``` ### Distinguishing Graceful Stops from Forced Stops The `wasForceStopped()` method is particularly useful for determining how the agent finished after execution. Natural endings return `false`, while all resource limits, errors, and explicit stops return `true`: ```php StopReason::Completed->wasForceStopped(); // false -- natural completion StopReason::FinishReasonReceived->wasForceStopped(); // false -- model signaled completion StopReason::StepsLimitReached->wasForceStopped(); // true -- resource limit hit StopReason::StopRequested->wasForceStopped(); // true -- explicit tool stop StopReason::ErrorForbade->wasForceStopped(); // true -- error prevented continuation // @doctest id="cb9a" ``` ## AgentStopException When a tool determines that the agent's task is complete (or that execution should not continue), it can throw an `AgentStopException`. The loop catches this exception, converts it to a `StopSignal` with reason `StopRequested`, and terminates cleanly. `AgentStopException` extends `RuntimeException` and is a control-flow exception -- it is not an error condition, but an intentional mechanism for tools to signal completion: ```php use Cognesy\Agents\Continuation\AgentStopException; use Cognesy\Agents\Continuation\StopReason; use Cognesy\Agents\Continuation\StopSignal; use Cognesy\Agents\Tool\Tools\BaseTool; class SubmitAnswerTool extends BaseTool { public function __invoke(string $answer): never { // Store the answer, then stop the loop throw new AgentStopException( signal: new StopSignal( reason: StopReason::StopRequested, message: "Answer submitted: {$answer}", ), ); } } // @doctest id="776a" ``` The exception carries several properties for rich diagnostic context: | Property | Type | Description | |----------|------|-------------| | `signal` | `StopSignal` | The stop signal to emit when the exception is caught | | `step` | `?AgentStep` | An optional reference to the current step for diagnostic purposes | | `context` | `array` | Additional context data passed through to `StopSignal::fromStopException()` | | `source` | `?string` | The class that threw the exception, for traceability | The exception message is resolved automatically from the signal's message, the exception's own message, or the stop reason value (in that priority order): ```php throw new AgentStopException( signal: new StopSignal( reason: StopReason::Completed, message: 'All tasks finished', ), context: ['tasks_completed' => 5], source: self::class, ); // @doctest id="0df6" ``` ### Common Use Cases for AgentStopException **Task completion tool** -- Let the model signal that it has finished its task: ```php class TaskCompleteTool extends BaseTool { public function __invoke(string $summary): never { throw new AgentStopException( signal: new StopSignal( reason: StopReason::StopRequested, message: "Task completed: {$summary}", context: ['summary' => $summary], ), source: self::class, ); } } // @doctest id="cf8c" ``` **Error-driven stop** -- Halt when a tool encounters an unrecoverable error: ```php class CriticalOperationTool extends BaseTool { public function __invoke(string $operation): mixed { try { return $this->performOperation($operation); } catch (\Exception $e) { throw new AgentStopException( signal: new StopSignal( reason: StopReason::ErrorForbade, message: "Critical failure: {$e->getMessage()}", ), previous: $e, ); } } } // @doctest id="9908" ``` ## ExecutionContinuation `ExecutionContinuation` is the state object that manages the interplay between stop signals and continuation requests. It holds two independent pieces of state: - **`StopSignals`** -- the collection of accumulated stop signals - **`isContinuationRequested`** -- a boolean flag that overrides stop signals when `true` The key method is `shouldStop()`, which returns `true` only when signals exist **and** no continuation has been requested: ```php use Cognesy\Agents\Continuation\ExecutionContinuation; $continuation = ExecutionContinuation::fresh(); // No signals, no continuation request $continuation->shouldStop(); // false (no signals present) $continuation->isContinuationRequested(); // false $continuation->stopSignals()->hasAny(); // false // @doctest id="ec11" ``` ### Modifying Continuation State `ExecutionContinuation` is immutable. All modifications return new instances: ```php // Add a stop signal $continuation = $continuation->withNewStopSignal($signal); // Request continuation (overrides stop signals) $continuation = $continuation->withContinuationRequested(true); // Replace all stop signals at once $continuation = $continuation->withStopSignals($newSignals); // @doctest id="04ca" ``` ### Overriding Stop Signals with Continuation In some scenarios, you may want the loop to continue even after a stop signal has been emitted. For example, a summarization hook might intercept a step-limit signal, summarize the conversation to free up context space, and request continuation: ```php $hook = new CallableHook(function (HookContext $ctx): HookContext { $state = $ctx->state(); // Check if we're being stopped due to step limit $signals = $state->execution()?->continuation()->stopSignals(); if (!$signals?->hasAny()) { return $ctx; } // Summarize and request continuation $state = $state->withExecutionContinued(); return $ctx->withState($state); }); // @doctest id="afc8" ``` The `withExecutionContinued()` method on `AgentState` sets the continuation flag to `true`, which causes `shouldStop()` to return `false` even though stop signals are present. This gives hooks the power to implement recovery strategies before allowing the loop to terminate. > **Caution:** Overriding stop signals should be done carefully. If a continuation hook resets the signal but the underlying condition persists (e.g., the token limit is still exceeded after summarization), the guard hook will re-emit the signal on the next step, potentially creating an infinite loop. Always ensure the override resolves the root cause. ### Diagnostic Output The `explain()` method produces a human-readable summary of the continuation state, useful for logging and debugging: ```php $continuation->explain(); // "Stop Signals: steps_limit: Step limit reached: 10/10; Continuation Requested: No" // or // "No Stop Signals; Continuation Requested: No" // @doctest id="cc28" ``` ## Inspecting Stop Reasons After Execution After the loop completes, you can inspect why it stopped through the agent state: ```php $state = $agent->run($state); $execution = $state->execution(); $continuation = $execution->continuation(); if ($continuation->stopSignals()->hasAny()) { $signal = $continuation->stopSignals()->highest(); // most authoritative by priority echo "Stopped: {$signal->reason->value} - {$signal->message}\n"; echo "Was force-stopped: " . ($signal->reason->wasForceStopped() ? 'yes' : 'no') . "\n"; } // Or get a human-readable explanation echo $continuation->explain(); // "Stop Signals: steps_limit: Step limit reached: 10/10; Continuation Requested: No" // @doctest id="2f2a" ``` ## Serialization All stop condition components support full serialization for persistence and debugging: ```php // StopSignal $data = $signal->toArray(); $signal = StopSignal::fromArray($data); // StopSignals collection $data = $signals->toArray(); $signals = StopSignals::fromArray($data); // ExecutionContinuation $data = $continuation->toArray(); $continuation = ExecutionContinuation::fromArray($data); // @doctest id="8d7a" ``` This makes it straightforward to persist the complete stop state alongside agent state when saving executions to a database or transferring them across process boundaries. ## Combining Guards and Stop Tools A typical agent setup combines guard hooks (to enforce resource limits) with a stop tool (to allow the model to signal task completion): ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Core\UseGuards; use Cognesy\Agents\Capability\Core\UseTools; $agent = AgentBuilder::base() ->withCapability(new UseGuards( maxSteps: 20, maxTokens: 16000, maxExecutionTime: 60.0, )) ->withCapability(new UseTools(new SubmitAnswerTool())) ->build(); // @doctest id="6ab1" ``` In this configuration, the agent will stop when any of these conditions is met: 1. The model calls `SubmitAnswerTool`, which throws `AgentStopException` 2. The step count reaches 20 3. Cumulative token usage exceeds 16,000 4. Wall-clock time exceeds 60 seconds 5. The model produces a final response with no tool calls (natural completion) ## Cooperative Cancellation The `UseCooperativeCancellation` capability lets external code request that a running agent stop — without subclassing `AgentLoop` or writing custom hook logic. Cancellation is **cooperative and checkpoint-based**: the loop checks for a signal at `BeforeExecution` and `BeforeStep`. It will not interrupt an in-flight LLM call or tool execution mid-stream. If the agent is between steps when the request arrives, it stops cleanly on the next checkpoint. ### Basic Usage ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Cancellation\InMemoryCancellationSource; use Cognesy\Agents\Capability\Cancellation\UseCooperativeCancellation; $source = new InMemoryCancellationSource(); $agent = AgentBuilder::base() ->withCapability(new UseCooperativeCancellation($source)) ->build(); // Cancel from a signal handler, timeout, or concurrent request: $source->cancel('user pressed stop'); $result = $agent->execute($state); // $result->stopReason() === StopReason::UserRequested // @doctest id="b98b" ``` `InMemoryCancellationSource` also exposes `reset()` and `isCancellationRequested()` for inspection and reuse across executions. ### Custom Cancellation Sources Implement `CanProvideCancellationSignal` to integrate any external cancel mechanism — a Redis key, database flag, HTTP endpoint, or PHP signal handler: ```php use Cognesy\Agents\Capability\Cancellation\CanProvideCancellationSignal; use Cognesy\Agents\Continuation\StopSignal; use Cognesy\Agents\Data\AgentState; class RedisCancellationSource implements CanProvideCancellationSignal { public function cancellationSignal(AgentState $state): ?StopSignal { $key = "agent:cancel:{$state->agentId()}"; return $this->redis->exists($key) ? StopSignal::userRequested('cancelled via redis', source: self::class) : null; } } // @doctest id="2966" ``` The method receives the full `AgentState`, so you can scope cancellation to a specific agent ID, execution ID, or session. ### Cancellation vs. Hard Interruption Unlike thread-based cancellation tokens (e.g. `CancellationToken` in .NET or `context.Context` in Go), cooperative cancellation only stops the loop at safe checkpoints. An ongoing HTTP request to the LLM or a running tool will complete before the loop checks for the signal. If you need to cancel mid-request, that requires interrupting the underlying HTTP transport — which is outside the scope of this capability. ## Quick Reference | I want to... | Use... | |--------------|--------| | Stop after N steps | `UseGuards(maxSteps: N)` or register `StepsLimitHook` directly | | Stop after N tokens | `UseGuards(maxTokens: N)` or register `TokenUsageLimitHook` directly | | Stop after N seconds | `UseGuards(maxExecutionTime: N)` or register `ExecutionTimeLimitHook` directly | | Stop on LLM finish reason | `UseGuards(finishReasons: [...])` or register `FinishReasonHook` directly | | Stop from inside a tool | Throw `AgentStopException` with a `StopSignal` | | Stop from a custom hook | Emit a `StopSignal` via `$state->withStopSignal()` | | Cancel from outside the loop | `UseCooperativeCancellation` + `CanProvideCancellationSignal` | | Override a stop signal | Call `$state->withExecutionContinued()` in a hook | | Check why the agent stopped | Inspect `$state->executionContinuation()->stopSignals()` | | Check if stop was forced | Call `$signal->reason->wasForceStopped()` | | Get human-readable stop info | Call `$continuation->explain()` | ================================================================================ FILE: packages/agents/10-testing.md ================================================================================ # Testing Agents The Agents package ships with first-class testing primitives that let you exercise agent behavior without making real LLM calls. By combining `FakeAgentDriver` with `FakeTool`, you can script deterministic scenarios, assert on individual steps, and verify that your agent's tool-calling logic, error handling, and multi-step loops behave exactly as expected. ## FakeAgentDriver `FakeAgentDriver` replaces a real driver (such as `ToolCallingDriver` or `ReActDriver`) with a scripted sequence of steps. Each step defines what the "LLM" would return -- a final response, a tool call, an intermediate message, or an error. The driver advances through the script one step per loop iteration, giving you full control over the agent's behavior. ### Creating a Driver from Simple Responses The simplest way to create a fake driver is from one or more string responses. Each string becomes a `FinalResponse` step, meaning the agent loop will stop after consuming it: ```php use Cognesy\Agents\Drivers\Testing\FakeAgentDriver; // Single response -- the agent loop runs one step and stops $driver = FakeAgentDriver::fromResponses('Hello!'); // Multiple responses -- each subsequent execute() call consumes the next response $driver = FakeAgentDriver::fromResponses( 'First execution result', 'Second execution result', ); // @doctest id="db52" ``` When all scripted steps are exhausted, the driver replays the last step indefinitely. This is useful for agents that may be executed multiple times against the same driver instance. ### Creating a Driver from Scenario Steps For more sophisticated tests -- especially those involving tool calls -- use `ScenarioStep` objects directly: ```php use Cognesy\Agents\Drivers\Testing\FakeAgentDriver; use Cognesy\Agents\Drivers\Testing\ScenarioStep; $driver = FakeAgentDriver::fromSteps( ScenarioStep::toolCall('search', ['query' => 'php'], 'Results found'), ScenarioStep::final('Based on the search, here is the answer.'), ); // @doctest id="2446" ``` In this example, the first iteration produces a tool call step (the loop continues), and the second iteration produces a final response (the loop stops). ## ScenarioStep Types `ScenarioStep` is a readonly value object that describes what a single agent loop iteration should produce. Four factory methods cover the common cases: ### `ScenarioStep::final()` Produces a `FinalResponse` step. The agent loop recognizes this as a terminal response and stops iterating: ```php ScenarioStep::final('The answer is 42.'); // @doctest id="8188" ``` ### `ScenarioStep::tool()` Produces a `ToolExecution` step type **without** attaching any tool calls. This is useful for simulating intermediate LLM responses that signal the loop should continue (because the step type is `ToolExecution`), but where no actual tool invocation is needed: ```php ScenarioStep::tool('Thinking about the problem...'); // @doctest id="05d0" ``` > **Note:** Because no tool calls are attached, this step will not trigger the `ToolExecutor`. If you need actual tool execution, use `ScenarioStep::toolCall()` instead. ### `ScenarioStep::error()` Produces an `Error` step. The step is created with a `RuntimeException` attached, which the agent loop treats as a failure: ```php ScenarioStep::error('Something went wrong'); // @doctest id="deb5" ``` ### `ScenarioStep::toolCall()` Produces a `ToolExecution` step **with** a tool call attached. This is the most powerful step type -- it simulates the LLM requesting a specific tool and optionally executes it through the `ToolExecutor`: ```php ScenarioStep::toolCall( toolName: 'bash', args: ['command' => 'ls -la'], response: '', // Optional LLM text alongside the tool call executeTools: true, // Whether to actually run the tool (default: true) ); // @doctest id="b455" ``` When `executeTools` is `true`, the tool call is forwarded to the `ToolExecutor`, which resolves the tool from the `Tools` collection and invokes it. Set it to `false` to skip execution entirely -- useful when you only need to verify that the correct tool call was produced. ### Custom Usage Tracking All step factories accept an optional `InferenceUsage` parameter for testing token-budget guards or usage reporting: ```php use Cognesy\Polyglot\Inference\Data\InferenceUsage; ScenarioStep::final('Done.', usage: new InferenceUsage(inputTokens: 100, outputTokens: 50)); ScenarioStep::toolCall('search', ['q' => 'test'], usage: new InferenceUsage(200, 80)); // @doctest id="e5a8" ``` ## FakeTool `FakeTool` creates tool stubs that implement both `ToolInterface` and `CanDescribeTool`. They can be registered in a `Tools` collection and will be resolved by the `ToolExecutor` when a matching tool call arrives. ### Fixed Return Value The simplest mock returns the same value regardless of the arguments passed: ```php use Cognesy\Agents\Tool\Tools\FakeTool; $tool = FakeTool::returning('search', 'Search the web', 'PHP is great'); // @doctest id="ad6d" ``` The three arguments are: tool name, description (used in the tool schema), and the fixed return value. ### Custom Logic For more realistic stubs, pass a callable that receives the tool's arguments and returns a result: ```php $tool = new FakeTool( name: 'format', description: 'Format a string', handler: fn(string $text) => strtoupper($text), ); // @doctest id="d8c2" ``` The callable is invoked with the same arguments the LLM would pass via the tool call. The return value is wrapped in a `Result::from()` automatically. ### Custom Schema and Metadata When you need the fake to advertise a specific JSON Schema (for example, to test schema validation), pass the `schema` parameter: ```php $tool = new FakeTool( name: 'calculate', description: 'Perform arithmetic', handler: fn(float $a, float $b) => $a + $b, schema: [ 'type' => 'function', 'function' => [ 'name' => 'calculate', 'description' => 'Perform arithmetic', 'parameters' => [ 'type' => 'object', 'properties' => [ 'a' => ['type' => 'number'], 'b' => ['type' => 'number'], ], 'required' => ['a', 'b'], ], ], ], ); // @doctest id="6e99" ``` ## Full Test Example The following Pest test demonstrates the complete pattern: create a `FakeTool`, script a `FakeAgentDriver` with a tool-call step followed by a final-response step, wire them into an `AgentLoop`, and assert on the result: ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Collections\Tools; use Cognesy\Agents\Data\AgentState; use Cognesy\Agents\Drivers\Testing\FakeAgentDriver; use Cognesy\Agents\Drivers\Testing\ScenarioStep; use Cognesy\Agents\Enums\AgentStepType; use Cognesy\Agents\Enums\ExecutionStatus; use Cognesy\Agents\Tool\Tools\FakeTool; it('executes tools and produces final response', function () { // 1. Create a fake tool that always returns the same string $tool = FakeTool::returning('search', 'Search the web', 'PHP is great'); // 2. Script the scenario: one tool call, then a final answer $driver = FakeAgentDriver::fromSteps( ScenarioStep::toolCall('search', ['query' => 'php']), ScenarioStep::final('PHP is a programming language.'), ); // 3. Build the loop with the fake tool and fake driver $loop = AgentLoop::default() ->withTools(new Tools($tool)) ->withDriver($driver); // 4. Run the agent $state = AgentState::empty()->withUserMessage('Tell me about PHP'); $result = $loop->execute($state); // 5. Assert on the outcome expect($result->stepCount())->toBe(2); expect($result->status())->toBe(ExecutionStatus::Completed); expect($result->finalResponse()->toString())->toContain('PHP'); expect($result->hasErrors())->toBeFalse(); }); // @doctest id="9765" ``` ## Using `iterate()` for Step-Level Testing The `AgentLoop::iterate()` method returns a generator that yields the `AgentState` after each completed step. This gives you fine-grained visibility into intermediate states -- useful for asserting on tool execution ordering, intermediate messages, or guard behavior: ```php use Cognesy\Agents\Enums\AgentStepType; $steps = []; foreach ($loop->iterate($state) as $stepState) { $steps[] = $stepState; } // The first yielded state is after the tool-call step expect($steps[0]->lastStepType())->toBe(AgentStepType::ToolExecution); // The second yielded state is after the final-response step expect($steps[1]->lastStepType())->toBe(AgentStepType::FinalResponse); expect($steps[1]->status())->toBe(ExecutionStatus::Completed); // @doctest id="3feb" ``` > **Tip:** The final state yielded by `iterate()` includes the `withExecutionCompleted()` transition, so you can also assert on `ExecutionStatus` and total usage. ## Testing Error Handling You can verify that your agent handles errors gracefully by scripting error steps: ```php it('handles tool errors without crashing', function () { $tool = new FakeTool( name: 'flaky_api', description: 'An unreliable API', handler: fn() => throw new \RuntimeException('API timeout'), ); $driver = FakeAgentDriver::fromSteps( ScenarioStep::toolCall('flaky_api', []), ScenarioStep::final('I could not reach the API.'), ); $loop = AgentLoop::default() ->withTools(new Tools($tool)) ->withDriver($driver); $state = AgentState::empty()->withUserMessage('Call the API'); $result = $loop->execute($state); // The tool error is recorded but the agent continues to the final response expect($result->hasFinalResponse())->toBeTrue(); }); // @doctest id="ddf5" ``` ## Testing Subagent Scenarios `FakeAgentDriver` supports child steps for subagent testing. When the driver is cloned for a subagent (via `withLLMProvider()` or `withLLMConfig()`), it uses the child steps instead of the parent steps: ```php $driver = FakeAgentDriver::fromSteps( ScenarioStep::toolCall('delegate', ['task' => 'research']), ScenarioStep::final('Research complete.'), )->withChildSteps([ ScenarioStep::final('Subagent result: found 42 papers.'), ]); // @doctest id="c1fc" ``` If no child steps are provided, subagent drivers default to a single `ScenarioStep::final('ok')`. ## Testing with Events You can attach event listeners to the `AgentLoop` to capture events emitted during execution. This is useful for verifying that specific lifecycle events fire at the right time: ```php use Cognesy\Agents\Events\AgentStepCompleted; $stepsCompleted = []; $loop = AgentLoop::default() ->withTools(new Tools($tool)) ->withDriver($driver); $loop->onEvent(AgentStepCompleted::class, function (AgentStepCompleted $event) use (&$stepsCompleted) { $stepsCompleted[] = $event; }); $result = $loop->execute($state); expect($stepsCompleted)->toHaveCount(2); // @doctest id="e702" ``` ## Summary | Component | Purpose | |---|---| | `FakeAgentDriver` | Replaces the LLM driver with a scripted sequence of steps | | `ScenarioStep` | Describes a single loop iteration (final, tool, error, or toolCall) | | `FakeTool` | Stubs a tool with a fixed return value or custom callable | | `AgentLoop::iterate()` | Yields state after each step for fine-grained assertions | | `withChildSteps()` | Scripts subagent behavior when using `FakeAgentDriver` | ================================================================================ FILE: packages/agents/11-state-internals.md ================================================================================ # Agent State Internals Every agent execution revolves around a single, immutable data structure: `AgentState`. This object carries the full picture of an agent's identity, conversation context, and execution progress. Understanding its internal structure is essential for building custom guards, hooks, and persistence layers. ## Design Philosophy `AgentState` follows two core principles: 1. **Immutability.** The class is declared `final readonly`. Every mutation method (`with*`, `forNextExecution`, etc.) returns a new instance, leaving the original untouched. This makes state transitions explicit and safe for concurrent inspection. 2. **Session vs. Execution separation.** Some data persists across executions (identity, context, message history), while other data is transient and scoped to a single execution (step results, timing, continuation signals). This split is represented by the nullable `ExecutionState` property. ## AgentState Structure The following diagram shows the complete object graph: ``` AgentState (final readonly) |-- agentId: AgentId # typed UUID, auto-generated |-- parentAgentId: ?AgentId # set when running as a subagent |-- createdAt: DateTimeImmutable # when the state was first created |-- updatedAt: DateTimeImmutable # bumped on every mutation |-- executionCount: int # increments with each execution |-- llmConfig: ?LLMConfig # optional per-agent LLM override |-- context: AgentContext | |-- store: MessageStore # underlying message storage | |-- metadata: Metadata # arbitrary key-value pairs | |-- systemPrompt: string # system-level instructions | |-- responseFormat: ResponseFormat |-- execution: ?ExecutionState # null between executions |-- executionId: ExecutionId # unique ID for this execution |-- status: ExecutionStatus # Pending|InProgress|Completed|Stopped|Failed |-- startedAt: DateTimeImmutable |-- completedAt: ?DateTimeImmutable |-- stepExecutions: StepExecutions # completed steps |-- continuation: ExecutionContinuation | |-- stopSignals: StopSignals # signals requesting execution to stop | |-- isContinuationRequested: bool |-- currentStepStartedAt: ?DateTimeImmutable |-- currentStep: ?AgentStep # the in-progress step |-- id: AgentStepId |-- inputMessages: Messages |-- outputMessages: Messages |-- inferenceResponse: InferenceResponse |-- toolExecutions: ToolExecutions |-- errors: ErrorList // @doctest id="f565" ``` ### Session Data (Persists Across Executions) Session-level properties survive between executions. When you call `forNextExecution()`, these fields are preserved while `execution` is reset to `null`: - **`agentId`** -- A typed UUID (`AgentId`) that uniquely identifies the agent instance. Generated automatically on construction. - **`parentAgentId`** -- Set when the agent is spawned as a subagent. Enables parent-child correlation in event tracing. - **`createdAt` / `updatedAt`** -- Timestamps for lifecycle tracking. `updatedAt` is bumped on every mutation via `with()`. - **`executionCount`** -- Monotonically increasing counter. Incremented by `AgentLoop::onBeforeExecution()` at the start of each execution. Useful for guards that behave differently on the first execution. - **`llmConfig`** -- Optional `LLMConfig` override. When set, the driver uses this configuration instead of its default provider settings. - **`context`** -- The `AgentContext` containing the message history, system prompt, metadata, and response format. ### Execution Data (Transient Per Execution) The `execution` property holds an `ExecutionState` that is created fresh at the start of each execution and discarded (set to `null`) when the execution completes: - **`executionId`** -- A unique `ExecutionId` for correlation. Generated via `ExecutionState::fresh()`. - **`status`** -- An `ExecutionStatus` enum tracking the execution lifecycle. - **`stepExecutions`** -- A `StepExecutions` collection of completed `StepExecution` objects. Each wraps an `AgentStep` together with its timing and continuation state. - **`continuation`** -- An `ExecutionContinuation` that holds stop signals and continuation requests. The agent loop consults this after each step to decide whether to continue or stop. - **`currentStep`** -- The `AgentStep` currently being processed. Set by the driver via `withCurrentStep()`, then archived into `stepExecutions` when `withCurrentStepCompleted()` is called. ## ExecutionStatus Lifecycle `ExecutionStatus` is a string-backed enum with five cases: | Status | Description | |---|---| | `Pending` | Between executions, ready for a fresh start | | `InProgress` | Execution is actively running | | `Completed` | Execution finished successfully | | `Stopped` | Execution was force-stopped by a guard, budget limit, or external request | | `Failed` | Execution encountered an unrecoverable error | The `AgentLoop` manages these transitions automatically: ``` Pending/null --> InProgress (onBeforeExecution) InProgress --> Completed (all steps done, no errors) InProgress --> Stopped (force-stopped by guard or stop signal) InProgress --> Failed (exception caught or errors accumulated) // @doctest id="3813" ``` ## AgentStep Internals Each step in the execution is represented by an `AgentStep` -- an immutable snapshot of what happened during a single driver invocation: ```php final readonly class AgentStep { private AgentStepId $id; // Unique step identifier private Messages $inputMessages; // Messages sent to the LLM private Messages $outputMessages; // Messages produced by the step private InferenceResponse $inferenceResponse; // Raw LLM response private ToolExecutions $toolExecutions; // Tool execution results private ErrorList $errors; // Accumulated errors } // @doctest id="e2e1" ``` The step type is **derived**, not stored. `AgentStep::stepType()` inspects the step's contents to determine its type: 1. If the step has errors (including tool execution errors), the type is `AgentStepType::Error`. 2. If the step has requested tool calls, the type is `AgentStepType::ToolExecution`. 3. Otherwise, the type is `AgentStepType::FinalResponse`. This derivation means you never need to manually set the step type -- it is always consistent with the step's actual contents. ### StepExecution Wrapper When a step is completed, it is wrapped in a `StepExecution` that bundles the step with timing and continuation data: ```php final readonly class StepExecution { private AgentStepId $id; // Follows AgentStep identity private AgentStep $step; // The completed step private ExecutionContinuation $continuation; // Stop signals at completion time private DateTimeImmutable $startedAt; private DateTimeImmutable $completedAt; } // @doctest id="036e" ``` This separation keeps `AgentStep` focused on what happened (messages, tools, errors) while `StepExecution` owns when it happened and whether the loop should continue. ## Message Metadata Tagging When a step's output messages are appended to the agent context, `AgentState::withCurrentStep()` automatically tags each message with metadata: - **`step_id`** -- The `AgentStepId` of the step that produced the message. - **`execution_id`** -- The `ExecutionId` of the current execution. - **`agent_id`** -- The `AgentId` of the agent. - **`is_trace`** -- Set to `true` for non-final steps (tool execution, error). Final response messages do not carry this flag. This metadata enables downstream compilers (such as `ConversationWithCurrentToolTrace`) to filter messages at read-time based on their origin, without modifying the underlying message store. ## Key Accessors `AgentState` provides a rich set of accessors for inspecting the current state at any point during or after execution: ### Identity and Timing ```php $state->agentId()->toString(); // UUID string $state->parentAgentId(); // ?AgentId -- null for root agents $state->createdAt(); // DateTimeImmutable $state->updatedAt(); // DateTimeImmutable -- bumped on every mutation $state->executionCount(); // int -- how many times the agent has been executed $state->executionDuration(); // ?float -- seconds elapsed in current execution // @doctest id="047a" ``` ### Context ```php $state->messages(); // Messages -- compiled message list $state->store(); // MessageStore -- raw message storage $state->metadata(); // Metadata -- arbitrary key-value pairs $state->context()->systemPrompt(); // string -- the system prompt // @doctest id="00ef" ``` ### Execution State ```php $state->status(); // ?ExecutionStatus -- null if between executions $state->execution(); // ?ExecutionState -- null if between executions $state->execution()?->executionId()->toString(); // UUID of current execution $state->stepCount(); // int -- number of steps in current execution $state->steps(); // AgentSteps -- collection of completed steps $state->lastStep(); // ?AgentStep -- most recently completed step $state->lastStepType(); // ?AgentStepType -- ToolExecution|FinalResponse|Error $state->stopReason(); // ?StopReason -- why execution stopped $state->usage(); // InferenceUsage -- accumulated token usage $state->hasErrors(); // ?bool -- whether any errors occurred $state->errors(); // ErrorList -- all accumulated errors // @doctest id="e516" ``` ### Final Output ```php $state->hasFinalResponse(); // bool -- true if the last step is a FinalResponse $state->finalResponse()->toString(); // string -- the final response text $state->currentResponse(); // Messages -- final response or latest step output // @doctest id="1976" ``` ## Continuation and Stop Signals The agent loop uses `ExecutionContinuation` to decide whether to keep iterating. After each step, the loop calls `$state->shouldStop()`, which delegates to: ```php class ExecutionState { // shouldStop() public function shouldStop(): bool { return match(true) { $this->continuation->shouldStop() => true, // Stop signals present and no override $this->continuation->isContinuationRequested() => false, // Hook requested continuation $this->hasToolCalls() => false, // Tool calls need execution default => true, // No tool calls, no continuation -- stop }; } } // @doctest id="9162" ``` Stop signals carry a `StopReason` enum with prioritized cases: | Priority | StopReason | Description | |---|---|---| | 0 (highest) | `ErrorForbade` | An error prevented continuation | | 1 | `StopRequested` | Explicit stop via `AgentStopException` | | 2 | `StepsLimitReached` | Step budget exhausted | | 3 | `TokenLimitReached` | Token budget exhausted | | 4 | `TimeLimitReached` | Time budget exhausted | | 5 | `RetryLimitReached` | Maximum retries exceeded | | 6 | `FinishReasonReceived` | LLM signaled completion | | 7 | `UserRequested` | External user request | | 8 | `Completed` | Normal completion | | 9 (lowest) | `Unknown` | Unspecified reason | Multiple stop signals can coexist. The `wasForceStopped()` method on `StopReason` returns `true` for all reasons except `Completed` and `FinishReasonReceived`, which represent natural completion. ## ExecutionBudget `ExecutionBudget` declares per-execution resource limits. It is defined on an `AgentDefinition` and applied as a `UseGuards` capability when the agent loop is built -- it is **not** stored inside `AgentState`. ```php use Cognesy\Agents\Data\ExecutionBudget; $budget = new ExecutionBudget( maxSteps: 20, // Maximum number of loop iterations maxTokens: 10000, // Maximum total tokens (input + output) maxSeconds: 60.0, // Maximum wall-clock seconds maxCost: 0.50, // Maximum cost in dollars deadline: new DateTimeImmutable('2025-12-31 23:59:59'), // Absolute deadline ); // @doctest id="5952" ``` All limits are optional -- pass `null` (or omit) for unlimited. You can check whether a budget has any limits set with `isEmpty()`, or whether all limits have been exhausted with `isExhausted()`. The `ExecutionBudget::unlimited()` factory returns a budget with all limits set to `null`: ```php $unlimited = ExecutionBudget::unlimited(); assert($unlimited->isEmpty() === true); // @doctest id="ec04" ``` Each subagent receives its own declared budget. Recursion depth is controlled separately via `SubagentPolicy` (`maxDepth`), not through the budget. ## Debugging `AgentState::debug()` returns an associative array summarizing the current state -- useful for logging or test assertions: ```php $info = $state->debug(); // [ // 'status' => ExecutionStatus::Completed, // 'executionCount' => 1, // 'hasExecution' => true, // 'executionId' => 'a1b2c3d4-...', // 'steps' => 3, // 'continuation' => 'No Stop Signals; Continuation Requested: No', // 'hasErrors' => false, // 'errors' => ErrorList::empty(), // 'usage' => ['inputTokens' => 150, 'outputTokens' => 42, ...], // ] // @doctest id="eb2e" ``` ## Serialization All state objects implement `toArray()` and `fromArray()` for persistence and hydration. This covers the full object graph -- `AgentState`, `ExecutionState`, `AgentStep`, `StepExecution`, `ToolExecution`, and `ExecutionContinuation`: ```php // Serialize the entire state to a plain array $data = $state->toArray(); // Restore the state from a plain array $restored = AgentState::fromArray($data); // Everything round-trips correctly expect($restored->agentId()->toString())->toBe($state->agentId()->toString()); expect($restored->stepCount())->toBe($state->stepCount()); expect($restored->status())->toBe($state->status()); // @doctest id="e529" ``` This is the foundation for session persistence. The `SessionStore` implementations use `toArray()` / `fromArray()` to save and restore agent state between requests or across process boundaries. ### Serialization Scope | Object | `toArray()` | `fromArray()` | |---|---|---| | `AgentState` | Full state including context and execution | Restores all fields | | `ExecutionState` | Execution ID, status, timing, steps, continuation | Restores all fields | | `AgentStep` | Step ID, messages, inference response, tool executions, errors | Restores all fields | | `StepExecution` | Step data, continuation, timing | Restores all fields | | `ToolExecution` | Tool call, result/error, timing | Restores all fields | | `ExecutionBudget` | All limit values | Restores all limits | | `ExecutionContinuation` | Stop signals, continuation flag | Restores all fields | ## Key Gotcha: `ensureExecution()` Creates Fresh State The private `ensureExecution()` method returns `ExecutionState::fresh()` with a **new UUID** when `execution` is `null`. This means calling it twice produces different execution IDs. The `AgentLoop` handles this correctly, but if you are building custom orchestration, be aware that you must capture and reuse the returned state: ```php // WRONG -- two different execution IDs $state->withStopSignal($signal); // internally calls ensureExecution() $state->withCurrentStep($step); // internally calls ensureExecution() again -- different ID! // CORRECT -- chain mutations on the same state $state = $state->withCurrentStep($step)->withStopSignal($signal); // @doctest id="fec0" ``` ================================================================================ FILE: packages/agents/12-tool-calling-internals.md ================================================================================ # Tool Calling Internals > Most users can skip this page. > For day-to-day usage, start with [Basic Agent](02-basic-agent.md), [Tools](05-tools.md), and [AgentBuilder & Capabilities](13-agent-builder.md). The agent's ability to use tools is built on a clean separation of concerns: a **driver** decides which tools to call (by consulting the LLM), and an **executor** runs the actual tools. Two contracts define this boundary, and three driver implementations satisfy the first contract in different ways. ## Architecture Overview ``` AgentLoop |-- CanUseTools (driver) # decides what tools to call | |-- ToolCallingDriver # native LLM function calling | |-- ReActDriver # Thought/Action/Observation via structured output | |-- FakeAgentDriver # scripted responses for testing | |-- CanExecuteToolCalls (executor) # runs the actual tools |-- ToolExecutor # default implementation // @doctest id="f487" ``` The `AgentLoop` owns both the driver and the executor. Before the first step, it binds the tool runtime to the driver via `CanAcceptToolRuntime::withToolRuntime()`, ensuring the driver has access to the same `Tools` collection and `ToolExecutor` that the loop manages. This binding happens once per `execute()` / `iterate()` call. ## The Two Contracts ### CanUseTools (Driver Contract) The driver receives the current `AgentState`, consults the LLM (or a scripted scenario), and returns an updated state with a new `AgentStep` attached. The step may contain tool calls, a final response, or an error: ```php interface CanUseTools { public function useTools(AgentState $state): AgentState; } // @doctest id="7a70" ``` The driver is responsible for: - Compiling messages from state via `CanCompileMessages` - Sending the messages to the LLM with tool schemas - Parsing the LLM response for tool calls - Delegating tool execution to the `ToolExecutor` - Formatting execution results as follow-up messages - Building and attaching the `AgentStep` to the returned state ### CanExecuteToolCalls (Executor Contract) The executor receives a set of `ToolCalls` and the current `AgentState`, runs each tool, and returns the results: ```php interface CanExecuteToolCalls { public function executeTools(ToolCalls $toolCalls, AgentState $state): ToolExecutions; } // @doctest id="7712" ``` The executor is responsible for: - Resolving tool instances from the `Tools` collection - Injecting context (agent state, tool call metadata) into tools that request it - Validating arguments against the tool schema - Running the tool and capturing the result - Handling errors, interception hooks, and events ## ToolCallingDriver `ToolCallingDriver` uses the LLM's **native function calling API**. This is the default driver created by `AgentLoop::default()` and is the recommended choice for models that support function calling (GPT-4o, Claude, Gemini, etc.). ### How It Works Each invocation of `useTools()` follows this sequence: 1. **Compile messages.** The message compiler (default: `ConversationWithCurrentToolTrace`) produces a `Messages` collection from the agent state. This compiler includes the full conversation history plus trace messages from the current execution only. 2. **Build the inference request.** The driver assembles an `InferenceRequest` with the compiled messages, tool schemas from the `Tools` collection, the model name, tool choice strategy, and any cached context. 3. **Send to the LLM.** The request is dispatched through the `InferenceRuntime`, which handles provider-specific API formatting, retries, and streaming. 4. **Parse tool calls.** The `InferenceResponse` is inspected for `toolCalls`. If present, they are forwarded to the `ToolExecutor`. 5. **Execute tools.** The `ToolExecutor` runs each tool call and returns `ToolExecutions`. 6. **Format results.** The `ToolExecutionFormatter` converts each `ToolExecution` into a pair of messages: an assistant message with `tool_calls` metadata, and a `tool` role message with the execution result (or error). 7. **Build the step.** An `AgentStep` is created with the input messages, output messages, inference response, and tool executions, then attached to the state via `withCurrentStep()`. ### Configuration ```php use Cognesy\Polyglot\Inference\InferenceRuntime; use Cognesy\Polyglot\Inference\LLMProvider; use Cognesy\Agents\Drivers\ToolCalling\ToolCallingDriver; use Cognesy\Events\Dispatchers\EventDispatcher; $llm = LLMProvider::new(); $events = new EventDispatcher('agent'); $inference = InferenceRuntime::fromProvider($llm, events: $events); $driver = new ToolCallingDriver( inference: $inference, llm: $llm, toolChoice: ToolChoice::auto(), // auto, required, none, or specific model: 'gpt-4o', options: [], // additional provider-specific options events: $events, ); // @doctest id="deb1" ``` > **Note:** You will need `use Cognesy\Polyglot\Inference\Data\ToolChoice;` for the `ToolChoice` value object. ### Tool Choice Strategies The `toolChoice` parameter accepts a `ToolChoice` value object: | Factory Method | Behavior | |---|---| | `ToolChoice::auto()` | The LLM decides whether to call a tool or respond directly (default) | | `ToolChoice::required()` | The LLM must call at least one tool | | `ToolChoice::none()` | Tool calling is disabled; the LLM responds with text only | | `ToolChoice::specific('toolName')` | The LLM must call the specified tool | ### Tool Args Leak Protection Some LLM providers accidentally echo tool call arguments as the response content. The `ToolCallingDriver` detects this by parsing the content as JSON and comparing it against the tool call arguments. If they match, the content is silently discarded to prevent duplicate data in the conversation. ## ReActDriver `ReActDriver` implements the **ReAct (Reasoning + Acting)** pattern using structured output extraction. Instead of relying on native function calling, it prompts the LLM to output a JSON decision with explicit `thought`, `type`, `tool`, `args`, and `answer` fields. ### How It Works 1. **Build system prompt.** The `MakeReActPrompt` action generates a system prompt that describes the available tools and the expected ReAct JSON format. 2. **Extract decision.** The `StructuredOutputRuntime` extracts a `ReActDecision` object from the LLM response. This uses the configured `OutputMode` (typically JSON) and includes retry logic for extraction failures. 3. **Validate decision.** The `ReActValidator` checks that the decision has a valid type, references an existing tool, and includes valid arguments. 4. **Route by type.** - If the decision type is `call_tool`: convert it to `ToolCalls`, execute via the `ToolExecutor`, and format the results as Thought/Action/Observation messages. - If the decision type is `final_answer`: extract the answer text and build a final response step. 5. **Optional final inference.** When `finalViaInference` is `true`, the driver makes a separate LLM call to produce the final answer, using the full conversation as context. This can improve answer quality at the cost of an extra API call. ### Configuration ```php use Cognesy\Polyglot\Inference\InferenceRuntime; use Cognesy\Polyglot\Inference\LLMProvider; use Cognesy\Agents\Drivers\ReAct\ReActDriver; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Instructor\Creation\StructuredOutputConfigBuilder; use Cognesy\Instructor\Enums\OutputMode; use Cognesy\Events\Dispatchers\EventDispatcher; $llm = LLMProvider::new(); $events = new EventDispatcher('agent'); $inference = InferenceRuntime::fromProvider($llm, events: $events); $structuredOutput = new StructuredOutputRuntime( inference: $inference, events: $events, config: (new StructuredOutputConfigBuilder()) ->withOutputMode(OutputMode::Json) ->withMaxRetries(2) ->create(), ); $driver = new ReActDriver( inference: $inference, structuredOutput: $structuredOutput, llm: $llm, model: 'gpt-4o', mode: OutputMode::Json, maxRetries: 2, // retries on decision extraction failure finalViaInference: false, // use a separate LLM call for the final answer finalModel: null, // optional different model for final answer finalOptions: [], // optional different options for final answer ); // @doctest id="7192" ``` ### Error Handling The `ReActDriver` handles two categories of extraction failures: - **Extraction failure.** If the `StructuredOutputRuntime` cannot parse the LLM output into a `ReActDecision`, the driver builds a failure step with a `decision_extraction` pseudo-tool execution and marks the state as failed. - **Validation failure.** If the decision is extracted but fails validation (invalid type, unknown tool, missing arguments), the driver builds a failure step with a `decision_validation` pseudo-tool execution and marks the state as failed. Both failure types emit dedicated events (`DecisionExtractionFailed`, `ValidationFailed`) for observability. ## ToolExecutor `ToolExecutor` is the default `CanExecuteToolCalls` implementation. It is created automatically by `AgentLoop::default()` and handles the complete lifecycle of executing a tool call, including interception hooks, event emission, and error handling. ### Execution Pipeline For each tool call in the `ToolCalls` collection, the executor runs this pipeline: ``` 1. beforeToolUse intercept |-- Interceptor can modify the tool call |-- Interceptor can modify the agent state |-- Interceptor can block execution (returns ToolExecution::blocked()) | 2. Emit ToolCallStarted event | 3. Prepare tool |-- Resolve tool instance from Tools collection |-- Inject AgentState if tool implements CanAccessAgentState |-- Inject ToolCall if tool implements CanAccessToolCall | 4. Validate arguments |-- Check required parameters from the tool schema |-- Return Failure result if parameters are missing | 5. Execute |-- Call $tool->use(...$args) |-- Wrap exceptions in ToolExecutionException |-- AgentStopException is re-thrown (not caught) | 6. Emit ToolCallCompleted event | 7. afterToolUse intercept |-- Interceptor can modify the execution result |-- Interceptor can modify the agent state // @doctest id="cd47" ``` ### Tool Context Injection Tools can opt into receiving execution context by implementing one or both of these interfaces: **`CanAccessAgentState`** -- The tool receives a read-only copy of the current `AgentState` before invocation. This is useful for tools that need to inspect the conversation history, metadata, or execution status: ```php use Cognesy\Agents\Tool\Contracts\CanAccessAgentState; use Cognesy\Agents\Data\AgentState; class ContextAwareTool implements ToolInterface, CanAccessAgentState { private ?AgentState $state = null; public function withAgentState(AgentState $state): static { $clone = clone $this; $clone->state = $state; return $clone; } public function use(mixed ...$args): Result { // Access conversation history, metadata, etc. $history = $this->state->messages(); // ... } } // @doctest id="b454" ``` **`CanAccessToolCall`** -- The tool receives the `ToolCall` object that triggered it. Useful for correlation and tracing, especially in subagent tools that emit their own events: ```php use Cognesy\Agents\Tool\Contracts\CanAccessToolCall; use Cognesy\Messages\ToolCall; class TracedTool implements ToolInterface, CanAccessToolCall { private ?ToolCall $toolCall = null; public function withToolCall(ToolCall $toolCall): static { $clone = clone $this; $clone->toolCall = $toolCall; return $clone; } } // @doctest id="07ef" ``` ### Configuration ```php use Cognesy\Agents\Tool\ToolExecutor; use Cognesy\Agents\Collections\Tools; use Cognesy\Events\Dispatchers\EventDispatcher; use Cognesy\Agents\Interception\PassThroughInterceptor; $executor = new ToolExecutor( tools: $tools, events: new EventDispatcher('agent'), interceptor: new PassThroughInterceptor(), throwOnToolFailure: false, // true = throw on the first tool error stopOnToolBlock: false, // true = stop executing remaining tools if one is blocked ); $loop = AgentLoop::default() ->withTools($tools) ->withToolExecutor($executor); // @doctest id="588f" ``` ### Error Handling Modes The `throwOnToolFailure` and `stopOnToolBlock` flags control how the executor responds to problems: | Flag | Default | When `true` | |---|---|---| | `throwOnToolFailure` | `false` | Throws a `ToolExecutionException` immediately when a tool returns a `Failure` result. The exception propagates to the `AgentLoop`, which catches it and marks the step as failed. | | `stopOnToolBlock` | `false` | When a `beforeToolUse` interceptor blocks a tool call, the executor stops processing remaining tool calls in the batch and returns what it has so far. | When both flags are `false` (the default), the executor collects all results -- successes, failures, and blocked executions -- and returns them as a `ToolExecutions` collection. The driver then formats them as messages and includes them in the step output, allowing the LLM to see and react to the errors on the next iteration. ## ToolExecution Result Each tool execution produces a `ToolExecution` value object containing: ```php final readonly class ToolExecution { private ToolExecutionId $id; // Unique execution identifier private ToolCall $toolCall; // The tool call that was executed private Result $result; // Success(value) or Failure(exception) private DateTimeImmutable $startedAt; private DateTimeImmutable $completedAt; } // @doctest id="64b8" ``` You can inspect the result using: ```php $execution->name(); // Tool name $execution->args(); // Arguments passed to the tool $execution->result(); // Result object (Success or Failure) $execution->value(); // Unwrapped value (null if failed) $execution->hasError(); // bool $execution->errorMessage(); // string $execution->wasBlocked(); // bool -- true if blocked by interceptor // @doctest id="f50d" ``` ## Message Formatting After tool execution, the results must be formatted as messages that the LLM can understand on the next iteration. Each driver handles this differently: ### ToolCallingDriver: Native Format The `ToolExecutionFormatter` produces two messages per tool execution: 1. **Assistant message** with `tool_calls` metadata -- represents the LLM's decision to call the tool. 2. **Tool message** with the execution result -- either the successful return value or an error description. Both messages carry a `tool_execution_id` metadata tag for correlation. ### ReActDriver: Observation Format The `ReActFormatter` produces messages in the Thought/Action/Observation pattern: 1. **Assistant message** containing the thought and action text from the `ReActDecision`. 2. **User message** (observation) containing the tool execution result, formatted as `Observation: `. ## Events Both drivers and the executor emit events at key lifecycle points. These can be observed via `AgentLoop::wiretap()` or `AgentLoop::onEvent()`: | Event | Emitted By | When | |---|---|---| | `InferenceRequestStarted` | Driver | Before sending the request to the LLM | | `InferenceResponseReceived` | Driver | After receiving the LLM response | | `ToolCallStarted` | ToolExecutor | Before executing a tool | | `ToolCallCompleted` | ToolExecutor | After a tool execution completes | | `DecisionExtractionFailed` | ReActDriver | When structured output extraction fails | | `ValidationFailed` | ReActDriver | When a ReAct decision fails validation | ## When to Use Which Driver | | ToolCallingDriver | ReActDriver | |---|---|---| | **Requires** | LLM with native function calling support | Any LLM capable of JSON output | | **Tool selection** | Native API -- reliable, low latency | Structured output extraction -- extra parsing step | | **Reasoning** | Implicit in the LLM's response | Explicit `thought` field in the decision | | **Reliability** | Higher (native API contract) | Lower (depends on extraction quality) | | **Flexibility** | Standard tool schemas only | Custom decision schemas possible | | **Retry support** | Handled by provider retry policy | Built-in `maxRetries` for extraction failures | | **Best for** | Production agents with capable models | Models without function calling, or when explicit reasoning traces are needed | ## Custom Drivers You can implement `CanUseTools` to create a custom driver. If your driver uses tools, also implement `CanAcceptToolRuntime` so the `AgentLoop` can inject the tool collection and executor: ```php use Cognesy\Agents\Drivers\CanUseTools; use Cognesy\Agents\Drivers\CanAcceptToolRuntime; use Cognesy\Agents\Collections\Tools; use Cognesy\Agents\Data\AgentState; use Cognesy\Agents\Tool\Contracts\CanExecuteToolCalls; class MyCustomDriver implements CanUseTools, CanAcceptToolRuntime { private Tools $tools; private CanExecuteToolCalls $executor; public function withToolRuntime(Tools $tools, CanExecuteToolCalls $executor): static { $clone = clone $this; $clone->tools = $tools; $clone->executor = $executor; return $clone; } public function useTools(AgentState $state): AgentState { // Your custom tool-calling logic here // Must return $state->withCurrentStep($step) } } // @doctest id="bce2" ``` The `AgentLoop` will call `withToolRuntime()` before the first step, passing the same `Tools` and `ToolExecutor` it manages internally. ================================================================================ FILE: packages/agents/13-agent-builder.md ================================================================================ ## Introduction When building agents, you will often find yourself repeating the same configuration: setting up an LLM provider, registering tools, attaching guard hooks, and wiring a message compiler. The `AgentBuilder` class provides a clean composition layer that lets you assemble fully configured `AgentLoop` instances from reusable, self-contained **capabilities**. Instead of manually constructing each dependency and passing them to `AgentLoop`, you describe what your agent should be able to do by stacking capabilities. Each capability encapsulates a single concern -- configuring the LLM provider, adding a tool, attaching a lifecycle hook, or enabling subagent delegation. The builder composes them all into a working agent in a single `build()` call. This approach makes agent configuration declarative, testable, and easy to share across your application. ## Quick Start The following example creates an agent that can execute bash commands, uses the Anthropic provider, and enforces step and token limits: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Bash\UseBash; use Cognesy\Agents\Capability\Core\UseGuards; use Cognesy\Agents\Capability\Core\UseLLMConfig; use Cognesy\Agents\Data\AgentState; use Cognesy\Polyglot\Inference\LLMProvider; $agent = AgentBuilder::base() ->withCapability(new UseLLMConfig( llm: LLMProvider::using('anthropic'), )) ->withCapability(new UseBash()) ->withCapability(new UseGuards(maxSteps: 20, maxTokens: 32768)) ->build(); $state = AgentState::empty()->withUserMessage('List files in /tmp'); $result = $agent->execute($state); // @doctest id="3595" ``` The `AgentBuilder::base()` factory creates a builder pre-configured with sensible defaults: a `ToolCallingDriver` backed by the default LLM provider, the `ConversationWithCurrentToolTrace` message compiler, and an empty hook stack. Every `withCapability()` call returns a **new builder instance** -- the builder is immutable, so you can safely branch configurations from a shared base. ## Immutability `AgentBuilder` is a `final readonly` class. Every `withCapability()` call returns a new builder instance with the capability appended, leaving the original unchanged. This means you can safely branch from a shared base without worrying about mutation side effects: ```php $base = AgentBuilder::base() ->withCapability(new UseLLMConfig(llm: LLMProvider::using('anthropic'))) ->withCapability(new UseGuards(maxSteps: 20)); // Two agents that share the same LLM config and guards $coder = $base ->withCapability(new UseBash()) ->withCapability(new UseFileTools('/my/project')) ->build(); $reviewer = $base ->withCapability(new UseFileTools('/my/project')) ->build(); // @doctest id="9b8f" ``` ## The Build Pipeline When you call `build()`, the builder delegates to an internal `AgentConfigurator` that resolves all components in a specific order: 1. **Message compiler** -- determines how `AgentState` messages are compiled into the LLM prompt. The default is `ConversationWithCurrentToolTrace`, which includes all non-trace messages plus the current execution's tool traces. 2. **Tool-use driver** -- the driver responsible for calling the LLM and parsing tool calls from the response. The default is `ToolCallingDriver`. The resolved message compiler is injected into the driver at this stage if the driver implements `CanAcceptMessageCompiler`. 3. **Concrete tools** -- all tools registered via capabilities, including deferred tools that need access to the finalized driver or event system. Deferred tools are resolved last because they may depend on the final driver and tool set. 4. **Interceptor** -- the hook stack is compiled into an interceptor that wraps every lifecycle phase of the agent loop. If no hooks have been registered, a lightweight `PassThroughInterceptor` is used instead. This ordering matters because deferred tools (registered via `UseToolFactory`, `UseSubagents`, or `UsePlanningSubagent`) receive the finalized driver, tool set, and event handler at resolution time. If you need a tool that references the driver, use `UseToolFactory` rather than `UseTools`. ## Core Capabilities Core capabilities modify fundamental aspects of the agent's behavior: which LLM it talks to, how it handles tool calls, what guards protect execution, and how the conversation context is compiled. ### UseLLMConfig Configures the LLM provider and creates a `ToolCallingDriver` for the agent. If omitted, the builder uses the default provider from `LLMProvider::new()`. ```php use Cognesy\Agents\Capability\Core\UseLLMConfig; use Cognesy\Polyglot\Inference\LLMProvider; new UseLLMConfig( llm: LLMProvider::using('anthropic'), maxRetries: 3, ); // @doctest id="57c4" ``` | Parameter | Type | Default | Description | |---|---|---|---| | `llm` | `LLMProvider\|null` | `null` (uses default) | The LLM provider to use | | `maxRetries` | `int` | `1` | Maximum inference attempts on transient failure | When `maxRetries` is greater than 1, an `InferenceRetryPolicy` is created and passed to the driver, enabling automatic retries on transient LLM errors. ### UseGuards Installs safety guards that stop execution when resource limits are reached. All parameters are optional; set a parameter to `null` to disable that specific guard entirely. ```php use Cognesy\Agents\Capability\Core\UseGuards; use Cognesy\Polyglot\Inference\Enums\InferenceFinishReason; new UseGuards( maxSteps: 20, // stop after 20 steps maxTokens: 32768, // stop when cumulative token usage exceeds limit maxExecutionTime: 300.0, // stop after 5 minutes of wall-clock time finishReasons: [ // stop on specific LLM finish reasons InferenceFinishReason::EndTurn, ], ); // @doctest id="a9e7" ``` | Parameter | Type | Default | Description | |---|---|---|---| | `maxSteps` | `int\|null` | `20` | Maximum number of steps before stopping | | `maxTokens` | `int\|null` | `32768` | Total token budget across all steps | | `maxExecutionTime` | `float\|null` | `300.0` | Maximum wall-clock seconds | | `finishReasons` | `array` | `[]` | Stop when the LLM returns one of these finish reasons | Guards are implemented as hooks. Step, token, and time guards run at `BeforeStep` with priority `200` (early in the lifecycle). The finish-reason guard runs at `AfterStep` with priority `-200` (late in the lifecycle, after the LLM response has been processed). ### UseTools Adds one or more tool instances to the agent. Tools are merged with any previously registered tools, never replaced. ```php use Cognesy\Agents\Capability\Core\UseTools; new UseTools($searchTool, $calculatorTool); // @doctest id="38d0" ``` You may call `UseTools` multiple times across different capabilities. Each invocation merges additional tools into the existing set. ### UseHook Attaches a single hook to the agent's lifecycle. Hooks intercept execution at defined trigger points and can modify the agent state or halt execution entirely. ```php use Cognesy\Agents\Capability\Core\UseHook; use Cognesy\Agents\Hook\Collections\HookTriggers; new UseHook( hook: $myHook, triggers: HookTriggers::afterStep(), priority: 10, name: 'my_custom_hook', ); // @doctest id="ef61" ``` | Parameter | Type | Default | Description | |---|---|---|---| | `hook` | `HookInterface` | (required) | The hook implementation | | `triggers` | `HookTriggers` | (required) | When the hook fires (e.g., `beforeStep()`, `afterStep()`, `beforeExecution()`) | | `priority` | `int` | `0` | Higher priority hooks run earlier within the same trigger phase | | `name` | `string\|null` | `null` | Optional name for debugging and logging | ### UseDriver Replaces the default tool-use driver entirely. Use this when you need a completely custom driver implementation rather than the default `ToolCallingDriver`. ```php use Cognesy\Agents\Capability\Core\UseDriver; new UseDriver($customDriver); // @doctest id="d9a9" ``` ### UseDriverDecorator Wraps the current driver with a decorator function. The decorator receives the existing driver and must return a new `CanUseTools` implementation. This is useful for adding cross-cutting concerns like logging, caching, or rate limiting around the driver without replacing it. ```php use Cognesy\Agents\Capability\Core\UseDriverDecorator; use Cognesy\Agents\Drivers\CanUseTools; new UseDriverDecorator( fn(CanUseTools $inner) => new LoggingDriver($inner) ); // @doctest id="0197" ``` ### UseContextCompiler Replaces the message compiler that prepares the conversation history for the LLM. The default compiler is `ConversationWithCurrentToolTrace`. ```php use Cognesy\Agents\Capability\Core\UseContextCompiler; new UseContextCompiler($customCompiler); // @doctest id="f374" ``` ### UseContextCompilerDecorator Wraps the current message compiler with a decorator. This is the recommended approach for adding token-limit trimming, context windowing, or injecting additional context without replacing the entire compilation pipeline. ```php use Cognesy\Agents\Capability\Core\UseContextCompilerDecorator; use Cognesy\Agents\Context\CanCompileMessages; new UseContextCompilerDecorator( fn(CanCompileMessages $inner) => new TokenLimitCompiler($inner, maxTokens: 4000) ); // @doctest id="7a42" ``` ### UseContextConfig Sets a system prompt and optional response format that are injected before each step via a `BeforeStep` hook at priority `100`. The system prompt is always present regardless of how messages are compiled. ```php use Cognesy\Agents\Capability\Core\UseContextConfig; new UseContextConfig( systemPrompt: 'You are a helpful coding assistant.', responseFormat: new ResponseFormat(type: 'json_object'), ); // @doctest id="d243" ``` Both a `string` system prompt and a `ResponseFormat` object are accepted. If both are empty, the capability is a no-op. ### UseReActConfig Replaces the driver with a `ReActDriver` that implements the Reasoning and Acting (ReAct) pattern. The agent alternates between explicit thinking steps and tool-use actions, producing structured reasoning traces that make the decision process transparent. ```php use Cognesy\Agents\Capability\Core\UseReActConfig; use Cognesy\Instructor\Enums\OutputMode; new UseReActConfig( inference: $inferenceRuntime, structuredOutput: $structuredOutputFactory, model: 'gpt-4o', maxRetries: 2, mode: OutputMode::Json, ); // @doctest id="42a4" ``` ### UseToolFactory Registers a deferred tool factory. The factory callback is not invoked immediately -- it runs during `build()` after the driver and tool set have been finalized. This gives the factory access to the complete agent context. ```php use Cognesy\Agents\Capability\Core\UseToolFactory; use Cognesy\Agents\Collections\Tools; use Cognesy\Agents\Drivers\CanUseTools; use Cognesy\Events\Contracts\CanHandleEvents; new UseToolFactory( fn(Tools $tools, CanUseTools $driver, CanHandleEvents $events) => new MyDynamicTool($tools, $driver) ); // @doctest id="d933" ``` The callback receives three arguments: the resolved `Tools` collection, the finalized `CanUseTools` driver, and the `CanHandleEvents` event dispatcher. It must return a single `ToolInterface` instance. ## Domain Capabilities Domain capabilities bundle tools, hooks, and configuration for specific workflows. They are built on top of the core capabilities and provide higher-level abstractions. | Capability | Location | Description | |---|---|---| | `UseBash` | `Capability\Bash` | Adds a bash command execution tool | | `UseFileTools` | `Capability\File` | Adds file read/write/edit tools scoped to a directory | | `UseSubagents` | `Capability\Subagent` | Enables spawning child agents from definitions | | `UsePlanningSubagent` | `Capability\PlanningSubagent` | Adds a planning subagent that creates execution plans | | `UseStructuredOutputs` | `Capability\StructuredOutput` | Configures structured (typed) output extraction | | `UseSummarization` | `Capability\Summarization` | Adds conversation summarization hooks | | `UseSelfCritique` | `Capability\SelfCritique` | Adds self-critique evaluation after responses | | `UseSkills` | `Capability\Skills` | Injects skill instructions into agent context | | `UseTaskPlanning` | `Capability\Tasks` | Adds task planning and decomposition tools | | `UseMetadataTools` | `Capability\Metadata` | Adds tools for reading/writing agent metadata | | `UseToolRegistry` | `Capability\Tools` | Resolves tools from a named registry at build time | | `UseExecutionHistory` | `Capability\ExecutionHistory` | Tracks and exposes execution history | | `UseExecutionRetrospective` | `Capability\Retrospective` | Adds retrospective analysis of past executions | ## Writing Custom Capabilities Every capability implements the `CanProvideAgentCapability` interface, which defines two methods: `capabilityName()` for registry lookups, and `configure()` for applying the capability's configuration to the agent. ```php use Cognesy\Agents\Builder\Contracts\CanConfigureAgent; use Cognesy\Agents\Builder\Contracts\CanProvideAgentCapability; final readonly class UseRateLimiting implements CanProvideAgentCapability { public function __construct( private int $maxCallsPerMinute, ) {} public static function capabilityName(): string { return 'use_rate_limiting'; } public function configure(CanConfigureAgent $agent): CanConfigureAgent { $driver = $agent->toolUseDriver(); return $agent->withToolUseDriver( new RateLimitedDriver($driver, $this->maxCallsPerMinute) ); } } // @doctest id="a65e" ``` The `CanConfigureAgent` interface provides read and write access to all configurable components: | Method | Returns | Purpose | |---|---|---| | `tools()` / `withTools()` | `Tools` | Registered tool instances | | `contextCompiler()` / `withContextCompiler()` | `CanCompileMessages` | Message compilation strategy | | `toolUseDriver()` / `withToolUseDriver()` | `CanUseTools` | LLM driver for tool calling | | `hooks()` / `withHooks()` | `HookStack` | Execution lifecycle hooks | | `deferredTools()` / `withDeferredTools()` | `DeferredToolProviders` | Tools resolved at build time | | `events()` | `CanHandleEvents` | Event dispatcher (read-only) | The `capabilityName()` method returns a string identifier used by `AgentCapabilityRegistry` to look up capabilities by name. This is how agent templates reference capabilities in their definition files (see [Agent Templates](14-agent-templates.md)). ## AgentBuilder vs AgentLoop Use `AgentLoop::default()` or construct `AgentLoop` directly when you have a small, one-off setup where the wiring is straightforward. Use `AgentBuilder` when: - The configuration is complex enough to benefit from decomposition into capabilities. - You want to share the same setup across multiple agents via branching. - You need deferred tool resolution (tools that depend on the final driver). - You want testable, reusable configuration units that can be independently verified. ## Event Propagation The `AgentBuilder::base()` method accepts an optional `CanHandleEvents` parent. Events dispatched by the built agent propagate upward to this parent handler, allowing you to collect events from multiple agents in a single place: ```php use Cognesy\Events\Dispatchers\EventDispatcher; $rootEvents = new EventDispatcher('root'); $rootEvents->wiretap(fn($event) => logger()->debug((string) $event)); $agent = AgentBuilder::base(parentEvents: $rootEvents) ->withCapability(new UseBash()) ->build(); // @doctest id="4ec6" ``` This is particularly useful when running subagents or sessions, where you want a unified event stream across all agent activity. ## Related - [Basic Agent](02-basic-agent.md) - [Hooks](08-hooks.md) - [Agent Templates](14-agent-templates.md) - [Subagents](15-subagents.md) - [Session Runtime](16-session-runtime.md) ================================================================================ FILE: packages/agents/14-agent-templates.md ================================================================================ ## Introduction Agent templates let you define agents as data rather than PHP code. Instead of writing a class that constructs an `AgentBuilder` with hardcoded capabilities and tools, you describe the agent's identity, instructions, tool access, and resource budget in a definition file. At runtime, a factory turns that definition into a working `AgentLoop` and `AgentState`. This separation between definition and instantiation makes it possible to manage agents through configuration files, version them alongside your prompts, and let non-developers create or adjust agents without touching PHP. It is also the foundation of the subagent system -- when a parent agent spawns a child, it looks up the child's `AgentDefinition` in a registry and builds a loop from it on the fly. ## AgentDefinition `AgentDefinition` is the core data object that describes an agent. It is a `final readonly` class with the following fields: | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | Yes | Unique identifier used to look up the agent in registries | | `description` | `string` | Yes | Human-readable summary of what the agent does. Also shown in tool schemas when the agent is available as a subagent. | | `systemPrompt` | `string` | Yes | The system prompt that instructs the agent's behavior | | `label` | `string\|null` | No | Display name (defaults to `name` if omitted) | | `llmConfig` | `LLMConfig\|string\|null` | No | LLM configuration. Pass a string like `'anthropic'` for just the driver name, or a full `LLMConfig` object for model-level control. | | `capabilities` | `NameList` | No | Named capabilities to activate (looked up in `AgentCapabilityRegistry`) | | `tools` | `NameList\|null` | No | Allow-list of tool names. `null` means inherit all available tools. | | `toolsDeny` | `NameList\|null` | No | Deny-list of tool names to exclude from the inherited or allowed set | | `skills` | `NameList\|null` | No | Named skills to inject into the agent's context | | `budget` | `ExecutionBudget\|null` | No | Resource limits: max steps, tokens, seconds, cost, and deadline | | `metadata` | `Metadata\|null` | No | Arbitrary key-value data merged into the agent's state | ### Creating Definitions in PHP ```php use Cognesy\Agents\Collections\NameList; use Cognesy\Agents\Data\ExecutionBudget; use Cognesy\Agents\Template\Data\AgentDefinition; use Cognesy\Polyglot\Inference\Config\LLMConfig; $definition = new AgentDefinition( name: 'researcher', description: 'Searches for information on a topic and summarizes findings', systemPrompt: 'You are a research assistant. Find and summarize information accurately.', label: 'Research Agent', llmConfig: LLMConfig::fromArray([ 'driver' => 'anthropic', 'model' => 'claude-sonnet-4-20250514', ]), budget: new ExecutionBudget(maxSteps: 10, maxTokens: 8000), tools: new NameList('bash', 'read_file'), toolsDeny: new NameList('write_file'), capabilities: new NameList('use_bash'), ); // @doctest id="08fd" ``` ### Tool Visibility Rules The `tools` and `toolsDeny` fields work together to control which tools the agent can access: - **`tools: null`** (the default) -- the agent inherits all tools available in its context. For subagents, this means all tools the parent has. - **`tools: new NameList('read_file', 'bash')`** -- only these named tools are allowed. Any other tools are excluded. - **`toolsDeny: new NameList('write_file')`** -- these tools are removed from whatever set the agent would otherwise have, whether inherited or explicitly allowed. The deny list is applied after the allow list. If you set `tools` to allow `read_file` and `write_file`, and `toolsDeny` to deny `write_file`, the agent will only have access to `read_file`. ### ExecutionBudget The `ExecutionBudget` class defines resource limits for a single agent execution. All fields are optional -- `null` means unlimited. ```php use Cognesy\Agents\Data\ExecutionBudget; $budget = new ExecutionBudget( maxSteps: 10, // maximum number of agent loop iterations maxTokens: 8000, // total token usage across all steps maxSeconds: 60.0, // wall-clock time limit maxCost: 0.50, // maximum cost in dollars deadline: new DateTimeImmutable('2025-12-31'), ); // @doctest id="26c9" ``` When an `AgentDefinition` declares a budget, it is translated into `UseGuards` during loop instantiation. ## Definition Files Agent definitions can be stored in markdown, YAML, or JSON files. Each format maps directly to the `AgentDefinition` fields. ### Markdown Format Markdown definitions use YAML front matter for structured fields and the document body for the system prompt. This is the most readable format for agents with long or complex system prompts. ```markdown --- name: researcher description: Searches for information on a topic label: Research Agent llmConfig: driver: anthropic model: claude-sonnet-4-20250514 budget: maxSteps: 10 maxTokens: 8000 tools: - bash - read_file toolsDeny: - write_file capabilities: - use_bash metadata: domain: research version: "2.0" --- You are a research assistant. Your job is to find and summarize information accurately. When given a topic, use the available tools to gather evidence, then synthesize your findings into a clear, well-structured summary. Always cite the sources you used. // @doctest id="b235" ``` The document body (everything after the front matter) becomes the `systemPrompt` field. ### YAML Format ```yaml name: researcher description: Searches for information on a topic systemPrompt: | You are a research assistant. Find and summarize information accurately. Always cite the sources you used. llmConfig: driver: anthropic budget: maxSteps: 10 maxTokens: 8000 tools: - bash - read_file # @doctest id="6836" ``` ### JSON Format ```json { "name": "researcher", "description": "Searches for information on a topic", "systemPrompt": "You are a research assistant. Find and summarize information accurately.", "llmConfig": { "driver": "anthropic" }, "budget": { "maxSteps": 10, "maxTokens": 8000 }, "tools": ["bash", "read_file"] } // @doctest id="dd8f" ``` All three formats produce identical `AgentDefinition` objects when loaded. ## Loading Definitions ### AgentDefinitionLoader The `AgentDefinitionLoader` class parses a single file into an `AgentDefinition`. It selects the appropriate parser based on the file extension. ```php use Cognesy\Agents\Template\AgentDefinitionLoader; $loader = new AgentDefinitionLoader(); $definition = $loader->loadFile('/path/to/researcher.md'); // @doctest id="0dff" ``` Supported extensions: `.md`, `.json`, `.yaml`, `.yml`. The loader throws a `RuntimeException` if the file cannot be read and an `InvalidArgumentException` for unsupported extensions. You can also supply custom parsers by passing an array to the constructor: ```php use Cognesy\Agents\Template\Parsers\MarkdownDefinitionParser; use Cognesy\Agents\Template\Parsers\JsonDefinitionParser; use Cognesy\Agents\Template\Parsers\YamlDefinitionParser; $loader = new AgentDefinitionLoader([ 'md' => new MarkdownDefinitionParser(), 'json' => new JsonDefinitionParser(), 'yaml' => new YamlDefinitionParser(), 'yml' => new YamlDefinitionParser(), ]); // @doctest id="2db3" ``` ### AgentDefinitionRegistry The `AgentDefinitionRegistry` is a named collection of agent definitions. It supports programmatic registration, file loading, directory scanning, and auto-discovery. ```php use Cognesy\Agents\Template\AgentDefinitionRegistry; $registry = new AgentDefinitionRegistry(); // @doctest id="466f" ``` #### Programmatic Registration ```php $registry->register($definition); $registry->registerMany($def1, $def2, $def3); // @doctest id="ada0" ``` #### Loading from Files ```php // Load a single file $registry->loadFromFile('/agents/researcher.md'); // Load all definition files from a directory $registry->loadFromDirectory('/agents'); // Load recursively, scanning subdirectories $registry->loadFromDirectory('/agents', recursive: true); // @doctest id="d532" ``` During directory scans, files that fail to parse are skipped rather than causing exceptions. The errors are collected and can be inspected afterward: ```php $errors = $registry->errors(); // Returns: ['path/to/broken.md' => 'Error message', ...] // @doctest id="6c78" ``` #### Auto-Discovery The `autoDiscover()` method scans up to three standard locations for agent definition files: ```php $registry->autoDiscover( projectPath: '/my/project', // scans /my/project/.claude/agents packagePath: '/package/agents', // scans this directory directly userPath: '/user/agents', // scans this directory directly ); // @doctest id="88ac" ``` Paths are scanned in order: `userPath`, `packagePath`, then `projectPath/.claude/agents`. Later registrations overwrite earlier ones with the same name, so user-level definitions take precedence over package defaults. #### Querying the Registry ```php $definition = $registry->get('researcher'); // throws AgentNotFoundException if missing $exists = $registry->has('researcher'); // returns bool $names = $registry->names(); // returns ['researcher', 'reviewer', ...] $count = $registry->count(); // returns int $all = $registry->all(); // returns ['name' => AgentDefinition, ...] // @doctest id="fd42" ``` ## Instantiation Factories Once you have an `AgentDefinition`, two factory classes turn it into runnable components: one for the initial `AgentState`, and one for the `AgentLoop` that executes it. ### DefinitionStateFactory Creates an `AgentState` pre-configured with the definition's system prompt, metadata, and LLM config. It implements the `CanInstantiateAgentState` contract. ```php use Cognesy\Agents\Template\Factory\DefinitionStateFactory; $factory = new DefinitionStateFactory(); $state = $factory->instantiateAgentState($definition); // @doctest id="c9ec" ``` You can also pass a seed state to merge the definition's settings onto an existing state: ```php $existingState = AgentState::empty()->withUserMessage('Start here'); $state = $factory->instantiateAgentState($definition, seed: $existingState); // @doctest id="2da9" ``` The factory applies settings in this order: system prompt, metadata merge, then LLM config. Each step is skipped if the corresponding field in the definition is empty or null. ### DefinitionLoopFactory Creates a fully configured `AgentLoop` from a definition. This factory implements `CanInstantiateAgentLoop` and is used internally by `SendMessage` and other session actions. ```php use Cognesy\Agents\Capability\AgentCapabilityRegistry; use Cognesy\Agents\Capability\Bash\UseBash; use Cognesy\Agents\Template\Factory\DefinitionLoopFactory; $capabilities = new AgentCapabilityRegistry(); $capabilities->register('use_bash', new UseBash()); $factory = new DefinitionLoopFactory($capabilities); $loop = $factory->instantiateAgentLoop($definition); // @doctest id="dbc5" ``` The factory builds the loop by applying the definition's fields in order: 1. **LLM config** -- if the definition specifies an `llmConfig`, a `ToolCallingDriver` is created with that config. 2. **Guards** -- if the definition declares a non-empty budget, `UseGuards` is applied with the budget's limits. 3. **Capabilities** -- each named capability in the definition is resolved from the `AgentCapabilityRegistry` and applied to the builder. 4. **Tools** -- if the definition references named tools, they are resolved from the tool registry and added via `UseTools`. #### Providing a Tool Registry When the definition references tools by name (via `tools` or `toolsDeny`), you must provide a tool registry that implements `CanManageTools`: ```php use Cognesy\Agents\Tool\ToolRegistry; $tools = new ToolRegistry(); $tools->register($searchTool); $tools->register($readFileTool); $factory = new DefinitionLoopFactory( capabilities: $capabilities, tools: $tools, ); // @doctest id="6982" ``` If a definition references tools and no registry is provided, `DefinitionLoopFactory` throws an `InvalidArgumentException`. Unknown tool names also cause an exception, listing which tools could not be found. #### Event Propagation Pass an event handler to propagate events from instantiated loops to a parent dispatcher: ```php use Cognesy\Events\Dispatchers\EventDispatcher; $events = new EventDispatcher('session'); $factory = new DefinitionLoopFactory($capabilities, $tools, $events); // @doctest id="c87c" ``` ## AgentCapabilityRegistry The `AgentCapabilityRegistry` maps string names to capability instances. It is the bridge between definition files (which reference capabilities by name) and the PHP capability classes that implement them. ```php use Cognesy\Agents\Capability\AgentCapabilityRegistry; use Cognesy\Agents\Capability\Bash\UseBash; use Cognesy\Agents\Capability\File\UseFileTools; $capabilities = new AgentCapabilityRegistry(); // Register a pre-built instance $capabilities->register('use_bash', new UseBash()); // Register a factory for lazy instantiation $capabilities->registerFactory('use_file_tools', fn() => new UseFileTools('/my/project')); // Query the registry $capabilities->has('use_bash'); // true $capabilities->get('use_bash'); // returns the UseBash instance $capabilities->names(); // ['use_bash', 'use_file_tools'] $capabilities->count(); // 2 // @doctest id="31b6" ``` Factory-registered capabilities are instantiated on first access and cached for subsequent lookups. If the factory does not return a `CanProvideAgentCapability`, an `InvalidArgumentException` is thrown. ## Using with Subagents The `AgentDefinitionRegistry` implements `CanManageAgentDefinitions`, making it the standard provider for the `UseSubagents` capability. When a parent agent calls `spawn_subagent`, the subagent system looks up the named definition in this registry and builds a child loop from it. ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Subagent\UseSubagents; use Cognesy\Agents\Template\AgentDefinitionRegistry; $registry = new AgentDefinitionRegistry(); $registry->loadFromDirectory('/agents'); $agent = AgentBuilder::base() ->withCapability(new UseSubagents(provider: $registry)) ->build(); // @doctest id="af5a" ``` The subagent tool's schema automatically includes the list of available agents and their descriptions, so the LLM knows which subagents it can delegate to. See [Subagents](15-subagents.md) for the full delegation model. ## Serialization `AgentDefinition` supports round-trip serialization via `toArray()` and `fromArray()`: ```php $array = $definition->toArray(); $restored = AgentDefinition::fromArray($array); // @doctest id="b7bc" ``` This is used internally by the session persistence layer to store agent definitions alongside session state. The `fromArray()` method also accepts `title` as an alias for `label` to support legacy formats. ## Related - [AgentBuilder & Capabilities](13-agent-builder.md) - [Subagents](15-subagents.md) - [Session Runtime](16-session-runtime.md) ================================================================================ FILE: packages/agents/15-subagents.md ================================================================================ ## Introduction Subagents allow one agent to delegate part of its work to another agent that runs in complete isolation. Each child agent has its own state, system prompt, tool set, resource budget, and LLM configuration. The parent agent decides when to delegate by calling the `spawn_subagent` tool, and the child's final output is returned to the parent as a tool result. This delegation model is useful when different parts of a task require different expertise, tool access, or resource limits. A code review agent might spawn a "security reviewer" subagent with read-only file access and a tight step budget, while also spawning a "style checker" subagent with different instructions. The parent orchestrates the overall workflow without needing to know the implementation details of each child. ## Quick Start The following example sets up a parent agent with file tools and a "reviewer" subagent that can only read files: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Core\UseGuards; use Cognesy\Agents\Capability\Core\UseTools; use Cognesy\Agents\Capability\File\SearchFilesTool; use Cognesy\Agents\Capability\File\UseFileTools; use Cognesy\Agents\Capability\Subagent\UseSubagents; use Cognesy\Agents\Collections\NameList; use Cognesy\Agents\Data\AgentState; use Cognesy\Agents\Template\AgentDefinitionRegistry; use Cognesy\Agents\Template\Data\AgentDefinition; // Define the subagent $registry = new AgentDefinitionRegistry(); $registry->register(new AgentDefinition( name: 'reviewer', description: 'Review one file and report important issues.', systemPrompt: 'You review code and report only high-signal findings.', tools: new NameList('read_file'), )); // Build the parent agent $agent = AgentBuilder::base() ->withCapability(new UseFileTools('/my/project')) ->withCapability(new UseTools(SearchFilesTool::inDirectory('/my/project'))) ->withCapability(new UseSubagents(provider: $registry)) ->withCapability(new UseGuards(maxSteps: 20)) ->build(); // Execute $state = AgentState::empty()->withUserMessage( 'Review src/AgentLoop.php and summarize key issues.' ); $result = $agent->execute($state); // @doctest id="6215" ``` The parent model decides on its own when to call `spawn_subagent`. The tool schema includes a description of all available subagents and their purposes, giving the LLM the information it needs to choose the right one. ## How Delegation Works When the parent agent calls `spawn_subagent(subagent: 'reviewer', prompt: 'Review this file...')`, the following sequence occurs: 1. **Depth check** -- the system verifies that the current nesting depth has not exceeded the configured maximum. If it has, a `SubagentDepthExceededException` is thrown and returned to the parent as a tool error. 2. **Definition lookup** -- the `AgentDefinitionRegistry` resolves the named `AgentDefinition`. If the name is not found, a `SubagentNotFoundException` is thrown. 3. **Tool filtering** -- the child's tool set is determined by applying the definition's `tools` allow-list and `toolsDeny` deny-list against the parent's available tools. 4. **Driver resolution** -- the child inherits the parent's tool-use driver. If the definition specifies an `llmConfig` and the driver supports `CanAcceptLLMConfig`, the child's driver is reconfigured with the specified model/provider. 5. **Budget application** -- if the definition declares an `ExecutionBudget`, `UseGuards` is applied to the child's builder with the budget's limits. 6. **Loop construction** -- an `AgentBuilder` assembles the child `AgentLoop` from the filtered tools, configured driver, and guards. 7. **State initialization** -- a fresh `AgentState` is created with the definition's system prompt and the caller's prompt as the user message. If the definition has skills, they are injected as additional system messages. 8. **Execution** -- the child loop runs to completion. If the child fails (`ExecutionStatus::Failed`), a `SubagentExecutionException` is thrown. 9. **Result return** -- the child's final `AgentState` is returned to the parent as the tool call result. The parent continues its execution with this information. ## Defining Subagents Subagents are defined using the same `AgentDefinition` class used by agent templates. You can register them programmatically or load them from files. ### Programmatic Registration ```php use Cognesy\Agents\Collections\NameList; use Cognesy\Agents\Data\ExecutionBudget; use Cognesy\Agents\Template\Data\AgentDefinition; use Cognesy\Agents\Template\AgentDefinitionRegistry; $registry = new AgentDefinitionRegistry(); $registry->register(new AgentDefinition( name: 'researcher', description: 'Search and analyze source files to find relevant evidence', systemPrompt: 'Find relevant evidence and summarize it clearly.', tools: new NameList('read_file', 'search_files'), budget: new ExecutionBudget(maxSteps: 8, maxTokens: 4000), )); $registry->register(new AgentDefinition( name: 'editor', description: 'Make precise edits to source files', systemPrompt: 'Apply the requested code changes carefully and verify correctness.', tools: new NameList('read_file', 'edit_file'), budget: new ExecutionBudget(maxSteps: 12, maxTokens: 8000), )); // @doctest id="c06f" ``` ### File-Based Registration Definitions can be loaded from `.md`, `.yaml`, or `.json` files: ```php $registry->loadFromDirectory('/agents', recursive: true); // @doctest id="0cc4" ``` See [Agent Templates](14-agent-templates.md) for the full file format specification. ## Tool Visibility Tool visibility is one of the most important aspects of subagent design. It determines what a child agent can do, and more importantly, what it cannot. ### Inheriting All Parent Tools By default (when `tools` is `null`), the child inherits every tool the parent has, including `spawn_subagent` itself: ```php new AgentDefinition( name: 'assistant', description: 'General-purpose helper', systemPrompt: 'Help with any task.', // tools: null -- inherits all parent tools ); // @doctest id="1303" ``` ### Allow-List Setting `tools` to a `NameList` creates a strict allow-list. Only the named tools are available to the child: ```php new AgentDefinition( name: 'reader', description: 'Read-only file analysis', systemPrompt: 'Analyze files but never modify them.', tools: new NameList('read_file', 'search_files'), ); // @doctest id="b9b9" ``` ### Deny-List The `toolsDeny` field removes specific tools from whatever set the child would otherwise have. This is useful when you want to inherit most tools but block a few dangerous ones: ```php new AgentDefinition( name: 'safe_editor', description: 'Edit files without shell access', systemPrompt: 'Edit files safely. Never run shell commands.', tools: new NameList('read_file', 'write_file', 'edit_file'), toolsDeny: new NameList('write_file'), ); // Result: child only has 'read_file' and 'edit_file' // @doctest id="07e5" ``` ### spawn_subagent in Children If the child inherits `spawn_subagent`, the tool is automatically replaced with a nested version that tracks depth. This means children can spawn their own subagents, subject to the depth policy. If you want to prevent this, add `spawn_subagent` to the deny list: ```php new AgentDefinition( name: 'leaf_worker', description: 'Performs a task without delegating', systemPrompt: 'Complete the task directly.', toolsDeny: new NameList('spawn_subagent'), ); // @doctest id="2582" ``` ## Depth Control Subagents can spawn their own subagents, creating a recursive hierarchy. The `SubagentPolicy` controls the maximum nesting depth to prevent unbounded recursion. ### Using SubagentPolicy ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Subagent\SubagentPolicy; use Cognesy\Agents\Capability\Subagent\UseSubagents; $agent = AgentBuilder::base() ->withCapability(new UseSubagents( provider: $registry, policy: new SubagentPolicy(maxDepth: 2), )) ->build(); // @doctest id="5d13" ``` The default `maxDepth` is `3`. A depth of `0` means the parent itself; a depth of `2` means the parent can spawn children, and those children can spawn grandchildren, but no further. ### Convenience Factory For simple depth configuration, use the static `forDepth()` factory: ```php $agent = AgentBuilder::base() ->withCapability(UseSubagents::forDepth(2, provider: $registry)) ->build(); // @doctest id="3c55" ``` ### Depth Exceeded Behavior When a subagent attempts to spawn at a depth that exceeds the policy, a `SubagentDepthExceededException` is thrown. This exception is returned to the calling agent as a tool error, allowing it to handle the situation gracefully (typically by performing the work itself). ## Child Budgets and Models Each child agent can declare its own resource budget and LLM configuration independently from the parent. ### Custom Budget ```php use Cognesy\Agents\Data\ExecutionBudget; new AgentDefinition( name: 'quick_reviewer', description: 'Short, fast review with tight limits', systemPrompt: 'Be concise. Focus on critical issues only.', budget: new ExecutionBudget( maxSteps: 5, maxTokens: 2500, maxSeconds: 20.0, ), ); // @doctest id="f33b" ``` If no budget is declared, the child runs without guards (unless the parent's guards indirectly limit it through total token accounting). ### Custom Model Children can use a different model or provider than the parent. This is useful for cost optimization -- simple tasks can use a cheaper model while complex analysis uses a more capable one. ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; new AgentDefinition( name: 'quick_classifier', description: 'Fast classification with a small model', systemPrompt: 'Classify the input into one of the provided categories.', llmConfig: LLMConfig::fromArray([ 'driver' => 'openai', 'model' => 'gpt-4o-mini', ]), budget: new ExecutionBudget(maxSteps: 3), ); // @doctest id="bd0c" ``` If no `llmConfig` is specified, the child inherits the parent's LLM configuration. You can also pass just a driver name as a string: ```php new AgentDefinition( name: 'anthropic_worker', description: 'Worker using Anthropic provider', systemPrompt: 'Complete the task.', llmConfig: 'anthropic', ); // @doctest id="36bc" ``` ## Skill Injection Subagents can reference named skills from a `SkillLibrary`. When skills are specified in the definition, their rendered content is injected as additional system messages before the user prompt. ```php use Cognesy\Agents\Capability\Skills\SkillLibrary; use Cognesy\Agents\Capability\Subagent\UseSubagents; use Cognesy\Agents\Collections\NameList; $skillLibrary = SkillLibrary::inDirectory(__DIR__ . '/skills'); // ... skills are loaded from the directory ... $registry->register(new AgentDefinition( name: 'code_reviewer', description: 'Reviews code using project-specific guidelines', systemPrompt: 'Review the code following the project guidelines.', skills: new NameList('code_style', 'security_checklist'), )); $agent = AgentBuilder::base() ->withCapability(new UseSubagents( provider: $registry, skillLibrary: $skillLibrary, )) ->build(); // @doctest id="118d" ``` ## Events The subagent lifecycle emits two events through the parent's event dispatcher, providing visibility into delegation activity. ### SubagentSpawning Dispatched when a parent agent is about to spawn a child. Contains context for tracing the delegation hierarchy. ```php use Cognesy\Agents\Events\SubagentSpawning; $agent->onEvent(SubagentSpawning::class, function (SubagentSpawning $e) { echo "Spawning '{$e->subagentName}' at depth {$e->depth}/{$e->maxDepth}\n"; echo "Parent agent: {$e->parentAgentId}\n"; echo "Prompt: {$e->prompt}\n"; }); // @doctest id="fda8" ``` The event includes: - `parentAgentId` -- the parent's agent ID - `subagentName` -- the name of the subagent being spawned - `prompt` -- the task/question sent to the child - `depth` / `maxDepth` -- current and maximum nesting depth - `parentExecutionId`, `parentStepNumber`, `toolCallId` -- correlation IDs for tracing ### SubagentCompleted Dispatched when a child agent finishes execution, regardless of success or failure. ```php use Cognesy\Agents\Events\SubagentCompleted; $agent->onEvent(SubagentCompleted::class, function (SubagentCompleted $e) { $tokens = $e->usage?->total() ?? 0; echo "'{$e->subagentName}' completed: status={$e->status->value}, " . "steps={$e->steps}, tokens={$tokens}\n"; }); // @doctest id="afdc" ``` The event includes: - `parentAgentId` -- the parent's agent ID - `subagentName` -- the name of the completed subagent - `subagentId` -- the child's unique agent ID - `status` -- the `ExecutionStatus` (completed, failed, etc.) - `steps` -- total steps the child took - `usage` -- token usage data (nullable) - `startedAt` / `completedAt` -- timestamps for duration calculation - `parentExecutionId`, `parentStepNumber`, `toolCallId` -- correlation IDs for tracing ## Error Handling The subagent system defines three specific exception types: | Exception | When | |---|---| | `SubagentNotFoundException` | The named subagent does not exist in the registry | | `SubagentDepthExceededException` | The spawn would exceed the configured `maxDepth` | | `SubagentExecutionException` | The child agent finished with `ExecutionStatus::Failed` | All three are returned to the parent agent as tool errors, so the parent can decide how to proceed -- retry with different instructions, try a different subagent, or handle the task itself. ## The Tool Schema The `spawn_subagent` tool automatically generates its schema from the registry. The schema includes: - A `subagent` parameter as an enum of all available agent names - A `prompt` parameter for the task or question - A description that lists all available subagents with their descriptions and tool access This means the LLM can see which subagents are available, what each one does, and what tools each has access to, all from the tool schema alone. ## Related - [AgentBuilder & Capabilities](13-agent-builder.md) - [Agent Templates](14-agent-templates.md) - [Session Runtime](16-session-runtime.md) ================================================================================ FILE: packages/agents/16-session-runtime.md ================================================================================ ## Introduction Agents are stateless by default -- an `AgentLoop` takes an `AgentState`, runs to completion, and returns the updated state. There is no built-in persistence between requests. The `SessionRuntime` layer adds that persistence, turning an agent into a long-lived conversation that survives across HTTP requests, CLI invocations, or background jobs. A session wraps an `AgentDefinition` (what the agent is) and an `AgentState` (what the agent has done) together with lifecycle metadata like status, version, and timestamps. The runtime manages loading, executing actions, and saving sessions through a transactional pipeline with optimistic locking and event emission. This is the foundation for building multi-turn chat applications, resumable workflows, and any scenario where agent state must persist beyond a single process. ## Core Types The session system is built around a small set of types, each with a focused responsibility: | Type | Purpose | |---|---| | `AgentSession` | Combines session info, agent definition, and agent state into one persistent unit | | `AgentSessionInfo` | Header data: session ID, agent name, status, version, timestamps, parent session ID | | `SessionId` | Value object wrapping a UUID string. Use `SessionId::generate()` to create new IDs. | | `SessionStatus` | Enum: `Active`, `Suspended`, `Completed`, `Failed`, `Deleted` | | `SessionRepository` | Thin wrapper over a `CanStoreSessions` implementation | | `SessionRuntime` | Preferred create/read/write boundary: creates sessions, executes actions, applies hooks, and emits events | | `SessionFactory` | Lower-level helper that builds fresh `AgentSession` instances from an `AgentDefinition` | ## The Runtime Contract The `CanManageAgentSessions` interface defines the public API that `SessionRuntime` implements: ```php interface CanManageAgentSessions { public function create(AgentDefinition $definition, ?AgentState $seed = null): AgentSession; public function listSessions(): SessionInfoList; public function getSessionInfo(SessionId $sessionId): AgentSessionInfo; public function getSession(SessionId $sessionId): AgentSession; public function execute(SessionId $sessionId, CanExecuteSessionAction $action): AgentSession; } // @doctest id="8962" ``` Use `create()` for brand-new root sessions. The read methods (`listSessions`, `getSessionInfo`, `getSession`) load data but do not persist any changes. The `execute()` method updates an existing persisted session by loading it, running an action, saving the result, and returning the updated session. ## Quick Start The following example creates a session, sends a message, and retrieves the result: ```php use Cognesy\Agents\Capability\AgentCapabilityRegistry; use Cognesy\Agents\Capability\Bash\UseBash; use Cognesy\Agents\Session\Actions\SendMessage; use Cognesy\Agents\Session\SessionRepository; use Cognesy\Agents\Session\SessionRuntime; use Cognesy\Agents\Session\Store\InMemorySessionStore; use Cognesy\Agents\Template\Data\AgentDefinition; use Cognesy\Agents\Template\Factory\DefinitionLoopFactory; use Cognesy\Events\Dispatchers\EventDispatcher; // 1. Define the agent $definition = new AgentDefinition( name: 'assistant', description: 'A helpful general assistant', systemPrompt: 'You are a helpful assistant. Be concise and accurate.', ); // 2. Set up the infrastructure $repo = new SessionRepository(new InMemorySessionStore()); $events = new EventDispatcher('session-runtime'); $runtime = new SessionRuntime($repo, $events); // 3. Create a session $session = $runtime->create($definition); // 4. Set up the loop factory $capabilities = new AgentCapabilityRegistry(); $capabilities->register('use_bash', new UseBash()); $loopFactory = new DefinitionLoopFactory($capabilities, events: $events); // 5. Send a message $updated = $runtime->execute( $session->sessionId(), new SendMessage('What is 2 + 2?', $loopFactory), ); // 6. The session now contains the agent's response $state = $updated->state(); // @doctest id="a180" ``` ## The Create and Execute Pipelines ### The Create Pipeline When you call `$runtime->create($definition, $seed)`, the following pipeline runs: 1. **Instantiate session** -- A fresh `AgentSession` is created from the `AgentDefinition` and optional seed state. 2. **BeforeCreate hook** -- The session controller's `onStage(BeforeCreate, ...)` is called. Use this for create-only logic such as setting defaults or validating creation-time policy. 3. **BeforeSave hook** -- The session controller's `onStage(BeforeSave, ...)` is called. This fires on both create and execute, so use it for logic that should run before every persist. 4. **Create** -- The session is persisted through the repository. Stores require a fresh session with version `0` and persist it as version `1`. 5. **AfterSave hook** -- Post-persistence processing runs on the persisted session returned by the store. Fires on both create and execute. 6. **AfterCreate hook** -- The session controller's `onStage(AfterCreate, ...)` is called. Use this for create-only post-persist logic such as sending notifications. 7. **SessionSaved event** -- Emitted to confirm successful persistence. If persistence fails, `SessionSaveFailed` is emitted and the original exception is rethrown. Use this path for new root sessions. Reach for `SessionFactory` + repository `create()` only when you already have a concrete `AgentSession` instance to persist, such as a forked branch. ### The Execute Pipeline When you call `$runtime->execute($sessionId, $action)`, the following pipeline runs: 1. **Load** -- The session is loaded from the repository. If not found, `SessionNotFoundException` is thrown. 2. **AfterLoad hook** -- The session controller's `onStage(AfterLoad, ...)` is called, allowing pre-processing. 3. **SessionLoaded event** -- Emitted for observability. 4. **Action execution** -- `$action->executeOn($session)` runs the action and returns the next session state. 5. **AfterAction hook** -- The session controller processes the post-action state. 6. **BeforeSave hook** -- Last chance to modify the session before persistence (e.g., auto-suspend). 7. **SessionActionExecuted event** -- Emitted with before/after status and version. 8. **Save** -- The session is saved with optimistic version checking. 9. **AfterSave hook** -- Post-persistence processing. 10. **SessionSaved event** -- Emitted to confirm successful persistence. If loading fails, `SessionLoadFailed` is emitted. If saving fails (e.g., version conflict), `SessionSaveFailed` is emitted. In both cases, the original exception is rethrown after the event. ## Built-in Actions Actions implement the `CanExecuteSessionAction` interface, which defines a single method: ```php interface CanExecuteSessionAction { public function executeOn(AgentSession $session): AgentSession; } // @doctest id="43e4" ``` Each action receives the current session and returns a new session with the desired changes applied. ### SendMessage The primary action for agent interaction. Appends a user message to the session's state, instantiates an `AgentLoop` from the session's definition, runs the loop to completion, and stores the resulting state. ```php use Cognesy\Agents\Session\Actions\SendMessage; $runtime->execute($sessionId, new SendMessage( message: 'Explain how dependency injection works.', loopFactory: $loopFactory, )); // @doctest id="97c2" ``` The `message` parameter accepts a `string`, `\Stringable`, or `Message` object. `Stringable` values are cast to string at the boundary. The `loopFactory` must implement `CanInstantiateAgentLoop` -- typically a `DefinitionLoopFactory`. ### SuspendSession and ResumeSession Pause and resume a session. Suspended sessions are preserved but not actively processing. ```php use Cognesy\Agents\Session\Actions\SuspendSession; use Cognesy\Agents\Session\Actions\ResumeSession; // Pause the session $runtime->execute($sessionId, new SuspendSession()); // Resume it later $runtime->execute($sessionId, new ResumeSession()); // @doctest id="7ffd" ``` `SuspendSession` sets the status to `Suspended`. `ResumeSession` sets it back to `Active`. ### ClearSession Resets the session's agent state while preserving the session identity and definition. The state is prepared for the next execution via `forNextExecution()`. ```php use Cognesy\Agents\Session\Actions\ClearSession; $runtime->execute($sessionId, new ClearSession()); // @doctest id="0b43" ``` ### ForkSession Creates a new session that inherits the state and definition of the source session. The forked session gets a fresh `SessionId` and its parent is set to the source session's ID. ```php use Cognesy\Agents\Session\Actions\ForkSession; // Fork returns a new session (not persisted automatically) $source = $runtime->getSession($sessionId); $forked = (new ForkSession())->executeOn($source); $forked = $repo->create($forked); // The forked session has a parent reference echo $forked->info()->parentId(); // original session ID // @doctest id="f999" ``` Note that `ForkSession` is typically used outside the runtime's `execute()` pipeline because it creates a new session rather than modifying the existing one. This is the main case where persisting via repository `create()` is still appropriate: you already have a fully constructed `AgentSession`, so you persist that branch directly instead of calling `SessionRuntime::create()`. ### ChangeSystemPrompt Updates the system prompt in the session's agent state. Accepts `string|\Stringable` -- `Stringable` values are cast to string at the boundary. ```php use Cognesy\Agents\Session\Actions\ChangeSystemPrompt; $runtime->execute($sessionId, new ChangeSystemPrompt( 'You are concise and direct. Respond in bullet points.' )); // @doctest id="911a" ``` ### ChangeModel Swaps the LLM configuration for future executions within the session. ```php use Cognesy\Agents\Session\Actions\ChangeModel; use Cognesy\Polyglot\Inference\Config\LLMConfig; $runtime->execute($sessionId, new ChangeModel( LLMConfig::fromArray(['driver' => 'openai', 'model' => 'gpt-4o']) )); // @doctest id="6c5c" ``` ### WriteMetadata Stores a key-value pair in the session's metadata. Useful for tracking external references, workflow state, or custom tags. ```php use Cognesy\Agents\Session\Actions\WriteMetadata; $runtime->execute($sessionId, new WriteMetadata('ticket_id', 'OPS-142')); $runtime->execute($sessionId, new WriteMetadata('priority', 'high')); // @doctest id="63da" ``` ### UpdateTask Updates the task description associated with the session. ```php use Cognesy\Agents\Session\Actions\UpdateTask; $runtime->execute($sessionId, new UpdateTask('Refactor the authentication module')); // @doctest id="1c9b" ``` ## Versioning and Optimistic Locking Sessions use optimistic locking to prevent concurrent modifications from silently overwriting each other. Every session has a monotonically increasing version number. ### Version Lifecycle - **Create** -- The session must have version `0`. It is persisted as version `1`. - **Save** -- The incoming session's version must match the stored version. The persisted session is returned with version incremented by `1`. - **Read** -- Loading a session returns it with the stored version, which must be used for the next write. ### Conflict Handling If two processes load the same session (both see version `5`), the first to save succeeds and advances the version to `6`. The second process's save fails because it still has version `5`, which no longer matches the stored version `6`. This triggers a `SessionConflictException`. ```php use Cognesy\Agents\Session\Exceptions\SessionConflictException; try { $runtime->execute($sessionId, $action); } catch (SessionConflictException $e) { // Reload and retry, or inform the user $fresh = $runtime->getSession($sessionId); } // @doctest id="70aa" ``` ### Exception Types | Exception | Condition | |---|---| | `SessionNotFoundException` | The session ID does not exist in the store | | `SessionConflictException` | Version mismatch during save, or attempting to create an existing session | | `InvalidSessionFileException` | File-based store encountered a corrupt or unreadable file | ## Persistence Stores The session system ships with two `CanStoreSessions` implementations. ### InMemorySessionStore Stores sessions in a PHP array. Useful for testing, prototyping, and single-process applications. ```php use Cognesy\Agents\Session\Store\InMemorySessionStore; $store = new InMemorySessionStore(); $repo = new SessionRepository($store); // @doctest id="91a2" ``` Sessions are lost when the process ends. All version checks and conflict detection still work correctly. ### FileSessionStore Stores each session as a JSON file on disk. Supports concurrent access through file locking (`flock`). ```php use Cognesy\Agents\Session\Store\FileSessionStore; $store = new FileSessionStore('/var/data/sessions'); $repo = new SessionRepository($store); // @doctest id="09e8" ``` The store creates the directory if it does not exist. Each session is stored as `{session_id}.json` with atomic writes (write to `.tmp`, then rename). Lock files (`{session_id}.lock`) are used for mutual exclusion during create and save operations. ### Custom Stores Implement the `CanStoreSessions` interface to integrate with any persistence backend: ```php use Cognesy\Agents\Session\Contracts\CanStoreSessions; use Cognesy\Agents\Session\Collections\SessionInfoList; use Cognesy\Agents\Session\Data\AgentSession; use Cognesy\Agents\Session\Data\SessionId; class RedisSessionStore implements CanStoreSessions { public function create(AgentSession $session): AgentSession { /* ... */ } public function save(AgentSession $session): AgentSession { /* ... */ } public function load(SessionId $sessionId): ?AgentSession { /* ... */ } public function exists(SessionId $sessionId): bool { /* ... */ } public function delete(SessionId $sessionId): void { /* ... */ } public function listHeaders(): SessionInfoList { /* ... */ } } // @doctest id="444e" ``` Your implementation must enforce the version semantics: `create()` requires version `0`, and `save()` must match the stored version. Use `AgentSession::reconstitute()` to set the next version and timestamp before persisting. ## Session Lifecycle vs Execution Lifecycle The session system has two distinct lifecycle models that operate independently. ### Session Lifecycle The session lifecycle tracks the overall status of the agent conversation across multiple requests. Status transitions are explicit -- they only happen when an action explicitly changes the status. ``` Active -> Suspended -> Active -> Completed -> Failed -> Deleted // @doctest id="b50b" ``` The `AgentSession::withState()` method updates the agent state without changing the session status. This is intentional: the session status represents a cross-run concern (is this conversation still active?), while the execution status represents a per-run concern (did this particular run succeed?). ### Execution Lifecycle Each call to `SendMessage` creates a new execution within the session. The `AgentState` tracks execution status (`Pending`, `InProgress`, `Completed`, `Stopped`, `Failed`) independently of the session status. Between executions, the state is reset via `forNextExecution()`. A session can be `Active` while its last execution was `Failed` -- the session is still open for new messages, even though the most recent run encountered an error. ## Session Controllers Session controllers intercept the runtime pipeline at four stages, allowing you to modify the session at each point. This is how you implement cross-cutting session concerns like auto-suspend, validation, or audit logging. ### The CanControlAgentSession Interface ```php interface CanControlAgentSession { public function onStage(AgentSessionStage $stage, AgentSession $session): AgentSession; } // @doctest id="cb96" ``` The `AgentSessionStage` enum defines the four interception points: | Stage | When | Typical Use | |---|---|---| | `AfterLoad` | After loading from the store | Validation, enrichment | | `AfterAction` | After the action has executed | Post-processing, derived state | | `BeforeSave` | Before persisting to the store | Auto-suspend, status derivation | | `AfterSave` | After successful persistence | Notifications, audit logging | ### Using SessionHookStack The `SessionHookStack` composes multiple controllers into a priority-ordered pipeline: ```php use Cognesy\Agents\Session\Contracts\CanControlAgentSession; use Cognesy\Agents\Session\Data\AgentSession; use Cognesy\Agents\Session\Enums\AgentSessionStage; use Cognesy\Agents\Session\SessionHookStack; use Cognesy\Agents\Session\SessionRuntime; // Auto-suspend after every action $autoSuspend = new class implements CanControlAgentSession { public function onStage(AgentSessionStage $stage, AgentSession $session): AgentSession { return match ($stage) { AgentSessionStage::BeforeSave => $session->suspended(), default => $session, }; } }; $hooks = SessionHookStack::empty()->with($autoSuspend, priority: 100); $runtime = new SessionRuntime($repo, $events, $hooks); // @doctest id="696c" ``` Higher priority hooks run first. The `SessionHookStack` itself implements `CanControlAgentSession`, so you can also pass a single controller directly to the runtime constructor. If no controller is provided, the runtime uses `PassThroughSessionController`, which returns the session unchanged at every stage. ## Events The `SessionRuntime` emits events at key points in the pipeline. All events are dispatched through the `CanHandleEvents` instance passed to the runtime constructor. | Event | When | Key Data | |---|---|---| | `SessionLoaded` | After successfully loading a session | `sessionId`, `version`, `status` | | `SessionActionExecuted` | After an action completes (before save) | `sessionId`, `action` class name, before/after version and status | | `SessionSaved` | After successful persistence | `sessionId`, `version`, `status` | | `SessionLoadFailed` | When loading throws an exception | `sessionId`, `error`, `errorType` | | `SessionSaveFailed` | When saving throws an exception | `sessionId`, `error`, `errorType` | You can listen for these events to build dashboards, audit logs, or monitoring alerts: ```php use Cognesy\Agents\Session\Events\SessionActionExecuted; use Cognesy\Agents\Session\Events\SessionSaveFailed; $events->addListener(SessionActionExecuted::class, function (SessionActionExecuted $e) { logger()->info("Session {$e->sessionId}: {$e->action} executed, " . "version {$e->beforeVersion} -> {$e->afterVersion}"); }); $events->addListener(SessionSaveFailed::class, function (SessionSaveFailed $e) { logger()->error("Session {$e->sessionId}: save failed - {$e->error}"); }); // @doctest id="1490" ``` ## Writing Custom Actions To create a custom action, implement the `CanExecuteSessionAction` interface: ```php use Cognesy\Agents\Session\Contracts\CanExecuteSessionAction; use Cognesy\Agents\Session\Data\AgentSession; final readonly class ArchiveSession implements CanExecuteSessionAction { public function __construct( private string $archiveReason, ) {} public function executeOn(AgentSession $session): AgentSession { // Store the reason in metadata, then mark as completed $state = $session->state()->withMetadata('archive_reason', $this->archiveReason); return $session->withState($state)->completed(); } } // Usage $runtime->execute($sessionId, new ArchiveSession('Ticket resolved')); // @doctest id="bec9" ``` Actions should be pure transformations on the session. Side effects (external API calls, notifications) are better handled through session controllers or event listeners. ## Related - [AgentBuilder & Capabilities](13-agent-builder.md) - [Agent Templates](14-agent-templates.md) - [Subagents](15-subagents.md) ================================================================================ FILE: packages/agents/17-building-tools-advanced.md ================================================================================ # Building Tools: Advanced Patterns Most projects only need [Building Tools](06-building-tools.md) with `FunctionTool` or `BaseTool`. This page covers advanced patterns for when you need lower-level control: context-aware tools, raw `SimpleTool` subclasses, custom descriptors, the `ToolRegistry`, and deferred tool providers. ## Class Hierarchy The tool class hierarchy is designed so each layer adds exactly one concern. You extend only the level you need: ``` SimpleTool (abstract) Descriptor + result wrapper + $this->arg() | +-- ReflectiveSchemaTool (abstract) | Adds auto-generated toToolSchema() via __invoke reflection | | | +-- FunctionTool (concrete) | Wraps a callable with cached reflective schema | +-- StateAwareTool (abstract) Adds withAgentState() / $this->agentState | +-- BaseTool (abstract) | Adds reflective schema + default metadata/instructions | +-- ContextAwareTool (abstract) Adds withToolCall() / $this->toolCall // @doctest id="179c" ``` | Class | What it adds | When to use | |---|---|---| | `SimpleTool` | Descriptor + result wrapper + `$this->arg()` | Full manual control, no state or schema magic | | `ReflectiveSchemaTool` | Auto-generates `toToolSchema()` from `__invoke()` | Rarely used directly; base for `FunctionTool` | | `FunctionTool` | Wraps a callable with cached reflective schema | Typed callable tools (most common) | | `StateAwareTool` | `withAgentState()` / `$this->agentState` | Read current execution state without schema support | | `BaseTool` | State + reflective schema + metadata/instructions defaults | State-aware class tools (most common class-based approach) | | `ContextAwareTool` | State + `withToolCall()` / `$this->toolCall` | Tools that need the raw `ToolCall` for correlation or tracing | ### Traits Under the Hood Each layer in the hierarchy is composed from focused traits. Understanding these traits helps when you need to implement `ToolInterface` directly rather than extending one of the base classes: | Trait | Provides | Used by | |---|---|---| | `HasDescriptor` | Delegates `name()`, `description()`, `metadata()`, `instructions()` to a `CanDescribeTool` instance | `SimpleTool` | | `HasResultWrapper` | Implements `use()` by calling `__invoke()` in a try/catch, wrapping results in `Result::success()` or `Result::failure()` | `SimpleTool` | | `HasArgs` | Provides `$this->arg($args, $name, $position, $default)` for named/positional parameter extraction | `SimpleTool` | | `HasAgentState` | Provides `$this->agentState` and `withAgentState()` (immutable clone + inject) | `StateAwareTool` | | `HasToolCall` | Provides `$this->toolCall` and `withToolCall()` (immutable clone + inject) | `ContextAwareTool` | | `HasReflectiveSchema` | Provides `toToolSchema()` and `paramsJsonSchema()` via `CallableSchemaFactory` reflection on `__invoke` | `ReflectiveSchemaTool`, `BaseTool` | ## ContextAwareTool `ContextAwareTool` extends `StateAwareTool` and adds access to the raw `ToolCall` object via `$this->toolCall`. This gives your tool the call ID, the tool name as the LLM specified it, and the raw arguments. It is particularly useful for tools that need to correlate their output with specific invocations -- for example, auditing tools, subagent spawners, or tools that emit events with tracing metadata. The framework injects both the `AgentState` and the `ToolCall` before each invocation via immutable cloning. You do not need to manage this yourself. ```php use Cognesy\Agents\Tool\ToolDescriptor; use Cognesy\Agents\Tool\Tools\ContextAwareTool; use Cognesy\Utils\JsonSchema\JsonSchema; use Cognesy\Utils\JsonSchema\ToolSchema; final class AuditingTool extends ContextAwareTool { public function __construct() { parent::__construct(new ToolDescriptor( name: 'audit_input', description: 'Record tool call metadata and input for audit trail.', )); } public function __invoke(mixed ...$args): string { $input = (string) $this->arg($args, 'input', 0, ''); // Access the raw ToolCall for correlation $callId = (string) ($this->toolCall?->id() ?? 'unknown'); // Access agent state for context $stepCount = $this->agentState?->stepCount() ?? 0; return "call_id={$callId}; steps={$stepCount}; input={$input}"; } public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') ->withProperties([ JsonSchema::string('input', 'Input text to audit'), ]) ->withRequiredProperties(['input']) )->toArray()); } } // @doctest id="671d" ``` ### Key Differences from BaseTool There are two important differences to keep in mind when choosing `ContextAwareTool` over `BaseTool`: 1. **No reflective schema.** `ContextAwareTool` does not include the `HasReflectiveSchema` trait, so you must always implement `toToolSchema()` yourself. 2. **Constructor signature.** The constructor takes a `CanDescribeTool` instance (typically a `ToolDescriptor`) rather than plain `name` and `description` strings. This gives you full control over metadata and instructions from the start. ### When to Use ContextAwareTool Use `ContextAwareTool` when your tool needs any of the following: - The `ToolCall` ID for log correlation or distributed tracing. - The raw arguments as the LLM specified them, before any processing. - The tool name as it appears in the LLM's request (which may differ from the registered name in edge cases). - Both state and tool call context in the same tool. If you only need agent state, prefer `BaseTool`. If you need neither state nor tool call context, prefer `FunctionTool` or `SimpleTool`. ## SimpleTool `SimpleTool` is the root abstract class in the tool hierarchy. It provides only the essentials: a descriptor for identity, a result wrapper that catches exceptions and returns `Result` objects, and the `$this->arg()` helper. Everything else -- schema, state access, tool call access -- is your responsibility. Use `SimpleTool` when you want complete control over a tool's behavior and do not need agent state or reflective schema generation. ```php use Cognesy\Agents\Tool\ToolDescriptor; use Cognesy\Agents\Tool\Tools\SimpleTool; use Cognesy\Utils\JsonSchema\JsonSchema; use Cognesy\Utils\JsonSchema\ToolSchema; final class EchoTool extends SimpleTool { public function __construct() { parent::__construct(new ToolDescriptor( name: 'echo_text', description: 'Echo back the provided text unchanged.', )); } public function __invoke(mixed ...$args): string { return (string) $this->arg($args, 'text', 0, ''); } public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') ->withProperties([ JsonSchema::string('text', 'Text to echo back'), ]) ->withRequiredProperties(['text']) )->toArray()); } } // @doctest id="69d8" ``` ### The Result Wrapper `SimpleTool` (via the `HasResultWrapper` trait) implements `ToolInterface::use()` by calling your `__invoke()` method inside a try/catch block. The behavior is straightforward: - If `__invoke()` returns normally, the value is wrapped in `Result::success()`. - If `__invoke()` throws any exception, the exception is wrapped in `Result::failure()` and the error message is sent back to the LLM. - The one exception that is **never caught** is `AgentStopException`. Throwing this from within a tool immediately halts the agent loop with the provided `StopSignal`. This means you can write `__invoke()` as a normal method that throws on error, and the framework will handle it gracefully: ```php public function __invoke(mixed ...$args): string { $path = (string) $this->arg($args, 'path', 0, ''); if (!file_exists($path)) { throw new \RuntimeException("File not found: {$path}"); } return file_get_contents($path); } // @doctest id="c257" ``` The LLM receives the error message and can decide whether to retry with different arguments or take a different approach entirely. ### Stopping the Agent Loop From a Tool If your tool detects a condition that should stop the entire agent, throw an `AgentStopException` with a `StopSignal`: ```php use Cognesy\Agents\Continuation\AgentStopException; use Cognesy\Agents\Continuation\StopSignal; use Cognesy\Agents\Continuation\StopReason; public function __invoke(mixed ...$args): string { $input = (string) $this->arg($args, 'input', 0, ''); if ($input === 'ABORT') { throw new AgentStopException( signal: new StopSignal( reason: StopReason::StopRequested, message: 'Abort signal received', ), ); } return "Processed: {$input}"; } // @doctest id="e536" ``` ## StateAwareTool `StateAwareTool` sits between `SimpleTool` and `BaseTool` in the hierarchy. It adds `CanAccessAgentState` support (via the `HasAgentState` trait) but does not include reflective schema generation or default metadata/instructions. Use `StateAwareTool` directly when you need agent state access but want full manual control over everything else. In practice, most developers use `BaseTool` instead, which adds schema and metadata defaults on top of `StateAwareTool`. ```php use Cognesy\Agents\Tool\ToolDescriptor; use Cognesy\Agents\Tool\Tools\StateAwareTool; use Cognesy\Utils\JsonSchema\JsonSchema; use Cognesy\Utils\JsonSchema\ToolSchema; final class StepCounterTool extends StateAwareTool { public function __construct() { parent::__construct(new ToolDescriptor( name: 'step_counter', description: 'Return the current step count.', )); } public function __invoke(mixed ...$args): string { return (string) ($this->agentState?->stepCount() ?? 0); } public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') )->toArray()); } } // @doctest id="90e2" ``` ## ReflectiveSchemaTool `ReflectiveSchemaTool` extends `SimpleTool` and adds automatic `toToolSchema()` generation from the `__invoke()` method signature via the `HasReflectiveSchema` trait. It is the base class for `FunctionTool` and is rarely extended directly. The reflective schema uses `CallableSchemaFactory` to introspect the `__invoke` method at runtime and generates a JSON Schema from the parameter types and `#[Description]` attributes. The result is cached after the first call to `paramsJsonSchema()`. If you are building a class-based tool and want reflective schema without state access, extend `ReflectiveSchemaTool`. However, because `__invoke` must use the `mixed ...$args` signature, the generated schema will not be useful for production -- making this class primarily an internal building block. ## Descriptors as Separate Classes When a tool's documentation is extensive -- detailed usage instructions, parameter descriptions, error codes, examples -- it can overwhelm the tool's runtime logic. In these cases, extract the documentation into a dedicated descriptor class that extends `ToolDescriptor`. ### The ToolDescriptor Class `ToolDescriptor` is a readonly value object that implements `CanDescribeTool`. Its constructor accepts four arguments: ```php use Cognesy\Agents\Tool\ToolDescriptor; $descriptor = new ToolDescriptor( name: 'search', description: 'Full-text search across documents.', metadata: [ // Merged with defaults (name, summary) 'namespace' => 'retrieval', 'tags' => ['search', 'rag'], ], instructions: [ // Merged with defaults (name, description, parameters, returns) 'parameters' => [ 'query' => 'Natural language search query.', 'limit' => 'Maximum results (1-100, default 10).', ], 'returns' => 'JSON array of matching documents.', 'errors' => [ 'empty_query' => 'Returned when query is blank.', ], ], ); // @doctest id="198e" ``` The `metadata` and `instructions` arrays are merged with default values at read time: - **`metadata()`** merges with `['name' => ..., 'summary' => ...]` - **`instructions()`** merges with `['name' => ..., 'description' => ..., 'parameters' => [], 'returns' => 'mixed']` This means you only need to specify the additional fields your tool requires. ### Subclassing ToolDescriptor For tools with extensive documentation, create a dedicated descriptor subclass: ```php use Cognesy\Agents\Tool\ToolDescriptor; final readonly class SearchToolDescriptor extends ToolDescriptor { public function __construct() { parent::__construct( name: 'search', description: 'Search indexed documents by query.', metadata: [ 'namespace' => 'retrieval', 'tags' => ['search', 'rag'], ], instructions: [ 'parameters' => [ 'query' => 'Natural language search query.', 'limit' => 'Maximum results (1-100, default 10).', 'filters' => 'Optional key-value filters.', ], 'returns' => 'JSON array of matching documents with relevance scores.', 'errors' => [ 'empty_query' => 'Returned when query is blank.', 'index_unavailable' => 'Returned when the search index is offline.', ], 'notes' => [ 'Results are sorted by relevance score descending.', 'Use filters to narrow by date, category, or author.', ], ], ); } } // @doctest id="4e29" ``` Then pass the descriptor to your tool's constructor: ```php final class SearchTool extends SimpleTool { public function __construct() { parent::__construct(new SearchToolDescriptor()); } // ... __invoke() and toToolSchema() } // @doctest id="71e6" ``` This pattern keeps tool runtime logic clean and makes documentation reusable across tools that share the same descriptor structure. ### How Metadata and Instructions Differ The two documentation levels serve different audiences: **`metadata()`** returns lightweight information suitable for listing or browsing: name, summary, namespace, and tags. It is designed for the "list" action of a tool registry where an agent needs to scan many tools quickly without consuming context. **`instructions()`** returns the full specification: name, description, parameters, return type, errors, examples, and notes. It is designed for the "help" action where an agent needs the complete documentation for a specific tool before using it. `BaseTool` provides default implementations that extract a summary from the description (first sentence or first line, truncated to 80 characters) and a namespace from dotted tool names (e.g., `file.read` yields namespace `file`). ## ToolRegistry The `ToolRegistry` is a mutable container that implements `CanManageTools`. Unlike the immutable `Tools` collection (which is a value object for passing tools around), `ToolRegistry` supports lazy instantiation through factories and is designed for managing large numbers of tools at runtime. ### Registering Tools ```php use Cognesy\Agents\Tool\ToolRegistry; $registry = new ToolRegistry(); // Register a tool instance directly $registry->register($searchTool); // Register a factory for lazy instantiation $registry->registerFactory('heavy_tool', function () { return new HeavyTool(); // Only created when first needed }); // @doctest id="be7b" ``` ### Querying the Registry ```php $registry->has('search'); // true $registry->get('search'); // ToolInterface (resolves factory on first call) $registry->names(); // ['search', 'heavy_tool'] $registry->count(); // 2 $registry->all(); // Resolves all factories, returns keyed array // @doctest id="b545" ``` When you call `get()` on a factory-registered tool, the factory is invoked once and the resulting instance is cached for subsequent calls. This makes `ToolRegistry` suitable for tools that are expensive to construct or that depend on runtime context. If a tool is not found, `get()` throws an `InvalidToolException`. ### ToolsTool: Agent-Facing Tool Discovery The `ToolsTool` is a built-in tool that exposes the `ToolRegistry` to the LLM, letting agents discover and browse available tools at runtime. It supports three actions: | Action | Parameters | Description | |---|---|---| | `list` | `limit` (optional) | Returns `metadata()` for all registered tools | | `help` | `tool` (required) | Returns full `instructions()` for a specific tool by name | | `search` | `query` (required), `limit` (optional) | Searches tool names, descriptions, summaries, namespaces, and tags by keyword | This pattern is useful when an agent has access to many tools but should not receive all their schemas upfront (which would consume context window space). Instead, the agent uses `ToolsTool` to discover relevant tools, then calls them by name. ```php use Cognesy\Agents\Capability\Tools\ToolsTool; use Cognesy\Agents\Tool\ToolRegistry; $registry = new ToolRegistry(); $registry->register($searchTool); $registry->register($fileTool); $toolsTool = new ToolsTool($registry); // Now add $toolsTool to the agent's Tools collection // @doctest id="2813" ``` ## Deferred Tool Providers Some tools cannot be constructed until the agent loop is being assembled, because they depend on the tool-use driver, the event dispatcher, or the current set of already-registered tools. Deferred tool providers solve this by delaying tool construction until build time. ### The `CanProvideDeferredTools` Interface Implement this interface to provide tools that are resolved lazily during the `AgentBuilder::build()` process: ```php use Cognesy\Agents\Builder\Contracts\CanProvideDeferredTools; use Cognesy\Agents\Builder\Data\DeferredToolContext; use Cognesy\Agents\Collections\Tools; final class SubagentToolProvider implements CanProvideDeferredTools { public function provideTools(DeferredToolContext $context): Tools { // Access build-time dependencies $existingTools = $context->tools(); $driver = $context->toolUseDriver(); $events = $context->events(); return new Tools( new SubagentTool($driver, $events), ); } } // @doctest id="4eb5" ``` The `DeferredToolContext` gives providers access to three things: | Method | Returns | Purpose | |---|---|---| | `tools()` | `Tools` | The current tool collection as it exists at resolution time | | `toolUseDriver()` | `CanUseTools` | The driver for making nested LLM calls (needed by subagent tools) | | `events()` | `CanHandleEvents` | The event dispatcher for emitting events | ### The `UseToolFactory` Capability For simple cases where you just need a factory closure rather than a full class, the `UseToolFactory` capability wraps a callable as a deferred provider: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Core\UseToolFactory; use Cognesy\Agents\Collections\Tools; use Cognesy\Agents\Drivers\CanUseTools; use Cognesy\Events\Contracts\CanHandleEvents; $loop = AgentBuilder::base() ->withCapability(new UseToolFactory( function (Tools $tools, CanUseTools $driver, CanHandleEvents $events) { return new SubagentTool($driver, $events); } )) ->build(); // @doctest id="0db3" ``` The factory callable receives the same three arguments that `DeferredToolContext` provides. The returned `ToolInterface` is wrapped in a `Tools` collection and merged into the agent's tool set. ## Schema Strategy Matrix | Class | Default schema source | Recommendation | |---|---|---| | `FunctionTool` | Callable reflection via `fromCallable()` | Usually no override needed | | `BaseTool` | Reflection of `__invoke(mixed ...$args)` | Override `toToolSchema()` for explicit parameters | | `ContextAwareTool` | None (no `HasReflectiveSchema`) | Must implement `toToolSchema()` | | `StateAwareTool` | None (no `HasReflectiveSchema`) | Must implement `toToolSchema()` | | `SimpleTool` | None (no `HasReflectiveSchema`) | Must implement `toToolSchema()` | | `ReflectiveSchemaTool` | Reflection of `__invoke()` | Usually no override needed (but see caveat) | `BaseTool` inherits reflective schema support via the `HasReflectiveSchema` trait, but because `__invoke` must use `mixed ...$args`, the auto-generated schema describes a single variadic parameter. This is rarely useful for production prompts. Always override `toToolSchema()` in `BaseTool` subclasses. ### Building Schema Manually All manual schemas use the `ToolSchema` and `JsonSchema` helpers: ```php use Cognesy\Utils\JsonSchema\JsonSchema; use Cognesy\Utils\JsonSchema\ToolSchema; class SearchTool extends BaseTool { public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') ->withProperties([ JsonSchema::string('query', 'Search query'), JsonSchema::integer('limit', 'Max results') ->withMeta(['minimum' => 1, 'maximum' => 100]), JsonSchema::enum('format', ['json', 'text'], 'Output format'), JsonSchema::array('tags') ->withItemSchema(JsonSchema::string()), JsonSchema::object('filters') ->withProperties([ JsonSchema::string('category', 'Filter by category'), JsonSchema::string('date_from', 'Start date (YYYY-MM-DD)'), ]), ]) ->withRequiredProperties(['query']) )->toArray()); } } // @doctest id="7114" ``` The resulting array follows the OpenAI function-calling format: ```php [ 'type' => 'function', 'function' => [ 'name' => 'search', 'description' => 'Search documents', 'parameters' => [ 'type' => 'object', 'properties' => [...], 'required' => ['query'], ], ], ] // @doctest id="9997" ``` ## Parameter Extraction with `$this->arg()` The `arg()` method (from the `HasArgs` trait) resolves a parameter from the arguments array using a three-step lookup: ```php $value = $this->arg($args, $name, $position, $default); // @doctest id="1bfb" ``` 1. **Named key** -- checks `$args[$name]` (the typical case when the LLM passes an associative array) 2. **Positional index** -- checks `$args[$position]` (useful for direct invocation in tests) 3. **Default value** -- falls back to `$default` ```php // Extract 'path' by name, or position 0, or default to empty string $path = (string) $this->arg($args, 'path', 0, ''); // Extract 'limit' by name, or position 1, or default to 10 $limit = (int) $this->arg($args, 'limit', 1, 10); // Extract 'verbose' by name, or position 2, or default to false $verbose = (bool) $this->arg($args, 'verbose', 2, false); // @doctest id="82ef" ``` Always cast the return value to the expected type, since the LLM may pass values as strings even for numeric parameters. ## Implementing ToolInterface Directly If none of the base classes fit your needs, you can implement `ToolInterface` directly. You must provide three methods: ```php use Cognesy\Agents\Tool\Contracts\CanDescribeTool; use Cognesy\Agents\Tool\Contracts\ToolInterface; use Cognesy\Agents\Tool\ToolDescriptor; use Cognesy\Utils\Result\Result; final class CustomTool implements ToolInterface { private ToolDescriptor $descriptor; public function __construct() { $this->descriptor = new ToolDescriptor( name: 'custom', description: 'A fully custom tool.', ); } public function use(mixed ...$args): Result { try { $value = $this->execute($args); return Result::success($value); } catch (\Throwable $e) { return Result::failure($e); } } public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray([ 'type' => 'function', 'function' => [ 'name' => 'custom', 'description' => 'A fully custom tool.', 'parameters' => [ 'type' => 'object', 'properties' => [ 'input' => ['type' => 'string', 'description' => 'Input value'], ], 'required' => ['input'], ], ], ]); } public function descriptor(): CanDescribeTool { return $this->descriptor; } private function execute(array $args): string { return 'Result: ' . ($args['input'] ?? ''); } } // @doctest id="d99e" ``` If your custom tool needs state or tool call injection, also implement `CanAccessAgentState` and/or `CanAccessToolCall`. The framework checks for these interfaces during tool preparation and calls the appropriate `with*()` methods. ## Building a Complete Tool: Real-World Example Here is a condensed view of how a production tool is structured, demonstrating the `SimpleTool` pattern with a separate descriptor, manual schema, and `$this->arg()`: ```php use Cognesy\Agents\Tool\ToolDescriptor; use Cognesy\Agents\Tool\Tools\SimpleTool; use Cognesy\Utils\JsonSchema\JsonSchema; use Cognesy\Utils\JsonSchema\ToolSchema; // Step 1: Descriptor in a separate class final readonly class BashToolDescriptor extends ToolDescriptor { public function __construct() { parent::__construct( name: 'bash', description: 'Execute a bash command in a sandboxed environment.', metadata: ['namespace' => 'system', 'tags' => ['shell', 'execution']], instructions: [ 'parameters' => ['command' => 'The bash command to execute'], 'returns' => 'Command output (stdout/stderr) with exit code', ], ); } } // Step 2: Tool class with manual schema and injected dependencies final class BashTool extends SimpleTool { public function __construct(private CanExecuteCommand $sandbox) { parent::__construct(new BashToolDescriptor()); } public function __invoke(mixed ...$args): string { $command = (string) $this->arg($args, 'command', 0, ''); $result = $this->sandbox->execute(['bash', '-c', $command]); return $result->stdout(); } public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') ->withProperties([ JsonSchema::string('command', 'The bash command to execute'), ]) ->withRequiredProperties(['command']) )->toArray()); } } // @doctest id="7cc0" ``` This structure separates concerns cleanly: the descriptor owns documentation, the tool class owns behavior, and the schema is explicit. ## Related - [Tools](05-tools.md) -- overview, registration, contracts, and execution lifecycle - [Building Tools](06-building-tools.md) -- quick path with `FunctionTool` and `BaseTool` ================================================================================ FILE: packages/agents/18-observing-agent-execution.md ================================================================================ ## Introduction For responsive chat interfaces, it is usually not enough to wait for `execute()` to finish and then render the final answer. The Agents package exposes the execution lifecycle through events, and the `AgentEventBroadcaster` converts selected events into UI-friendly envelopes that can be forwarded to SSE, WebSocket, or any custom transport. This gives your application a simple observation layer: - live text chunks while the LLM is producing output - step and tool status updates while the agent is working - execution status transitions such as `processing`, `completed`, and `failed` The broadcaster is observation-only. It does not change agent behavior or persist any state. It listens to events already emitted by the agent loop and forwards them in a consistent format for your UI. ## When to Use It Use `AgentEventBroadcaster` when your app needs to reflect agent progress while execution is still in flight: - chat UIs that should show text as it arrives - TUIs that need progress indicators and tool activity - web apps that stream agent status over SSE or WebSockets - observability dashboards that track step, tool, and continuation events If you only need the final answer, `execute()` is enough. If you need step-by-step inspection, use `iterate()`. If you need UI updates during execution, attach a broadcaster. ## How It Works `AgentLoop`, the active driver, and the underlying inference runtime share the same event dispatcher. `AgentEventBroadcaster` listens to that dispatcher and translates selected events into envelopes such as: - `agent.status` - `agent.step.started` - `agent.step.completed` - `agent.tool.started` - `agent.tool.completed` - `agent.stream.chunk` Your application provides the final transport by implementing `CanBroadcastAgentEvents`. ## Required Steps To make agent events available to your application, set up four pieces: 1. Create the agent with a shared event bus. The default builder and `AgentLoop::default()` already do this. 2. Implement `CanBroadcastAgentEvents` to forward envelopes to your transport. 3. Add `UseAgentBroadcasting` to the builder so the relevant listeners are registered for you. 4. If you want streamed text chunks, make sure the LLM request is created with `stream: true`. Minimal example: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Broadcasting\CanBroadcastAgentEvents; use Cognesy\Agents\Capability\Broadcasting\UseAgentBroadcasting; use Cognesy\Agents\Data\AgentState; final class SseTransport implements CanBroadcastAgentEvents { public function broadcast(string $channel, array $envelope): void { // Forward the envelope to SSE, WebSocket, Redis, etc. } } $agent = AgentBuilder::base() ->withCapability(new UseAgentBroadcasting( broadcaster: new SseTransport(), sessionId: 'chat-42', )) ->build(); $result = $agent->execute( AgentState::empty()->withUserMessage('Explain closures in PHP.') ); // @doctest id="d9b5" ``` Once installed, the capability listens to the relevant execution, step, tool, continuation, and streaming events and emits normalized envelopes through your transport. Your app consumes those envelopes and updates the UI. ## Enabling Live Text Chunks `agent.stream.chunk` is emitted only when the underlying inference request is streamed. Attaching a broadcaster alone is not enough: the LLM request must be created with streaming enabled. In practice, that means configuring the driver so its inference request includes `stream: true` in the request options. Minimal example with an explicit driver: ```php use Cognesy\Agents\AgentLoop; use Cognesy\Agents\Drivers\ToolCalling\ToolCallingDriver; use Cognesy\Events\Dispatchers\EventDispatcher; use Cognesy\Polyglot\Inference\InferenceRuntime; use Cognesy\Polyglot\Inference\LLMProvider; $events = new EventDispatcher('agent'); $llm = LLMProvider::new(); $agent = AgentLoop::default()->withDriver( new ToolCallingDriver( llm: $llm, inference: InferenceRuntime::fromProvider($llm, events: $events), options: ['stream' => true], events: $events, ) ); // @doctest id="f378" ``` Without streamed inference, you still receive step, tool, and status envelopes, but not incremental text chunks. ## Transport Integration `AgentEventBroadcaster` emits envelopes through a simple contract: ```php interface CanBroadcastAgentEvents { public function broadcast(string $channel, array $envelope): void; } // @doctest id="65a5" ``` This keeps the integration boundary small. The broadcaster does not require a framework or network stack. Your implementation decides how envelopes leave the process. Typical patterns: - SSE endpoint writes each envelope as a server-sent event - WebSocket handler publishes envelopes to a client-specific channel - queue worker forwards envelopes to Redis or another pub/sub layer - CLI/TUI adapter renders envelopes directly to the terminal ## Advanced Option If you need lower-level control, you can create `AgentEventBroadcaster` yourself and attach it via `$agent->wiretap($broadcaster->wiretap())`. `UseAgentBroadcasting` is just the prewired integration path for the event set that is usually useful in interactive applications. ## Choosing a Broadcast Configuration `BroadcastConfig` provides three presets: - `minimal()` for status-only tracking - `standard()` for status plus streamed text chunks - `debug()` for status, stream chunks, continuation trace, and tool arguments Minimal example: ```php use Cognesy\Agents\Broadcasting\BroadcastConfig; $broadcaster = new AgentEventBroadcaster( broadcaster: new SseTransport(), sessionId: 'chat-42', executionId: 'exec-1', config: BroadcastConfig::standard(), ); // @doctest id="ac0b" ``` For most user-facing apps, `standard()` is the right default. ## What the UI Can Rely On The broadcaster emits stable, app-facing event types. A typical UI flow looks like this: 1. `agent.status` changes to `processing` 2. `agent.step.started` appears 3. zero or more `agent.stream.chunk` envelopes arrive 4. zero or more `agent.tool.started` / `agent.tool.completed` envelopes arrive 5. `agent.step.completed` appears 6. `agent.status` changes to `completed`, `failed`, `cancelled`, or `stopped` This is usually enough to drive: - typing indicators - streaming assistant text - tool activity rows - execution status badges ## Notes - `AgentEventBroadcaster` is transport-agnostic. It formats envelopes but does not send HTTP responses or manage sockets. - The broadcaster does not replace `iterate()`. Use `iterate()` for application-side control flow, and use broadcasting for UI observation. - If you need additional behavior, you can attach your own listeners alongside the broadcaster with `onEvent()`. ================================================================================ FILE: packages/agents/19-skills.md ================================================================================ # Skills Skills are reusable instruction modules that extend what an agent can do. Each skill is a directory containing a `SKILL.md` file with YAML frontmatter and markdown instructions. The agent discovers available skills at startup, advertises their descriptions to the LLM, and loads full skill content on demand via tool call. The skill system follows the [Agent Skills Open Standard](https://agentskills.io), a portable specification adopted by 30+ AI tools including Claude Code, OpenAI Codex, Cursor, GitHub Copilot, and others. Skills written for this framework are compatible with those tools and vice versa. ## Directory Structure Each skill lives in its own directory under a skills root: ``` skills/ ├── code-review/ │ ├── SKILL.md # Main instructions (required) │ ├── examples/ │ │ └── sample.md # Example output │ └── scripts/ │ └── lint.sh # Helper script ├── deploy/ │ └── SKILL.md └── api-conventions/ ├── SKILL.md └── references/ └── openapi.yaml // @doctest id="cf33" ``` Resource folders (`scripts/`, `references/`, `assets/`, `examples/`) are automatically discovered and listed in the skill's `resources` property. ## SKILL.md Format Every skill needs a `SKILL.md` file with optional YAML frontmatter between `---` markers, followed by markdown content: ```yaml --- name: code-review description: Review code for quality, bugs, and best practices argument-hint: "[file-or-directory]" license: MIT --- When reviewing code, check for: 1. Logic errors and edge cases 2. Security vulnerabilities 3. Performance issues 4. Style consistency Focus on $ARGUMENTS if provided. # @doctest id="37f3" ``` ### Frontmatter Fields #### Agent Skills Open Standard (portable) | Field | Type | Default | Description | |-------|------|---------|-------------| | `name` | string | directory name | Skill name (lowercase, hyphens, max 64 chars) | | `description` | string | `''` | What the skill does and when to use it | | `license` | string | `null` | License (e.g. `MIT`, `Apache-2.0`) | | `compatibility` | string | `null` | Environment requirements | | `metadata` | map | `[]` | Arbitrary key-value pairs | | `allowed-tools` | string/list | `[]` | Space/comma-delimited or YAML list of allowed tools | #### Cross-platform Extensions | Field | Type | Default | Description | |-------|------|---------|-------------| | `disable-model-invocation` | bool | `false` | Prevent the model from auto-loading this skill | | `user-invocable` | bool | `true` | Whether to show in user-facing skill listings | | `argument-hint` | string | `null` | Hint for expected arguments (e.g. `[issue-number]`) | #### Execution Context Extensions | Field | Type | Default | Description | |-------|------|---------|-------------| | `model` | string | `null` | Override model when skill is active | | `context` | string | `null` | Set to `fork` for subagent execution | | `agent` | string | `null` | Subagent type when `context: fork` | Unknown frontmatter fields are silently ignored, ensuring forward compatibility. ## Setting Up Skills ### Creating a SkillLibrary The `SkillLibrary` scans a directory for skill subdirectories: ```php use Cognesy\Agents\Capability\Skills\SkillLibrary; $library = SkillLibrary::inDirectory(__DIR__ . '/skills'); // List all skills (name + description) $skills = $library->listSkills(); // Check and load a specific skill if ($library->hasSkill('code-review')) { $skill = $library->getSkill('code-review'); } // @doctest id="9794" ``` Skills are lazy-loaded: only frontmatter is read during discovery, full content is loaded on first `getSkill()` call and cached thereafter. ### Wiring Into an Agent The `UseSkills` capability registers the `load_skill` tool and injects skill metadata via a hook: ```php use Cognesy\Agents\Builder\AgentBuilder; use Cognesy\Agents\Capability\Skills\SkillLibrary; use Cognesy\Agents\Capability\Skills\UseSkills; $library = SkillLibrary::inDirectory(__DIR__ . '/skills'); $agent = AgentBuilder::base() ->withCapability(new UseSkills($library)) ->build(); // @doctest id="5c3b" ``` This does two things: 1. **Registers `load_skill` tool** — the LLM can call `load_skill(skill_name: "code-review")` to load full skill content, or `load_skill(list_skills: true)` to see available skills. 2. **Injects metadata hook** — `AppendSkillMetadataHook` prepends a system message listing skill names and descriptions so the LLM knows what's available. ## Argument Substitution When loading a skill with arguments, placeholders in the body are replaced: | Placeholder | Replaced with | |-------------|--------------| | `$ARGUMENTS` | Full argument string | | `$ARGUMENTS[N]` | Nth argument (0-based) | | `$N` | Shorthand for `$ARGUMENTS[N]` | If no placeholder is present, arguments are appended as `ARGUMENTS: `. ```yaml --- name: fix-issue description: Fix a GitHub issue argument-hint: "[issue-number]" --- Fix GitHub issue $ARGUMENTS following our coding standards. # @doctest id="eb16" ``` When loaded with `load_skill(skill_name: "fix-issue", arguments: "123")`, the body becomes "Fix GitHub issue 123 following our coding standards." ## Invocation Control Two flags control who can invoke a skill: | Configuration | Model sees it | User sees it | Use case | |--------------|--------------|-------------|----------| | *(default)* | Yes | Yes | General-purpose skills | | `disable-model-invocation: true` | No | Yes | Side-effect workflows (deploy, commit) | | `user-invocable: false` | Yes | No | Background knowledge (legacy system context) | ```yaml --- name: deploy description: Deploy to production disable-model-invocation: true --- Deploy the application: 1. Run tests 2. Build 3. Push to production # @doctest id="9264" ``` ## Components ### Skill Immutable value object holding parsed skill data: ```php $skill->name; // string $skill->description; // string $skill->body; // string (markdown content) $skill->path; // string (absolute path to SKILL.md) $skill->license; // ?string $skill->compatibility; // ?string $skill->metadata; // array $skill->allowedTools; // list $skill->disableModelInvocation; // bool $skill->userInvocable; // bool $skill->argumentHint; // ?string $skill->model; // ?string $skill->context; // ?string $skill->agent; // ?string $skill->resources; // list $skill->render(); // Full skill content with XML tags $skill->render('arg1 arg2'); // With argument substitution $skill->renderMetadata(); // "[name]: description" $skill->toArray(); // All non-null fields as array // @doctest id="da37" ``` ### SkillLibrary Discovery and lazy-loading of skills from a directory: ```php $library = SkillLibrary::inDirectory($path); $library->listSkills(); // All skills $library->listSkills(modelInvocable: true); // Exclude disabled $library->listSkills(userInvocable: true); // Exclude background $library->hasSkill('name'); // bool $library->getSkill('name'); // ?Skill $library->renderSkillList(); // Formatted list // @doctest id="e1c9" ``` ### LoadSkillTool Tool exposed to the LLM for loading skills: ``` load_skill(skill_name: "code-review") // Load a skill load_skill(skill_name: "fix-issue", arguments: "123") // With args load_skill(list_skills: true) // List available // @doctest id="b777" ``` ### AppendSkillMetadataHook Fires on `BeforeStep`. Before the first agent step, injects a system message listing available model-invocable skills with their descriptions and argument hints. Skips subsequent steps if already injected. ### TrackActiveSkillHook Fires on `AfterToolUse`. When `load_skill` completes successfully, updates the agent state metadata with the loaded skill's `allowed-tools` list and `model` override. Clears these values when a skill without them is loaded. ### SkillToolFilterHook Fires on `BeforeToolUse`. Enforces `allowed-tools` restrictions when a skill with an `allowed-tools` field is active. If the tool being called is not in the list, blocks execution. The `load_skill` tool itself is never blocked, allowing the agent to switch skills. ### SkillModelOverrideHook Fires on `BeforeStep`. Checks agent state metadata for an active skill's `model` override and applies it by creating a new `LLMConfig` with the specified model. This allows skills to target specific models (e.g., a coding skill that requires a more capable model). ## Shell Preprocessing Skills can embed shell commands using the `` !`command` `` syntax. When a `SkillPreprocessor` is configured, these patterns are executed and replaced with their output before argument substitution occurs. ```yaml --- name: project-info description: Show project context --- Project version: !`cat VERSION` Current branch: !`git branch --show-current` Recent changes: !`git log --oneline -5` Review the code in $ARGUMENTS. # @doctest id="f02e" ``` When loaded, the `` !`...` `` patterns are replaced with live command output, giving the LLM up-to-date context. ### Enabling Preprocessing Pass a `SkillPreprocessor` to `UseSkills`: ```php use Cognesy\Agents\Capability\Skills\SkillLibrary; use Cognesy\Agents\Capability\Skills\SkillPreprocessor; use Cognesy\Agents\Capability\Skills\UseSkills; $library = SkillLibrary::inDirectory(__DIR__ . '/skills'); $preprocessor = new SkillPreprocessor( workingDirectory: getcwd(), // optional, defaults to cwd timeoutSeconds: 10, // optional, default 10s ); $agent = AgentBuilder::base() ->withCapability(new UseSkills($library, $preprocessor)) ->build(); // @doctest id="7d25" ``` Commands that fail or time out are replaced with `[error: ...]` markers instead of crashing the skill load. ## Cross-Platform Compatibility The portable subset that works across all Agent Skills-compatible tools: - `name` and `description` in frontmatter - Markdown instructions in the body - Directory-per-skill layout with `SKILL.md` entry point Extension fields (`disable-model-invocation`, `context`, `model`, etc.) are tool-specific. Unknown fields are ignored gracefully by all compliant tools, so skills with extensions remain portable — the extensions simply don't activate in tools that don't support them. ================================================================================ FILE: packages/agents/testing-doubles.md ================================================================================ ## Overview Agents has several different testing seams. They solve different problems, so the main question is which layer you want to isolate. - use `FakeAgentDriver` to script whole agent-loop steps - use `FakeInferenceDriver` when a test drives raw inference responses directly - use `FakeTool` for deterministic tool execution - use `FakeSubagentProvider` for subagent registry and lookup tests - use `TestAgentLoop` when you need a small harness around loop stopping behavior - use `FakeSandbox` from the sandbox package when testing bash or process-backed tools ## `FakeAgentDriver` `FakeAgentDriver` is the main high-level fake for agent-loop tests. Use it when you want to: - script full loop steps with `ScenarioStep` - test tool-call and final-response paths without any LLM calls - drive subagent child steps with `withChildSteps()` This is the best seam for most package-level agent behavior tests. ## `FakeInferenceDriver` `FakeInferenceDriver` lives in `packages/agents/tests/Support`. Use it when the test is closer to the raw inference boundary and you want queued: - `InferenceResponse` objects for sync paths - `PartialInferenceDelta` batches for streaming paths This seam is narrower than `FakeAgentDriver`. It is useful when a test exercises agent logic that still interacts with the Polyglot-style inference contract. ## `FakeTool` `FakeTool` is the deterministic tool double for loop and registry tests. Use it when you need: - a fixed return value with `FakeTool::returning(...)` - a custom callable-backed tool without building a full real tool class - optional schema and metadata for tool-definition coverage For detailed examples, see `10-testing.md` and `05-tools.md`. ## `FakeSubagentProvider` `FakeSubagentProvider` is the in-memory agent-definition registry for subagent tests. Use it when you need: - deterministic subagent lookup - explicit control over which definitions exist - error-path coverage for missing subagents ## `TestAgentLoop` `TestAgentLoop` is a test harness, not a fake. It subclasses `AgentLoop` and adds a small stop condition based on a maximum iteration count. Use it when the test needs a controllable loop wrapper rather than a different driver or tool. ## `FakeSandbox` For bash-backed or process-backed tools, pull in `FakeSandbox` from the sandbox package. That is the right seam when the agent test still needs command execution behavior but must stay deterministic and process-free. ## Which One To Use Use this rule of thumb: - `FakeAgentDriver` for most agent-loop behavior tests - `FakeInferenceDriver` for raw inference-boundary tests - `FakeTool` for deterministic tool execution - `FakeSubagentProvider` for subagent lookup and registry behavior - `TestAgentLoop` for loop-harness cases - `FakeSandbox` when shell or process execution is part of the scenario ================================================================================ FILE: packages/agent-ctrl/1-overview.md ================================================================================ ## Introduction Agent-Ctrl is a PHP package that gives your application a single entry point for running CLI-based code agents. Instead of writing separate integration code for each agent's command-line interface, JSON output format, and streaming protocol, you configure one fluent builder and receive one normalized response -- regardless of which agent performed the work. The package ships as part of the Instructor-PHP monorepo and can be installed standalone via Composer: ```bash composer require cognesy/agent-ctrl # @doctest id="4d92" ``` ## Entry Point All interactions start with the `AgentCtrl` facade. It exposes dedicated factory methods for each supported agent, as well as a generic `make()` method that accepts an `AgentType` enum for runtime switching: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Enum\AgentType; // Dedicated factory methods AgentCtrl::claudeCode(); // Returns ClaudeCodeBridgeBuilder AgentCtrl::codex(); // Returns CodexBridgeBuilder AgentCtrl::openCode(); // Returns OpenCodeBridgeBuilder AgentCtrl::pi(); // Returns PiBridgeBuilder AgentCtrl::gemini(); // Returns GeminiBridgeBuilder // Runtime selection via enum AgentCtrl::make(AgentType::from('codex')); // @doctest id="45fa" ``` Each factory method returns a bridge builder -- a fluent configuration object that lets you set the model, timeout, working directory, streaming callbacks, and agent-specific options before calling `execute()` or `executeStreaming()`. ## Execution Flow Every agent interaction follows the same three-step lifecycle: 1. **Configure** -- Use the builder's fluent methods to set the model, working directory, timeout, sandbox driver, streaming callbacks, and any agent-specific options (system prompts, permission modes, sandbox modes, etc.). 2. **Execute** -- Call `execute()` for a blocking request that returns the final result, or `executeStreaming()` when you need real-time text and tool activity delivered through callbacks while the agent is still running. 3. **Read the response** -- Both execution methods return an `AgentResponse` object with a normalized shape: the agent's text output, exit code, session ID, token usage (when available), cost (when available), tool calls, parse diagnostics, and the raw bridge-specific response for advanced inspection. ```php use Cognesy\AgentCtrl\AgentCtrl; $response = AgentCtrl::claudeCode() ->withModel('claude-sonnet-4-5') ->withTimeout(300) ->inDirectory('/projects/my-app') ->execute('Summarize the architecture of this project.'); if ($response->isSuccess()) { echo $response->text(); } // @doctest id="7e02" ``` ## Core Capabilities **Unified API across agents.** Switch between Claude Code, Codex, OpenCode, Pi, and Gemini without changing your application's control flow or response handling. The same `execute()` call and `AgentResponse` shape work with every bridge. **Real-time streaming.** Register `onText()`, `onToolUse()`, `onComplete()`, and `onError()` callbacks to receive incremental updates while the agent works. Streaming and final-result access are not mutually exclusive -- `executeStreaming()` returns the complete `AgentResponse` when the agent finishes. **Session continuity.** Continue the most recent session or resume a specific session by ID. Session identifiers are extracted from each agent's native output format and normalized into `AgentSessionId` value objects. **Configurable execution environment.** Set the working directory, execution timeout, and sandbox driver (Host, Docker, Podman, Firejail, or Bubblewrap). The sandbox integration runs through the Instructor-PHP `Sandbox` package, providing consistent process isolation across all agents. **Normalized tool call tracking.** Every tool invocation -- whether it is a Claude Code tool use, a Codex command execution or file change, an OpenCode tool call, or a Pi tool execution -- is normalized into a `ToolCall` DTO with a tool name, input parameters, optional output, call ID, and error flag. **Observable execution pipeline.** The builder emits granular events at every stage of the execution lifecycle: request building, command spec creation, sandbox initialization, process start/completion, stream chunk processing, response parsing, and data extraction. Connect the built-in `AgentCtrlConsoleLogger` via `wiretap()` for color-coded console output during development. **Binary preflight checks.** Before every execution, `CliBinaryGuard` verifies that the required CLI binary (`claude`, `codex`, `opencode`, or `pi`) is available in the system `PATH`. If the binary is missing, a clear exception is thrown immediately -- before any prompt is sent. ## Supported Agents ### Claude Code Anthropic's `claude` CLI. A strong default choice for general coding workflows, tool-heavy tasks, and scenarios where you want fine-grained control over the agent's system prompt and permission behavior. Supports system prompt replacement and appending, permission modes (default, plan, accept-edits, bypass), turn limits, additional directory access, session management, and verbose streaming output. ### OpenAI Codex OpenAI's `codex` CLI. Best suited when you want Codex-specific sandbox controls (read-only, workspace-write, or full-access modes), full-auto or dangerous-bypass approval settings, image input support, and Codex thread-based session management. Returns token usage data when available. ### OpenCode The `opencode` CLI. Best suited when you want flexible model selection using provider-prefixed model IDs (e.g., `anthropic/claude-sonnet-4-5`), named agent selection, file attachments, session sharing, and session titles. Returns both token usage and cost data when available. ### Pi The `pi` CLI (from pi-mono). A minimal, aggressively extensible terminal coding harness. Best suited when you want thinking level control (6 levels from off to xhigh), multi-provider model selection, TypeScript extensions, skills, fine-grained tool selection, system prompt control, and ephemeral sessions. Returns both token usage and cost data. ### Gemini The `gemini` CLI (from @google/gemini-cli). Google's terminal coding agent with a free tier. Best suited when you want model aliases (pro, flash, flash-lite), approval modes (default, auto_edit, yolo, plan), sandbox isolation (Seatbelt, Docker, Podman, gVisor), extensions, MCP server integration, policy-based tool approval, and include directories. Returns token usage data. ## Documentation - [Getting Started](/packages/agent-ctrl/2-getting-started) -- Installation, first request, and basic configuration - [Streaming](/packages/agent-ctrl/3-streaming) -- Real-time text, tool activity, completion, and error callbacks - [Session Management](/packages/agent-ctrl/4-session-management) -- Continuing and resuming agent sessions - [Agent Options](/packages/agent-ctrl/5-agent-options) -- Shared and provider-specific builder configuration - [Response Object](/packages/agent-ctrl/6-response-object) -- Reading text, session, usage, cost, and tool data from AgentResponse - [Troubleshooting](/packages/agent-ctrl/7-troubleshooting) -- Diagnosing binary, directory, timeout, streaming, and parse issues - [Claude Code Bridge](/packages/agent-ctrl/8-claude-code-bridge) -- System prompts, permissions, turns, and Claude Code-specific features - [Codex Bridge](/packages/agent-ctrl/9-codex-bridge) -- Sandbox modes, auto-approval, images, and Codex-specific features - [OpenCode Bridge](/packages/agent-ctrl/10-opencode-bridge) -- Model flexibility, agents, files, sharing, and OpenCode-specific features - [Pi Bridge](/packages/agent-ctrl/11-pi-bridge) -- Thinking levels, extensions, skills, tool control, and Pi-specific features - [Gemini Bridge](/packages/agent-ctrl/12-gemini-bridge) -- Approval modes, sandbox, extensions, MCP servers, and Gemini-specific features ================================================================================ FILE: packages/agent-ctrl/2-getting-started.md ================================================================================ ## Requirements - PHP 8.2 or later - At least one supported CLI-based code agent installed and authenticated: - **Claude Code** -- the `claude` binary ([Anthropic CLI](https://docs.anthropic.com/en/docs/claude-code)) - **OpenAI Codex** -- the `codex` binary (`npm install -g @openai/codex`) - **OpenCode** -- the `opencode` binary (`curl -fsSL https://get.opencode.dev | bash`) - **Pi** -- the `pi` binary (from pi-mono) - **Gemini** -- the `gemini` binary (`npm install -g @google/gemini-cli`) Each CLI must be available in the system `PATH` visible to your PHP process. Run the binary interactively at least once to complete any first-run authentication flows before using it through Agent-Ctrl. ## Installation Install the package via Composer: ```bash composer require cognesy/agent-ctrl # @doctest id="b35c" ``` Agent-Ctrl is part of the Instructor-PHP monorepo. It depends on the `cognesy/sandbox` package for process execution and isolation, which is pulled in automatically. ## Your First Request The simplest way to use Agent-Ctrl is to pick an agent, pass a prompt, and read the result: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Config\AgentCtrlConfig; $response = AgentCtrl::codex() ->withConfig(new AgentCtrlConfig( timeout: 300, workingDirectory: getcwd() ?: null, )) ->execute('Summarize this repository.'); echo $response->text(); // @doctest id="fa59" ``` The `execute()` method runs the agent synchronously, waits for it to finish, and returns an `AgentResponse` object containing the text output, exit code, session ID, and any tool calls the agent made. ## Choosing a Different Agent Each supported agent has its own factory method on the `AgentCtrl` facade: ```php use Cognesy\AgentCtrl\AgentCtrl; // Use Claude Code $response = AgentCtrl::claudeCode() ->execute('List the main packages in this monorepo.'); // Use OpenCode $response = AgentCtrl::openCode() ->execute('Explain the package layout.'); // @doctest id="1773" ``` All factory methods return a builder that supports the same core API (`withConfig()`, `withModel()`, `withTimeout()`, `inDirectory()`, `execute()`, `executeStreaming()`, etc.), so you can switch agents without restructuring your code. ## Selecting the Agent at Runtime When the agent type is determined by configuration or user input rather than being hard-coded, use the `AgentType` enum with `AgentCtrl::make()`: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Config\AgentCtrlConfig; use Cognesy\AgentCtrl\Enum\AgentType; // AgentType is a backed enum: 'claude-code', 'codex', 'opencode', 'pi', 'gemini' $agent = AgentType::from($config['agent']); $agentConfig = AgentCtrlConfig::fromArray([ 'model' => $config['model'] ?? null, 'timeout' => $config['timeout'] ?? null, 'directory' => $config['directory'] ?? null, 'sandbox' => $config['sandbox'] ?? null, ]); $response = AgentCtrl::make($agent) ->withConfig($agentConfig) ->execute('Explain the package layout.'); // @doctest id="953f" ``` The `AgentType` enum has five cases: | Case | Value | Builder | |------|-------|---------| | `AgentType::ClaudeCode` | `'claude-code'` | `ClaudeCodeBridgeBuilder` | | `AgentType::Codex` | `'codex'` | `CodexBridgeBuilder` | | `AgentType::OpenCode` | `'opencode'` | `OpenCodeBridgeBuilder` | | `AgentType::Pi` | `'pi'` | `PiBridgeBuilder` | | `AgentType::Gemini` | `'gemini'` | `GeminiBridgeBuilder` | ## Common Configuration Every builder -- regardless of the agent type -- supports the same set of core configuration methods. These methods are defined in the `AgentBridgeBuilder` interface and implemented by the `AbstractBridgeBuilder` base class. ### `withConfig(AgentCtrlConfig $config): static` Apply a typed config object for the shared builder options: ```php use Cognesy\AgentCtrl\Config\AgentCtrlConfig; $config = AgentCtrlConfig::fromArray([ 'model' => 'o4-mini', 'timeout' => 300, 'directory' => '/projects/my-app', 'sandbox' => 'docker', ]); AgentCtrl::codex() ->withConfig($config) ->execute('Review the current directory.'); // @doctest id="6cd9" ``` `AgentCtrlConfig::fromArray()` accepts Laravel-style aliases: - `directory` -> `workingDirectory` - `sandbox` -> `sandboxDriver` ### Model Selection Specify which model the agent should use. The format depends on the agent: Claude Code uses Anthropic model names, Codex uses OpenAI model names, and OpenCode uses provider-prefixed IDs: ```php // Claude Code AgentCtrl::claudeCode()->withModel('claude-sonnet-4-5'); // Codex AgentCtrl::codex()->withModel('o4-mini'); // OpenCode (provider/model format) AgentCtrl::openCode()->withModel('anthropic/claude-sonnet-4-5'); // @doctest id="b514" ``` ### Execution Timeout Set the maximum time (in seconds) the agent is allowed to run. The default is 120 seconds. If the timeout is exceeded, the process is killed and the response will have a non-zero exit code: ```php $response = AgentCtrl::claudeCode() ->withTimeout(600) // 10 minutes ->execute('Perform a comprehensive codebase review.'); // @doctest id="36c2" ``` The minimum accepted timeout is 1 second. Values less than 1 are clamped to 1. ### Working Directory Set the directory the agent should operate in. The bridge validates that the directory exists before changing into it and restores the original working directory after execution: ```php $response = AgentCtrl::codex() ->inDirectory('/projects/my-app') ->execute('Review the current directory.'); // @doctest id="4fc7" ``` > **Note:** Always use absolute paths. The bridge changes the PHP process's current working directory for the duration of the execution. If your PHP process handles concurrent requests (e.g., Swoole or RoadRunner), be aware that this affects the entire process. ### Sandbox Driver By default, Agent-Ctrl runs the CLI binary directly on the host. You can switch to a containerized sandbox driver for additional process isolation: ```php use Cognesy\Sandbox\Enums\SandboxDriver; $response = AgentCtrl::claudeCode() ->withSandboxDriver(SandboxDriver::Docker) ->execute('Analyze this codebase.'); // @doctest id="845b" ``` Available sandbox drivers: | Driver | Description | |--------|-------------| | `SandboxDriver::Host` | Run directly on the host (default) | | `SandboxDriver::Docker` | Run inside a Docker container | | `SandboxDriver::Podman` | Run inside a Podman container | | `SandboxDriver::Firejail` | Run inside a Firejail sandbox (Linux) | | `SandboxDriver::Bubblewrap` | Run inside a Bubblewrap sandbox (Linux) | When using a containerized driver (Docker or Podman), the binary preflight check is skipped -- the binary is expected to be available inside the container image. ## Checking the Result The `execute()` method returns an `AgentResponse`. Always check `isSuccess()` before using the text output, because a completed execution with a non-zero exit code does not throw an exception: ```php $response = AgentCtrl::codex() ->withTimeout(300) ->inDirectory(__DIR__) ->execute('Review the current directory.'); if ($response->isSuccess()) { echo $response->text(); } else { echo "Agent failed with exit code: {$response->exitCode}"; } // @doctest id="658c" ``` See the [Response Object](/packages/agent-ctrl/6-response-object) documentation for the full set of properties and methods available on `AgentResponse`. ## Next Steps - [Streaming](/packages/agent-ctrl/3-streaming) -- Receive real-time updates while the agent is working - [Session Management](/packages/agent-ctrl/4-session-management) -- Continue or resume agent sessions - [Agent Options](/packages/agent-ctrl/5-agent-options) -- Explore shared and agent-specific configuration - [Claude Code Bridge](/packages/agent-ctrl/8-claude-code-bridge), [Codex Bridge](/packages/agent-ctrl/9-codex-bridge), [OpenCode Bridge](/packages/agent-ctrl/10-opencode-bridge), [Pi Bridge](/packages/agent-ctrl/11-pi-bridge), [Gemini Bridge](/packages/agent-ctrl/12-gemini-bridge) -- Deep dives into each agent's unique capabilities ================================================================================ FILE: packages/agent-ctrl/testing-doubles.md ================================================================================ ## Overview `agent-ctrl` is different from `instructor`, `polyglot`, and `agents`. It does not currently ship a package-native `FakeAgentCtrl` or fake bridge inside `packages/agent-ctrl`. The main deterministic seams are lower or higher in the stack: - unit-test command building and response parsing directly - use `FakeSandbox` when you need deterministic command execution without running a real CLI - use `AgentCtrlFake` only when you are testing the Laravel facade layer ## In-Package Unit Tests Most `agent-ctrl` logic is easiest to test at the pure-object level. That includes: - config normalization with `AgentCtrlConfig` - command building - response parsing - session and DTO behavior Prefer this seam whenever the test does not need process execution. ## `FakeSandbox` When the test crosses into execution, use `FakeSandbox` from the sandbox package. This is the right seam for: - deterministic command execution - timeout and exit-code scenarios - stdout and stderr handling - process-free coverage for bridge execution paths `FakeSandbox` is the main fake that matters for core `agent-ctrl` execution tests. ## Laravel `AgentCtrlFake` If you are testing the Laravel integration, use `AgentCtrlFake` from the Laravel package. That fake belongs to the facade layer, not to core `agent-ctrl`. Use it when you want to test: - facade-based application code - queued fake responses at the framework boundary - execution assertions in Laravel feature tests ## Which One To Use Use this rule of thumb: - pure package logic: test the value objects and builders directly - command execution paths: use `FakeSandbox` - Laravel integration paths: use `AgentCtrlFake` Until `agent-ctrl` grows a package-native fake bridge, those are the current deterministic seams to rely on. ================================================================================ FILE: packages/agent-ctrl/3-streaming.md ================================================================================ ## Introduction When an agent works on a complex task, it may run for minutes -- reading files, executing commands, reasoning through problems, and producing output incrementally. Streaming lets your application display progress, log tool activity, and react to errors in real time rather than waiting for the agent to finish. Agent-Ctrl provides streaming through four callback methods on the builder. These callbacks are invoked as the agent's JSON Lines output is parsed, and the same callback API works identically across Claude Code, Codex, OpenCode, Pi, and Gemini. ## Using `executeStreaming()` To enable streaming, register one or more callbacks and call `executeStreaming()` instead of `execute()`: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; $response = AgentCtrl::claudeCode() ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onComplete(fn(AgentResponse $response) => print("\nDone\n")) ->onError(fn(string $message, ?string $code) => print("\nError: {$message}\n")) ->executeStreaming('Explain the architecture of this project.'); // @doctest id="0acf" ``` `executeStreaming()` returns the final `AgentResponse` just like `execute()` does. The callbacks provide real-time visibility into the work, but the complete result is always available at the end for inspection, storage, or further processing. ## Callback Reference ### `onText(callable $handler): static` Called whenever the agent produces text content. The handler receives a single `string` argument containing the text fragment. Text is delivered incrementally -- each call may contain a word, a sentence, or a paragraph depending on how the agent's CLI emits output. ```php $agent->onText(function (string $text): void { // Append to a buffer, write to a stream, or display directly echo $text; }); // @doctest id="5f47" ``` Empty text fragments are filtered out before reaching your callback. ### `onToolUse(callable $handler): static` Called whenever the agent invokes a tool or receives a tool result. The handler receives three arguments: - `string $tool` -- The tool name (e.g., `'bash'`, `'file_change'`, `'web_search'`, `'tool_result'`) - `array $input` -- The tool's input parameters as an associative array - `?string $output` -- The tool's output, or `null` if the tool has not completed yet ```php $agent->onToolUse(function (string $tool, array $input, ?string $output): void { echo "[Tool: {$tool}]"; if ($output !== null) { echo " => " . substr($output, 0, 100); } echo "\n"; }); // @doctest id="2287" ``` The tool names and input structures are normalized across all agents. For example, Codex `CommandExecution` items become `'bash'` tool calls with `['command' => '...']` input, and Codex `FileChange` items become `'file_change'` tool calls with `['path' => '...', 'action' => '...']` input. ### `onComplete(callable $handler): static` Called exactly once when the agent finishes and the final `AgentResponse` is assembled. The handler receives the complete response object: ```php $agent->onComplete(function (AgentResponse $response): void { echo "\nCompleted with exit code: {$response->exitCode}"; echo "\nTool calls made: " . count($response->toolCalls); }); // @doctest id="1147" ``` The completion callback is deduplicated internally -- even if the bridge processes both streamed and parsed data, your handler is invoked only once. ### `onError(callable $handler): static` Called when the agent emits an error event during streaming. These are operational errors reported by the agent itself (e.g., a tool failure, a rate limit, or a malformed request), not PHP exceptions. The handler receives two arguments: - `string $message` -- The error description - `?string $code` -- An optional error code (agent-specific) ```php $agent->onError(function (string $message, ?string $code): void { error_log("Agent stream error [{$code}]: {$message}"); }); // @doctest id="953a" ``` Stream errors do not terminate the execution. The agent may recover and continue working after emitting an error event. ## Streaming Without Callbacks You can call `executeStreaming()` without registering any callbacks. In this case, the builder still processes the streaming output internally (emitting events for the `wiretap()` system), but no user-facing callbacks are invoked. The final `AgentResponse` is returned normally. ## When to Use `execute()` Instead Use `execute()` when you only care about the final result and do not need incremental updates. Internally, `execute()` delegates to the same streaming infrastructure -- it simply does not register a stream handler. The performance characteristics are identical; the only difference is whether callbacks fire during execution. ```php // No streaming -- just get the result $response = AgentCtrl::codex()->execute('Create a short summary.'); // With streaming -- same result, but with real-time visibility $response = AgentCtrl::codex() ->onText(fn(string $text) => print($text)) ->executeStreaming('Create a short summary.'); // @doctest id="4195" ``` ## How Streaming Works Internally Understanding the internal streaming pipeline can help with debugging and advanced usage: 1. **Process execution.** The bridge launches the CLI binary via the `SandboxCommandExecutor`, which runs the process and captures stdout in real time. 2. **JSON Lines buffering.** Raw process output arrives in arbitrary-sized chunks. A `JsonLinesBuffer` accumulates bytes until complete JSON Lines (newline-delimited) are available. 3. **Event parsing.** Each complete JSON line is decoded and passed through the agent-specific `StreamEvent::fromArray()` factory, which produces typed event objects (text events, tool use events, error events, etc.). 4. **Callback dispatch.** Typed events are normalized into the common callback signatures (`onText`, `onToolUse`, `onError`) and dispatched to your handlers. 5. **Event system.** In parallel, the builder dispatches internal events (`AgentTextReceived`, `AgentToolUsed`, `AgentErrorOccurred`, `StreamChunkProcessed`, etc.) that can be observed through the `wiretap()` system. 6. **Final parse.** After the process completes, the full stdout is re-parsed to extract the authoritative final data (text, tool calls, session ID, usage). This ensures no data is lost due to streaming chunk boundaries. ## Combining Streaming with the Console Logger For development and debugging, you can combine user-facing streaming callbacks with the built-in `AgentCtrlConsoleLogger` to see both the agent's output and detailed execution telemetry: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Broadcasting\AgentCtrlConsoleLogger; $logger = new AgentCtrlConsoleLogger( showStreaming: true, showPipeline: true, ); $response = AgentCtrl::claudeCode() ->wiretap($logger->wiretap()) ->onText(fn(string $text) => print($text)) ->executeStreaming('Analyze the test suite.'); // @doctest id="6133" ``` The console logger displays color-coded events for execution lifecycle, tool usage, stream processing, and response parsing, while your `onText` callback displays the agent's actual output. ## Complete Streaming Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; $toolLog = []; $response = AgentCtrl::openCode() ->withModel('anthropic/claude-sonnet-4-5') ->withTimeout(300) ->inDirectory('/projects/my-app') ->onText(function (string $text): void { // Stream text to the user in real time echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) use (&$toolLog): void { // Log tool activity for post-execution analysis $toolLog[] = ['tool' => $tool, 'input' => $input, 'output' => $output]; }) ->onComplete(function (AgentResponse $response): void { echo "\n--- Execution complete ---\n"; echo "Exit code: {$response->exitCode}\n"; if ($response->cost() !== null) { echo sprintf("Cost: $%.4f\n", $response->cost()); } }) ->onError(function (string $message, ?string $code): void { error_log("Stream error [{$code}]: {$message}"); }) ->executeStreaming('Review the authentication module and suggest improvements.'); // The response is also available for further processing if (!$response->isSuccess()) { echo "Warning: agent exited with code {$response->exitCode}\n"; } echo "Total tool calls: " . count($toolLog) . "\n"; // @doctest id="20dc" ``` ================================================================================ FILE: packages/agent-ctrl/4-session-management.md ================================================================================ ## Introduction CLI-based code agents maintain internal session state that includes the conversation history, file context, and previous tool results. Agent-Ctrl exposes this session mechanism through two builder methods -- `continueSession()` and `resumeSession()` -- that work consistently across all supported agents. Session continuity is valuable when a task spans multiple steps. Rather than starting fresh each time and re-establishing context, you can continue an existing session so the agent remembers what was discussed and what actions were taken previously. ## How Sessions Work Each agent manages sessions differently under the hood, but Agent-Ctrl normalizes the experience: - **Claude Code** stores sessions internally and exposes a `session_id` in its JSON stream output. Agent-Ctrl extracts this ID from the stream and makes it available via `AgentResponse::sessionId()`. - **Codex** uses thread-based conversations and returns a thread ID in its response. Agent-Ctrl normalizes this into an `AgentSessionId`. - **OpenCode** maintains named sessions with their own session ID format. Agent-Ctrl extracts and normalizes these as well. - **Pi** maintains sessions with a session ID. It supports ephemeral mode (no session saved) and custom session directories. Agent-Ctrl normalizes the session ID into an `AgentSessionId`. - **Gemini** supports session resume by a session ID or the special value `'latest'` for the most recent session. Agent-Ctrl normalizes these into an `AgentSessionId`. Regardless of the agent, the flow is the same: execute a prompt, capture the session ID from the response, and pass it to a subsequent execution. ## Continuing the Most Recent Session The simplest form of session continuity is `continueSession()`, which tells the agent to pick up where the last session left off. You do not need to know or store the session ID: ```php use Cognesy\AgentCtrl\AgentCtrl; // First execution starts a new session $response = AgentCtrl::claudeCode() ->execute('Create an implementation plan for the payment module.'); echo $response->text(); // Second execution continues the most recent session $response = AgentCtrl::claudeCode() ->continueSession() ->execute('Now implement the first item in the plan.'); echo $response->text(); // @doctest id="48c0" ``` This approach works well for sequential, script-like workflows where each step builds on the previous one and there is no need to branch or revisit earlier sessions. ## Resuming a Specific Session When you need to resume a particular session -- for example, after a delay, from a different process, or to branch from a specific point -- use `resumeSession()` with the session ID: ```php use Cognesy\AgentCtrl\AgentCtrl; // First execution: capture the session ID $first = AgentCtrl::claudeCode() ->execute('Create a detailed plan for refactoring the UserService.'); $sessionId = $first->sessionId(); // Store $sessionId somewhere (database, cache, file, etc.) // ... // Later: resume the exact same session if ($sessionId !== null) { $second = AgentCtrl::claudeCode() ->resumeSession((string) $sessionId) ->execute('Implement step 2 from the plan.'); } // @doctest id="a9f4" ``` The `resumeSession()` method accepts a plain string. The `AgentSessionId` value object returned by `sessionId()` implements `__toString()`, so you can cast it directly. ## Reading the Session ID `AgentResponse::sessionId()` returns an `AgentSessionId` value object or `null`. A `null` value means the agent did not expose a session identifier in its output -- this can happen if the agent's CLI version does not support sessions or if the execution failed before session data was emitted. ```php $response = AgentCtrl::codex()->execute('Explain the test structure.'); $sessionId = $response->sessionId(); if ($sessionId !== null) { echo "Session ID: {$sessionId}\n"; // Uses __toString() echo "Session ID: " . (string) $sessionId . "\n"; // Explicit cast } else { echo "No session ID available.\n"; } // @doctest id="7d16" ``` The `AgentSessionId` is an opaque value object (extending `OpaqueExternalId`) that wraps the raw string identifier. It provides type safety and prevents accidental mixing of session IDs with other string values. ## Session Management by Agent Each agent's builder exposes the same two methods, but the underlying behavior varies: ### Claude Code ```php // Continue most recent session AgentCtrl::claudeCode() ->continueSession() ->execute('Continue the previous task.'); // Resume specific session AgentCtrl::claudeCode() ->resumeSession('abc-123-def') ->execute('Pick up from where we left off.'); // @doctest id="eebb" ``` Claude Code passes `--continue` or `--resume ` to the `claude` CLI. Session IDs are extracted from the `session_id` field in the JSON stream output. ### Codex ```php // Continue most recent session AgentCtrl::codex() ->continueSession() ->execute('Continue the previous task.'); // Resume specific session (uses Codex thread ID) AgentCtrl::codex() ->resumeSession('thread_abc123') ->execute('Pick up from where we left off.'); // @doctest id="3025" ``` Codex maps session management to its thread system. The session ID corresponds to the Codex thread ID. ### OpenCode ```php // Continue most recent session AgentCtrl::openCode() ->continueSession() ->execute('Continue the previous task.'); // Resume specific session AgentCtrl::openCode() ->resumeSession('session-xyz-789') ->execute('Pick up from where we left off.'); // @doctest id="932f" ``` OpenCode maintains its own session format with support for session titles and sharing. ### Pi ```php // Continue most recent session AgentCtrl::pi() ->continueSession() ->execute('Continue the previous task.'); // Resume specific session AgentCtrl::pi() ->resumeSession('session-abc-123') ->execute('Pick up from where we left off.'); // Ephemeral mode (session not saved) AgentCtrl::pi() ->ephemeral() ->execute('Quick one-off task.'); // Custom session directory AgentCtrl::pi() ->withSessionDir('/custom/sessions') ->execute('Work in a custom session location.'); // @doctest id="0bca" ``` Pi supports `continueSession()`, `resumeSession()`, `ephemeral()` (no session saved), and `withSessionDir()` (custom session storage directory). ### Gemini ```php // Continue most recent session AgentCtrl::gemini() ->continueSession() ->execute('Continue the previous task.'); // Resume specific session AgentCtrl::gemini() ->resumeSession('session-xyz-789') ->execute('Pick up from where we left off.'); // @doctest id="3cea" ``` Gemini maps `continueSession()` to resuming the `'latest'` session internally. `resumeSession()` accepts a session ID or index. ## Important Considerations **Session IDs are agent-specific.** Do not attempt to resume a Claude Code session with the Codex bridge, or vice versa. Each agent's session format is incompatible with the others. **Session availability is not guaranteed.** Some agent versions, configurations, or error scenarios may not produce a session ID. Always check for `null` before storing or reusing a session ID. **Sessions persist on the agent's side.** Agent-Ctrl does not store or manage session state -- it only passes session identifiers to the CLI. The actual session data (conversation history, file context, etc.) is managed by the agent's own storage system. **`continueSession()` and `resumeSession()` are mutually exclusive in intent.** If you call both on the same builder, the behavior depends on the agent's CLI -- typically, the explicit session ID from `resumeSession()` takes precedence. For clarity, use only one per execution. ## Multi-Step Workflow Example ```php use Cognesy\AgentCtrl\AgentCtrl; // Step 1: Create a plan $plan = AgentCtrl::claudeCode() ->withTimeout(300) ->inDirectory('/projects/my-app') ->execute('Create a 3-step plan for adding rate limiting to the API.'); $sessionId = $plan->sessionId(); echo "Plan:\n" . $plan->text() . "\n"; if ($sessionId === null) { echo "No session available -- cannot continue.\n"; exit(1); } // Step 2: Implement step 1 $step1 = AgentCtrl::claudeCode() ->withTimeout(300) ->inDirectory('/projects/my-app') ->resumeSession((string) $sessionId) ->execute('Implement step 1 from the plan.'); echo "\nStep 1 result:\n" . $step1->text() . "\n"; // Step 3: Implement step 2 (still using the same session) $step2 = AgentCtrl::claudeCode() ->withTimeout(300) ->inDirectory('/projects/my-app') ->resumeSession((string) $sessionId) ->execute('Implement step 2 from the plan.'); echo "\nStep 2 result:\n" . $step2->text() . "\n"; // @doctest id="8947" ``` ================================================================================ FILE: packages/agent-ctrl/5-agent-options.md ================================================================================ ## Introduction Agent-Ctrl's builder API is split into two layers: a shared set of methods that every builder supports, and agent-specific methods that expose the unique capabilities of each CLI tool. This design lets you write agent-agnostic code for common configuration while still accessing the full feature set of each agent when needed. All configuration methods return `static`, so they can be chained fluently in any order before calling `execute()` or `executeStreaming()`. ## Shared Options The following methods are defined in the `AgentBridgeBuilder` interface and implemented by every bridge builder. They work identically regardless of which agent you are using. ### `withConfig(AgentCtrlConfig $config): static` Apply a typed config object containing the shared builder options: ```php use Cognesy\AgentCtrl\Config\AgentCtrlConfig; $config = AgentCtrlConfig::fromArray([ 'model' => 'claude-sonnet-4-5', 'timeout' => 300, 'directory' => '/projects/my-app', 'sandbox' => 'docker', ]); AgentCtrl::claudeCode() ->withConfig($config) ->execute('Review the payment flow.'); // @doctest id="f359" ``` This is the preferred way to pass shared builder defaults around your own application code. The object covers: - `model` - `timeout` - `workingDirectory` - `sandboxDriver` ### `withModel(string $model): static` Set the model the agent should use. The accepted model name format depends on the agent: ```php // Claude Code: Anthropic model names AgentCtrl::claudeCode()->withModel('claude-sonnet-4-5'); // Codex: OpenAI model names AgentCtrl::codex()->withModel('o4-mini'); // OpenCode: provider/model format AgentCtrl::openCode()->withModel('anthropic/claude-sonnet-4-5'); // @doctest id="fe6e" ``` If not specified, each agent uses its own default model. ### `withTimeout(int $seconds): static` Set the maximum execution time in seconds. The default is 120 seconds. The minimum accepted value is 1 second -- values below 1 are clamped. ```php AgentCtrl::claudeCode() ->withTimeout(600) // 10 minutes for complex tasks ->execute('Perform a comprehensive codebase review.'); // @doctest id="7c32" ``` When the timeout is reached, the sandbox executor kills the process. The response will contain whatever output was produced before the timeout and will have a non-zero exit code. ### `inDirectory(string $path): static` Set the working directory for the agent. The bridge validates that the directory exists before execution and throws an `InvalidArgumentException` if it does not. ```php AgentCtrl::codex() ->inDirectory('/projects/my-app') ->execute('List the source files.'); // @doctest id="cdca" ``` Always use absolute paths. The bridge changes the PHP process's current working directory for the duration of the execution and restores it afterward. ### `withSandboxDriver(SandboxDriver $driver): static` Set the sandbox driver for process isolation. The default is `SandboxDriver::Host`, which runs the CLI binary directly on the host system. ```php use Cognesy\Sandbox\Enums\SandboxDriver; AgentCtrl::claudeCode() ->withSandboxDriver(SandboxDriver::Docker) ->execute('Analyze this codebase.'); // @doctest id="4687" ``` Available drivers: `Host`, `Docker`, `Podman`, `Firejail`, `Bubblewrap`. ### Streaming Callbacks Four callback methods are shared across all builders. See the [Streaming](/packages/agent-ctrl/3-streaming) documentation for full details. - `onText(callable $handler): static` -- Receive incremental text output - `onToolUse(callable $handler): static` -- Receive normalized tool call events - `onComplete(callable $handler): static` -- Receive the final `AgentResponse` - `onError(callable $handler): static` -- Receive streamed error events ### `wiretap(callable $handler): static` Connect an event observer to the builder's internal event system. This is primarily used with the `AgentCtrlConsoleLogger` for development-time debugging: ```php use Cognesy\AgentCtrl\Broadcasting\AgentCtrlConsoleLogger; $logger = new AgentCtrlConsoleLogger(showStreaming: true); AgentCtrl::claudeCode() ->wiretap($logger->wiretap()) ->execute('Review this code.'); // @doctest id="7f82" ``` ### `build(): AgentBridge` Build the configured bridge without executing a prompt. This is an advanced method for scenarios where you need to call the bridge's `execute()` or `executeStreaming()` methods directly: ```php use Cognesy\AgentCtrl\Config\AgentCtrlConfig; $bridge = AgentCtrl::claudeCode() ->withConfig(new AgentCtrlConfig( model: 'claude-sonnet-4-5', timeout: 300, )) ->build(); $response = $bridge->execute('First prompt.'); $response2 = $bridge->executeStreaming('Second prompt.', $streamHandler); // @doctest id="6cac" ``` ## Claude Code Options The `ClaudeCodeBridgeBuilder` adds the following methods on top of the shared options. ### `withSystemPrompt(string|\Stringable $prompt): static` Replace the agent's default system prompt entirely with a custom one: ```php AgentCtrl::claudeCode() ->withSystemPrompt('You are a security auditor. Focus on vulnerabilities.') ->execute('Audit the authentication module.'); // @doctest id="c0ae" ``` ### `appendSystemPrompt(string|\Stringable $prompt): static` Add instructions on top of the default system prompt without replacing it. This preserves Claude Code's built-in behavior while layering in project-specific context: ```php AgentCtrl::claudeCode() ->appendSystemPrompt('This project uses Laravel conventions. Follow PSR-12.') ->execute('Refactor the UserService class.'); // @doctest id="a806" ``` You can use both `withSystemPrompt()` and `appendSystemPrompt()` together -- `withSystemPrompt()` sets the base and `appendSystemPrompt()` appends to it. Both accept `Stringable` objects (e.g. xprompt `Prompt` classes), which are cast to string at the boundary. ### `withMaxTurns(int $turns): static` Limit the number of agentic turns. Each turn represents one cycle where the agent reads context, reasons, and takes an action. The minimum is 1. ```php AgentCtrl::claudeCode() ->withMaxTurns(10) ->execute('Make a focused improvement to the README.'); // @doctest id="e138" ``` Without a turn limit, Claude Code continues working until it decides the task is complete or the timeout is reached. For simple tasks, 5-10 turns is often sufficient. Complex refactoring may need 20-50 turns. ### `withPermissionMode(PermissionMode $mode): static` Control how the agent handles tool permission requests during headless execution. The default is `BypassPermissions` because Agent-Ctrl runs headlessly and cannot respond to interactive permission prompts. ```php use Cognesy\AgentCtrl\ClaudeCode\Domain\Enum\PermissionMode; AgentCtrl::claudeCode() ->withPermissionMode(PermissionMode::AcceptEdits) ->execute('Write unit tests for the PaymentService.'); // @doctest id="7dd7" ``` | Mode | Behavior | |------|----------| | `PermissionMode::DefaultMode` | Standard interactive prompts. Not suitable for headless execution. | | `PermissionMode::Plan` | Agent can plan and reason but prompts before executing any tool. | | `PermissionMode::AcceptEdits` | Auto-approve file editing tools; prompt for shell commands and other actions. | | `PermissionMode::BypassPermissions` | Auto-approve all tool uses without prompting (default). | ### `verbose(bool $enabled = true): static` Enable or disable verbose output. Verbose mode is required for proper stream-JSON parsing and is enabled by default. You generally do not need to change this setting. ### `continueSession(): static` Continue the most recent Claude Code session. See [Session Management](/packages/agent-ctrl/4-session-management). ### `resumeSession(string $sessionId): static` Resume a specific Claude Code session by its ID. See [Session Management](/packages/agent-ctrl/4-session-management). ### `withAdditionalDirs(array $paths): static` Grant the agent access to additional directories beyond the working directory: ```php AgentCtrl::claudeCode() ->inDirectory('/projects/my-app') ->withAdditionalDirs(['/shared/libraries', '/configs/production']) ->execute('Update the app to use the latest shared auth library.'); // @doctest id="5134" ``` ### Complete Claude Code Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\ClaudeCode\Domain\Enum\PermissionMode; $response = AgentCtrl::claudeCode() ->withModel('claude-sonnet-4-5') ->withSystemPrompt('You are a careful code reviewer.') ->appendSystemPrompt('Focus on error handling and edge cases.') ->withPermissionMode(PermissionMode::BypassPermissions) ->withMaxTurns(15) ->withTimeout(300) ->inDirectory('/projects/my-app') ->withAdditionalDirs(['/shared/utils']) ->onText(fn(string $text) => print($text)) ->executeStreaming('Review the PaymentService for error handling issues.'); // @doctest id="9bdb" ``` ## Codex Options The `CodexBridgeBuilder` adds the following methods on top of the shared options. ### `withSandbox(SandboxMode $mode): static` Set the Codex sandbox mode, which controls filesystem and network access: ```php use Cognesy\AgentCtrl\OpenAICodex\Domain\Enum\SandboxMode; AgentCtrl::codex() ->withSandbox(SandboxMode::WorkspaceWrite) ->execute('Write tests for the UserService.'); // @doctest id="58b9" ``` | Mode | Filesystem | Network | |------|-----------|---------| | `SandboxMode::ReadOnly` | Read-only access | No | | `SandboxMode::WorkspaceWrite` | Write access to workspace only | No | | `SandboxMode::DangerFullAccess` | Full access | Yes | ### `disableSandbox(): static` Shorthand for `withSandbox(SandboxMode::DangerFullAccess)`. Provides full filesystem and network access: ```php AgentCtrl::codex() ->disableSandbox() ->execute('Install dependencies and run the test suite.'); // @doctest id="1430" ``` ### `fullAuto(bool $enabled = true): static` Enable full-auto mode, which combines workspace-write sandbox access with automatic on-failure approval. This is enabled by default for headless execution: ```php AgentCtrl::codex() ->fullAuto() ->execute('Refactor the database layer.'); // @doctest id="d939" ``` ### `dangerouslyBypass(bool $enabled = true): static` Skip all approval prompts and sandbox restrictions. This is the most permissive mode and should be used with caution: ```php AgentCtrl::codex() ->dangerouslyBypass() ->execute('Deploy to staging.'); // @doctest id="ee31" ``` ### `skipGitRepoCheck(bool $enabled = true): static` Allow the agent to run outside a Git repository. By default, Codex requires the working directory to be inside a Git repository: ```php AgentCtrl::codex() ->skipGitRepoCheck() ->inDirectory('/tmp/workspace') ->execute('Create a new project skeleton.'); // @doctest id="0c77" ``` ### `withImages(array $imagePaths): static` Attach image files to the prompt for visual analysis: ```php AgentCtrl::codex() ->withImages(['/tmp/mockup.png', '/tmp/screenshot.png']) ->execute('Implement the UI shown in the mockup images.'); // @doctest id="f98b" ``` ### `continueSession(): static` Continue the most recent Codex session. See [Session Management](/packages/agent-ctrl/4-session-management). ### `resumeSession(string $sessionId): static` Resume a specific Codex session by its thread ID. See [Session Management](/packages/agent-ctrl/4-session-management). ### `withAdditionalDirs(array $paths): static` Add additional writable directories for the Codex agent: ```php AgentCtrl::codex() ->withAdditionalDirs(['/shared/assets']) ->execute('Update the shared configuration.'); // @doctest id="5d92" ``` ### Complete Codex Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\OpenAICodex\Domain\Enum\SandboxMode; $response = AgentCtrl::codex() ->withModel('o4-mini') ->withSandbox(SandboxMode::WorkspaceWrite) ->fullAuto() ->withTimeout(300) ->inDirectory('/projects/my-app') ->withImages(['/tmp/design-spec.png']) ->onText(fn(string $text) => print($text)) ->executeStreaming('Implement the component shown in the design spec.'); // @doctest id="9a9d" ``` ## OpenCode Options The `OpenCodeBridgeBuilder` adds the following methods on top of the shared options. ### `withAgent(string $agentName): static` Select a named agent within OpenCode (e.g., `'coder'`, `'task'`): ```php AgentCtrl::openCode() ->withAgent('coder') ->execute('Refactor the authentication module.'); // @doctest id="11b6" ``` ### `withFiles(array $filePaths): static` Attach specific files to the prompt for the agent to reference: ```php AgentCtrl::openCode() ->withFiles(['/projects/my-app/src/UserService.php']) ->execute('Review this file for potential issues.'); // @doctest id="2e9c" ``` ### `withTitle(string $title): static` Set a descriptive title for the session, which appears in OpenCode's session listing: ```php AgentCtrl::openCode() ->withTitle('Payment module refactoring') ->execute('Plan the payment module refactoring.'); // @doctest id="ed0e" ``` ### `shareSession(): static` Mark the session for sharing after completion, making it accessible to other users or tools: ```php AgentCtrl::openCode() ->shareSession() ->execute('Create a code review summary.'); // @doctest id="ec20" ``` ### `continueSession(): static` Continue the most recent OpenCode session. See [Session Management](/packages/agent-ctrl/4-session-management). ### `resumeSession(string $sessionId): static` Resume a specific OpenCode session by its ID. See [Session Management](/packages/agent-ctrl/4-session-management). ### Complete OpenCode Example ```php use Cognesy\AgentCtrl\AgentCtrl; $response = AgentCtrl::openCode() ->withModel('anthropic/claude-sonnet-4-5') ->withAgent('coder') ->withTitle('Architecture review') ->withFiles(['/projects/my-app/src/Kernel.php']) ->withTimeout(300) ->inDirectory('/projects/my-app') ->onText(fn(string $text) => print($text)) ->executeStreaming('Review the application architecture.'); if ($response->cost() !== null) { echo sprintf("\nCost: $%.4f\n", $response->cost()); } // @doctest id="7bd7" ``` ## Pi Options The `PiBridgeBuilder` adds the following methods on top of the shared options. ### `withProvider(string $provider): static` Set the provider explicitly (e.g., `'anthropic'`, `'openai'`, `'google'`): ```php AgentCtrl::pi() ->withProvider('anthropic') ->execute('Analyze this codebase.'); // @doctest id="f188" ``` ### `withThinking(ThinkingLevel $level): static` Set the thinking level, which controls how much reasoning the agent does: ```php use Cognesy\AgentCtrl\Pi\Domain\Enum\ThinkingLevel; AgentCtrl::pi() ->withThinking(ThinkingLevel::High) ->execute('Solve this complex problem.'); // @doctest id="e0e7" ``` | Level | Value | |-------|-------| | `ThinkingLevel::Off` | `'off'` | | `ThinkingLevel::Minimal` | `'minimal'` | | `ThinkingLevel::Low` | `'low'` | | `ThinkingLevel::Medium` | `'medium'` | | `ThinkingLevel::High` | `'high'` | | `ThinkingLevel::ExtraHigh` | `'xhigh'` | ### `withSystemPrompt(string|\Stringable $prompt): static` Replace the agent's default system prompt: ```php AgentCtrl::pi() ->withSystemPrompt('You are a security auditor.') ->execute('Audit the authentication module.'); // @doctest id="7f5e" ``` ### `appendSystemPrompt(string|\Stringable $prompt): static` Add instructions on top of the default system prompt: ```php AgentCtrl::pi() ->appendSystemPrompt('Focus on PSR-12 compliance.') ->execute('Review this code.'); // @doctest id="02c9" ``` ### `withTools(array $tools): static` Enable specific built-in tools: ```php AgentCtrl::pi() ->withTools(['read', 'bash', 'edit']) ->execute('Refactor this file.'); // @doctest id="0d51" ``` ### `noTools(): static` Disable all built-in tools. ### `withFiles(array $filePaths): static` Attach files to the prompt: ```php AgentCtrl::pi() ->withFiles(['/projects/my-app/src/UserService.php']) ->execute('Review this file.'); // @doctest id="fc36" ``` ### `withExtensions(array $extensions): static` Load extensions from paths or sources. ### `noExtensions(): static` Disable extension discovery. ### `withSkills(array $skills): static` Load skills from paths. ### `noSkills(): static` Disable skill discovery. ### `withApiKey(string $apiKey): static` Override the API key for this execution. ### `ephemeral(): static` Run in ephemeral mode -- the session is not saved. ### `withSessionDir(string $dir): static` Set a custom session storage directory. ### `verbose(bool $enabled = true): static` Enable verbose output. ### `continueSession(): static` Continue the most recent Pi session. See [Session Management](/packages/agent-ctrl/4-session-management). ### `resumeSession(string $sessionId): static` Resume a specific Pi session by its ID. See [Session Management](/packages/agent-ctrl/4-session-management). ### Complete Pi Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Pi\Domain\Enum\ThinkingLevel; $response = AgentCtrl::pi() ->withProvider('anthropic') ->withThinking(ThinkingLevel::High) ->withSystemPrompt('You are a careful code reviewer.') ->withTools(['read', 'bash']) ->withTimeout(300) ->inDirectory('/projects/my-app') ->onText(fn(string $text) => print($text)) ->executeStreaming('Review the PaymentService for edge cases.'); // @doctest id="d720" ``` ## Gemini Options The `GeminiBridgeBuilder` adds the following methods on top of the shared options. ### `withApprovalMode(ApprovalMode $mode): static` Set the approval mode for tool execution: ```php use Cognesy\AgentCtrl\Gemini\Domain\Enum\ApprovalMode; AgentCtrl::gemini() ->withApprovalMode(ApprovalMode::Yolo) ->execute('Refactor the database layer.'); // @doctest id="70fc" ``` | Mode | Value | Behavior | |------|-------|----------| | `ApprovalMode::Default` | `'default'` | Standard interactive prompts | | `ApprovalMode::AutoEdit` | `'auto_edit'` | Auto-approve edits, prompt for others | | `ApprovalMode::Yolo` | `'yolo'` | Auto-approve all actions | | `ApprovalMode::Plan` | `'plan'` | Read-only analysis mode | ### `yolo(): static` Shorthand for `withApprovalMode(ApprovalMode::Yolo)`: ```php AgentCtrl::gemini() ->yolo() ->execute('Implement the feature.'); // @doctest id="bee9" ``` ### `planMode(): static` Shorthand for `withApprovalMode(ApprovalMode::Plan)`: ```php AgentCtrl::gemini() ->planMode() ->execute('Analyze the codebase architecture.'); // @doctest id="e77b" ``` ### `withSandbox(bool $enabled = true): static` Enable Gemini's sandbox mode for additional isolation. ### `withIncludeDirectories(array $paths): static` Add additional workspace directories: ```php AgentCtrl::gemini() ->withIncludeDirectories(['/shared/libraries', '/configs']) ->execute('Review the shared library usage.'); // @doctest id="f066" ``` ### `withExtensions(array $extensions): static` Use specific extensions. ### `withAllowedTools(array $tools): static` Restrict which tools the agent can use: ```php AgentCtrl::gemini() ->withAllowedTools(['read_file', 'search_files', 'list_directory']) ->execute('Analyze the codebase structure.'); // @doctest id="1b58" ``` ### `withAllowedMcpServers(array $names): static` Set allowed MCP server names. ### `withPolicy(array $paths): static` Add policy files or directories. ### `debug(bool $enabled = true): static` Enable debug output for troubleshooting CLI behavior. ### `continueSession(): static` Continue the most recent Gemini session (resumes `'latest'` internally). See [Session Management](/packages/agent-ctrl/4-session-management). ### `resumeSession(string $sessionId): static` Resume a specific Gemini session by its ID or index. See [Session Management](/packages/agent-ctrl/4-session-management). ### Complete Gemini Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Gemini\Domain\Enum\ApprovalMode; $response = AgentCtrl::gemini() ->withApprovalMode(ApprovalMode::Yolo) ->withAllowedTools(['read_file', 'edit_file', 'shell']) ->withIncludeDirectories(['/shared/utils']) ->withTimeout(300) ->inDirectory('/projects/my-app') ->onText(fn(string $text) => print($text)) ->executeStreaming('Review the authentication module.'); // @doctest id="fd9e" ``` ================================================================================ FILE: packages/agent-ctrl/6-response-object.md ================================================================================ ## Introduction Every call to `execute()` or `executeStreaming()` returns an `AgentResponse` -- a readonly DTO that provides a normalized view of the agent's output regardless of which CLI tool produced it. The response contains the text content, process exit code, session identifier, token usage statistics, cost, tool call records, and parse diagnostics. The `AgentResponse` class is defined at `Cognesy\AgentCtrl\Dto\AgentResponse`. ## Response Properties The following public properties are available directly on the `AgentResponse` object: ### `agentType: AgentType` The agent type that produced this response. This is one of `AgentType::ClaudeCode`, `AgentType::Codex`, `AgentType::OpenCode`, `AgentType::Pi`, or `AgentType::Gemini`: ```php echo "Produced by: {$response->agentType->value}"; // e.g., "claude-code" // @doctest id="72b3" ``` ### `text: string` The agent's main text output. This is the concatenation of all text content blocks from the agent's response. For most use cases, you should access this through the `text()` method instead: ```php echo $response->text; // or echo $response->text(); // @doctest id="0b14" ``` ### `exitCode: int` The process exit code. A value of `0` indicates success. Non-zero values indicate errors, timeouts, or other failures: ```php if ($response->exitCode !== 0) { echo "Process failed with exit code: {$response->exitCode}"; } // @doctest id="9bc4" ``` Common exit codes: - **0** -- Success - **1** -- General error (invalid configuration, agent-side failure) - **2** -- Invalid arguments passed to the CLI - **124 / 137** -- Process killed due to timeout ### `usage: ?TokenUsage` Token usage statistics, when available. This is `null` for agents that do not expose usage data (Claude Code does not; Codex, OpenCode, Pi, and Gemini do when the data is present in the CLI output): ```php $usage = $response->usage; if ($usage !== null) { echo "Input tokens: {$usage->input}\n"; echo "Output tokens: {$usage->output}\n"; echo "Total tokens: {$usage->total()}\n"; } // @doctest id="a390" ``` ### `cost: ?float` The estimated cost in USD, when available. Currently, OpenCode and Pi expose cost data: ```php if ($response->cost !== null) { echo sprintf("Cost: $%.4f", $response->cost); } // @doctest id="aced" ``` ### `toolCalls: array` An array of `ToolCall` objects representing every tool invocation made during the execution. See the [Tool Calls](#tool-calls) section below for details: ```php echo "Tool calls made: " . count($response->toolCalls); // @doctest id="a790" ``` ### `rawResponse: mixed` The original bridge-specific response object (`ClaudeResponse`, `CodexResponse`, `OpenCodeResponse`, `PiResponse`, or `GeminiResponse`). This provides access to agent-specific data that is not part of the normalized response: ```php // Access the raw Codex response for agent-specific data $codexResponse = $response->rawResponse; // @doctest id="8b51" ``` ### `parseFailures: int` The number of malformed JSON lines that were skipped during response parsing: ```php if ($response->parseFailures > 0) { echo "Warning: {$response->parseFailures} JSON parse failures"; } // @doctest id="bb4e" ``` ## Response Methods ### `isSuccess(): bool` Returns `true` if the exit code is `0`. This is the recommended way to check whether the execution succeeded: ```php $response = AgentCtrl::codex()->execute('Create a short summary.'); if ($response->isSuccess()) { echo $response->text(); } else { echo "Failed with exit code: {$response->exitCode}"; echo "Partial output: " . $response->text(); } // @doctest id="fb22" ``` A completed execution with a non-zero exit code does **not** throw an exception. Always check `isSuccess()` before treating the text output as authoritative. ### `text(): string` Returns the agent's text output. Equivalent to accessing the `text` property directly: ```php echo $response->text(); // @doctest id="9db4" ``` ### `sessionId(): ?AgentSessionId` Returns the session identifier as an `AgentSessionId` value object, or `null` if no session ID was available. The value object implements `__toString()` for easy serialization: ```php $sessionId = $response->sessionId(); if ($sessionId !== null) { // Store for later resumption $cache->set('last_session', (string) $sessionId); } // @doctest id="014d" ``` ### `usage(): ?TokenUsage` Returns the token usage statistics, or `null` if the agent does not expose this data: ```php $usage = $response->usage(); if ($usage !== null) { echo "Tokens used: {$usage->total()}"; } // @doctest id="b2fd" ``` ### `cost(): ?float` Returns the estimated cost in USD, or `null` if the agent does not expose cost data: ```php $cost = $response->cost(); if ($cost !== null) { echo sprintf("This execution cost $%.4f", $cost); } // @doctest id="1261" ``` ### `parseFailures(): int` Returns the number of malformed JSON lines encountered during parsing: ```php echo "Parse failures: {$response->parseFailures()}"; // @doctest id="30d7" ``` ### `parseFailureSamples(): array` Returns a list of sample malformed payload strings (truncated to 200 characters each) for debugging purposes: ```php if ($response->parseFailures() > 0) { echo "Skipped {$response->parseFailures()} malformed JSON lines:\n"; foreach ($response->parseFailureSamples() as $sample) { echo " - {$sample}\n"; } } // @doctest id="a41f" ``` ## Tool Calls The `toolCalls` array contains `ToolCall` objects -- a normalized representation of every tool invocation across all agent types. Each `ToolCall` has the following structure: ### ToolCall Properties | Property | Type | Description | |----------|------|-------------| | `tool` | `string` | The tool name (e.g., `'bash'`, `'file_change'`, `'web_search'`, `'tool_result'`) | | `input` | `array` | The tool's input parameters as an associative array | | `output` | `?string` | The tool's output, or `null` if not yet completed | | `isError` | `bool` | Whether the tool call resulted in an error | ### ToolCall Methods | Method | Return Type | Description | |--------|-------------|-------------| | `callId()` | `?AgentToolCallId` | The unique identifier for this tool call, or `null` | | `isCompleted()` | `bool` | Whether the tool has produced output (`output !== null`) | ### Tool Name Normalization Agent-Ctrl normalizes tool names across all agents so you can handle them consistently: **Claude Code** tool calls preserve their original tool names and include separate `tool_result` entries for tool outputs: - Tool invocations: original tool name (e.g., `'Read'`, `'Edit'`, `'Bash'`) - Tool results: `'tool_result'` with `['tool_use_id' => '...']` in the input **Codex** items are normalized as follows: - `CommandExecution` becomes `tool: 'bash'`, `input: ['command' => '...']` - `FileChange` becomes `tool: 'file_change'`, `input: ['path' => '...', 'action' => '...']` - `McpToolCall` becomes `tool: `, `input: ` - `WebSearch` becomes `tool: 'web_search'`, `input: ['query' => '...']` - `PlanUpdate` becomes `tool: 'plan_update'` - `Reasoning` becomes `tool: 'reasoning'` **OpenCode** tool calls preserve their original tool names from the `ToolUseEvent`. ### Working with Tool Calls ```php $response = AgentCtrl::claudeCode()->execute('Refactor the UserService.'); foreach ($response->toolCalls as $toolCall) { echo "Tool: {$toolCall->tool}\n"; echo "Input: " . json_encode($toolCall->input) . "\n"; if ($toolCall->isCompleted()) { echo "Output: " . substr($toolCall->output, 0, 200) . "\n"; } if ($toolCall->isError) { echo " [ERROR]\n"; } $callId = $toolCall->callId(); if ($callId !== null) { echo "Call ID: {$callId}\n"; } echo "---\n"; } // @doctest id="89cb" ``` ## Token Usage The `TokenUsage` DTO provides detailed token statistics when the agent's CLI exposes this data. It is defined at `Cognesy\AgentCtrl\Dto\TokenUsage`. ### TokenUsage Properties | Property | Type | Description | |----------|------|-------------| | `input` | `int` | Number of input tokens consumed | | `output` | `int` | Number of output tokens produced | | `cacheRead` | `?int` | Tokens read from cache (when available) | | `cacheWrite` | `?int` | Tokens written to cache (when available) | | `reasoning` | `?int` | Tokens used for reasoning (when available) | ### TokenUsage Methods | Method | Return Type | Description | |--------|-------------|-------------| | `total()` | `int` | Sum of `input` and `output` tokens | ### Usage Data Availability by Agent | Agent | Token Usage | Cost | |-------|------------|------| | Claude Code | No | No | | Codex | Yes (input, output, cacheRead) | No | | OpenCode | Yes (input, output, cacheRead, cacheWrite, reasoning) | Yes | | Pi | Yes (input, output, cacheRead, cacheWrite) | Yes | | Gemini | Yes (input, output, cacheRead) | No | ```php $response = AgentCtrl::openCode() ->withModel('anthropic/claude-sonnet-4-5') ->execute('Summarize this project.'); $usage = $response->usage(); if ($usage !== null) { echo "Input tokens: {$usage->input}\n"; echo "Output tokens: {$usage->output}\n"; echo "Total tokens: {$usage->total()}\n"; if ($usage->cacheRead !== null) { echo "Cache read: {$usage->cacheRead}\n"; } if ($usage->cacheWrite !== null) { echo "Cache write: {$usage->cacheWrite}\n"; } if ($usage->reasoning !== null) { echo "Reasoning tokens: {$usage->reasoning}\n"; } } $cost = $response->cost(); if ($cost !== null) { echo sprintf("Cost: $%.4f\n", $cost); } // @doctest id="0b8c" ``` ## Complete Example ```php use Cognesy\AgentCtrl\AgentCtrl; $response = AgentCtrl::codex() ->withTimeout(300) ->inDirectory('/projects/my-app') ->execute('Review the authentication module and list any issues.'); // Check success if (!$response->isSuccess()) { echo "Execution failed (exit code {$response->exitCode})\n"; exit(1); } // Display text output echo $response->text() . "\n"; // Display session info $sessionId = $response->sessionId(); if ($sessionId !== null) { echo "Session: {$sessionId}\n"; } // Display usage stats $usage = $response->usage(); if ($usage !== null) { echo "Tokens: {$usage->total()} (in: {$usage->input}, out: {$usage->output})\n"; } // Display cost if ($response->cost() !== null) { echo sprintf("Cost: $%.4f\n", $response->cost()); } // Summarize tool activity echo "Tool calls: " . count($response->toolCalls) . "\n"; $toolSummary = []; foreach ($response->toolCalls as $tc) { $toolSummary[$tc->tool] = ($toolSummary[$tc->tool] ?? 0) + 1; } foreach ($toolSummary as $tool => $count) { echo " {$tool}: {$count}x\n"; } // Check parse health if ($response->parseFailures() > 0) { echo "Warning: {$response->parseFailures()} parse failures\n"; } // @doctest id="b8f1" ``` ================================================================================ FILE: packages/agent-ctrl/7-troubleshooting.md ================================================================================ ## CLI Binary Not Found Agent-Ctrl uses `CliBinaryGuard` to verify that the required CLI binary (`claude`, `codex`, `opencode`, `pi`, or `gemini`) is available before every execution. If the binary cannot be found, a `RuntimeException` is thrown immediately -- before any prompt is sent to the agent. The error message identifies the missing binary and provides installation guidance: ``` Claude Code CLI executable `claude` was not found in PATH. Install Claude Code CLI and ensure `claude` is available in PATH. // @doctest id="7aea" ``` ### Resolution Steps 1. **Verify installation.** Run the CLI binary directly in your terminal to confirm it is installed: ```bash claude --version codex --version opencode --version pi --version gemini --version ``` 2. **Complete authentication.** Most agents require interactive authentication on first use. Run the CLI interactively at least once to complete any setup flows before using it through Agent-Ctrl. 3. **Check PATH visibility.** The binary must be in the system `PATH` visible to your PHP process. This is not always the same as your shell's `PATH`. If you installed the CLI in a non-standard location (e.g., via `nvm` or a custom prefix), ensure that location is included in the `PATH` used by your web server, supervisor, or CLI runner. You can verify the PATH available to PHP: ```bash php -r "echo getenv('PATH');" ``` 4. **Consider the sandbox driver.** The binary preflight check behaves differently depending on the sandbox driver: - **Host, Firejail, Bubblewrap** -- The binary must be available on the host system. The guard checks the host `PATH`. - **Docker, Podman** -- The guard skips the preflight check entirely, because the binary is expected to be inside the container image. ## Working Directory Problems When you call `inDirectory()`, the bridge validates that the directory exists before changing into it. If it does not exist, an `InvalidArgumentException` is thrown with the path: ```php // This will throw if /nonexistent/path does not exist $response = AgentCtrl::claudeCode() ->inDirectory('/nonexistent/path') ->execute('List files.'); // @doctest id="e483" ``` ### Common Causes - **Typos or relative paths.** Always use absolute paths. Relative paths resolve against the PHP process's current working directory, which may differ from what you expect. - **Deleted or unmounted directories.** The directory may have been removed or unmounted between the time you configured the builder and the time execution begins. - **Permission issues.** The PHP process may not have permission to access the directory. Check file permissions with `ls -la` and ensure the user running PHP has read (and possibly write) access. ### Concurrency Warning `inDirectory()` changes the PHP process's current working directory using `chdir()` for the duration of the execution and restores it afterward. If your PHP process serves multiple requests concurrently (e.g., with Swoole, RoadRunner, or a threaded server), working directory changes affect the entire process. In concurrent environments, ensure that each request either uses absolute paths exclusively or coordinates directory changes carefully. ## Non-Zero Exit Codes Agent-Ctrl treats exit codes as data, not errors. A completed execution with a non-zero exit code does **not** throw an exception. It is your responsibility to check the result: ```php $response = AgentCtrl::codex()->execute('Perform a complex refactoring.'); if (!$response->isSuccess()) { echo "Agent failed with exit code: {$response->exitCode}\n"; echo "Partial output: " . $response->text() . "\n"; } // @doctest id="88b5" ``` ### Common Exit Codes | Exit Code | Typical Cause | |-----------|---------------| | **0** | Successful completion | | **1** | General error -- invalid configuration, agent-side failure, or unhandled exception within the agent | | **2** | Invalid arguments passed to the CLI binary | | **124** | Process killed due to timeout (SIGTERM) | | **137** | Process killed due to timeout (SIGKILL, after SIGTERM grace period) | The text output from a failed execution may still contain useful information -- partial results, error messages from the agent, or diagnostic output. Always inspect `text()` even when `isSuccess()` returns `false`. ## Timeout Issues The default timeout is 120 seconds. Complex tasks, large codebases, or agents that perform many tool calls may need significantly more time: ```php $response = AgentCtrl::claudeCode() ->withTimeout(600) // 10 minutes ->execute('Perform a comprehensive codebase review.'); // @doctest id="087d" ``` When an execution times out: 1. The sandbox executor sends a termination signal to the process. 2. The response contains whatever output was produced before the timeout. 3. The exit code will be non-zero (typically 124 or 137). 4. `isSuccess()` will return `false`. ### Guideline for Timeout Values | Task Type | Suggested Timeout | |-----------|-------------------| | Simple questions or summaries | 60-120 seconds | | Code review of a single file | 120-300 seconds | | Multi-file refactoring | 300-600 seconds | | Full codebase analysis | 600-900 seconds | | Complex multi-step tasks | 600+ seconds | Setting timeouts below 30 seconds is generally not recommended. Agents need time to start up, read context, plan, and execute tools before producing output. ## Streaming vs. Process Errors There are two distinct categories of errors during execution, and they are handled differently. ### Stream Errors (Operational) These are error events emitted by the agent during normal streaming. They represent issues the agent encountered while working -- a tool failure, a rate limit, an API error, or a malformed request. They are delivered through the `onError()` callback: ```php $response = AgentCtrl::openCode() ->onError(function (string $message, ?string $code): void { error_log("Stream error [{$code}]: {$message}"); }) ->executeStreaming('Process this task.'); // @doctest id="1d78" ``` Stream errors do **not** prevent the execution from completing. The agent may recover and continue working after emitting an error event. The final `AgentResponse` is still returned normally. ### Process Errors (Fatal) These are PHP exceptions thrown when something goes fundamentally wrong -- the binary is missing, the working directory does not exist, the process cannot be started, or the sandbox executor encounters a fatal error. These exceptions propagate normally and must be caught with try/catch: ```php try { $response = AgentCtrl::claudeCode()->execute('Do something.'); } catch (\RuntimeException $e) { // Binary not found, process failed to start, etc. echo "Process error: " . $e->getMessage(); } catch (\InvalidArgumentException $e) { // Working directory does not exist echo "Configuration error: " . $e->getMessage(); } // @doctest id="5c15" ``` The key distinction: stream errors are **data** (delivered via callbacks), while process errors are **exceptions** (thrown and propagated through the call stack). ## Parse Failures Agent-Ctrl parses each agent's JSON Lines output to extract text, tool calls, session IDs, and metadata. If a line contains malformed JSON, the behavior depends on the parser's fail-fast setting: ### Fail-Fast Mode (Default) When fail-fast is enabled, a `JsonParsingException` is thrown immediately upon encountering malformed JSON. This is the default behavior and ensures that corrupt data does not silently corrupt your results: ```php use Cognesy\Utils\Json\JsonParsingException; try { $response = AgentCtrl::claudeCode()->execute('Do something.'); } catch (JsonParsingException $e) { echo "Malformed JSON in agent output: " . $e->getMessage(); } // @doctest id="9449" ``` ### Tolerant Mode When fail-fast is disabled (configured at the bridge level), malformed lines are silently skipped and counted. After execution, you can inspect the parse diagnostics: ```php if ($response->parseFailures() > 0) { echo "Skipped {$response->parseFailures()} malformed JSON lines.\n"; foreach ($response->parseFailureSamples() as $sample) { echo " Sample: {$sample}\n"; } } // @doctest id="e168" ``` ### Common Causes of Parse Failures - **CLI version mismatch.** The agent's CLI tool was updated and its output format changed. Update Agent-Ctrl to the latest version. - **Debug output mixed into the stream.** The agent's CLI is emitting debug messages, warnings, or progress indicators alongside its JSON output. Check the CLI's verbose/quiet settings. - **Corrupted process output.** The process was interrupted mid-line, producing incomplete JSON. This can happen during timeouts or system resource exhaustion. ## Debugging with the Console Logger Agent-Ctrl includes a built-in `AgentCtrlConsoleLogger` that displays detailed, color-coded execution telemetry. This is invaluable for understanding the execution flow, diagnosing performance bottlenecks, and identifying where problems occur. ### Basic Usage ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Broadcasting\AgentCtrlConsoleLogger; $logger = new AgentCtrlConsoleLogger(); $response = AgentCtrl::claudeCode() ->wiretap($logger->wiretap()) ->execute('Analyze this codebase.'); // @doctest id="edf3" ``` ### Configuration Options The logger accepts several constructor parameters to control what is displayed: ```php $logger = new AgentCtrlConsoleLogger( useColors: true, // Color-coded output (auto-detects terminal support) showTimestamps: true, // Show HH:MM:SS.mmm timestamps showAgentType: true, // Show [claude-code] / [codex] / [opencode] prefix showToolArgs: true, // Show tool input arguments showStreaming: true, // Show stream processing events showSandbox: true, // Show sandbox setup events showPipeline: true, // Show request/response pipeline events maxArgLength: 100, // Truncate tool arguments to this length ); // @doctest id="28c3" ``` ### Event Categories The logger groups events into categories with color-coded labels: | Label | Color | Events | |-------|-------|--------| | `EXEC` | Cyan | Execution started | | `DONE` | Green | Execution completed (with exit code, tool count, cost, tokens) | | `FAIL` | Red | Error occurred | | `TOOL` | Yellow | Tool used (with name and arguments) | | `TEXT` | Gray | Text received (with length) | | `PROC` | Cyan | Process started / completed | | `SBOX` | Blue | Sandbox initialized / policy configured / ready | | `STRM` | Gray | Stream processing started / completed | | `REQT` | Gray | Request built | | `CMD` | Gray | Command spec created | | `RESP` | Gray | Response parsing started / data extracted / completed | ### Example Output ``` 14:23:01.456 [claude-code] [EXEC] Execution started [model=claude-sonnet-4-5, prompt=Analyze this codebase...] 14:23:01.458 [claude-code] [PROC] Process started [commands=12] 14:23:03.210 [claude-code] [TOOL] Read {path=/src/UserService.php} 14:23:04.890 [claude-code] [TOOL] Bash {command=php -l src/UserService.php} 14:23:06.123 [claude-code] [TEXT] Text received [length=1432] 14:23:06.125 [claude-code] [DONE] Execution completed [exit=0, tools=5, tokens=0] // @doctest id="87fc" ``` ## Common Pitfalls **Using `continueSession()` with the wrong agent.** Session IDs are agent-specific. Session IDs are agent-specific and incompatible across bridges. Always use the same agent type when continuing or resuming a session. **Forgetting to check `isSuccess()`.** A completed execution with a non-zero exit code does not throw an exception. Always verify the result before using the text output as authoritative. **Setting very short timeouts.** Agents need time to start up, read context, and execute tools. Timeouts under 30 seconds may cause premature termination for anything beyond trivial prompts. **Relative paths in `inDirectory()`.** Always use absolute paths. Relative paths resolve against the PHP process's current working directory, which may differ from your expectations depending on how the process was started. **Running in concurrent PHP environments.** The `inDirectory()` method uses `chdir()`, which affects the entire PHP process. In Swoole, RoadRunner, or other concurrent PHP environments, this can cause race conditions between requests. Use absolute paths throughout and avoid `inDirectory()` if possible, or ensure proper isolation. **Ignoring parse failures.** If `parseFailures()` returns a non-zero value, some of the agent's output was not processed. This may mean missing tool calls, incomplete text, or lost metadata. Investigate the `parseFailureSamples()` to determine the cause. ================================================================================ FILE: packages/agent-ctrl/8-claude-code-bridge.md ================================================================================ ## Overview The Claude Code bridge wraps Anthropic's `claude` CLI, providing access to Claude's code-generation and reasoning capabilities through Agent-Ctrl's unified API. Claude Code is a strong default choice for general coding workflows, tool-heavy tasks, and scenarios where you want fine-grained control over the agent's system prompt and permission behavior. The bridge is implemented by `ClaudeCodeBridge` and configured through `ClaudeCodeBridgeBuilder`. Access the builder through the `AgentCtrl` facade: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Enum\AgentType; // Dedicated factory method $builder = AgentCtrl::claudeCode(); // Or via the generic factory $builder = AgentCtrl::make(AgentType::ClaudeCode); // @doctest id="f267" ``` ## Basic Usage The simplest Claude Code interaction requires just a prompt: ```php use Cognesy\AgentCtrl\AgentCtrl; $response = AgentCtrl::claudeCode() ->execute('Review this package and summarize the design.'); echo $response->text(); // @doctest id="76cc" ``` With model selection: ```php $response = AgentCtrl::claudeCode() ->withModel('claude-sonnet-4-5') ->execute('Explain the architecture of this project.'); echo $response->text(); // @doctest id="0214" ``` ## System Prompts Claude Code supports two complementary approaches to system prompt configuration, giving you precise control over the agent's behavior. ### Replacing the System Prompt Use `withSystemPrompt()` to completely replace the default system prompt with your own. The agent will follow only your instructions, without the built-in Claude Code behavior: ```php $response = AgentCtrl::claudeCode() ->withSystemPrompt('You are a security auditor. Focus exclusively on identifying vulnerabilities, injection risks, and authentication weaknesses.') ->execute('Audit the authentication module.'); // @doctest id="b735" ``` ### Appending to the System Prompt Use `appendSystemPrompt()` to add instructions on top of the default system prompt. This preserves Claude Code's built-in capabilities (file reading, code editing, command execution) while layering in your project-specific context: ```php $response = AgentCtrl::claudeCode() ->appendSystemPrompt('This project uses Laravel conventions. Follow PSR-12 coding standards. Always add type declarations to method signatures.') ->execute('Refactor the UserService class.'); // @doctest id="b9ba" ``` ### Combining Both Methods You can use both methods together. `withSystemPrompt()` sets the base prompt and `appendSystemPrompt()` adds to it: ```php $response = AgentCtrl::claudeCode() ->withSystemPrompt('You are a code reviewer specializing in PHP.') ->appendSystemPrompt('Pay special attention to error handling, edge cases, and performance implications.') ->execute('Review the PaymentGateway class.'); // @doctest id="f0a2" ``` ## Permission Modes When running Claude Code headlessly (as Agent-Ctrl does), you need to configure how the agent handles tool permission requests. The `PermissionMode` enum provides four levels of autonomy: ```php use Cognesy\AgentCtrl\ClaudeCode\Domain\Enum\PermissionMode; // @doctest id="fd79" ``` | Mode | CLI Flag | Behavior | |------|----------|----------| | `DefaultMode` | `default` | Standard interactive permission prompts. **Not suitable for headless execution** -- prompts cannot be answered. | | `Plan` | `plan` | The agent can plan and reason but will prompt before executing any tool. Useful for review workflows where you want to inspect the plan before execution. | | `AcceptEdits` | `acceptEdits` | Auto-approve file editing tools (create, write, edit) but prompt for other actions like shell commands. A middle ground between safety and automation. | | `BypassPermissions` | `bypassPermissions` | Auto-approve all tool uses without prompting. **This is the default** for Agent-Ctrl because headless execution cannot respond to permission prompts. | ```php $response = AgentCtrl::claudeCode() ->withPermissionMode(PermissionMode::AcceptEdits) ->execute('Write unit tests for the PaymentService.'); // @doctest id="dc6c" ``` The default is `BypassPermissions` because Agent-Ctrl runs the CLI in a non-interactive, headless mode. If you use `DefaultMode` or `Plan` without an interactive terminal, the agent will hang waiting for permission responses that never come, eventually timing out. ## Turn Limits Each "turn" represents one cycle where the agent reads context, reasons, and takes an action (such as reading a file, editing code, or running a command). Limiting turns helps control execution time, cost, and scope: ```php $response = AgentCtrl::claudeCode() ->withMaxTurns(5) ->execute('Make a small improvement to the README.'); // @doctest id="24d4" ``` ### Guidelines for Turn Limits | Task Complexity | Suggested Turns | |----------------|----------------| | Simple question or summary | 3-5 | | Single-file edit | 5-10 | | Multi-file refactoring | 15-30 | | Complex feature implementation | 30-50 | Without a turn limit, Claude Code continues working until it decides the task is complete or the timeout is reached. For predictable behavior, combine `withMaxTurns()` with `withTimeout()`. ## Additional Directories By default, the agent operates within the working directory set by `inDirectory()`. Use `withAdditionalDirs()` to grant access to additional directories, such as shared libraries, configuration repositories, or reference codebases: ```php $response = AgentCtrl::claudeCode() ->inDirectory('/projects/my-app') ->withAdditionalDirs(['/shared/libraries', '/configs/production']) ->execute('Update the app to use the latest shared authentication library.'); // @doctest id="f8a6" ``` Each path in the array must be an absolute path to an existing directory. ## Verbose Mode The `verbose()` method controls whether Claude Code emits detailed output. Verbose mode is enabled by default and is required for proper JSON stream parsing. In most cases, you should leave this at its default value: ```php // Verbose is true by default -- you rarely need to change this AgentCtrl::claudeCode()->verbose(true); // @doctest id="0c17" ``` Disabling verbose mode may prevent Agent-Ctrl from correctly parsing the agent's output. ## Streaming with Claude Code Claude Code streams output as JSON Lines containing message events, system events, error events, and result events. The bridge parses these in real time and delivers them through the standard streaming callbacks: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; $response = AgentCtrl::claudeCode() ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onError(fn(string $message, ?string $code) => print("\nError: {$message}\n")) ->executeStreaming('Explain the architecture of this project.'); // @doctest id="def3" ``` ### Event Normalization During streaming, Claude Code emits several types of events that are normalized into the callback API: - **Text content** from `MessageEvent` messages (type `text`) is delivered through `onText()` with the text string. - **Tool use** from `MessageEvent` messages (type `tool_use`) is delivered through `onToolUse()` with the tool name, input parameters, and call ID. - **Tool results** from `MessageEvent` messages (type `tool_result`) are delivered through `onToolUse()` with `tool` set to `'tool_result'`, the tool use ID in the input array, and the result content as output. - **Error events** are delivered through `onError()` with the error message. ## Session Management Claude Code session IDs are extracted from the `session_id` field in the stream JSON output. Use them to maintain conversational context across multiple executions: ```php // First execution $first = AgentCtrl::claudeCode()->execute('Create an implementation plan.'); $sessionId = $first->sessionId(); // Continue the most recent session (no ID needed) $next = AgentCtrl::claudeCode() ->continueSession() ->execute('Begin implementing the plan.'); // Or resume a specific session by ID if ($sessionId !== null) { $next = AgentCtrl::claudeCode() ->resumeSession((string) $sessionId) ->execute('Now implement the first item in the plan.'); } // @doctest id="4bb5" ``` ## Data Availability Not all data points are available from every agent. Claude Code's current JSON output format has the following coverage: | Data Point | Available | Notes | |------------|-----------|-------| | Text output | Yes | Concatenated from all text content blocks | | Tool calls | Yes | With call IDs, inputs, and results | | Session ID | Yes | Extracted from `session_id` field in stream | | Token usage | No | Claude Code CLI does not expose token counts | | Cost | No | Claude Code CLI does not expose cost data | | Parse diagnostics | Yes | Malformed JSON line counts and samples | If you need token usage and cost tracking, consider using OpenCode with an Anthropic model, which provides both. ## Complete Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\ClaudeCode\Domain\Enum\PermissionMode; use Cognesy\AgentCtrl\Broadcasting\AgentCtrlConsoleLogger; $logger = new AgentCtrlConsoleLogger(showPipeline: true); $response = AgentCtrl::claudeCode() ->withModel('claude-sonnet-4-5') ->withSystemPrompt('You are a careful code reviewer.') ->appendSystemPrompt('Focus on error handling and edge cases.') ->withPermissionMode(PermissionMode::BypassPermissions) ->withMaxTurns(15) ->withTimeout(300) ->inDirectory('/projects/my-app') ->withAdditionalDirs(['/shared/utils']) ->wiretap($logger->wiretap()) ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->executeStreaming('Review the PaymentService for error handling issues.'); if ($response->isSuccess()) { echo "\n\nReview completed successfully."; echo "\nTools used: " . count($response->toolCalls); $sessionId = $response->sessionId(); if ($sessionId !== null) { echo "\nSession: {$sessionId}"; } } else { echo "\n\nReview failed with exit code: {$response->exitCode}"; } // @doctest id="1d55" ``` ================================================================================ FILE: packages/agent-ctrl/9-codex-bridge.md ================================================================================ ## Overview The Codex bridge wraps OpenAI's `codex` CLI, providing access to Codex's code-generation capabilities through Agent-Ctrl's unified API. Codex is particularly well-suited when you need fine-grained sandbox controls over filesystem and network access, image-based prompts, and automatic approval workflows. The bridge is implemented by `CodexBridge` and configured through `CodexBridgeBuilder`. Access the builder through the `AgentCtrl` facade: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Enum\AgentType; // Dedicated factory method $builder = AgentCtrl::codex(); // Or via the generic factory $builder = AgentCtrl::make(AgentType::Codex); // @doctest id="e708" ``` ## Basic Usage ```php use Cognesy\AgentCtrl\AgentCtrl; $response = AgentCtrl::codex() ->execute('Summarize the test suite in this repository.'); echo $response->text(); // @doctest id="2b39" ``` With model and sandbox configuration: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\OpenAICodex\Domain\Enum\SandboxMode; $response = AgentCtrl::codex() ->withModel('o4-mini') ->withSandbox(SandboxMode::WorkspaceWrite) ->execute('Write tests for the UserService class.'); echo $response->text(); // @doctest id="2733" ``` ## Sandbox Modes Codex provides three sandbox modes that control what filesystem and network access the agent has during execution. These are managed through the `SandboxMode` enum: ```php use Cognesy\AgentCtrl\OpenAICodex\Domain\Enum\SandboxMode; // @doctest id="1fa5" ``` | Mode | Filesystem | Network | CLI Value | Use Case | |------|-----------|---------|-----------|----------| | `SandboxMode::ReadOnly` | Read-only | No | `read-only` | Safe analysis, code review, reading files without modification | | `SandboxMode::WorkspaceWrite` | Write access to workspace | No | `workspace-write` | Code generation, refactoring, test writing | | `SandboxMode::DangerFullAccess` | Full access | Yes | `danger-full-access` | Tasks requiring network access or system-wide file operations | ```php // Read-only: safe for analysis tasks $response = AgentCtrl::codex() ->withSandbox(SandboxMode::ReadOnly) ->execute('Analyze the code structure and identify issues.'); // Workspace write: for code modifications $response = AgentCtrl::codex() ->withSandbox(SandboxMode::WorkspaceWrite) ->execute('Refactor the database layer to use the repository pattern.'); // Full access: for tasks needing network or system access $response = AgentCtrl::codex() ->withSandbox(SandboxMode::DangerFullAccess) ->execute('Install a dependency and update the code to use it.'); // @doctest id="6650" ``` ### Disabling the Sandbox The `disableSandbox()` method is a shorthand for `withSandbox(SandboxMode::DangerFullAccess)`: ```php $response = AgentCtrl::codex() ->disableSandbox() ->execute('Run the full test suite and report results.'); // @doctest id="3705" ``` ## Approval Modes Codex supports two approval configuration methods that control how the agent handles permission requests. ### Full Auto Mode `fullAuto()` enables automatic approval with workspace-write sandbox access. This is the default configuration (`true`), making it suitable for headless execution: ```php $response = AgentCtrl::codex() ->fullAuto() ->execute('Implement the feature described in SPEC.md.'); // @doctest id="193b" ``` When full-auto is enabled, the agent automatically approves tool executions that would normally require user confirmation, and on-failure actions are also auto-approved. Disable it when you want more conservative behavior: ```php $response = AgentCtrl::codex() ->fullAuto(false) ->execute('Analyze the codebase structure.'); // @doctest id="8bdc" ``` ### Dangerous Bypass `dangerouslyBypass()` skips all approval prompts and all sandbox restrictions. This is the most permissive mode and should be used only when you fully trust the agent and the execution environment: ```php $response = AgentCtrl::codex() ->dangerouslyBypass() ->execute('Deploy the application to staging.'); // @doctest id="c8a1" ``` > **Warning:** This mode disables all safety guardrails. The agent can execute arbitrary commands, modify any file, and access the network without restriction. ## Git Repository Check By default, Codex requires the working directory to be inside a Git repository. Use `skipGitRepoCheck()` to bypass this requirement when working with non-Git directories: ```php $response = AgentCtrl::codex() ->skipGitRepoCheck() ->inDirectory('/tmp/workspace') ->execute('Create a new project skeleton.'); // @doctest id="b144" ``` ## Image Input Codex supports image attachments, allowing the agent to analyze visual content alongside text prompts. Use `withImages()` to attach one or more image files: ```php $response = AgentCtrl::codex() ->withImages(['/tmp/mockup.png']) ->execute('Implement the UI component shown in the mockup.'); // @doctest id="4248" ``` Multiple images can be attached: ```php $response = AgentCtrl::codex() ->withImages([ '/tmp/current-ui.png', '/tmp/target-design.png', ]) ->execute('Compare the current UI with the target design and list the differences.'); // @doctest id="fbab" ``` Each path must point to an existing image file on the local filesystem. ## Additional Directories Use `withAdditionalDirs()` to grant the agent write access to directories beyond the working directory: ```php $response = AgentCtrl::codex() ->inDirectory('/projects/my-app') ->withAdditionalDirs(['/shared/assets', '/configs']) ->execute('Update the shared configuration files.'); // @doctest id="4675" ``` ## Streaming with Codex Codex streams output as JSON Lines containing item events (started, completed), turn events, thread events, and error events. The bridge normalizes these into the standard callback API: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; $response = AgentCtrl::codex() ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onError(fn(string $message, ?string $code) => print("\nError [{$code}]: {$message}\n")) ->executeStreaming('Explain the test framework used in this project.'); // @doctest id="28b1" ``` ### Tool Call Normalization Codex produces several item types that are normalized into `ToolCall` objects: | Codex Item Type | Normalized Tool Name | Input Structure | |----------------|---------------------|----------------| | `CommandExecution` | `'bash'` | `['command' => '...']` | | `FileChange` | `'file_change'` | `['path' => '...', 'action' => '...']` | | `McpToolCall` | Original tool name | Original arguments | | `WebSearch` | `'web_search'` | `['query' => '...']` | | `PlanUpdate` | `'plan_update'` | `[]` | | `Reasoning` | `'reasoning'` | `[]` | | `UnknownItem` | Original item type | `[]` | The `isError` flag is set when the item has an error status (`error`, `failed`, `cancelled`) or when a `CommandExecution` has a non-zero exit code. ### Working with Tool Calls ```php foreach ($response->toolCalls as $tc) { if ($tc->tool === 'bash') { echo "Command: {$tc->input['command']}\n"; echo "Output: {$tc->output}\n"; } if ($tc->tool === 'file_change') { echo "Changed: {$tc->input['path']} ({$tc->input['action']})\n"; } if ($tc->tool === 'web_search') { echo "Searched: {$tc->input['query']}\n"; } } // @doctest id="47eb" ``` ## Session Management Codex uses thread-based conversations. Agent-Ctrl normalizes the thread ID into an `AgentSessionId`: ```php // First execution $first = AgentCtrl::codex()->execute('Create a plan for the refactoring.'); $sessionId = $first->sessionId(); // Continue the most recent session $next = AgentCtrl::codex() ->continueSession() ->execute('Proceed with step 1.'); // Resume a specific thread if ($sessionId !== null) { $next = AgentCtrl::codex() ->resumeSession((string) $sessionId) ->execute('Continue from where we left off.'); } // @doctest id="2df6" ``` ## Data Availability | Data Point | Available | Notes | |------------|-----------|-------| | Text output | Yes | Extracted from `AgentMessage` items | | Tool calls | Yes | Normalized from all item types (see table above) | | Session ID | Yes | Normalized from Codex thread ID | | Token usage | Yes | Input tokens, output tokens, cached input tokens | | Cost | No | Codex CLI does not expose cost data | | Parse diagnostics | Yes | Malformed JSON line counts and samples | ### Token Usage When Codex exposes usage statistics, they are converted into the unified `TokenUsage` DTO: ```php $response = AgentCtrl::codex()->execute('Analyze the codebase.'); $usage = $response->usage(); if ($usage !== null) { echo "Input tokens: {$usage->input}\n"; echo "Output tokens: {$usage->output}\n"; echo "Cache read: " . ($usage->cacheRead ?? 'N/A') . "\n"; echo "Total: {$usage->total()}\n"; } // @doctest id="ae00" ``` ## Complete Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\OpenAICodex\Domain\Enum\SandboxMode; use Cognesy\AgentCtrl\Broadcasting\AgentCtrlConsoleLogger; $logger = new AgentCtrlConsoleLogger( showStreaming: true, showPipeline: true, ); $response = AgentCtrl::codex() ->withModel('o4-mini') ->withSandbox(SandboxMode::WorkspaceWrite) ->fullAuto() ->withTimeout(300) ->inDirectory('/projects/my-app') ->withAdditionalDirs(['/shared/test-fixtures']) ->withImages(['/tmp/design-spec.png']) ->wiretap($logger->wiretap()) ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->executeStreaming('Implement the component shown in the design spec and write tests.'); if ($response->isSuccess()) { echo "\n\nTask completed successfully.\n"; echo "Tools used: " . count($response->toolCalls) . "\n"; // Summarize file changes $fileChanges = array_filter($response->toolCalls, fn($tc) => $tc->tool === 'file_change'); echo "Files changed: " . count($fileChanges) . "\n"; $usage = $response->usage(); if ($usage !== null) { echo "Tokens: {$usage->total()}\n"; } $sessionId = $response->sessionId(); if ($sessionId !== null) { echo "Thread: {$sessionId}\n"; } } else { echo "\n\nTask failed with exit code: {$response->exitCode}\n"; } // @doctest id="15d2" ``` ================================================================================ FILE: packages/agent-ctrl/10-opencode-bridge.md ================================================================================ ## Overview The OpenCode bridge wraps the `opencode` CLI, providing access to a multi-provider code agent through Agent-Ctrl's unified API. OpenCode is the most flexible bridge in terms of model selection -- it supports provider-prefixed model IDs that let you use models from Anthropic, OpenAI, Google, and other providers through a single CLI tool. It exposes both token usage and cost data (as does the Pi bridge). The bridge is implemented by `OpenCodeBridge` and configured through `OpenCodeBridgeBuilder`. Access the builder through the `AgentCtrl` facade: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Enum\AgentType; // Dedicated factory method $builder = AgentCtrl::openCode(); // Or via the generic factory $builder = AgentCtrl::make(AgentType::OpenCode); // @doctest id="d60d" ``` ## Basic Usage ```php use Cognesy\AgentCtrl\AgentCtrl; $response = AgentCtrl::openCode() ->execute('Explain the architecture of this project in short paragraphs.'); echo $response->text(); // @doctest id="f76a" ``` With model selection: ```php $response = AgentCtrl::openCode() ->withModel('anthropic/claude-sonnet-4-5') ->execute('Review the test suite.'); echo $response->text(); // @doctest id="1181" ``` ## Model Selection OpenCode uses provider-prefixed model IDs, giving you access to models from multiple providers through a single CLI. The format is `provider/model-name`: ```php // Anthropic models AgentCtrl::openCode()->withModel('anthropic/claude-sonnet-4-5'); AgentCtrl::openCode()->withModel('anthropic/claude-opus-4'); // OpenAI models AgentCtrl::openCode()->withModel('openai/gpt-4o'); AgentCtrl::openCode()->withModel('openai/o4-mini'); // Google models AgentCtrl::openCode()->withModel('google/gemini-2.5-pro'); // @doctest id="4920" ``` The exact set of available providers and models depends on your OpenCode installation and configuration. If no model is specified, OpenCode uses its configured default. Check OpenCode's documentation for the full list of supported providers and models. ## Named Agents OpenCode supports named agents -- preconfigured agent profiles that define specific behaviors, tools, and system prompts. Use `withAgent()` to select one: ```php $response = AgentCtrl::openCode() ->withAgent('coder') ->execute('Refactor the authentication module.'); // @doctest id="f106" ``` ```php $response = AgentCtrl::openCode() ->withAgent('task') ->execute('Create a detailed implementation plan.'); // @doctest id="f6c9" ``` The available agent names depend on your OpenCode configuration. Common agents include `coder` (for code-focused tasks) and `task` (for planning and general tasks). ## File Attachments Use `withFiles()` to attach specific files to the prompt. The agent will have direct access to these files as context, without needing to discover and read them: ```php $response = AgentCtrl::openCode() ->withFiles([ '/projects/my-app/src/Services/PaymentService.php', '/projects/my-app/src/Models/Payment.php', ]) ->execute('Refactor the PaymentService to handle partial refunds.'); // @doctest id="16b2" ``` Unlike `inDirectory()` which sets the working directory, `withFiles()` explicitly includes specific files in the prompt context. This is useful when you want the agent to focus on particular files rather than browsing the project directory. ## Session Title Use `withTitle()` to set a descriptive title for the session. The title appears in OpenCode's session listing and makes it easier to identify sessions later: ```php $response = AgentCtrl::openCode() ->withTitle('Payment module refactoring') ->execute('Plan the payment module refactoring.'); // @doctest id="9818" ``` Titles are especially useful when you manage multiple sessions and need to identify them by purpose rather than by opaque session IDs. ## Session Sharing Use `shareSession()` to mark the session for sharing after completion. Shared sessions can be accessed by other users or tools, enabling collaborative workflows where multiple team members can review or continue an agent's work: ```php $response = AgentCtrl::openCode() ->shareSession() ->withTitle('Architecture review for team') ->execute('Create a comprehensive architecture review.'); // The session ID can be shared with others $sessionId = $response->sessionId(); if ($sessionId !== null) { echo "Share this session ID with your team: {$sessionId}\n"; } // @doctest id="aa35" ``` ## Streaming with OpenCode OpenCode streams output as JSON Lines containing text events, tool use events, step events, and error events. The bridge normalizes these into the standard callback API: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; $response = AgentCtrl::openCode() ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onError(fn(string $message, ?string $code) => print("\nError [{$code}]: {$message}\n")) ->executeStreaming('Analyze the error handling in this codebase.'); // @doctest id="3671" ``` ### Event Normalization During streaming, OpenCode emits several event types that are normalized: - **`TextEvent`** -- Text content is delivered through `onText()` with the text string. - **`ToolUseEvent`** -- Tool invocations are delivered through `onToolUse()` with the tool name, input parameters, optional output, and call ID. The `isError` flag is set based on whether the tool completed successfully. - **`ErrorEvent`** -- Error events are delivered through `onError()` with the message, optional code, and raw data. - **`StepStartEvent` / `StepFinishEvent`** -- Step lifecycle events are processed internally and available through the `wiretap()` event system but not directly exposed through user callbacks. ## Session Management OpenCode maintains its own session system with session IDs. Agent-Ctrl extracts and normalizes these into `AgentSessionId` value objects. Internally, OpenCode uses `OpenCodeSessionId` which is mapped to the unified `AgentSessionId`: ```php // First execution $first = AgentCtrl::openCode()->execute('Create an implementation plan.'); $sessionId = $first->sessionId(); // Continue the most recent session (no ID needed) $next = AgentCtrl::openCode() ->continueSession() ->execute('Begin implementing the plan.'); // Resume a specific session by ID if ($sessionId !== null) { $next = AgentCtrl::openCode() ->resumeSession((string) $sessionId) ->execute('Continue with the next step.'); } // @doctest id="8eba" ``` ## Usage and Cost Data OpenCode provides comprehensive usage and cost reporting. Both token usage and cost data are available after execution. ### Token Usage OpenCode's `TokenUsage` includes all five token categories -- input, output, cache read, cache write, and reasoning: ```php $response = AgentCtrl::openCode() ->withModel('anthropic/claude-sonnet-4-5') ->execute('Analyze the project dependencies.'); $usage = $response->usage(); if ($usage !== null) { echo "Input tokens: {$usage->input}\n"; echo "Output tokens: {$usage->output}\n"; echo "Total tokens: {$usage->total()}\n"; if ($usage->cacheRead !== null) { echo "Cache read: {$usage->cacheRead}\n"; } if ($usage->cacheWrite !== null) { echo "Cache write: {$usage->cacheWrite}\n"; } if ($usage->reasoning !== null) { echo "Reasoning: {$usage->reasoning}\n"; } } // @doctest id="0126" ``` ### Cost Tracking OpenCode exposes cost data (as does Pi). The cost is returned in USD: ```php $cost = $response->cost(); if ($cost !== null) { echo sprintf("This execution cost $%.4f\n", $cost); } // @doctest id="6b4a" ``` This makes OpenCode a good choice when you need to track and report on the cost of agent executions, build usage dashboards, or enforce cost budgets. ## Data Availability | Data Point | Available | Notes | |------------|-----------|-------| | Text output | Yes | Extracted from `TextEvent` stream events | | Tool calls | Yes | Normalized from `ToolUseEvent` with call IDs and completion status | | Session ID | Yes | Extracted from OpenCode session data | | Token usage | Yes | Input, output, cache read, cache write, reasoning tokens | | Cost | Yes | Cost in USD | | Parse diagnostics | Yes | Malformed JSON line counts and samples | ## Complete Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; use Cognesy\AgentCtrl\Broadcasting\AgentCtrlConsoleLogger; $logger = new AgentCtrlConsoleLogger( showStreaming: true, showPipeline: true, ); $response = AgentCtrl::openCode() ->withModel('anthropic/claude-sonnet-4-5') ->withAgent('coder') ->withTitle('Comprehensive architecture review') ->withFiles([ '/projects/my-app/src/Kernel.php', '/projects/my-app/src/routes.php', ]) ->shareSession() ->withTimeout(300) ->inDirectory('/projects/my-app') ->wiretap($logger->wiretap()) ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onComplete(fn(AgentResponse $r) => print("\n--- Complete ---\n")) ->executeStreaming('Review the application architecture and suggest improvements.'); if ($response->isSuccess()) { echo "\nReview completed successfully.\n"; echo "Tools used: " . count($response->toolCalls) . "\n"; $usage = $response->usage(); if ($usage !== null) { echo "Tokens: {$usage->total()} (in: {$usage->input}, out: {$usage->output})\n"; } $cost = $response->cost(); if ($cost !== null) { echo sprintf("Cost: $%.4f\n", $cost); } $sessionId = $response->sessionId(); if ($sessionId !== null) { echo "Session: {$sessionId}\n"; echo "Use this ID with resumeSession() to continue later.\n"; } } else { echo "\nReview failed with exit code: {$response->exitCode}\n"; echo "Partial output: " . substr($response->text(), 0, 500) . "\n"; } // @doctest id="d25b" ``` ## Comparison with Other Bridges | Feature | Claude Code | Codex | OpenCode | Pi | Gemini | |---------|------------|-------|----------|-----|--------| | System prompts | Yes (replace + append) | No | No | Yes (replace + append) | Yes (GEMINI.md file) | | Permission modes | Yes (4 levels) | No | No | No | Yes (4 modes) | | Turn limits | Yes | No | No | No | Yes (via settings) | | Sandbox modes | No | Yes (3 levels) | No | No | Yes (Seatbelt/Docker/Podman/gVisor) | | Image input | No | Yes | No | No | No | | Thinking levels | No | No | No | Yes (6 levels) | No | | Named agents | No | No | Yes | No | No | | File attachments | No | No | Yes | Yes (@-prefix) | No | | Extensions | No | No | No | Yes (TypeScript) | Yes | | Skills | No | No | No | Yes | No | | Tool control | No | No | No | Yes (select/disable) | Yes (allowlist) | | MCP servers | No | No | No | No | Yes | | Policy engine | No | No | No | No | Yes | | Session sharing | No | No | Yes | No | No | | Session titles | No | No | Yes | No | No | | Ephemeral mode | No | No | No | Yes | No | | API key override | No | No | No | Yes | No | | Token usage | No | Yes (partial) | Yes (full) | Yes | Yes (with cache) | | Cost tracking | No | No | Yes | Yes | No | | Multi-provider models | No | No | Yes | Yes | No | ================================================================================ FILE: packages/agent-ctrl/11-pi-bridge.md ================================================================================ ## Overview The Pi bridge wraps the `pi` CLI (from the [pi-mono](https://github.com/badlogic/pi-mono) project), a minimal terminal coding harness that is aggressively extensible. Pi supports multi-provider model selection, thinking levels, TypeScript extensions, skills, prompt templates, and fine-grained JSONL event streaming. It provides both token usage and cost data. The bridge is implemented by `PiBridge` and configured through `PiBridgeBuilder`. Access the builder through the `AgentCtrl` facade: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Enum\AgentType; // Dedicated factory method $builder = AgentCtrl::pi(); // Or via the generic factory $builder = AgentCtrl::make(AgentType::Pi); // @doctest id="16aa" ``` ### Prerequisites Install Pi globally via npm or bun: ```bash npm install -g @mariozechner/pi-coding-agent # or bun install -g @mariozechner/pi-coding-agent # @doctest id="cd5d" ``` Configure an API key: ```bash export ANTHROPIC_API_KEY=sk-ant-... # or export OPENAI_API_KEY=sk-... # @doctest id="5937" ``` ## Basic Usage ```php use Cognesy\AgentCtrl\AgentCtrl; $response = AgentCtrl::pi() ->execute('Explain the architecture of this project.'); echo $response->text(); // @doctest id="a37f" ``` With model selection: ```php $response = AgentCtrl::pi() ->withModel('sonnet') ->execute('Review the test suite.'); echo $response->text(); // @doctest id="82f7" ``` ## Model Selection Pi supports flexible model identification with optional provider prefix and thinking level shorthand: ```php // Model name only (uses default provider) AgentCtrl::pi()->withModel('sonnet'); // Provider/model format AgentCtrl::pi()->withModel('openai/gpt-4o'); AgentCtrl::pi()->withModel('anthropic/claude-opus-4-6'); AgentCtrl::pi()->withModel('google/gemini-2.5-pro'); // Model with thinking level shorthand AgentCtrl::pi()->withModel('sonnet:high'); // @doctest id="7759" ``` Use `withProvider()` to explicitly set the provider when the model name alone is ambiguous: ```php AgentCtrl::pi() ->withProvider('anthropic') ->withModel('sonnet') ->execute('...'); // @doctest id="23a2" ``` ## Thinking Levels Pi supports six thinking levels that control how much the model reasons before responding: ```php use Cognesy\AgentCtrl\Pi\Domain\Enum\ThinkingLevel; AgentCtrl::pi()->withThinking(ThinkingLevel::Off); // No thinking AgentCtrl::pi()->withThinking(ThinkingLevel::Minimal); // Minimal reasoning AgentCtrl::pi()->withThinking(ThinkingLevel::Low); // Light reasoning AgentCtrl::pi()->withThinking(ThinkingLevel::Medium); // Moderate reasoning AgentCtrl::pi()->withThinking(ThinkingLevel::High); // Deep reasoning AgentCtrl::pi()->withThinking(ThinkingLevel::ExtraHigh); // Maximum reasoning // @doctest id="9d15" ``` Alternatively, use the model shorthand: `->withModel('sonnet:high')`. ## System Prompts Replace or extend the default system prompt: ```php // Replace entirely AgentCtrl::pi() ->withSystemPrompt('You are a PHP code reviewer.') ->execute('Review this code.'); // Append to default AgentCtrl::pi() ->appendSystemPrompt('Focus on security issues.') ->execute('Review the authentication module.'); // @doctest id="0b6f" ``` ## Tool Control By default, Pi provides four tools: `read`, `write`, `edit`, and `bash`. Additional built-in tools include `grep`, `find`, and `ls`. ```php // Restrict to read-only tools AgentCtrl::pi() ->withTools(['read', 'grep', 'find', 'ls']) ->execute('Analyze the codebase structure.'); // Disable all tools (pure conversation) AgentCtrl::pi() ->noTools() ->execute('Explain dependency injection.'); // @doctest id="8b9e" ``` ## File Arguments Attach files to the prompt using `withFiles()`. These are passed as `@`-prefixed arguments to Pi: ```php $response = AgentCtrl::pi() ->withFiles([ '/projects/app/src/PaymentService.php', '/projects/app/tests/PaymentServiceTest.php', ]) ->execute('Review these files for potential issues.'); // @doctest id="4903" ``` ## Extensions and Skills Pi supports TypeScript extensions and skills that add custom tools, commands, and capabilities: ```php // Load specific extensions AgentCtrl::pi() ->withExtensions(['./my-extension.ts']) ->execute('...'); // Disable extension auto-discovery (load only explicit ones) AgentCtrl::pi() ->noExtensions() ->withExtensions(['./deploy-ext.ts']) ->execute('...'); // Load specific skills AgentCtrl::pi() ->withSkills(['/path/to/my-skill']) ->execute('...'); // Disable skill auto-discovery AgentCtrl::pi() ->noSkills() ->execute('...'); // @doctest id="1c9d" ``` ## Streaming with Pi Pi streams output as JSONL with granular event types. The bridge normalizes these into the standard callback API: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; $response = AgentCtrl::pi() ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onError(fn(string $message, ?string $code) => print("\nError: {$message}\n")) ->onComplete(fn(AgentResponse $r) => print("\n--- Done ---\n")) ->executeStreaming('Analyze the error handling in this codebase.'); // @doctest id="1419" ``` ### Event Normalization Pi emits a rich set of JSONL events that are normalized: - **`MessageUpdateEvent` (text_delta)** -- Text deltas delivered through `onText()`. - **`ToolExecutionEndEvent`** -- Tool results delivered through `onToolUse()` with tool name, call ID, result, and error flag. - **`ErrorEvent`** -- Errors delivered through `onError()`. - **`SessionEvent`, `AgentStart/End`, `TurnStart/End`, `MessageStart/End`, `ToolExecutionStart`** -- Lifecycle events available through the `wiretap()` event system. ## Session Management Pi maintains sessions as JSONL files. Agent-Ctrl extracts session IDs from the session header event: ```php // First execution $first = AgentCtrl::pi()->execute('Create an implementation plan.'); $sessionId = $first->sessionId(); // Continue the most recent session $next = AgentCtrl::pi() ->continueSession() ->execute('Begin implementing the plan.'); // Resume a specific session by ID if ($sessionId !== null) { $next = AgentCtrl::pi() ->resumeSession((string) $sessionId) ->execute('Continue with the next step.'); } // Ephemeral mode -- don't save session AgentCtrl::pi() ->ephemeral() ->execute('Quick one-off question.'); // Custom session storage AgentCtrl::pi() ->withSessionDir('/tmp/pi-sessions') ->execute('...'); // @doctest id="a5b7" ``` ## API Key Override Override the API key for a specific execution without changing environment variables: ```php AgentCtrl::pi() ->withApiKey('sk-ant-...') ->withProvider('anthropic') ->execute('...'); // @doctest id="cf05" ``` ## Usage and Cost Data Pi provides token usage and cost data from the message events: ```php $response = AgentCtrl::pi() ->withModel('sonnet') ->execute('Analyze the project dependencies.'); $usage = $response->usage(); if ($usage !== null) { echo "Input tokens: {$usage->input}\n"; echo "Output tokens: {$usage->output}\n"; echo "Total tokens: {$usage->total()}\n"; if ($usage->cacheRead !== null) { echo "Cache read: {$usage->cacheRead}\n"; } if ($usage->cacheWrite !== null) { echo "Cache write: {$usage->cacheWrite}\n"; } } $cost = $response->cost(); if ($cost !== null) { echo sprintf("Cost: $%.6f\n", $cost); } // @doctest id="0997" ``` ## Data Availability | Data Point | Available | Notes | |------------|-----------|-------| | Text output | Yes | Extracted from `message_update` text_delta events | | Tool calls | Yes | Normalized from `tool_execution_end` with call IDs and error status | | Session ID | Yes | Extracted from JSONL `session` header event | | Token usage | Yes | Input, output, cache read, cache write tokens | | Cost | Yes | Cost in USD from usage data | | Parse diagnostics | Yes | Malformed JSON line counts and samples | ## Complete Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; use Cognesy\AgentCtrl\Pi\Domain\Enum\ThinkingLevel; $response = AgentCtrl::pi() ->withModel('sonnet') ->withThinking(ThinkingLevel::High) ->appendSystemPrompt('Focus on security and performance.') ->withTools(['read', 'bash', 'edit', 'grep']) ->withFiles(['/projects/app/src/Kernel.php']) ->withTimeout(300) ->inDirectory('/projects/app') ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onComplete(fn(AgentResponse $r) => print("\n--- Complete ---\n")) ->executeStreaming('Review the application architecture and suggest improvements.'); if ($response->isSuccess()) { echo "\nReview completed successfully.\n"; echo "Tools used: " . count($response->toolCalls) . "\n"; $usage = $response->usage(); if ($usage !== null) { echo "Tokens: {$usage->total()} (in: {$usage->input}, out: {$usage->output})\n"; } $cost = $response->cost(); if ($cost !== null) { echo sprintf("Cost: $%.6f\n", $cost); } } else { echo "\nFailed with exit code: {$response->exitCode}\n"; } // @doctest id="251f" ``` ## Comparison with Other Bridges | Feature | Claude Code | Codex | OpenCode | Pi | |---------|------------|-------|----------|-----| | System prompts | Yes (replace + append) | No | No | Yes (replace + append) | | Permission modes | Yes (4 levels) | No | No | No | | Turn limits | Yes | No | No | No | | Sandbox modes | No | Yes (3 levels) | No | No | | Image input | No | Yes | No | No | | Thinking levels | No | No | No | Yes (6 levels) | | Named agents | No | No | Yes | No | | File attachments | No | No | Yes | Yes (@-prefix) | | Extensions | No | No | No | Yes (TypeScript) | | Skills | No | No | No | Yes | | Tool control | No | No | No | Yes (select/disable) | | Session sharing | No | No | Yes | No | | Session titles | No | No | Yes | No | | Ephemeral mode | No | No | No | Yes | | API key override | No | No | No | Yes | | Token usage | No | Yes (partial) | Yes (full) | Yes | | Cost tracking | No | No | Yes | Yes | | Multi-provider models | No | No | Yes | Yes | ## Environment Variables | Variable | Description | |----------|-------------| | `ANTHROPIC_API_KEY` | Anthropic API key | | `OPENAI_API_KEY` | OpenAI API key | | `PI_CODING_AGENT_DIR` | Override Pi config directory (default: `~/.pi/agent`) | | `PI_SKIP_VERSION_CHECK` | Skip version check at startup | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache | ================================================================================ FILE: packages/agent-ctrl/12-gemini-bridge.md ================================================================================ ## Overview The Gemini bridge wraps the `gemini` CLI (from [@google/gemini-cli](https://github.com/google-gemini/gemini-cli)), Google's terminal-based coding agent. Gemini CLI supports model aliases, approval modes (default, auto_edit, yolo, plan), sandbox isolation, extensions, MCP servers, policy files, session management, and stream-json event streaming. It provides token usage data including cached token counts. The bridge is implemented by `GeminiBridge` and configured through `GeminiBridgeBuilder`. Access the builder through the `AgentCtrl` facade: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Enum\AgentType; // Dedicated factory method $builder = AgentCtrl::gemini(); // Or via the generic factory $builder = AgentCtrl::make(AgentType::Gemini); // @doctest id="6060" ``` ### Prerequisites Install Gemini CLI globally: ```bash # npm npm install -g @google/gemini-cli # Homebrew brew install gemini-cli # npx (no install) npx @google/gemini-cli # @doctest id="e50c" ``` Configure authentication (one of): ```bash # Gemini API key export GEMINI_API_KEY=... # Google Cloud API key export GOOGLE_API_KEY=... # Or authenticate via Google account (free tier) gemini # @doctest id="c58d" ``` ## Basic Usage ```php use Cognesy\AgentCtrl\AgentCtrl; $response = AgentCtrl::gemini() ->execute('Explain the architecture of this project.'); echo $response->text(); // @doctest id="7108" ``` With model selection: ```php $response = AgentCtrl::gemini() ->withModel('flash') ->execute('Review the test suite.'); echo $response->text(); // @doctest id="4b38" ``` ## Model Selection Gemini CLI supports model aliases and full model names: ```php // Model aliases AgentCtrl::gemini()->withModel('auto'); // Default (gemini-2.5-pro) AgentCtrl::gemini()->withModel('pro'); // gemini-2.5-pro AgentCtrl::gemini()->withModel('flash'); // gemini-2.5-flash AgentCtrl::gemini()->withModel('flash-lite'); // gemini-2.5-flash-lite // Full model name AgentCtrl::gemini()->withModel('gemini-2.5-pro'); // @doctest id="f050" ``` ## Approval Modes Gemini CLI supports four approval modes that control how tool execution is approved: ```php use Cognesy\AgentCtrl\Gemini\Domain\Enum\ApprovalMode; // Default — prompt for approval on each tool use AgentCtrl::gemini()->withApprovalMode(ApprovalMode::Default); // Auto-edit — auto-approve edit tools, prompt for others AgentCtrl::gemini()->withApprovalMode(ApprovalMode::AutoEdit); // YOLO — auto-approve all tool executions AgentCtrl::gemini()->yolo(); // Plan — read-only analysis mode AgentCtrl::gemini()->planMode(); // @doctest id="fc4e" ``` ## Sandbox Mode Enable sandboxed execution for process isolation: ```php AgentCtrl::gemini() ->withSandbox() ->execute('Analyze the codebase.'); // @doctest id="8c6c" ``` On macOS, this uses Seatbelt (`sandbox-exec`). Docker, Podman, and gVisor are also supported. ## System Prompt Gemini CLI reads instructions from a `GEMINI.md` file in the project root (similar to `CLAUDE.md`). You can also set the `GEMINI_SYSTEM_MD` environment variable to point to a custom system prompt file. ## Include Directories Add additional workspace directories for the agent to access: ```php AgentCtrl::gemini() ->withIncludeDirectories(['/projects/shared-lib', '/projects/config']) ->execute('Check for shared dependencies.'); // @doctest id="ea16" ``` ## Extensions Use specific extensions: ```php AgentCtrl::gemini() ->withExtensions(['my-extension']) ->execute('...'); // @doctest id="235a" ``` ## MCP Servers Restrict which MCP servers are available: ```php AgentCtrl::gemini() ->withAllowedMcpServers(['filesystem', 'github']) ->execute('...'); // @doctest id="329b" ``` ## Policy Files Load additional policy files for fine-grained tool approval rules: ```php AgentCtrl::gemini() ->withPolicy(['/path/to/policy.yaml']) ->execute('...'); // @doctest id="5359" ``` ## Allowed Tools Restrict which tools the agent can use: ```php AgentCtrl::gemini() ->withAllowedTools(['read_file', 'search_files', 'list_directory']) ->execute('Analyze the codebase structure.'); // @doctest id="a43c" ``` ## Debug Mode Enable debug output for troubleshooting CLI behavior: ```php AgentCtrl::gemini() ->debug() ->execute('Analyze the codebase.'); // @doctest id="61b7" ``` ## Streaming with Gemini Gemini streams output as JSONL with the `stream-json` format. The bridge normalizes these into the standard callback API: ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; $response = AgentCtrl::gemini() ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onError(fn(string $message, ?string $code) => print("\nError: {$message}\n")) ->onComplete(fn(AgentResponse $r) => print("\n--- Done ---\n")) ->executeStreaming('Analyze the error handling in this codebase.'); // @doctest id="f13c" ``` ### Event Normalization Gemini emits stream-json events that are normalized: - **`message` (role=assistant, delta=true)** -- Text deltas delivered through `onText()`. - **`tool_result`** -- Tool results delivered through `onToolUse()` with tool name, input (from paired `tool_use` event), result, and error status. - **`error`** -- Errors delivered through `onError()` with severity and message. - **`init`, `tool_use`, `result`** -- Lifecycle events available through the `wiretap()` event system. ## Session Management Gemini CLI maintains session history. Agent-Ctrl extracts session IDs from the `init` event: ```php // First execution $first = AgentCtrl::gemini()->execute('Create an implementation plan.'); $sessionId = $first->sessionId(); // Continue the most recent session $next = AgentCtrl::gemini() ->continueSession() ->execute('Begin implementing the plan.'); // Resume a specific session by ID if ($sessionId !== null) { $next = AgentCtrl::gemini() ->resumeSession((string) $sessionId) ->execute('Continue with the next step.'); } // @doctest id="eb8f" ``` ## Usage Data Gemini provides token usage data from the `result` event stats: ```php $response = AgentCtrl::gemini() ->withModel('flash') ->execute('Analyze the project dependencies.'); $usage = $response->usage(); if ($usage !== null) { echo "Input tokens: {$usage->input}\n"; echo "Output tokens: {$usage->output}\n"; echo "Total tokens: {$usage->total()}\n"; if ($usage->cacheRead !== null) { echo "Cached tokens: {$usage->cacheRead}\n"; } } // @doctest id="576f" ``` ## Data Availability | Data Point | Available | Notes | |------------|-----------|-------| | Text output | Yes | Extracted from `message` events (role=assistant, delta=true) | | Tool calls | Yes | Normalized from `tool_use` + `tool_result` event pairs | | Session ID | Yes | Extracted from `init` event | | Token usage | Yes | Input, output, cached tokens from `result` stats | | Cost | No | Gemini CLI does not expose cost data | | Parse diagnostics | Yes | Malformed JSON line counts and samples | ## Complete Example ```php use Cognesy\AgentCtrl\AgentCtrl; use Cognesy\AgentCtrl\Dto\AgentResponse; use Cognesy\AgentCtrl\Gemini\Domain\Enum\ApprovalMode; $response = AgentCtrl::gemini() ->withModel('pro') ->withApprovalMode(ApprovalMode::AutoEdit) ->withIncludeDirectories(['/projects/shared']) ->withTimeout(300) ->inDirectory('/projects/app') ->onText(fn(string $text) => print($text)) ->onToolUse(fn(string $tool, array $input, ?string $output) => print("\n> [{$tool}]\n")) ->onComplete(fn(AgentResponse $r) => print("\n--- Complete ---\n")) ->executeStreaming('Review the application architecture and suggest improvements.'); if ($response->isSuccess()) { echo "\nReview completed successfully.\n"; echo "Tools used: " . count($response->toolCalls) . "\n"; $usage = $response->usage(); if ($usage !== null) { echo "Tokens: {$usage->total()} (in: {$usage->input}, out: {$usage->output})\n"; } } else { echo "\nFailed with exit code: {$response->exitCode}\n"; } // @doctest id="8023" ``` ## Comparison with Other Bridges | Feature | Claude Code | Codex | OpenCode | Pi | Gemini | |---------|------------|-------|----------|-----|--------| | System prompts | Yes (replace + append) | No | No | Yes (replace + append) | Yes (GEMINI.md file) | | Permission modes | Yes (4 levels) | No | No | No | Yes (4 modes) | | Turn limits | Yes | No | No | No | Yes (via settings) | | Sandbox modes | No | Yes (3 levels) | No | No | Yes (Seatbelt/Docker/Podman/gVisor) | | Image input | No | Yes | No | No | No | | Thinking levels | No | No | No | Yes (6 levels) | No | | Named agents | No | No | Yes | No | No | | File attachments | No | No | Yes | Yes (@-prefix) | No | | Extensions | No | No | No | Yes (TypeScript) | Yes | | Skills | No | No | No | Yes | No | | Tool control | No | No | No | Yes (select/disable) | Yes (allowlist) | | MCP servers | No | No | No | No | Yes | | Policy engine | No | No | No | No | Yes | | Session sharing | No | No | Yes | No | No | | Session titles | No | No | Yes | No | No | | Ephemeral mode | No | No | No | Yes | No | | API key override | No | No | No | Yes | No | | Token usage | No | Yes (partial) | Yes (full) | Yes | Yes | | Cost tracking | No | No | Yes | Yes | No | | Multi-provider models | No | No | Yes | Yes | No | | Include directories | No | No | No | No | Yes | | Debug mode | No | No | No | No | Yes | | Free tier | No | No | No | No | Yes | ## Environment Variables | Variable | Description | |----------|-------------| | `GEMINI_API_KEY` | Gemini API key | | `GOOGLE_API_KEY` | Google Cloud API key | | `GOOGLE_APPLICATION_CREDENTIALS` | Service account JSON path | | `GOOGLE_CLOUD_PROJECT` | Project ID for Code Assist | | `GOOGLE_GENAI_USE_VERTEXAI` | Enable Vertex AI | | `GEMINI_SANDBOX` | Enable sandbox without CLI flag | ================================================================================ FILE: packages/telemetry/01-introduction.md ================================================================================ # Introduction The Telemetry package is the shared tracing layer for Instructor PHP runtimes. It does not generate runtime events on its own. Runtime packages such as `agents`, `agent-ctrl`, `instructor`, `polyglot`, and `http-client` emit events, and telemetry projectors turn those events into correlated spans and logs. At the center is `Cognesy\Telemetry\Application\Telemetry`. You give it: - a `TraceRegistry` to keep active spans in memory - an exporter such as `OtelExporter`, `LogfireExporter`, or `LangfuseExporter` Then runtime projectors write observations into that shared telemetry instance. ## Main Pieces ### `Telemetry` `Telemetry` is the main application service. It opens spans, records logs, closes spans, stores pending metrics, and flushes exporters. Common methods: - `openRoot(string $key, string $name)` - `openChild(string $key, string $parentKey, string $name)` - `log(string $key, string $name)` - `complete(string $key)` - `fail(string $key)` - `flush()` ### `TraceRegistry` `TraceRegistry` tracks active spans by key during the current process. The key is local to your app. It can be an execution id, request id, or any stable name you use while the run is active. ### Exporters The package ships with three main exporters: - `OtelExporter` for generic OTEL payloads - `LogfireExporter` for Logfire OTLP export - `LangfuseExporter` for Langfuse OTLP export You can combine them with `CompositeTelemetryExporter`. ## Where Runtime Wiring Happens Projectors live in the packages that own the events, not in `packages/telemetry`. Typical examples: - `Cognesy\Agents\Telemetry\AgentsTelemetryProjector` - `Cognesy\AgentCtrl\Telemetry\AgentCtrlTelemetryProjector` - `Cognesy\Instructor\Telemetry\InstructorTelemetryProjector` - `Cognesy\Polyglot\Telemetry\PolyglotTelemetryProjector` - `Cognesy\Http\Telemetry\HttpClientTelemetryProjector` Use `RuntimeEventBridge` to attach those projectors to a shared event bus. ## Recommended Reading 1. `02-basic-setup.md` 2. `03-runtime-wiring.md` 3. `04-troubleshooting.md` 4. `05-langfuse.md` 5. `06-logfire.md` ================================================================================ FILE: packages/telemetry/02-basic-setup.md ================================================================================ # Basic Setup The smallest useful setup is: 1. create a `TraceRegistry` 2. create an exporter 3. create `Telemetry` 4. open a root span 5. add logs or child spans 6. complete the span 7. call `flush()` ## Minimal Example ```php use Cognesy\Telemetry\Adapters\OTel\OtelExporter; use Cognesy\Telemetry\Application\Registry\TraceRegistry; use Cognesy\Telemetry\Application\Telemetry; use Cognesy\Telemetry\Domain\Value\AttributeBag; $telemetry = new Telemetry( registry: new TraceRegistry(), exporter: new OtelExporter(), ); $telemetry->openRoot( key: 'run', name: 'demo.run', attributes: AttributeBag::fromArray([ 'component' => 'demo', ]), ); $telemetry->log( key: 'run', name: 'demo.step', attributes: AttributeBag::fromArray([ 'status' => 'ok', ]), ); $telemetry->complete('run'); $telemetry->flush(); // @doctest id="590e" ``` ## Adding Child Spans Use `openChild()` when a unit of work should sit under another span: ```php $telemetry->openRoot('request', 'http.request'); $telemetry->openChild('llm', 'request', 'llm.inference'); $telemetry->complete('llm'); $telemetry->complete('request'); $telemetry->flush(); // @doctest id="320f" ``` The keys `request` and `llm` are local registry keys. They do not have to match the span name. ## Exporting To More Than One Backend Use `CompositeTelemetryExporter` when you want more than one output: ```php use Cognesy\Telemetry\Adapters\OTel\OtelExporter; use Cognesy\Telemetry\Application\Exporter\CompositeTelemetryExporter; use Cognesy\Telemetry\Application\Registry\TraceRegistry; use Cognesy\Telemetry\Application\Telemetry; $telemetry = new Telemetry( registry: new TraceRegistry(), exporter: new CompositeTelemetryExporter([ new OtelExporter(), $logfireExporter, $langfuseExporter, ]), ); // @doctest id="f398" ``` ## Notes - `flush()` matters. Exporters buffer observations until you call it. - `OtelExporter()` without a transport is useful for local debugging and tests. ================================================================================ FILE: packages/telemetry/03-runtime-wiring.md ================================================================================ # Runtime Wiring In normal application code, telemetry is usually driven by runtime events rather than manual `openRoot()` calls. The pattern is: 1. create one shared event dispatcher 2. create one shared `Telemetry` instance 3. create the projectors for the runtimes you use 4. attach them through `RuntimeEventBridge` 5. build your runtime objects with the same event dispatcher ## Minimal Wiring Example ```php use Cognesy\Agents\Telemetry\AgentsTelemetryProjector; use Cognesy\Events\Dispatchers\EventDispatcher; use Cognesy\Http\Telemetry\HttpClientTelemetryProjector; use Cognesy\Polyglot\Telemetry\PolyglotTelemetryProjector; use Cognesy\Telemetry\Application\Projector\CompositeTelemetryProjector; use Cognesy\Telemetry\Application\Projector\RuntimeEventBridge; $events = new EventDispatcher('app'); (new RuntimeEventBridge(new CompositeTelemetryProjector([ new AgentsTelemetryProjector($telemetry), new PolyglotTelemetryProjector($telemetry), new HttpClientTelemetryProjector($telemetry), ])))->attachTo($events); // @doctest id="b6ff" ``` Pass `$events` into the runtime objects that should emit telemetry. ## Which Projectors To Add Add only the projectors for the packages you actually use: - agents: `AgentsTelemetryProjector` - agent control: `AgentCtrlTelemetryProjector` - instructor: `InstructorTelemetryProjector` - polyglot: `PolyglotTelemetryProjector` - http client: `HttpClientTelemetryProjector` If a package has no matching projector attached, its events will not be turned into telemetry. ## Practical Examples The examples directory has working end-to-end setups: - `examples/D05_AgentTroubleshooting/TelemetryLangfuse/run.php` - `examples/D05_AgentTroubleshooting/TelemetryLogfire/run.php` - `examples/D05_AgentTroubleshooting/SubagentTelemetryLangfuse/run.php` - `examples/D05_AgentTroubleshooting/SubagentTelemetryLogfire/run.php` These examples keep normal console output and add telemetry export at the same time. ================================================================================ FILE: packages/telemetry/04-troubleshooting.md ================================================================================ # Troubleshooting ## Nothing Is Sent To The Backend Check these first: - did you call `$telemetry->flush()` at the end of the run - did you attach `RuntimeEventBridge` to the same event bus used by the runtime - did you add the right projectors for the packages in play - are your backend credentials and endpoints set correctly If you skip `flush()`, exporters can keep data in memory and never send it. ## Spans Are Missing Or Not Connected Usually this means one of two things: 1. different parts of the app are using different event dispatchers 2. the relevant runtime projector is missing Telemetry correlation depends on one shared event flow. If `agents` emits on one event bus and `polyglot` emits on another, you will get broken or partial traces. ## Check Payloads Locally First For local debugging, use `OtelExporter` without a transport and inspect what it captured: ```php use Cognesy\Telemetry\Adapters\OTel\OtelExporter; use Cognesy\Telemetry\Application\Registry\TraceRegistry; use Cognesy\Telemetry\Application\Telemetry; $exporter = new OtelExporter(); $telemetry = new Telemetry(new TraceRegistry(), $exporter); // run your code $telemetry->flush(); var_dump($exporter->observations()); var_dump($exporter->tracesPayload()); // @doctest id="d091" ``` This is the fastest way to answer: "Did the app produce telemetry at all?" ## HTTP Export Errors Transport errors throw explicit exceptions. Typical cases: - bad token or key: HTTP 4xx - backend unavailable: HTTP 5xx - invalid endpoint or network issue: transport exception before a valid status The exception message includes the target URL, which usually makes configuration problems obvious. ## Langfuse-Specific Note Langfuse export goes to: - `/api/public/otel/v1/traces` Give `LangfuseConfig` the base URL, not the full traces URL. ## Logfire-Specific Note Logfire export uses the OTLP base endpoint. Give `LogfireConfig` the base OTLP URL, not a full `/v1/traces` or `/v1/metrics` path. The example helper in `examples/_support/logfire.php` normalizes this for you. ## Good Reference Points If your own setup is not working, compare it with: - `examples/_support/langfuse.php` - `examples/_support/logfire.php` - `examples/D05_AgentTroubleshooting/TelemetryLangfuse/run.php` - `examples/D05_AgentTroubleshooting/TelemetryLogfire/run.php` ================================================================================ FILE: packages/telemetry/05-langfuse.md ================================================================================ # Langfuse Setup The simplest Langfuse setup uses: - `LangfuseConfig` - `LangfuseHttpTransport` - `LangfuseExporter` - `Telemetry` ## Minimal Example ```php use Cognesy\Telemetry\Adapters\Langfuse\LangfuseConfig; use Cognesy\Telemetry\Adapters\Langfuse\LangfuseExporter; use Cognesy\Telemetry\Adapters\Langfuse\LangfuseHttpTransport; use Cognesy\Telemetry\Application\Registry\TraceRegistry; use Cognesy\Telemetry\Application\Telemetry; $telemetry = new Telemetry( registry: new TraceRegistry(), exporter: new LangfuseExporter( transport: new LangfuseHttpTransport(new LangfuseConfig( baseUrl: $_ENV['LANGFUSE_BASE_URL'], publicKey: $_ENV['LANGFUSE_PUBLIC_KEY'], secretKey: $_ENV['LANGFUSE_SECRET_KEY'], )), ), ); // @doctest id="895d" ``` ## Environment Variables The examples use: - `LANGFUSE_BASE_URL` - `LANGFUSE_PUBLIC_KEY` - `LANGFUSE_SECRET_KEY` See: - `examples/_support/langfuse.php` ## Agent Runtime Example The working agent example is: - `examples/D05_AgentTroubleshooting/TelemetryLangfuse/run.php` That example shows the full pattern: 1. create telemetry 2. create a shared event dispatcher 3. attach `RuntimeEventBridge` 4. install runtime projectors 5. execute the agent 6. call `$hub->flush()` ## Subagent Example For nested telemetry across parent and child agents, see: - `examples/D05_AgentTroubleshooting/SubagentTelemetryLangfuse/run.php` ## Notes - Langfuse uses the base URL and sends traces to `/api/public/otel/v1/traces` - request-scoped traces can fall back to request ids as `session.id` - if you get a 4xx response, check the base URL and keys first ================================================================================ FILE: packages/telemetry/06-logfire.md ================================================================================ # Logfire Setup The simplest Logfire setup uses: - `LogfireConfig` - `LogfireExporter` - `Telemetry` ## Minimal Example ```php use Cognesy\Telemetry\Adapters\Logfire\LogfireConfig; use Cognesy\Telemetry\Adapters\Logfire\LogfireExporter; use Cognesy\Telemetry\Application\Registry\TraceRegistry; use Cognesy\Telemetry\Application\Telemetry; $telemetry = new Telemetry( registry: new TraceRegistry(), exporter: new LogfireExporter(new LogfireConfig( endpoint: 'https://logfire-eu.pydantic.dev', serviceName: 'my-service', headers: ['Authorization' => $_ENV['LOGFIRE_TOKEN']], )), ); // @doctest id="4a01" ``` ## Environment Variables The example helpers look for: - `LOGFIRE_TOKEN` - `LOGFIRE_API_TOKEN` - `LOGFIRE_OTLP_ENDPOINT` - `LOGFIRE_BASE_URL` See: - `examples/_support/logfire.php` ## Endpoint Rule `LogfireConfig` expects the OTLP base endpoint. Do not pass a full `/v1/traces` or `/v1/metrics` path. The helper in `examples/_support/logfire.php` strips those suffixes if present. ## Agent Runtime Example The working agent example is: - `examples/D05_AgentTroubleshooting/TelemetryLogfire/run.php` ## Subagent Example For nested parent and child traces, see: - `examples/D05_AgentTroubleshooting/SubagentTelemetryLogfire/run.php` ## Notes - `LogfireExporter` requires either `LogfireConfig` or a custom transport - service name comes from `LogfireConfig::serviceName()` - if export fails with a 4xx response, check the token and endpoint first ================================================================================ FILE: packages/sandbox/1-overview.md ================================================================================ ## Introduction The Sandbox package provides a unified API for executing shell commands with controlled resource limits and configurable isolation. Whether you need to run a quick script on the host machine or execute untrusted code inside a locked-down container, Sandbox gives you a single, consistent interface backed by pluggable drivers. Every execution is governed by an immutable `ExecutionPolicy` that defines timeout limits, memory caps, network access, environment variables, and file-system boundaries. The policy travels with the sandbox instance, ensuring that your constraints are always enforced regardless of which driver you choose. ## Core Architecture The package is built around four primary components: ### Sandbox The `Sandbox` class is the main entry point. It accepts an `ExecutionPolicy` and produces a driver instance that implements the `CanExecuteCommand` contract. You can select a driver through static factory methods (`Sandbox::host()`, `Sandbox::docker()`, etc.) or dynamically via the `SandboxDriver` enum. ```php use Cognesy\Sandbox\Sandbox; use Cognesy\Sandbox\Config\ExecutionPolicy; // Static factory $sandbox = Sandbox::host(ExecutionPolicy::in('/tmp')); // Dynamic selection $sandbox = Sandbox::fromPolicy($policy)->using('docker'); // @doctest id="aefc" ``` ### ExecutionPolicy The `ExecutionPolicy` is an immutable configuration object that controls every aspect of command execution. Each `with*()` method returns a new instance, making policies safe to share and compose without side effects. ```php $policy = ExecutionPolicy::in('/tmp') ->withTimeout(30) ->withMemory('256M') ->withNetwork(false); // @doctest id="e778" ``` ### CanExecuteCommand All drivers implement the `CanExecuteCommand` interface, which exposes an `execute()` method and a `policy()` accessor. This contract guarantees that you can swap drivers without changing any calling code, making it straightforward to use the host driver in development and a container driver in production. ```php use Cognesy\Sandbox\Contracts\CanExecuteCommand; function runScript(CanExecuteCommand $sandbox): string { $result = $sandbox->execute(['php', 'script.php']); return $result->stdout(); } // @doctest id="ee3a" ``` ### ExecResult Every execution returns an `ExecResult` -- a readonly value object that provides access to stdout, stderr, the exit code, wall-clock duration, and flags indicating whether the output was truncated or the command timed out. ```php $result = $sandbox->execute(['php', '-v']); $result->stdout(); // Captured standard output $result->stderr(); // Captured standard error $result->exitCode(); // Process exit code (0 = success) $result->success(); // true when exit code is 0 and no timeout $result->duration(); // Wall-clock seconds as float $result->timedOut(); // true if wall or idle timeout was hit $result->truncatedStdout(); // true if stdout exceeded the cap $result->truncatedStderr(); // true if stderr exceeded the cap $result->combinedOutput(); // stdout + stderr joined $result->toArray(); // Full result as associative array // @doctest id="f121" ``` ## Supported Drivers The package ships with five drivers, each offering a different level of isolation: | Driver | Isolation | Platform | Use Case | |---|---|---|---| | **Host** | None (process-level only) | All | Development, trusted scripts | | **Docker** | Full container | Linux, macOS, Windows | Production workloads, untrusted code | | **Podman** | Full container (rootless) | Linux | Rootless container execution | | **Firejail** | Linux namespaces + seccomp | Linux | Lightweight sandboxing without containers | | **Bubblewrap** | Linux namespaces | Linux | Minimal sandbox with namespace isolation | All drivers enforce the same `ExecutionPolicy` constraints and return the same `ExecResult` type, so your application code remains driver-agnostic. ## Security Defaults The package ships with secure defaults that apply across all drivers: - **Network disabled** -- Commands cannot reach external services unless you explicitly call `withNetwork(true)`. - **Environment scrubbed** -- Security-sensitive variables (`AWS_*`, `LD_PRELOAD`, `GOOGLE_APPLICATION_CREDENTIALS`, etc.) are always stripped, even when environment inheritance is enabled. - **Output bounded** -- Both stdout and stderr are capped at 1 MB by default. When exceeded, only the most recent bytes are retained. - **Timeout enforced** -- Commands are terminated after 5 seconds by default. Both wall-clock and idle timeouts are supported. - **Container hardening** -- Docker and Podman drivers run with a read-only root filesystem, all capabilities dropped, `no-new-privileges` set, and commands execute as the `nobody` user (UID 65534). ## Documentation - [Getting Started](2-getting-started.md) -- Run your first sandboxed command in three steps. - [Execution Policy](3-execution-policy.md) -- Configure timeouts, memory, paths, environment, network, and output limits. - [Drivers](4-drivers.md) -- Choose and configure the right isolation backend. - [Streaming and Results](5-streaming-and-results.md) -- Consume output in real time and inspect execution results. - [Testing](6-testing.md) -- Use `FakeSandbox` for fast, deterministic tests. - [Troubleshooting](7-troubleshooting.md) -- Diagnose and resolve common issues. ================================================================================ FILE: packages/sandbox/2-getting-started.md ================================================================================ ## Introduction Getting started with the Sandbox package requires just three steps: define an execution policy, create a sandbox instance with your chosen driver, and execute a command. This guide walks through each step and covers the most common patterns you will use day-to-day. ## Step 1: Create an Execution Policy Every sandbox requires an `ExecutionPolicy` that defines the constraints for command execution. The simplest way to create one is with the `in()` factory, which sets the base working directory: ```php use Cognesy\Sandbox\Config\ExecutionPolicy; $policy = ExecutionPolicy::in(__DIR__); // @doctest id="1f60" ``` The base directory serves as the working directory for host-mode execution and as the parent for temporary work directories that container drivers create. It must exist and be writable by the PHP process. If you do not need a specific directory, use the `default()` factory, which defaults to `/tmp`: ```php $policy = ExecutionPolicy::default(); // @doctest id="0c60" ``` Both factories return a policy with secure defaults: a 5-second timeout, 128 MB memory limit, network disabled, no environment inheritance, and 1 MB output caps for both stdout and stderr. ## Step 2: Create a Sandbox Instance Use one of the static factory methods on the `Sandbox` class to create an instance with your preferred driver: ```php use Cognesy\Sandbox\Sandbox; // Host driver -- runs commands directly on the host $sandbox = Sandbox::host($policy); // Docker driver -- runs commands inside a container $sandbox = Sandbox::docker($policy, image: 'php:8.3-cli-alpine'); // Podman driver -- rootless container execution $sandbox = Sandbox::podman($policy, image: 'alpine:3'); // Firejail driver -- Linux namespace sandboxing $sandbox = Sandbox::firejail($policy); // Bubblewrap driver -- minimal Linux namespace isolation $sandbox = Sandbox::bubblewrap($policy); // @doctest id="b163" ``` All factory methods return an instance of `CanExecuteCommand`, so you can swap drivers without changing your calling code. ### Dynamic Driver Selection When the driver is determined at runtime (for example, from a configuration file), use the `fromPolicy()` builder combined with the `using()` method. You can pass either a `SandboxDriver` enum case or a plain string: ```php use Cognesy\Sandbox\Enums\SandboxDriver; use Cognesy\Sandbox\Sandbox; // Using the enum $sandbox = Sandbox::fromPolicy($policy)->using(SandboxDriver::Docker); // Using a string (e.g., from config) $driverName = config('sandbox.driver'); // 'docker' $sandbox = Sandbox::fromPolicy($policy)->using($driverName); // @doctest id="971b" ``` Valid string values are: `host`, `docker`, `podman`, `firejail`, `bubblewrap`. An `InvalidArgumentException` is thrown if the value does not match any known driver. ## Step 3: Execute a Command Call the `execute()` method with a command expressed as an array of strings (argv format): ```php $result = $sandbox->execute(['php', '-v']); echo $result->stdout(); // "PHP 8.3.0 (cli) ..." echo $result->exitCode(); // 0 // @doctest id="358c" ``` Always pass commands in argv format -- each argument as a separate array element. This avoids shell injection vulnerabilities and ensures correct argument parsing across all drivers. ### Passing Standard Input To pipe data into the command's stdin, pass it as the second argument: ```php $result = $sandbox->execute( ['php', '-r', 'echo strtoupper(fgets(STDIN));'], 'hello world' ); echo $result->stdout(); // "HELLO WORLD" // @doctest id="0197" ``` ### Checking the Result The `ExecResult` object provides everything you need to determine whether the command succeeded and to retrieve its output: ```php if ($result->success()) { // Exit code is 0 and no timeout occurred echo $result->stdout(); } else { // Something went wrong echo "Exit code: " . $result->exitCode() . "\n"; echo "Stderr: " . $result->stderr() . "\n"; if ($result->timedOut()) { echo "The command exceeded its time limit.\n"; } } // @doctest id="b268" ``` ## Complete Example Here is a complete example that creates a policy, builds a sandbox, executes a PHP script, and handles the result: ```php use Cognesy\Sandbox\Config\ExecutionPolicy; use Cognesy\Sandbox\Sandbox; // 1. Define constraints $policy = ExecutionPolicy::in('/tmp') ->withTimeout(10) ->withMemory('256M'); // 2. Create sandbox with host driver $sandbox = Sandbox::host($policy); // 3. Execute and inspect result $result = $sandbox->execute(['php', '-r', 'echo json_encode(["status" => "ok"]);']); if ($result->success()) { $data = json_decode($result->stdout(), true); echo "Status: " . $data['status']; // "ok" } else { echo "Command failed with exit code " . $result->exitCode(); } // @doctest id="e5e2" ``` ## Dependency Injection Since all drivers implement the `CanExecuteCommand` interface, you can type-hint against the interface in your application services. This makes it easy to swap implementations and simplifies testing with `FakeSandbox`: ```php use Cognesy\Sandbox\Contracts\CanExecuteCommand; use Cognesy\Sandbox\Data\ExecResult; class CodeRunner { public function __construct( private readonly CanExecuteCommand $sandbox, ) {} public function run(string $code): ExecResult { return $this->sandbox->execute(['php', '-r', $code]); } } // @doctest id="1472" ``` ## Next Steps - Learn how to fine-tune execution constraints in [Execution Policy](3-execution-policy.md). - Explore the available isolation backends in [Drivers](4-drivers.md). - Set up real-time output consumption in [Streaming and Results](5-streaming-and-results.md). ================================================================================ FILE: packages/sandbox/3-execution-policy.md ================================================================================ ## Introduction The `ExecutionPolicy` class is the central configuration object for every sandbox execution. It controls how long a command may run, how much memory it may consume, which files it can access, which environment variables are available, whether network access is permitted, and how much output is retained. The policy is **immutable**: every `with*()` method returns a new `ExecutionPolicy` instance, leaving the original unchanged. This makes policies safe to share across services, store in configuration, and compose through method chaining without any risk of side effects. ```php use Cognesy\Sandbox\Config\ExecutionPolicy; $base = ExecutionPolicy::in('/tmp')->withTimeout(10); // $base is unchanged -- $extended is a new instance $extended = $base->withMemory('256M')->withNetwork(true); // @doctest id="3c62" ``` ## Creating a Policy ### From a Directory The most common way to create a policy is with the `in()` static factory, which sets the base working directory: ```php $policy = ExecutionPolicy::in('/var/sandbox'); // @doctest id="0c76" ``` The base directory must exist and be writable. For container drivers (Docker, Podman, Firejail, Bubblewrap), a unique temporary subdirectory is created inside this path for each execution and automatically cleaned up afterward. ### Default Policy When you do not need a specific directory, use `default()`, which sets the base directory to `/tmp`: ```php $policy = ExecutionPolicy::default(); // @doctest id="1442" ``` ### Default Values A freshly created policy uses the following defaults: | Setting | Default | Description | |---|---|---| | `baseDir` | `/tmp` (or the value passed to `in()`) | Working directory / temp parent | | `timeoutSeconds` | `5` | Maximum wall-clock duration | | `idleTimeoutSeconds` | `null` (disabled) | Maximum time without output | | `memoryLimit` | `128M` | Memory cap (container drivers) | | `readablePaths` | `[]` | Extra paths mounted as read-only | | `writablePaths` | `[]` | Extra paths mounted as read-write | | `env` | `[]` | Explicit environment variables | | `inheritEnv` | `false` | Whether to inherit host environment | | `networkEnabled` | `false` | Whether network access is allowed | | `stdoutLimitBytes` | `1048576` (1 MB) | Maximum retained stdout | | `stderrLimitBytes` | `1048576` (1 MB) | Maximum retained stderr | ## Timeout Configuration ### Wall-Clock Timeout The wall-clock timeout defines the maximum number of seconds a command may run before it is forcefully terminated. The minimum allowed value is 1 second. ```php $policy = $policy->withTimeout(30); // 30 seconds // @doctest id="d129" ``` When a timeout occurs, the resulting `ExecResult` will have `timedOut()` returning `true` and an exit code of `124` (matching the GNU `timeout` convention). ### Idle Timeout The idle timeout terminates a command if it produces no output for the specified number of seconds. This is useful for detecting stuck processes that are technically still running but have stopped making progress. ```php $policy = $policy->withIdleTimeout(10); // Kill after 10 seconds of silence // @doctest id="bd83" ``` To disable the idle timeout (the default), pass `null`: ```php $policy = $policy->withIdleTimeout(null); // @doctest id="233f" ``` The idle timeout is tracked independently of the wall-clock timeout. A command is terminated as soon as either limit is reached, whichever comes first. The `TimeoutTracker` internally records the reason (`TimeoutReason::WALL` or `TimeoutReason::IDLE`) for diagnostic purposes. ## Memory Limit The memory limit controls the maximum amount of RAM a sandboxed process may use. It is enforced by container drivers (Docker, Podman) via `--memory` flags. The host driver does not enforce memory limits at the OS level. ```php $policy = $policy->withMemory('256M'); // @doctest id="8c46" ``` Accepted formats include numeric values with `K`, `M`, or `G` suffixes (e.g., `512K`, `256M`, `1G`). The value is normalized internally to megabytes and clamped to a maximum of 1 GB. An `InvalidArgumentException` is thrown for invalid formats or if `-1` (unbounded) is passed. ```php // All of these are valid $policy->withMemory('512M'); // 512 megabytes $policy->withMemory('1G'); // Clamped to 1024M $policy->withMemory('65536K'); // Normalized to 64M // @doctest id="1fe9" ``` ## File-System Access Container and sandbox drivers restrict file-system access by default. Only the working directory is writable. To grant access to additional paths, use the `withReadablePaths()` and `withWritablePaths()` methods. ### Readable Paths Mount host paths as read-only inside the sandbox: ```php $policy = $policy->withReadablePaths('/data/config', '/etc/app'); // @doctest id="0b6a" ``` In container drivers, these are mounted at sequential container paths: `/mnt/ro0`, `/mnt/ro1`, and so on. Your command should reference these container paths, not the host paths. ### Writable Paths Mount host paths as read-write inside the sandbox: ```php $policy = $policy->withWritablePaths('/data/output', '/var/cache'); // @doctest id="e03d" ``` In container drivers, these are mounted at `/mnt/rw0`, `/mnt/rw1`, etc. ### Important Notes - Both methods **replace** the current list of paths. Pass all paths in a single call: ```php // Correct -- both paths are included $policy = $policy->withReadablePaths('/data/a', '/data/b'); // Incorrect -- only '/data/b' survives $policy = $policy->withReadablePaths('/data/a'); $policy = $policy->withReadablePaths('/data/b'); ``` - Paths containing symlinks, `..` components, or colons are silently skipped for safety. Use `realpath()` to resolve symlinks before passing paths to the policy. ## Environment Variables By default, the sandboxed process receives no environment variables from the host. You can pass specific variables and optionally inherit the host environment. ### Explicit Variables Pass an associative array of key-value pairs: ```php $policy = $policy->withEnv([ 'APP_ENV' => 'testing', 'LOG_LEVEL' => 'debug', ]); // @doctest id="ac2f" ``` ### Inheriting the Host Environment To start with the host's environment and then overlay your own variables, pass `inherit: true`: ```php $policy = $policy->withEnv( ['APP_ENV' => 'staging'], inherit: true, ); // @doctest id="cf70" ``` You can also toggle inheritance independently: ```php $policy = $policy->inheritEnvironment(true); // @doctest id="695d" ``` ### Blocked Variables Certain security-sensitive environment variables are **always** stripped, regardless of inheritance settings. This is a hard-coded safety measure that cannot be overridden. The blocked patterns include: - **Dynamic linker:** `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, `DYLD_FRAMEWORK_PATH` - **PHP configuration:** `PHP_INI_SCAN_DIR`, `PHPRC` - **Cloud credentials:** `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `GOOGLE_APPLICATION_CREDENTIALS`, `GCP_*`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` - **Language tooling:** `GEM_HOME`, `GEM_PATH`, `RUBY*`, `NODE_OPTIONS`, `NPM_*`, `PYTHON*`, `PIP_*` ## Network Access Network access is disabled by default. Enable it when your command needs to reach external services: ```php $policy = $policy->withNetwork(true); // @doctest id="3301" ``` For container drivers, this controls the `--network` flag (`none` vs. default bridge). For the host driver, this setting serves as a policy declaration only -- no OS-level network restriction is enforced. For Firejail, the `--net=none` flag is used. For Bubblewrap, the `--unshare-net` flag isolates the network namespace. ## Output Caps Output caps limit how much stdout and stderr data is retained in memory. When a stream exceeds its cap, only the most recent bytes (up to the cap size) are kept, and the corresponding `truncatedStdout()` or `truncatedStderr()` flag is set on the result. ```php $policy = $policy->withOutputCaps( stdoutBytes: 5 * 1024 * 1024, // 5 MB for stdout stderrBytes: 1 * 1024 * 1024, // 1 MB for stderr ); // @doctest id="7a91" ``` The minimum cap is 1024 bytes. Values below this threshold are automatically clamped upward. The default cap is 1 MB (1,048,576 bytes) for each stream. Output caps protect your application from memory exhaustion when running commands that produce large amounts of output. The streaming callback (if provided) still receives all chunks in real time, even when the retained buffer is truncated. ## The `with()` Method For advanced use cases, you can set multiple policy properties in a single call using the general-purpose `with()` method. Any parameter you omit retains its current value: ```php $policy = $policy->with( timeoutSeconds: 60, memoryLimit: '512M', networkEnabled: true, inheritEnv: true, ); // @doctest id="86e3" ``` This is the same mechanism that all `with*()` convenience methods use internally. ## Accessing Policy Values Every policy setting has a corresponding accessor method: ```php $policy->baseDir(); // string $policy->timeoutSeconds(); // int $policy->idleTimeoutSeconds(); // ?int $policy->memoryLimit(); // string (e.g., "128M") $policy->readablePaths(); // list $policy->writablePaths(); // list $policy->env(); // array $policy->inheritEnv(); // bool $policy->networkEnabled(); // bool $policy->stdoutLimitBytes(); // int $policy->stderrLimitBytes(); // int // @doctest id="eb7b" ``` These accessors are useful when building custom drivers or when your application logic needs to inspect the policy (for example, to display timeout values in a UI). ================================================================================ FILE: packages/sandbox/4-drivers.md ================================================================================ ## Introduction The Sandbox package ships with five drivers, each offering a different trade-off between convenience and isolation. All drivers implement the `CanExecuteCommand` interface, so your application code works identically regardless of which backend is in use. Choosing a driver depends on your security requirements, platform, and operational constraints. You might use the host driver during development for simplicity and switch to Docker or Podman in production for full container isolation. ## Driver Selection ### Static Factory Methods The `Sandbox` class provides dedicated static methods for each driver: ```php use Cognesy\Sandbox\Sandbox; use Cognesy\Sandbox\Config\ExecutionPolicy; $policy = ExecutionPolicy::in('/tmp'); $host = Sandbox::host($policy); $docker = Sandbox::docker($policy, image: 'php:8.3-cli-alpine'); $podman = Sandbox::podman($policy, image: 'alpine:3'); $firejail = Sandbox::firejail($policy); $bubblewrap = Sandbox::bubblewrap($policy); // @doctest id="d174" ``` ### Enum-Based Selection When the driver is determined at runtime, use the `SandboxDriver` enum with the fluent builder: ```php use Cognesy\Sandbox\Enums\SandboxDriver; use Cognesy\Sandbox\Sandbox; $sandbox = Sandbox::fromPolicy($policy)->using(SandboxDriver::Docker); // @doctest id="4923" ``` You can also pass a plain string. The accepted values are `host`, `docker`, `podman`, `firejail`, and `bubblewrap`: ```php $sandbox = Sandbox::fromPolicy($policy)->using('firejail'); // @doctest id="e86a" ``` An `InvalidArgumentException` is thrown if the string does not match any known driver. ## Host Driver The host driver executes commands directly on the host machine using the Symfony Process component. It provides no file-system or network isolation -- the command runs with the same privileges as the PHP process, constrained only by the execution policy's timeout and output caps. ```php $sandbox = Sandbox::host($policy); $result = $sandbox->execute(['php', '-r', 'echo phpversion();']); // @doctest id="75ff" ``` **When to use:** Development, trusted scripts, CI pipelines where container overhead is unnecessary. **Key characteristics:** - Commands run in the policy's `baseDir` directly (no temporary subdirectory is created). - Timeout enforcement uses Symfony Process's built-in timeout and idle timeout. - Environment variables are filtered through `EnvUtils` to strip security-sensitive patterns. - Memory limits and network settings are policy declarations only -- they are not enforced at the OS level. ## Docker Driver The Docker driver runs each command inside an ephemeral Docker container with aggressive security hardening applied by default. ```php $sandbox = Sandbox::docker($policy, image: 'python:3.12-alpine'); $result = $sandbox->execute(['python3', '-c', 'print("hello")']); // @doctest id="ad33" ``` **When to use:** Production workloads, untrusted code execution, any scenario requiring strong isolation. ### Container Hardening Every Docker execution applies the following security measures automatically: | Setting | Value | Purpose | |---|---|---| | `--read-only` | Enabled | Root filesystem is read-only | | `--cap-drop=ALL` | All capabilities dropped | No elevated privileges | | `--security-opt no-new-privileges` | Enabled | Prevents privilege escalation | | `-u 65534:65534` | `nobody` user | Non-root execution | | `--pids-limit=20` | 20 processes | Prevents fork bombs | | `--memory` | From policy (default `128M`) | Memory cap | | `--cpus` | `0.5` | CPU throttle | | `--network=none` | When network disabled | Network isolation | | `--tmpfs /tmp` | `rw,noexec,nodev,nosuid,size=64m` | Writable temp with noexec | ### Working Directory A unique temporary directory is created on the host inside the policy's `baseDir` for each execution. This directory is mounted into the container at `/work` as the writable working directory. It is automatically cleaned up after execution, even if the command fails. ### File Mounts Readable paths from the policy are mounted at `/mnt/ro0`, `/mnt/ro1`, etc. Writable paths are mounted at `/mnt/rw0`, `/mnt/rw1`, etc. Your command should reference these container paths: ```php $policy = ExecutionPolicy::in('/tmp') ->withReadablePaths('/data/input') ->withWritablePaths('/data/output'); $sandbox = Sandbox::docker($policy, image: 'alpine:3'); // Inside the container: /mnt/ro0 is /data/input, /mnt/rw0 is /data/output $result = $sandbox->execute(['cp', '/mnt/ro0/file.txt', '/mnt/rw0/copy.txt']); // @doctest id="f052" ``` ### Custom Image The default image is `alpine:3`. Pass any Docker image as the second argument: ```php $sandbox = Sandbox::docker($policy, image: 'node:20-alpine'); // @doctest id="f279" ``` ### Binary Override If Docker is not on the default `PATH`, specify the binary location: ```php $sandbox = Sandbox::docker($policy, dockerBin: '/usr/local/bin/docker'); // @doctest id="8022" ``` Or set the `DOCKER_BIN` environment variable before your PHP process starts. ## Podman Driver The Podman driver works identically to the Docker driver but uses Podman as the container runtime. It is designed for rootless container execution on Linux. ```php $sandbox = Sandbox::podman($policy, image: 'alpine:3'); // @doctest id="4c70" ``` **When to use:** Linux environments where rootless containers are preferred over Docker. ### WSL2 Compatibility The Podman driver automatically detects WSL2 environments (by reading `/proc/version` and `/proc/self/cgroup`) and applies compatibility adjustments: - Switches to `cgroupfs` as the cgroup manager (via `--cgroup-manager=cgroupfs`). - Disables memory and CPU resource limits, which are unreliable under WSL2's cgroup configuration. All other security hardening (read-only root, dropped capabilities, nobody user, etc.) remains active. ### Binary Override ```php $sandbox = Sandbox::podman($policy, podmanBin: '/usr/bin/podman'); // @doctest id="0137" ``` Or set the `PODMAN_BIN` environment variable. ## Firejail Driver The Firejail driver uses Linux namespaces and seccomp filtering to sandbox commands without requiring a container runtime. It offers lighter weight isolation than Docker or Podman. ```php $sandbox = Sandbox::firejail($policy); $result = $sandbox->execute(['python3', 'script.py']); // @doctest id="cd94" ``` **When to use:** Linux systems where you want sandbox isolation without the overhead of pulling container images. ### Sandbox Configuration Firejail applies the following restrictions: | Setting | Value | Purpose | |---|---|---| | `--net=none` | When network disabled | Network isolation | | `--rlimit-nproc=20` | 20 processes | Fork bomb prevention | | `--rlimit-nofile=100` | 100 file descriptors | File descriptor limit | | `--rlimit-fsize=10485760` | 10 MB | Maximum file size | | `--rlimit-cpu` | Policy timeout + 1 second | CPU time limit | The working directory is bind-mounted at `/work` with a whitelist applied. Readable and writable paths follow the same `/mnt/ro*` and `/mnt/rw*` convention, with readable paths additionally marked as `--read-only`. ### Binary Override ```php $sandbox = Sandbox::firejail($policy, firejailBin: '/usr/bin/firejail'); // @doctest id="4819" ``` Or set the `FIREJAIL_BIN` environment variable. ## Bubblewrap Driver The Bubblewrap (`bwrap`) driver provides minimal Linux namespace isolation. It is the lightest-weight option and is commonly used in Flatpak applications. ```php $sandbox = Sandbox::bubblewrap($policy); $result = $sandbox->execute(['ls', '-la']); // @doctest id="abc9" ``` **When to use:** Linux systems where you need basic namespace isolation with minimal dependencies. ### Namespace Isolation Bubblewrap applies the following namespace unsharing: - `--unshare-pid` -- Process ID namespace - `--unshare-uts` -- Hostname namespace - `--unshare-ipc` -- IPC namespace - `--unshare-cgroup` -- Cgroup namespace - `--unshare-net` -- Network namespace (when network is disabled) - `--die-with-parent` -- Sandbox terminates if the parent process exits The host root filesystem is mounted read-only (`--ro-bind / /`) to make system binaries available. The working directory is bind-mounted to `/tmp` inside the sandbox. Writable and readable paths are mounted at their original host paths (not `/mnt/rw*` like container drivers). ### Binary Override ```php $sandbox = Sandbox::bubblewrap($policy, bubblewrapBin: '/usr/bin/bwrap'); // @doctest id="d4d7" ``` Or set the `BWRAP_BIN` environment variable. ## Binary Discovery All drivers that depend on an external binary follow the same discovery strategy: 1. Check the corresponding environment variable (`DOCKER_BIN`, `PODMAN_BIN`, `FIREJAIL_BIN`, `BWRAP_BIN`). 2. Search the system `PATH`. 3. Search additional common directories: `/usr/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `/opt/local/bin`, `/snap/bin`. 4. Fall back to the bare binary name (e.g., `docker`), which will fail at execution time if the binary truly is not available. You can bypass discovery entirely by passing the binary path to the constructor. ## Process Management Container drivers (Docker, Podman, Firejail, Bubblewrap) use `proc_open` directly for process management, while the host driver uses the Symfony Process component. All drivers: - Use `setsid` (when available) to run commands in a new session group, ensuring clean termination of the entire process tree on timeout. - Send `SIGTERM` first, wait briefly, then escalate to `SIGKILL` if the process does not exit. - Create and automatically clean up temporary working directories (container drivers only). ## Driver Comparison | Feature | Host | Docker | Podman | Firejail | Bubblewrap | |---|---|---|---|---|---| | File-system isolation | No | Full | Full | Partial | Partial | | Network isolation | No | Yes | Yes | Yes | Yes | | Memory enforcement | No | Yes | Yes* | No | No | | CPU throttling | No | Yes | Yes* | Via rlimit | No | | Process limit | No | Yes | Yes | Yes | No | | Requires runtime | No | Docker | Podman | Firejail | bwrap | | Platform | All | Linux/macOS/Win | Linux | Linux | Linux | *Podman skips memory and CPU limits on WSL2 for compatibility. ================================================================================ FILE: packages/sandbox/5-streaming-and-results.md ================================================================================ ## Introduction The Sandbox package supports two complementary ways to work with command output. You can consume output **incrementally** through a streaming callback as the command runs, and you can inspect the **complete result** after execution finishes through the `ExecResult` value object. Both mechanisms work with every driver. ## Streaming Output ### The Streaming Callback The `execute()` method accepts an optional third argument: a callable that receives output chunks in real time as the command produces them. This is useful for progress reporting, logging, or forwarding output to a user interface. ```php $result = $sandbox->execute( ['php', 'long-running-script.php'], null, function (string $type, string $chunk): void { if ($type === 'out') { echo "[stdout] " . $chunk; } else { echo "[stderr] " . $chunk; } } ); // @doctest id="4ce4" ``` The callback receives two arguments: | Argument | Type | Description | |---|---|---| | `$type` | `string` | `'out'` for stdout, `'err'` for stderr | | `$chunk` | `string` | Raw bytes from the output stream | Chunks are delivered as they arrive from the process. They are **not** line-buffered -- a chunk may contain a partial line, multiple lines, or even binary data depending on how the underlying process writes to its output streams. ### Streaming and Output Caps The streaming callback receives **all** output, even when the retained buffer in `ExecResult` has been truncated due to output caps. This means you can use the callback to write output to a file or database without worrying about the cap: ```php $logFile = fopen('/tmp/execution.log', 'w'); $result = $sandbox->execute( ['php', 'noisy-script.php'], null, function (string $type, string $chunk) use ($logFile): void { fwrite($logFile, "[{$type}] {$chunk}"); } ); fclose($logFile); // $result->stdout() may be truncated, but the log file has everything // @doctest id="e6e6" ``` ### Streaming with Standard Input You can combine stdin and the streaming callback in the same call: ```php $result = $sandbox->execute( ['php', '-r', 'echo strtoupper(fgets(STDIN));'], 'hello world', function (string $type, string $chunk): void { echo $chunk; // "HELLO WORLD" } ); // @doctest id="d128" ``` ### Idle Timeout Interaction The streaming callback does not affect idle timeout tracking. The idle timeout is reset whenever the process produces any output on either stdout or stderr, regardless of whether a callback is provided. However, the callback gives you visibility into when output arrives, which is valuable for diagnosing timeout issues: ```php $result = $sandbox->execute( $argv, null, function (string $type, string $chunk): void { $timestamp = date('H:i:s'); echo "[{$timestamp}] {$type}: " . strlen($chunk) . " bytes\n"; } ); // @doctest id="b51d" ``` ## The ExecResult API Every call to `execute()` returns an `ExecResult` instance -- a readonly value object containing the complete outcome of the execution. ### Output Access ```php $result->stdout(); // string -- captured standard output $result->stderr(); // string -- captured standard error $result->combinedOutput(); // string -- stdout + stderr joined with newline // @doctest id="e757" ``` The `combinedOutput()` method appends stderr to stdout, separated by a newline if both are non-empty. This is convenient when you do not need to distinguish between the two streams. ### Exit Status ```php $result->exitCode(); // int -- the process exit code $result->success(); // bool -- true when exitCode is 0 AND no timeout occurred // @doctest id="ed8a" ``` The `success()` method checks both the exit code and the timeout flag. A command that exits with code 0 but was forcefully terminated due to a timeout is not considered successful. Common exit codes: | Code | Meaning | |---|---| | `0` | Success | | `1` | General error | | `124` | Timeout (GNU convention, used by the Sandbox package) | | `126` | Command found but not executable | | `127` | Command not found | | `128+N` | Killed by signal N (e.g., 137 = SIGKILL) | ### Timing ```php $result->duration(); // float -- wall-clock seconds (e.g., 1.234) // @doctest id="67f9" ``` The duration measures the wall-clock time from process start to completion (or termination). It is always available, even for timed-out executions. ### Timeout Detection ```php $result->timedOut(); // bool -- true if wall-clock or idle timeout was triggered // @doctest id="0d63" ``` When a timeout occurs, the exit code is set to `124` and `timedOut()` returns `true`. The output captured up to the point of termination is still available through `stdout()` and `stderr()`. ### Truncation Detection ```php $result->truncatedStdout(); // bool -- true if stdout exceeded the output cap $result->truncatedStderr(); // bool -- true if stderr exceeded the output cap // @doctest id="88b1" ``` When truncation occurs, only the most recent bytes (up to the cap size) are retained. Earlier output is discarded. This tail-preserving strategy ensures you always have the most recent output, which typically contains error messages and final status information. ### Serialization ```php $result->toArray(); // [ // 'stdout' => '...', // 'stderr' => '...', // 'exit_code' => 0, // 'duration' => 1.234, // 'timed_out' => false, // 'truncated_stdout' => false, // 'truncated_stderr' => false, // 'success' => true, // ] // @doctest id="ca55" ``` The `toArray()` method returns a flat associative array suitable for JSON serialization, logging, or passing to the `FakeSandbox` for test fixtures. ## Common Patterns ### Guard Against Failure The most common pattern is to check `success()` before using the output: ```php $result = $sandbox->execute(['php', 'migrate.php']); if (!$result->success()) { throw new RuntimeException( "Migration failed (exit {$result->exitCode()}): {$result->stderr()}" ); } echo $result->stdout(); // @doctest id="9481" ``` ### Capture Output with Timeout Awareness When running potentially long commands, check for both failure and timeout: ```php $result = $sandbox->execute(['php', 'import.php']); if ($result->timedOut()) { logger()->warning('Import timed out after ' . $result->duration() . 's'); logger()->warning('Partial output: ' . $result->stdout()); } elseif (!$result->success()) { logger()->error('Import failed: ' . $result->stderr()); } else { logger()->info('Import complete: ' . $result->stdout()); } // @doctest id="564b" ``` ### Real-Time Progress with Final Summary Combine the streaming callback for live updates with the result for a final summary: ```php $lines = 0; $result = $sandbox->execute( ['php', 'process-data.php'], null, function (string $type, string $chunk) use (&$lines): void { if ($type === 'out') { $lines += substr_count($chunk, "\n"); echo "\rProcessed {$lines} lines..."; } } ); echo "\n"; echo "Finished in {$result->duration()}s with exit code {$result->exitCode()}\n"; // @doctest id="bd8e" ``` ### Parsing Structured Output When the command produces JSON or other structured output: ```php $result = $sandbox->execute(['php', '-r', 'echo json_encode(["count" => 42]);']); if ($result->success()) { $data = json_decode($result->stdout(), true); echo "Count: " . $data['count']; } // @doctest id="8387" ``` ### Handling Truncated Output When working with commands that may produce large output, check for truncation: ```php $result = $sandbox->execute(['php', 'generate-report.php']); if ($result->truncatedStdout()) { logger()->warning( 'Report output was truncated. Consider increasing output caps or ' . 'using a streaming callback to capture the full output.' ); } // @doctest id="ad5d" ``` ================================================================================ FILE: packages/sandbox/6-testing.md ================================================================================ ## Introduction The Sandbox package includes `FakeSandbox`, a test double that implements the same `CanExecuteCommand` interface as all real drivers. It lets you write fast, deterministic tests for code that depends on sandbox execution -- without spawning any processes, pulling container images, or requiring any system binaries. `FakeSandbox` supports canned responses, FIFO queuing for repeated commands, a default fallback response, array-based response definitions, command recording, streaming callback simulation, and custom policies. ## Creating a Fake ### The `fromResponses()` Factory The quickest way to create a mock is with the `fromResponses()` static factory. Pass an associative array where keys are the expected command strings and values are lists of `ExecResult` objects to return in order: ```php use Cognesy\Sandbox\Data\ExecResult; use Cognesy\Sandbox\Testing\FakeSandbox; $sandbox = FakeSandbox::fromResponses([ 'php -v' => [ new ExecResult( stdout: 'PHP 8.3.0 (cli)', stderr: '', exitCode: 0, duration: 0.01, ), ], ]); $result = $sandbox->execute(['php', '-v']); echo $result->stdout(); // "PHP 8.3.0 (cli)" // @doctest id="f26d" ``` The command key is formed by joining the argv array with spaces: `['php', '-v']` becomes `'php -v'`. Make sure the key in your response map matches exactly. ### The Constructor For more control, use the constructor directly. This lets you specify a custom `ExecutionPolicy`: ```php use Cognesy\Sandbox\Config\ExecutionPolicy; $policy = ExecutionPolicy::in('/tmp')->withTimeout(60); $sandbox = new FakeSandbox( policy: $policy, responses: [ 'php script.php' => [ new ExecResult(stdout: 'done', stderr: '', exitCode: 0, duration: 1.5), ], ], ); echo $sandbox->policy()->timeoutSeconds(); // 60 // @doctest id="9204" ``` The `fromResponses()` factory uses `ExecutionPolicy::default()` when no policy is specified. ## Queueing Multiple Responses When the same command is called multiple times, provide multiple results in the list. They are consumed in FIFO order -- each call to `execute()` shifts the next response off the queue: ```php $sandbox = FakeSandbox::fromResponses([ 'php script.php' => [ new ExecResult(stdout: 'first run', stderr: '', exitCode: 0, duration: 0.1), new ExecResult(stdout: 'second run', stderr: '', exitCode: 0, duration: 0.2), new ExecResult(stdout: 'third run', stderr: '', exitCode: 0, duration: 0.3), ], ]); $sandbox->execute(['php', 'script.php'])->stdout(); // "first run" $sandbox->execute(['php', 'script.php'])->stdout(); // "second run" $sandbox->execute(['php', 'script.php'])->stdout(); // "third run" // @doctest id="e64e" ``` If the queue is exhausted and no default response is set, the next call throws a `RuntimeException`. ## Default Response To provide a fallback for any command that does not have a specific canned response, pass a `defaultResponse`: ```php $sandbox = FakeSandbox::fromResponses( responses: [], defaultResponse: new ExecResult( stdout: '', stderr: 'command not found', exitCode: 127, duration: 0.0, ), ); $result = $sandbox->execute(['anything', '--help']); echo $result->exitCode(); // 127 // @doctest id="2686" ``` The default response is also used when a command's specific queue has been exhausted. ## Enqueuing Responses After Construction You can add responses to an existing fake at any time using the `enqueue()` method. This is useful in test setups where you build the fake incrementally: ```php $sandbox = FakeSandbox::fromResponses([]); $sandbox->enqueue('php -v', new ExecResult( stdout: 'PHP 8.3.0', stderr: '', exitCode: 0, duration: 0.01, )); $sandbox->enqueue('php -v', new ExecResult( stdout: 'PHP 8.3.1', stderr: '', exitCode: 0, duration: 0.01, )); $sandbox->execute(['php', '-v'])->stdout(); // "PHP 8.3.0" $sandbox->execute(['php', '-v'])->stdout(); // "PHP 8.3.1" // @doctest id="1ffc" ``` ## Array-Based Responses For convenience, you can define responses as associative arrays instead of `ExecResult` objects. The mock normalizes them automatically: ```php $sandbox = FakeSandbox::fromResponses([ 'php -v' => [ ['stdout' => 'PHP 8.3.0', 'exit_code' => 0, 'duration' => 0.01], ], 'php script.php' => [ ['stdout' => 'output', 'stderr' => 'warning', 'exit_code' => 0], ], ]); // @doctest id="c816" ``` The recognized keys match the `ExecResult::toArray()` format: | Key | Type | Default | |---|---|---| | `stdout` | `string` | `''` | | `stderr` | `string` | `''` | | `exit_code` | `int` | `0` | | `duration` | `float` | `0.0` | | `timed_out` | `bool` | `false` | | `truncated_stdout` | `bool` | `false` | | `truncated_stderr` | `bool` | `false` | Any omitted key uses its default value, so you only need to specify the fields relevant to your test. ## Inspecting Recorded Commands The mock records every command it receives. Use the `commands()` method to retrieve the full history: ```php $sandbox = FakeSandbox::fromResponses([ 'php -v' => [ new ExecResult(stdout: 'PHP 8.3.0', stderr: '', exitCode: 0, duration: 0.01), ], 'php -r echo 1;' => [ new ExecResult(stdout: '1', stderr: '', exitCode: 0, duration: 0.01), ], ]); $sandbox->execute(['php', '-v']); $sandbox->execute(['php', '-r', 'echo 1;']); $commands = $sandbox->commands(); // [ // ['php', '-v'], // ['php', '-r', 'echo 1;'], // ] // @doctest id="900b" ``` This is useful for asserting that your code called the expected commands in the expected order. ### Standard Input Recording When stdin is provided, it is appended to the recorded argv as a `[stdin=...]` entry: ```php $sandbox = FakeSandbox::fromResponses([ 'php -r echo fgets(STDIN);' => [ new ExecResult(stdout: 'hello', stderr: '', exitCode: 0, duration: 0.01), ], ]); $sandbox->execute(['php', '-r', 'echo fgets(STDIN);'], 'hello'); $commands = $sandbox->commands(); // [ // ['php', '-r', 'echo fgets(STDIN);', '[stdin=hello]'], // ] // @doctest id="3837" ``` ## Streaming Callback Support The `FakeSandbox` honors the streaming callback, just like real drivers. When a callback is provided, the fake delivers stdout and stderr from the canned response as single chunks: ```php $sandbox = FakeSandbox::fromResponses([ 'php script.php' => [ new ExecResult(stdout: 'output', stderr: 'warning', exitCode: 0, duration: 0.1), ], ]); $chunks = []; $sandbox->execute( ['php', 'script.php'], null, function (string $type, string $chunk) use (&$chunks) { $chunks[] = [$type, $chunk]; } ); // $chunks === [['out', 'output'], ['err', 'warning']] // @doctest id="6130" ``` Empty stdout or stderr is not delivered to the callback, matching the behavior of real drivers. ## Testing Failure Scenarios ### Simulating Timeouts Create an `ExecResult` with `timedOut: true` and exit code `124`: ```php $sandbox = FakeSandbox::fromResponses([ 'php long-script.php' => [ new ExecResult( stdout: 'partial output...', stderr: '', exitCode: 124, duration: 30.0, timedOut: true, ), ], ]); $result = $sandbox->execute(['php', 'long-script.php']); assert($result->timedOut() === true); assert($result->exitCode() === 124); // @doctest id="087d" ``` ### Simulating Truncated Output Set the truncation flags to test how your code handles oversized output: ```php $sandbox = FakeSandbox::fromResponses([ 'php noisy-script.php' => [ new ExecResult( stdout: '...last portion of output', stderr: '', exitCode: 0, duration: 5.0, truncatedStdout: true, ), ], ]); $result = $sandbox->execute(['php', 'noisy-script.php']); assert($result->truncatedStdout() === true); // @doctest id="cc9e" ``` ### Simulating Command Failures Test error handling by returning non-zero exit codes: ```php $sandbox = FakeSandbox::fromResponses([ 'php broken.php' => [ new ExecResult( stdout: '', stderr: 'PHP Fatal error: ...', exitCode: 255, duration: 0.5, ), ], ]); $result = $sandbox->execute(['php', 'broken.php']); assert($result->success() === false); assert($result->exitCode() === 255); // @doctest id="25ea" ``` ## Using FakeSandbox with Dependency Injection The `FakeSandbox` implements `CanExecuteCommand`, so it can be injected anywhere a real sandbox is expected. This is the recommended approach for testing application services: ```php use Cognesy\Sandbox\Contracts\CanExecuteCommand; class CodeExecutor { public function __construct( private readonly CanExecuteCommand $sandbox, ) {} public function run(string $code): string { $result = $this->sandbox->execute(['php', '-r', $code]); if (!$result->success()) { throw new \RuntimeException("Execution failed: " . $result->stderr()); } return $result->stdout(); } } // In your test: $mock = FakeSandbox::fromResponses([ 'php -r echo "hello";' => [ new ExecResult(stdout: 'hello', stderr: '', exitCode: 0, duration: 0.01), ], ]); $executor = new CodeExecutor($mock); assert($executor->run('echo "hello";') === 'hello'); // Verify the command was called assert($mock->commands() === [['php', '-r', 'echo "hello";']]); // @doctest id="bf00" ``` ## Quick Reference | Method | Purpose | |---|---| | `FakeSandbox::fromResponses($responses, $defaultResponse)` | Create fake with canned responses and optional default | | `new FakeSandbox($policy, $responses, $defaultResponse)` | Create fake with custom policy | | `$mock->enqueue($key, $result)` | Add a response to the queue after construction | | `$mock->execute($argv, $stdin, $onOutput)` | Execute and return next canned response | | `$mock->commands()` | Get all recorded commands as `list>` | | `$mock->policy()` | Access the execution policy | ================================================================================ FILE: packages/sandbox/7-troubleshooting.md ================================================================================ ## Introduction This page covers the most common issues you may encounter when using the Sandbox package. Each entry describes the symptom, explains the root cause, and provides concrete solutions. ## Driver Binary Not Found **Symptom:** A `RuntimeException` with the message "Failed to start docker" (or podman, firejail, bwrap) is thrown when calling `execute()`. **Cause:** The driver cannot locate the required binary on the system. The `ProcRunner` wraps the `proc_open` failure and reports it with the driver name. **Solutions:** 1. Verify the binary is installed and executable: ```bash which docker # or podman, firejail, bwrap ``` 2. Check what PHP sees as `PATH`. In web server or systemd contexts, the `PATH` is often more restrictive than your shell's: ```php echo getenv('PATH'); ``` 3. Set the binary path explicitly through an environment variable before your PHP process starts: ```bash export DOCKER_BIN=/usr/local/bin/docker export PODMAN_BIN=/usr/bin/podman export FIREJAIL_BIN=/usr/bin/firejail export BWRAP_BIN=/usr/bin/bwrap ``` 4. Pass the binary path directly to the static factory: ```php $sandbox = Sandbox::docker($policy, dockerBin: '/usr/local/bin/docker'); $sandbox = Sandbox::podman($policy, podmanBin: '/usr/bin/podman'); $sandbox = Sandbox::firejail($policy, firejailBin: '/usr/bin/firejail'); $sandbox = Sandbox::bubblewrap($policy, bubblewrapBin: '/usr/bin/bwrap'); ``` The package searches the following directories in addition to `PATH`: `/usr/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `/opt/local/bin`, and `/snap/bin`. On Windows, `.exe` extensions are tried automatically. ## Invalid Driver Name **Symptom:** An `InvalidArgumentException` is thrown listing the valid driver names. **Cause:** A string passed to `Sandbox::fromPolicy($policy)->using()` does not match any known driver value. **Solution:** Use the `SandboxDriver` enum to avoid typos: ```php use Cognesy\Sandbox\Enums\SandboxDriver; $sandbox = Sandbox::fromPolicy($policy)->using(SandboxDriver::Docker); // @doctest id="04f9" ``` The valid string values are: `host`, `docker`, `podman`, `firejail`, `bubblewrap`. These match the `SandboxDriver` enum's backing values exactly. ## Command Times Out **Symptom:** The `ExecResult` has `timedOut()` returning `true` and `exitCode()` returning `124`. The output may be incomplete. **Cause:** The command exceeded either the wall-clock timeout or the idle timeout specified in the execution policy. The `TimeoutTracker` monitors both independently and terminates the process as soon as either limit is reached. **Solutions:** 1. Increase the wall-clock timeout: ```php $policy = $policy->withTimeout(60); // 60 seconds ``` 2. If the process produces output in bursts with long pauses, increase or disable the idle timeout: ```php $policy = $policy->withIdleTimeout(30); // 30 seconds of no output $policy = $policy->withIdleTimeout(null); // disable idle timeout entirely ``` 3. Use the streaming callback to monitor progress and identify where the command stalls: ```php $result = $sandbox->execute($argv, null, function (string $type, string $chunk) { echo "[" . date('H:i:s') . "] {$type}: {$chunk}"; }); ``` 4. For container drivers, keep in mind that the timeout includes container startup time. If image pulling is needed on the first run, it may consume a significant portion of the budget. Pre-pull images to avoid this. **Note:** The package sends `SIGTERM` first and waits briefly, then escalates to `SIGKILL` if the process does not exit. For container drivers, this terminates the entire process group (via `setsid`) to ensure no orphan processes remain. ## Truncated Output **Symptom:** Output appears incomplete, and `truncatedStdout()` or `truncatedStderr()` returns `true`. **Cause:** The command produced more output than the policy's output caps allow. The `StreamAggregator` retains only the most recent bytes up to the cap, discarding earlier content. This tail-preserving strategy ensures error messages and final status information are always captured. **Solution:** Increase the output caps in the policy: ```php $policy = $policy->withOutputCaps( stdoutBytes: 10 * 1024 * 1024, // 10 MB stderrBytes: 2 * 1024 * 1024, // 2 MB ); // @doctest id="8d27" ``` The default cap is 1 MB (1,048,576 bytes) for each stream. The minimum is 1024 bytes -- values below this are clamped upward. If you need the complete output but want to keep the policy cap low for memory safety, use a streaming callback to write output to a file: ```php $logFile = fopen('/tmp/full-output.log', 'w'); $result = $sandbox->execute($argv, null, function (string $type, string $chunk) use ($logFile) { fwrite($logFile, $chunk); }); fclose($logFile); // $result->stdout() may be truncated, but /tmp/full-output.log has everything // @doctest id="e96b" ``` ## Working Directory Errors **Symptom:** A `RuntimeException` with the message "Base directory is invalid or not writable" is thrown. **Cause:** The `baseDir` specified in the policy does not exist, is not a directory, or is not writable by the PHP process. The `Workdir::create()` method validates this before attempting to create a temporary subdirectory. **Solutions:** 1. Verify the directory exists and has correct permissions: ```bash ls -ld /path/to/base/dir ``` 2. Create the directory if it does not exist: ```bash mkdir -p /path/to/base/dir chmod 755 /path/to/base/dir ``` 3. Use a known-writable location: ```php $policy = ExecutionPolicy::in('/tmp'); ``` For container drivers (Docker, Podman, Firejail, Bubblewrap), a unique temporary subdirectory is created inside the base directory for each execution using cryptographically random names (24 hex characters). The directory is set to mode `0700` and cleaned up in a `finally` block, ensuring removal even when the command fails or throws an exception. ## File Access Denied in Sandbox **Symptom:** The sandboxed command cannot read or write files at expected paths. **Cause:** Container and sandbox drivers restrict file-system access by default. Only the working directory and explicitly mounted paths are accessible. **Solutions:** 1. For files the command needs to read, add them as readable paths: ```php $policy = $policy->withReadablePaths('/data/input', '/etc/config'); ``` 2. For files the command needs to write, add them as writable paths: ```php $policy = $policy->withWritablePaths('/data/output'); ``` 3. Remember that `withReadablePaths()` and `withWritablePaths()` **replace** the current list. Pass all paths in a single call: ```php // Correct $policy = $policy->withReadablePaths('/data/a', '/data/b'); // Wrong -- only '/data/b' is mounted $policy = $policy->withReadablePaths('/data/a'); $policy = $policy->withReadablePaths('/data/b'); ``` 4. Paths containing symlinks, `..` components, or colons are silently skipped for security. Use `realpath()` to resolve paths before passing them to the policy: ```php $resolved = realpath('/data/symlinked-dir'); $policy = $policy->withReadablePaths($resolved); ``` 5. For Docker and Podman, paths are mounted at `/mnt/ro0`, `/mnt/ro1`, ... (readable) and `/mnt/rw0`, `/mnt/rw1`, ... (writable). Your command must reference these container paths, not the host paths. For Bubblewrap, paths are mounted at their original host locations. ## Docker / Podman Permission Errors **Symptom:** The command fails with "permission denied" errors inside the container. **Cause:** Container drivers run commands as the `nobody` user (UID 65534, GID 65534) with a read-only root filesystem and all capabilities dropped. This prevents writing to most locations inside the container. **Solutions:** 1. The working directory (`/work`) is mounted as writable. Write output files there. 2. A writable tmpfs is mounted at `/tmp` inside the container (64 MB, with `noexec`, `nodev`, `nosuid` flags). Use it for temporary files -- but note that executables cannot be run from `/tmp` due to `noexec`. 3. For additional writable locations, add them through `withWritablePaths()` in the policy. 4. If the container image requires root to set up (e.g., installing packages), build a custom image with a Dockerfile that performs setup as root and then switches to user 65534. ## Podman on WSL2 **Symptom:** Podman commands fail with cgroup-related errors under WSL2. **Cause:** WSL2's default cgroup configuration is not fully compatible with Podman's expectations for resource limits. **What the driver does automatically:** The `PodmanSandbox` detects WSL2 environments by checking `/proc/version` for "WSL2" or "microsoft" strings, and by checking `/proc/self/cgroup` for the root cgroup indicator. When WSL2 is detected: - The `--cgroup-manager=cgroupfs` flag is added as a global Podman flag. - Memory (`--memory`) and CPU (`--cpus`) resource limits are skipped entirely. All other security hardening (read-only root, dropped capabilities, nobody user, pids limit, etc.) remains fully active. If you still encounter issues, verify that: - Your WSL2 distribution has cgroup v2 mounted. - Podman is configured for rootless operation. - The Podman binary is accessible (check with `PODMAN_BIN` or `which podman`). ## Network Connectivity Issues **Symptom:** The sandboxed command cannot reach external services (DNS resolution fails, connections time out). **Cause:** Network access is disabled by default in the execution policy. **Solution:** Enable network access explicitly: ```php $policy = $policy->withNetwork(true); // @doctest id="d01f" ``` **How network isolation is implemented per driver:** | Driver | Mechanism | Notes | |---|---|---| | Host | None | `withNetwork()` is a policy declaration only | | Docker | `--network=none` | Full network stack isolation | | Podman | `--network=none` | Full network stack isolation | | Firejail | `--net=none` | Linux network namespace | | Bubblewrap | `--unshare-net` | Linux network namespace | For the host driver, there is no OS-level network enforcement. If you need actual network isolation on the host, use a container or sandbox driver. ## Environment Variables Not Available **Symptom:** The sandboxed command does not see expected environment variables. **Cause:** By default, the host environment is **not** inherited. Additionally, security-sensitive variables are always stripped by `EnvUtils`, even when inheritance is enabled. **Solutions:** 1. Pass specific variables explicitly: ```php $policy = $policy->withEnv(['APP_ENV' => 'production', 'DB_HOST' => 'localhost']); ``` 2. Enable environment inheritance with your overrides on top: ```php $policy = $policy->withEnv(['APP_ENV' => 'test'], inherit: true); ``` 3. Be aware that the following variable patterns are **always blocked** and cannot be overridden: | Category | Patterns | |---|---| | Dynamic linker | `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT` | | macOS linker | `DYLD_INSERT_LIBRARIES`, `DYLD_LIBRARY_PATH`, `DYLD_FRAMEWORK_PATH` | | PHP config | `PHP_INI_SCAN_DIR`, `PHPRC` | | AWS credentials | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` | | Google Cloud | `GOOGLE_APPLICATION_CREDENTIALS`, `GCP_*` | | Azure | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | | Ruby | `GEM_HOME`, `GEM_PATH`, `RUBY*` | | Node.js | `NODE_OPTIONS`, `NPM_*` | | Python | `PYTHON*`, `PIP_*` | Pattern matching uses `fnmatch()`, so `AWS_*` matches any variable starting with `AWS_`. ## FakeSandbox Throws "No Response" Error **Symptom:** A `RuntimeException` with the message "FakeSandbox has no response for command: ..." is thrown. **Cause:** The command key (argv joined with spaces) does not match any registered response, and no default response was provided. **Solutions:** 1. Verify the command key matches exactly. The key is formed by joining the argv array with spaces: ```php // ['php', '-r', 'echo 1;'] becomes 'php -r echo 1;' ``` 2. Provide a default response for unmatched commands: ```php $sandbox = FakeSandbox::fromResponses( responses: [...], defaultResponse: new ExecResult(stdout: '', stderr: '', exitCode: 0, duration: 0.0), ); ``` 3. If a command is called multiple times, ensure enough responses are enqueued. Each call consumes one response from the queue. Use `enqueue()` to add more: ```php $sandbox->enqueue('php -v', new ExecResult( stdout: 'PHP 8.3.0', stderr: '', exitCode: 0, duration: 0.01, )); ``` 4. Check `$sandbox->commands()` after a test failure to see exactly which commands were called and in what order. ## Memory Limit Format Errors **Symptom:** An `InvalidArgumentException` with the message "Invalid memory limit format" or "Unbounded memory limit (-1) is not allowed" is thrown when creating or modifying a policy. **Cause:** The `withMemory()` method validates the format strictly. The value must be a positive integer optionally followed by `K`, `M`, or `G`. The value `-1` (commonly used in PHP to mean "unlimited") is explicitly rejected. **Solutions:** 1. Use a valid format: ```php $policy = $policy->withMemory('256M'); // Valid $policy = $policy->withMemory('1G'); // Valid (clamped to 1024M) $policy = $policy->withMemory('512K'); // Valid ``` 2. Avoid passing raw byte counts without a suffix. The value `134217728` (128 MB in bytes) would be interpreted as 134,217,728 bytes without a unit, which is valid but may not produce the result you expect. Prefer using `M` or `G` suffixes for clarity. 3. The maximum memory limit is clamped to 1 GB. Any value above this is silently reduced to `1024M`. ## Process Group Termination **Symptom:** After a timeout, child processes spawned by the sandboxed command continue running. **Cause:** The command spawned child processes that were not in the same process group. **How the package handles this:** All container drivers use `setsid` (when available on the system) to run the command in a new session group. On timeout, `SIGTERM` is sent to the entire process group (`kill -15 -$PID`), followed by a brief wait and then `SIGKILL` (`kill -9 -$PID`) if the process is still running. The host driver relies on Symfony Process's built-in termination logic. If orphan processes persist, ensure that: - `setsid` is available on your system (check `/usr/bin/setsid` or `/bin/setsid`). - The container driver is being used instead of the host driver for better isolation. - Your command does not detach processes into separate sessions. ================================================================================ FILE: packages/http/1-overview.md ================================================================================ The HTTP client package provides a unified transport layer for making HTTP requests across different PHP environments. Whether your application runs on Symfony, Laravel, or plain PHP, you interact with the same small set of types and the underlying driver handles the rest. ## Why a Custom HTTP Layer? PHP has no shortage of HTTP clients, but each one exposes a different API. Switching from Guzzle to Symfony's HttpClient means rewriting every call site. This package solves that problem by placing a thin abstraction over the driver of your choice: - **Framework agnostic** -- the same request code works in Laravel, Symfony, or vanilla PHP without modification. - **Pluggable drivers** -- switch between cURL, Guzzle, Symfony, or your own driver with a single configuration change. - **Streaming first** -- first-class support for streamed responses, which is essential for LLM token-by-token output and server-sent events. - **Middleware pipeline** -- add retry logic, circuit breakers, idempotency keys, or debug logging without touching driver internals. - **Immutable value objects** -- requests, responses, and configuration are all immutable. Every `with*()` call returns a new instance. ## Core Types The package is built around a handful of focused types: | Type | Role | |------|------| | `CanSendHttpRequests` | Top-level transport contract | | `HttpClient` | Default implementation of `CanSendHttpRequests` | | `HttpRequest` | Immutable request value object (URL, method, headers, body, options) | | `PendingHttpResponse` | Deferred execution wrapper returned by `send()` | | `HttpResponse` | Buffered or streamed response value object | | `HttpClientBuilder` | Explicit composition entry point for building clients | | `HttpClientConfig` | Typed driver configuration (timeouts, chunk sizes, error handling) | ## Request Lifecycle Every request follows the same path through the system: ```text HttpClient -> send(HttpRequest) -> PendingHttpResponse -> get() or stream() -> HttpResponse // @doctest id="0d62" ``` 1. You create an `HttpRequest` with a URL, method, headers, and body. 2. You pass it to `HttpClient::send()`, which returns a `PendingHttpResponse`. 3. The pending response is lazy -- no network call happens until you call `get()` (for buffered responses) or `stream()` (for streamed responses). 4. The driver executes the request through the middleware pipeline and returns an `HttpResponse`. ## Architecture The package follows a layered architecture: ```text Your Code -> HttpClient (CanSendHttpRequests) -> MiddlewareStack -> Middleware 1 -> Middleware 2 -> ... -> Driver | <- Middleware 1 <- Middleware 2 <- ... <- Driver -> HttpResponse // @doctest id="eb05" ``` **Client layer.** `HttpClient` is the public entry point. It delegates to `HttpClientRuntime`, which wires together the driver, middleware stack, and event dispatcher. **Middleware layer.** A pipeline of `HttpMiddleware` implementations that can inspect or modify requests before they reach the driver and responses after they come back. Middleware is processed in order for requests and in reverse order for responses. **Driver layer.** Drivers implement `CanHandleHttpRequest` and adapt a specific HTTP library to the common interface. The bundled drivers are: | Driver | Library | Dependency | |--------|---------|------------| | `CurlDriver` | PHP cURL extension | None (built-in) | | `GuzzleDriver` | Guzzle HTTP | `guzzlehttp/guzzle` | | `SymfonyDriver` | Symfony HttpClient | `symfony/http-client` | | `MockHttpDriver` | Built-in test double | None | ## Immutability All core types are immutable. When you call a `with*()` method on a request, response, config, or client, you receive a new instance. Always reassign the result: ```php // Correct $request = $request->withHeader('Authorization', 'Bearer ' . $token); // Wrong -- the return value is discarded $request->withHeader('Authorization', 'Bearer ' . $token); // @doctest id="d7ff" ``` ## Pooling Concurrent request execution has been extracted to its own package. See `packages/http-pool` for `HttpPool`, `PendingHttpPool`, and `HttpPoolBuilder`. The request and response collection types (`HttpRequestList` and `HttpResponseList`) remain in this package. ================================================================================ FILE: packages/http/2-getting-started.md ================================================================================ ## Installation Install the package via Composer: ```bash composer require cognesy/instructor-http-client # @doctest id="32a3" ``` You also need at least one supported HTTP library. The default driver uses PHP's built-in cURL extension, so if cURL is available you can start immediately. For other drivers, install the corresponding package: ```bash # Guzzle composer require guzzlehttp/guzzle # Symfony HttpClient composer require symfony/http-client # @doctest id="55fa" ``` ### Requirements - PHP 8.2 or higher - JSON extension - cURL extension (included by default in most PHP installations) ## Sending Your First Request Using the HTTP client involves three steps: create a client, build a request, and read the response. ```php use Cognesy\Http\Data\HttpRequest; use Cognesy\Http\HttpClient; $client = HttpClient::default(); $response = $client->send(new HttpRequest( url: 'https://api.example.com/health', method: 'GET', headers: ['Accept' => 'application/json'], body: '', options: [], ))->get(); echo $response->statusCode(); // 200 echo $response->body(); // {"status":"ok"} // @doctest id="b3b3" ``` `HttpClient::default()` creates a client with the default cURL driver. The `send()` method returns a `PendingHttpResponse`, which is lazy -- the network call does not happen until you call `get()` or `stream()`. ## Choosing a Driver If you want a specific driver, use a preset name or pass an `HttpClientConfig`: ```php use Cognesy\Http\HttpClient; $client = HttpClient::using('guzzle'); // @doctest id="e3cd" ``` Or construct the config explicitly: ```php use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\HttpClient; $client = HttpClient::fromConfig(new HttpClientConfig(driver: 'guzzle')); // @doctest id="4da8" ``` Or use the builder for more control: ```php use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withConfig(new HttpClientConfig( driver: 'symfony', connectTimeout: 5, requestTimeout: 30, )) ->create(); // @doctest id="29a2" ``` ## Error Handling HTTP requests can fail for many reasons. Wrap your calls in a try-catch block: ```php use Cognesy\Http\Exceptions\HttpRequestException; try { $response = $client->send($request)->get(); } catch (HttpRequestException $e) { echo "Request failed: {$e->getMessage()}\n"; if ($e->getResponse()) { echo "Status: {$e->getResponse()->statusCode()}\n"; } } // @doctest id="da88" ``` The exception hierarchy gives you granular control: | Exception | When | |-----------|------| | `HttpRequestException` | Base class for all HTTP errors | | `NetworkException` | Network-level failures | | `ConnectionException` | Could not connect to the host | | `TimeoutException` | Connect or request timeout exceeded | | `HttpClientErrorException` | HTTP 4xx response (when `failOnError` is true) | | `ServerErrorException` | HTTP 5xx response (when `failOnError` is true) | | `CircuitBreakerOpenException` | Circuit breaker is open for the target host | When `failOnError` is set to `true` in the config, the client throws typed exceptions for 4xx and 5xx responses automatically. When it is `false` (the default), you need to check the status code yourself. ## Testing with Mocks For tests, use the builder's `withMock()` method to supply predefined responses without making real HTTP calls: ```php use Cognesy\Http\Creation\HttpClientBuilder; use Cognesy\Http\Data\HttpResponse; $client = (new HttpClientBuilder()) ->withMock(function ($mock) { $mock->addResponse( HttpResponse::sync(200, ['Content-Type' => 'application/json'], '{"ok":true}'), url: 'https://api.example.com/health', method: 'GET', ); }) ->create(); $response = $client->send(new HttpRequest( url: 'https://api.example.com/health', method: 'GET', headers: [], body: '', options: [], ))->get(); echo $response->body(); // {"ok":true} // @doctest id="ecd6" ``` The mock driver matches responses by URL and method, making it straightforward to verify that your application sends the right requests. ## What's Next Now that you have a working client, explore the rest of the documentation: - [Making Requests](3-making-requests.md) -- learn about request construction, HTTP methods, headers, and bodies. - [Handling Responses](4-handling-responses.md) -- read buffered content, inspect headers, and decode JSON. - [Streaming Responses](5-streaming-responses.md) -- consume chunked data as it arrives. - [Middleware](10-middleware.md) -- add retry logic, circuit breakers, and custom behaviors. ================================================================================ FILE: packages/http/testing-doubles.md ================================================================================ ## Overview `http-client` uses a real mock, not a fake. The package-level deterministic testing seam is `MockHttpDriver`, plus the builder shortcut `withMock()` and the helper factory `MockHttpResponseFactory`. Use this seam when you want to test: - request matching and expectations - response status and body handling - retries and sequential responses - streaming and SSE payloads without network calls ## `MockHttpDriver` `MockHttpDriver` is expectation-driven. That means it is the right tool when the test needs to say: - which request should be matched - how many times it should match - which response should be returned It supports: - fluent expectations through `expect()` and `on()` - request matching by method, URL, path, headers, body, and stream mode - sequential replies with `times(...)` - request inspection through `getReceivedRequests()` and `getLastRequest()` ## `withMock()` `HttpClientBuilder::withMock()` is the easiest way to build a deterministic client. Minimal example: ```php use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withMock(function ($mock) { $mock->expect() ->get('https://api.example.com/health') ->replyJson(['ok' => true]); }) ->create(); // @doctest id="acba" ``` Use this for most package and downstream tests. ## `MockHttpResponseFactory` `MockHttpResponseFactory` helps when a test needs to construct: - JSON responses - error responses - streaming chunk responses - SSE responses This is useful when the reply needs more structure than `replyJson(...)` or `replyText(...)`. ## Which One To Use Use this rule of thumb: - `withMock()` for most client-builder tests - `MockHttpDriver` directly when you need to inspect or reuse the driver object - `MockHttpResponseFactory` when you need richer reply shapes For a denser cookbook of examples, see `packages/http-client/MOCK_HTTP_CLIENT.md`. ================================================================================ FILE: packages/http/3-making-requests.md ================================================================================ Every HTTP request is represented by an `HttpRequest` value object. You construct it with explicit parameters -- URL, method, headers, body, and options -- and pass it to `HttpClient::send()`. There are no magic methods or implicit state; what you see is what gets sent. ## Request Structure The `HttpRequest` constructor accepts five named parameters: ```php use Cognesy\Http\Data\HttpRequest; $request = new HttpRequest( url: 'https://api.example.com/users', method: 'GET', headers: ['Accept' => 'application/json'], body: '', options: [], ); // @doctest id="54af" ``` | Parameter | Type | Description | |-----------|------|-------------| | `url` | `string` | The full URL including any query parameters | | `method` | `string` | The HTTP method (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, etc.) | | `headers` | `array` | Associative array of header name to value | | `body` | `string\|array` | Request body -- arrays are JSON-encoded automatically; strings are sent verbatim | | `options` | `array` | Driver-level options (e.g., `['stream' => true]`) | Each request is also assigned a unique `id` and timestamped with `createdAt` and `updatedAt` properties automatically. ## GET Requests GET requests are the simplest form. Pass query parameters directly in the URL: ```php $request = new HttpRequest( url: 'https://api.example.com/users?page=1&limit=10', method: 'GET', headers: ['Accept' => 'application/json'], body: '', options: [], ); $response = $client->send($request)->get(); // @doctest id="1920" ``` ## POST with JSON Body When you pass an array as the body, it is automatically JSON-encoded through the `HttpRequestBody` class: ```php $request = new HttpRequest( url: 'https://api.example.com/users', method: 'POST', headers: [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], body: [ 'name' => 'John Doe', 'email' => 'john@example.com', ], options: [], ); $response = $client->send($request)->get(); // @doctest id="790d" ``` You can also pass a pre-encoded JSON string if you need precise control over the encoding. String bodies are sent as-is; the drivers do not parse and reserialize them: ```php $request = new HttpRequest( url: 'https://api.example.com/users', method: 'POST', headers: ['Content-Type' => 'application/json'], body: json_encode(['name' => 'John Doe']), options: [], ); // @doctest id="b919" ``` ## PUT, PATCH, and DELETE All HTTP methods work the same way. Just change the method string: ```php // Update an entire resource $putRequest = new HttpRequest( url: 'https://api.example.com/users/123', method: 'PUT', headers: ['Content-Type' => 'application/json'], body: ['name' => 'Jane Doe', 'email' => 'jane@example.com'], options: [], ); // Partially update a resource $patchRequest = new HttpRequest( url: 'https://api.example.com/users/123', method: 'PATCH', headers: ['Content-Type' => 'application/json'], body: ['email' => 'new@example.com'], options: [], ); // Delete a resource $deleteRequest = new HttpRequest( url: 'https://api.example.com/users/123', method: 'DELETE', headers: [], body: '', options: [], ); // @doctest id="fa80" ``` ## Setting Headers Headers are passed as a flat associative array. Common headers include authentication tokens, content types, and custom application headers: ```php $request = new HttpRequest( url: 'https://api.example.com/data', method: 'GET', headers: [ 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $apiToken, 'User-Agent' => 'MyApp/1.0', 'X-Request-Source' => 'backend', ], body: '', options: [], ); // @doctest id="67cc" ``` ## Modifying Requests `HttpRequest` is immutable. The `with*()` methods return a new instance with the modification applied: ```php $request = $request->withHeader('Authorization', 'Bearer ' . $token); $request = $request->withStreaming(true); // @doctest id="f141" ``` You can read request properties at any time through accessor methods: ```php $request->url(); // The request URL $request->method(); // The HTTP method $request->headers(); // All headers as an array $request->headers('Accept'); // A specific header value $request->body(); // The HttpRequestBody instance $request->options(); // The options array $request->isStreamed(); // Whether streaming is enabled // @doctest id="9537" ``` The body is managed by `HttpRequestBody`, which provides `toString()` and `toArray()` conversions: ```php $bodyString = $request->body()->toString(); // Exact outbound body string $bodyArray = $request->body()->toArray(); // Decoded array when the body contains valid JSON // @doctest id="fc42" ``` ## Streaming Option To enable streaming on a request, either set the `stream` option in the constructor or use `withStreaming()`: ```php // Via constructor $request = new HttpRequest( url: 'https://api.example.com/stream', method: 'POST', headers: ['Content-Type' => 'application/json'], body: ['prompt' => 'Hello', 'stream' => true], options: ['stream' => true], ); // Or via the mutator $request = $request->withStreaming(true); // @doctest id="b9fa" ``` When `isStreamed()` returns `true`, calling `stream()` on the `PendingHttpResponse` will yield chunks as they arrive instead of buffering the entire response. ## Building Clients with Config For production use, you will typically configure the client with typed options: ```php use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withConfig(new HttpClientConfig( driver: 'guzzle', connectTimeout: 5, requestTimeout: 30, failOnError: true, )) ->create(); // @doctest id="762a" ``` This gives you a client that uses Guzzle, connects within 5 seconds, times out after 30 seconds, and throws exceptions on 4xx/5xx responses. See [Changing Client Config](8-changing-client-config.md) for the full list of options. ================================================================================ FILE: packages/http/4-handling-responses.md ================================================================================ When you call `HttpClient::send()`, it returns a `PendingHttpResponse`. This object is lazy -- no HTTP call is made until you explicitly request the response data. This design lets you decide whether to consume the response as a buffered body or as a stream of chunks. ## The Pending Response `PendingHttpResponse` provides several methods for reading the response: ```php $pending = $client->send($request); // @doctest id="e637" ``` | Method | Returns | Description | |--------|---------|-------------| | `get()` | `HttpResponse` | Execute the request and return the full response | | `statusCode()` | `int` | Execute (if needed) and return the HTTP status code | | `headers()` | `array` | Execute (if needed) and return the response headers | | `content()` | `string` | Execute and return the response body as a string | | `stream()` | `Generator` | Execute in streaming mode and yield chunks | The pending response caches its result internally. Calling `get()` multiple times will not send the request again. Streaming and synchronous execution are cached independently to avoid mode collisions -- you can safely call both `content()` and `stream()` on the same pending response. ## Buffered Responses For a standard request, call `get()` to receive the full `HttpResponse`: ```php $response = $client->send($request)->get(); $status = $response->statusCode(); // 200 $headers = $response->headers(); // ['Content-Type' => 'application/json', ...] $body = $response->body(); // '{"id":1,"name":"John"}' // @doctest id="c9bf" ``` ### Decoding JSON Most APIs return JSON. Decode the body with PHP's built-in function: ```php $data = json_decode($response->body(), true); if (json_last_error() !== JSON_ERROR_NONE) { throw new \RuntimeException('Invalid JSON: ' . json_last_error_msg()); } echo $data['name']; // John // @doctest id="9a80" ``` ### Checking Status Codes You can inspect the status code to branch on success or failure: ```php $response = $client->send($request)->get(); if ($response->statusCode() >= 200 && $response->statusCode() < 300) { $data = json_decode($response->body(), true); // Process successful response } elseif ($response->statusCode() === 404) { // Resource not found } elseif ($response->statusCode() >= 500) { // Server error -- maybe retry } // @doctest id="c159" ``` ## Streamed Responses For streamed requests, call `stream()` on the pending response. This returns a PHP Generator that yields chunks as they arrive from the server: ```php foreach ($client->send($request)->stream() as $chunk) { echo $chunk; } // @doctest id="6063" ``` The `stream()` method always forces streaming mode regardless of the request's `isStreamed()` flag. Similarly, `get()` and `content()` always force synchronous mode. > **Important:** Calling `body()` on a streamed `HttpResponse` throws a `LogicException`. Use `stream()` instead. This prevents accidentally buffering a large response that was intended to be consumed incrementally. ## Creating Responses Programmatically The `HttpResponse` class provides factory methods for creating responses in tests or middleware: ```php use Cognesy\Http\Data\HttpResponse; // Synchronous response $sync = HttpResponse::sync( statusCode: 200, headers: ['Content-Type' => 'application/json'], body: '{"ok":true}', ); // Streaming response $streamed = HttpResponse::streaming( statusCode: 200, headers: ['Content-Type' => 'text/event-stream'], stream: $someStreamInterface, ); // Empty response $empty = HttpResponse::empty(); // @doctest id="23c9" ``` ## Error Handling with failOnError By default, `failOnError` is `false` and 4xx/5xx responses are returned normally. When you set it to `true` in the config, the client throws typed exceptions: ```php use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\Exceptions\HttpClientErrorException; use Cognesy\Http\Exceptions\ServerErrorException; $client = HttpClient::fromConfig(new HttpClientConfig(failOnError: true)); try { $response = $client->send($request)->get(); } catch (HttpClientErrorException $e) { // 4xx error echo "Client error {$e->getStatusCode()}: {$e->getMessage()}\n"; echo "Response body: {$e->getResponse()->body()}\n"; } catch (ServerErrorException $e) { // 5xx error echo "Server error {$e->getStatusCode()}: {$e->getMessage()}\n"; } // @doctest id="82c3" ``` Each exception carries the original request, the response (if available), and the duration of the call. Use `$e->getRequest()`, `$e->getResponse()`, and `$e->getDuration()` to inspect them. ## Response Metadata The `HttpResponse` object also exposes metadata about the response: ```php $response->isStreamed(); // true if the response was created in streaming mode $response->isStreaming(); // true if the stream has not yet completed $response->rawStream(); // access the underlying StreamInterface // @doctest id="7800" ``` You can create a new response with a replaced stream using `withStream()`: ```php $decorated = $response->withStream($transformedStream); // @doctest id="4011" ``` This is the primary mechanism used by middleware to intercept and transform streamed data. ================================================================================ FILE: packages/http/5-streaming-responses.md ================================================================================ Streaming responses let you process data as it arrives from the server rather than waiting for the entire response to buffer in memory. This is particularly valuable when working with LLM APIs that generate tokens incrementally, downloading large files, or consuming real-time event streams. ## Enabling Streaming To receive a streaming response, set the `stream` option on the request: ```php use Cognesy\Http\Data\HttpRequest; $request = new HttpRequest( url: 'https://api.example.com/stream', method: 'GET', headers: ['Accept' => 'text/event-stream'], body: '', options: ['stream' => true], ); // @doctest id="0017" ``` You can also enable streaming on an existing request using `withStreaming()`: ```php $request = $request->withStreaming(true); // @doctest id="aea3" ``` ## Consuming the Stream Once you have a streaming request, call `stream()` on the pending response. This returns a PHP Generator that yields string chunks: ```php foreach ($client->send($request)->stream() as $chunk) { echo $chunk; flush(); } // @doctest id="cb36" ``` Each chunk is a raw string as received from the transport layer. The size of individual chunks depends on the driver and the `streamChunkSize` setting in `HttpClientConfig` (default: 256 bytes). > **Note:** You do not need to explicitly set `stream => true` on the request when using `PendingHttpResponse::stream()`. The pending response will force streaming mode automatically. However, setting it on the request is useful when middleware needs to know the intended mode before execution. ## Streaming LLM Responses Streaming is essential for AI/LLM integrations where responses are generated token by token. Here is a typical pattern for streaming a chat completion: ```php $request = new HttpRequest( url: 'https://api.openai.com/v1/chat/completions', method: 'POST', headers: [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $apiKey, ], body: [ 'model' => 'gpt-4', 'messages' => [ ['role' => 'user', 'content' => 'Write a haiku about PHP.'], ], 'stream' => true, ], options: ['stream' => true], ); foreach ($client->send($request)->stream() as $chunk) { echo $chunk; flush(); } // @doctest id="fd66" ``` The raw chunks from the transport layer will contain server-sent event framing (e.g., `data: {...}\n\n`). To parse these into clean payloads, use the `EventSourceMiddleware`. ## Server-Sent Events with EventSourceMiddleware The `EventSourceMiddleware` handles the SSE protocol for you. It strips the `data:` prefixes, buffers partial lines, and yields complete event payloads: ```php use Cognesy\Http\Extras\Middleware\EventSource\EventSourceMiddleware; $client = $client->withMiddleware( (new EventSourceMiddleware(true)) ->withParser(fn(string $payload): string => $payload), 'eventsource', ); // @doctest id="33dd" ``` The parser callback receives the raw payload string from each `data:` line and returns the value to yield. Return `false` to skip an event. This is useful for filtering out `[DONE]` markers or parsing JSON: ```php $client = $client->withMiddleware( (new EventSourceMiddleware(true)) ->withParser(function (string $payload): string|bool { if ($payload === '[DONE]') { return false; // skip } return $payload; }), 'eventsource', ); // @doctest id="60bf" ``` You can also attach listeners for debugging or event dispatching: ```php use Cognesy\Http\Extras\Support\EventSource\Listeners\PrintToConsole; use Cognesy\Http\Config\DebugConfig; $middleware = (new EventSourceMiddleware(true)) ->withListeners(new PrintToConsole(new DebugConfig(httpEnabled: true))) ->withParser(fn(string $payload): string => $payload); // @doctest id="a311" ``` ## Downloading Large Files Streaming is the right approach for downloading large files without exhausting memory: ```php $request = new HttpRequest( url: 'https://example.com/large-dataset.csv', method: 'GET', headers: [], body: '', options: ['stream' => true], ); $handle = fopen('dataset.csv', 'wb'); foreach ($client->send($request)->stream() as $chunk) { fwrite($handle, $chunk); } fclose($handle); // @doctest id="dc2f" ``` ## Considerations When working with streaming responses, keep these points in mind: - **Memory usage.** Streaming avoids buffering the entire response, but be careful not to accumulate chunks in a variable unless you actually need the full content. - **Connection stability.** Streaming connections stay open longer and are more sensitive to network interruptions. Pair streaming with retry middleware for resilience. - **Timeouts.** The `idleTimeout` setting in `HttpClientConfig` controls how long the client waits between data packets. Set it to `-1` to disable idle timeouts for long-lived streams. - **Body access.** Calling `body()` on a streamed `HttpResponse` throws a `LogicException`. Always use `stream()` for streamed responses. - **Middleware order.** Middleware that decorates the stream (like `EventSourceMiddleware`) should be registered before middleware that reads the final content. ================================================================================ FILE: packages/http/6-pooling.md ================================================================================ Concurrent request execution has been moved to its own dedicated package at `packages/http-pool`. This separation keeps the core HTTP client focused on single-request transport while giving the pooling layer its own lifecycle and dependencies. ## Where to Find Pooling The pooling API is provided by the `http-pool` package. The key types are: | Type | Namespace | Role | |------|-----------|------| | `HttpPool` | `Cognesy\HttpPool` | Executes a batch of requests concurrently | | `PendingHttpPool` | `Cognesy\HttpPool` | Deferred pool execution wrapper | | `HttpPoolBuilder` | `Cognesy\HttpPool\Creation` | Builder for constructing pool instances | Refer to `packages/http-pool/docs/` for full documentation on configuring concurrency limits, handling per-request errors, and collecting results. ## Collection Types The request and response collection types remain in the `http-client` package since they are useful beyond pooling: ### HttpRequestList A typed, immutable collection of `HttpRequest` objects: ```php use Cognesy\Http\Collections\HttpRequestList; use Cognesy\Http\Data\HttpRequest; $requests = HttpRequestList::of( new HttpRequest(url: 'https://api.example.com/a', method: 'GET', headers: [], body: '', options: []), new HttpRequest(url: 'https://api.example.com/b', method: 'GET', headers: [], body: '', options: []), ); // Access methods $requests->count(); // 2 $requests->first(); // First HttpRequest $requests->last(); // Last HttpRequest $requests->isEmpty(); // false $requests->all(); // Array of all requests // Immutable mutation $requests = $requests->withAppended($newRequest); $requests = $requests->filter(fn($r) => $r->method() === 'POST'); // @doctest id="e4c6" ``` ### HttpResponseList A typed, immutable collection of `Result` objects, where each result wraps either a successful `HttpResponse` or an error: ```php use Cognesy\Http\Collections\HttpResponseList; // After pool execution, you get an HttpResponseList $responses->count(); // Total results $responses->successful(); // Array of HttpResponse objects $responses->failed(); // Array of error values $responses->hasFailures(); // true if any request failed $responses->successCount(); // Number of successful responses $responses->failureCount(); // Number of failed responses // @doctest id="4528" ``` This design lets you handle partial failures gracefully -- some requests in a batch may succeed while others fail, and you can inspect each result independently. ================================================================================ FILE: packages/http/10-middleware.md ================================================================================ Middleware is the primary extension mechanism for the HTTP client. It lets you add behaviors -- logging, retries, circuit breaking, authentication, response transformation -- without modifying drivers or request code. Each middleware sits in a pipeline: requests pass through in order on the way out, and responses pass through in reverse order on the way back. ## How Middleware Works The middleware pipeline follows a simple pattern: ```text Request -> Middleware A -> Middleware B -> Middleware C -> Driver -> Server Response <- Middleware A <- Middleware B <- Middleware C <- Driver <- Server // @doctest id="6c85" ``` Each middleware receives the request and a reference to the next handler in the chain. It can modify the request, call the next handler, inspect or modify the response, or short-circuit the chain entirely by returning a response without calling next. ## The HttpMiddleware Interface All middleware implements a single interface: ```php namespace Cognesy\Http\Contracts; interface HttpMiddleware { public function handle(HttpRequest $request, CanHandleHttpRequest $next): HttpResponse; } // @doctest id="0a04" ``` Here is a complete example that adds a header to every request: ```php use Cognesy\Http\Contracts\CanHandleHttpRequest; use Cognesy\Http\Contracts\HttpMiddleware; use Cognesy\Http\Data\HttpRequest; use Cognesy\Http\Data\HttpResponse; final class AddHeaderMiddleware implements HttpMiddleware { public function __construct( private string $name, private string $value, ) {} public function handle(HttpRequest $request, CanHandleHttpRequest $next): HttpResponse { $request = $request->withHeader($this->name, $this->value); return $next->handle($request); } } // @doctest id="93bd" ``` ## The BaseMiddleware Abstract Class For most middleware, you do not need to implement the full `handle()` method. The `BaseMiddleware` class provides a template with overridable hooks: ```php use Cognesy\Http\Extras\Support\BaseMiddleware; use Cognesy\Http\Data\HttpRequest; use Cognesy\Http\Data\HttpResponse; final class TimingMiddleware extends BaseMiddleware { private float $start; protected function beforeRequest(HttpRequest $request): HttpRequest { $this->start = microtime(true); return $request; } protected function afterRequest(HttpRequest $request, HttpResponse $response): HttpResponse { $duration = round((microtime(true) - $this->start) * 1000, 2); error_log("Request to {$request->url()} took {$duration}ms"); return $response; } } // @doctest id="7d05" ``` The available hooks are: | Method | Purpose | |--------|---------| | `beforeRequest($request)` | Modify the request before sending. Return the (possibly modified) request. | | `afterRequest($request, $response)` | Inspect or modify the response after receiving it. Return the response. | | `shouldDecorateResponse($request, $response)` | Return `true` to wrap the response through `toResponse()`. Defaults to `true`. | | `toResponse($request, $response)` | Return a decorated response (e.g., with a transformed stream). | | `shouldExecute($request)` | Return `false` to skip this middleware entirely for a given request. | ## Registering Middleware ### On an Existing Client The `HttpClient` is immutable. `withMiddleware()` returns a new client with the middleware appended: ```php $client = $client->withMiddleware(new AddHeaderMiddleware('X-Request-ID', 'req-123'), 'request-id'); // @doctest id="0780" ``` The second argument is an optional name, which lets you remove the middleware later: ```php $client = $client->withoutMiddleware('request-id'); // @doctest id="da90" ``` ### Via the Builder The builder collects middleware before creating the client: ```php use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withMiddleware(new AddHeaderMiddleware('X-Api-Version', '2')) ->withMiddleware(new TimingMiddleware()) ->create(); // @doctest id="870a" ``` ## Built-in Middleware The package ships with several production-ready middleware components. ### RetryMiddleware Automatically retries failed requests with exponential backoff and jitter: ```php use Cognesy\Http\Extras\Middleware\RetryMiddleware; use Cognesy\Http\Extras\Support\RetryPolicy; $client = (new HttpClientBuilder()) ->withRetryPolicy(new RetryPolicy( maxRetries: 3, baseDelayMs: 250, maxDelayMs: 8000, jitter: 'full', // none, full, or equal retryOnStatus: [408, 429, 500, 502, 503, 504], respectRetryAfter: true, )) ->create(); // @doctest id="e103" ``` The retry middleware only operates on synchronous (non-streamed) requests. It respects the `Retry-After` header when present. The jitter options are: - `none` -- exact exponential backoff - `full` -- random delay between 0 and the calculated backoff - `equal` -- half the backoff plus a random portion of the other half ### CircuitBreakerMiddleware Prevents repeated calls to a failing service by tracking failures per host: ```php use Cognesy\Http\Extras\Middleware\CircuitBreakerMiddleware; use Cognesy\Http\Extras\Support\CircuitBreakerPolicy; $client = (new HttpClientBuilder()) ->withCircuitBreakerPolicy(new CircuitBreakerPolicy( failureThreshold: 5, openForSec: 30, halfOpenMaxRequests: 2, successThreshold: 2, failureStatusCodes: [429, 500, 502, 503, 504], )) ->create(); // @doctest id="caef" ``` The circuit breaker follows the standard state machine: - **Closed** -- requests flow normally; failures are counted. - **Open** -- after `failureThreshold` failures, the circuit opens and all requests throw `CircuitBreakerOpenException` for `openForSec` seconds. - **Half-open** -- after the timeout, a limited number of probe requests are allowed. If `successThreshold` probes succeed, the circuit closes. If any fail, it reopens. State is stored in APCu when available, with an in-memory fallback for environments without it. ### IdempotencyMiddleware Attaches a unique idempotency key to requests, which prevents duplicate processing when retries occur: ```php use Cognesy\Http\Extras\Middleware\IdempotencyMiddleware; $client = (new HttpClientBuilder()) ->withIdempotencyMiddleware(new IdempotencyMiddleware( headerName: 'Idempotency-Key', methods: ['POST'], hostAllowList: ['api.stripe.com'], )) ->create(); // @doctest id="d935" ``` The middleware only attaches keys to the specified HTTP methods and hosts. If the request already has an idempotency key header, it is left unchanged. ### EventSourceMiddleware Parses server-sent event streams into clean payloads. See [Streaming Responses](5-streaming-responses.md) for usage details. ### RecordReplayMiddleware Records HTTP interactions to disk and replays them later, which is invaluable for testing and development: ```php use Cognesy\Http\Extras\Middleware\RecordReplay\RecordReplayMiddleware; // Record mode -- real requests are made and saved $recorder = new RecordReplayMiddleware( mode: RecordReplayMiddleware::MODE_RECORD, storageDir: '/tmp/http_recordings', ); // Replay mode -- saved responses are returned without network calls $replayer = new RecordReplayMiddleware( mode: RecordReplayMiddleware::MODE_REPLAY, storageDir: '/tmp/http_recordings', fallbackToRealRequests: true, ); $client = (new HttpClientBuilder()) ->withMiddleware($replayer) ->create(); // @doctest id="9dc7" ``` When `fallbackToRealRequests` is `true`, unrecorded requests are sent to the real server. When `false`, a `RecordingNotFoundException` is thrown. Record/replay matching is intentionally narrow in 2.0.0: recordings are keyed by request method, full URL, and body. Request headers and request options are not part of the identity contract. For streamed responses, recording mode buffers the full upstream stream before returning a replayable streamed response. That keeps replay deterministic, but it means recording mode is not a transparent progressive-streaming path. ## Response Decoration For middleware that needs to transform streamed responses, use `BaseResponseDecorator` to wrap the stream with a transformation function: ```php use Cognesy\Http\Extras\Support\BaseResponseDecorator; $decorated = BaseResponseDecorator::decorate( $response, fn(string $chunk): string => strtoupper($chunk), ); // @doctest id="caa5" ``` This creates a new `HttpResponse` with a `TransformStream` that applies your function to each chunk. The original response is not modified. ## Writing Custom Middleware Here is a practical example of authentication middleware: ```php use Cognesy\Http\Extras\Support\BaseMiddleware; use Cognesy\Http\Data\HttpRequest; final class BearerAuthMiddleware extends BaseMiddleware { public function __construct( private string $token, ) {} protected function beforeRequest(HttpRequest $request): HttpRequest { return $request->withHeader('Authorization', 'Bearer ' . $this->token); } } // @doctest id="4224" ``` And a logging middleware that records request duration: ```php use Cognesy\Http\Contracts\CanHandleHttpRequest; use Cognesy\Http\Contracts\HttpMiddleware; use Cognesy\Http\Data\HttpRequest; use Cognesy\Http\Data\HttpResponse; use Psr\Log\LoggerInterface; final class LoggingMiddleware implements HttpMiddleware { public function __construct( private LoggerInterface $logger, ) {} public function handle(HttpRequest $request, CanHandleHttpRequest $next): HttpResponse { $this->logger->info('HTTP request', [ 'method' => $request->method(), 'url' => $request->url(), ]); $start = microtime(true); $response = $next->handle($request); $duration = microtime(true) - $start; $this->logger->info('HTTP response', [ 'status' => $response->statusCode(), 'duration_ms' => round($duration * 1000, 2), ]); return $response; } } // @doctest id="48e4" ``` ## Middleware Order The order you register middleware determines the execution flow. Middleware registered first is the outermost layer: ```php $client = (new HttpClientBuilder()) ->withMiddleware(new LoggingMiddleware($logger)) // 1st: logs everything ->withMiddleware(new RetryMiddleware($retryPolicy)) // 2nd: retries include auth ->withMiddleware(new BearerAuthMiddleware($token)) // 3rd: adds auth header ->create(); // @doctest id="d846" ``` In this setup: - **Request flow:** Logging -> Retry -> Auth -> Driver - **Response flow:** Driver -> Auth -> Retry -> Logging The retry middleware wraps the auth middleware, so retried requests get fresh auth headers. The logging middleware sees all attempts, including retries. ## Middleware Stack API The `MiddlewareStack` class provides fine-grained control over the middleware collection: ```php $stack->append($middleware, 'name'); // Add to end $stack->prepend($middleware, 'name'); // Add to beginning $stack->remove('name'); // Remove by name $stack->replace('name', $newMiddleware); // Replace by name $stack->has('name'); // Check existence $stack->get('name'); // Get by name $stack->clear(); // Remove all $stack->all(); // Get all middleware // @doctest id="fdde" ``` You can replace the entire stack on a client: ```php $client = $client->withMiddlewareStack($newStack); // @doctest id="21b8" ``` ## See Also - [Streaming Responses](5-streaming-responses.md) -- EventSourceMiddleware for SSE parsing. - [Custom Clients](9-1-custom-clients.md) -- create drivers that middleware wraps around. ================================================================================ FILE: packages/http/7-changing-client.md ================================================================================ One of the core design goals of this package is driver independence. You write your request code once, and the underlying HTTP library can be swapped at any time -- through configuration, the builder, or direct injection. This is especially valuable when moving between environments (e.g., a Symfony project that uses the Symfony driver vs. a CLI tool that uses raw cURL). ## Available Drivers The package ships with three production drivers and one test driver: | Driver name | Library | Package | |-------------|---------|---------| | `curl` | PHP cURL extension | Built-in (no dependency) | | `guzzle` | Guzzle HTTP | `guzzlehttp/guzzle` | | `symfony` | Symfony HttpClient | `symfony/http-client` | | (mock) | Built-in test double | Built-in | The default driver is `curl`, which requires no additional dependencies since the cURL extension is included with most PHP installations. ### CurlDriver The cURL driver provides zero-dependency HTTP support. It uses PHP's native cURL extension with automatic HTTP/1.1 and HTTP/2 negotiation, full header parsing, streaming support, and built-in SSL verification. This is the best choice for quick starts and lightweight applications. ### GuzzleDriver Guzzle is a mature, feature-rich HTTP client with its own middleware ecosystem, PSR-7 message support, and excellent streaming capabilities. Use it when you need Guzzle-specific features or when your project already depends on it. ### SymfonyDriver The Symfony HttpClient offers native HTTP/2 support, automatic content-type detection, and multiple transports (native PHP, cURL, amphp). It is the natural choice for Symfony applications. ## Switching Drivers ### Via Configuration The simplest way to choose a driver is through `HttpClientConfig`: ```php use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\HttpClient; // Use Guzzle $client = HttpClient::fromConfig(new HttpClientConfig(driver: 'guzzle')); // Use Symfony $client = HttpClient::fromConfig(new HttpClientConfig(driver: 'symfony')); // Use cURL (default) $client = HttpClient::fromConfig(new HttpClientConfig(driver: 'curl')); // @doctest id="7689" ``` ### Via the Builder The builder gives you the same choice in a more explicit, composable form: ```php use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withConfig(new HttpClientConfig(driver: 'guzzle')) ->create(); // @doctest id="b4ca" ``` ### Injecting a Driver Directly If you have a pre-configured driver instance, bypass the registry entirely: ```php use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withDriver($myCustomDriver) ->create(); // @doctest id="20b3" ``` Or use the static shorthand: ```php use Cognesy\Http\HttpClient; $client = HttpClient::fromDriver($myCustomDriver); // @doctest id="28c5" ``` ## Reusing a Vendor Client Instance Sometimes you already have a configured vendor client (e.g., a `GuzzleHttp\Client` with custom options or a Symfony client with specific transport settings). You can pass that instance directly: ```php use Cognesy\Http\Creation\HttpClientBuilder; use GuzzleHttp\Client; $guzzle = new Client([ 'timeout' => 10, 'verify' => '/path/to/cacert.pem', ]); $client = (new HttpClientBuilder()) ->withClientInstance('guzzle', $guzzle) ->create(); // @doctest id="a998" ``` The `withClientInstance()` method selects the driver by name and passes your vendor client to it, so the driver uses your instance instead of creating its own. This works with any supported driver: ```php use Symfony\Component\HttpClient\HttpClient as SymfonyHttpClient; $client = (new HttpClientBuilder()) ->withClientInstance('symfony', SymfonyHttpClient::create(['timeout' => 30])) ->create(); // @doctest id="7652" ``` ## Using the Mock Driver For testing, the mock driver lets you define responses without making network calls: ```php use Cognesy\Http\Creation\HttpClientBuilder; use Cognesy\Http\Data\HttpResponse; $client = (new HttpClientBuilder()) ->withMock(function ($mock) { $mock->addResponse( HttpResponse::sync(200, ['Content-Type' => 'application/json'], '{"users":[]}'), url: 'https://api.example.com/users', method: 'GET', ); }) ->create(); // @doctest id="2ca9" ``` ## Request Code Stays the Same The key insight is that your request code never changes when you switch drivers. The same `HttpRequest`, the same `send()` call, the same response handling: ```php $response = $client->send(new HttpRequest( url: 'https://api.example.com/data', method: 'GET', headers: ['Accept' => 'application/json'], body: '', options: [], ))->get(); echo $response->body(); // @doctest id="5f99" ``` This code works identically with cURL, Guzzle, Symfony, or the mock driver. ## See Also - [Changing Client Config](8-changing-client-config.md) -- configure timeouts, error handling, and stream settings. - [Custom Clients](9-1-custom-clients.md) -- register your own driver when the bundled ones are not enough. ================================================================================ FILE: packages/http/8-changing-client-config.md ================================================================================ The `HttpClientConfig` class provides typed, immutable configuration for the HTTP client. Every setting has a sensible default, so you only need to specify what you want to change. ## Configuration Options The constructor accepts the following parameters: ```php use Cognesy\Http\Config\HttpClientConfig; $config = new HttpClientConfig( driver: 'curl', connectTimeout: 3, requestTimeout: 30, idleTimeout: -1, streamChunkSize: 256, streamHeaderTimeout: 5, failOnError: false, ); // @doctest id="d2b9" ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `driver` | `string` | `'curl'` | Which driver to use (`curl`, `guzzle`, `symfony`) | | `connectTimeout` | `int` | `3` | Maximum seconds to wait for connection establishment | | `requestTimeout` | `int` | `30` | Maximum seconds for the entire request-response cycle | | `idleTimeout` | `int` | `-1` | Maximum seconds between data packets (`-1` disables) | | `streamChunkSize` | `int` | `256` | Bytes per chunk when streaming responses | | `streamHeaderTimeout` | `int` | `5` | Seconds to wait for the initial response headers during streaming | | `failOnError` | `bool` | `false` | Throw exceptions on 4xx/5xx responses | ### Understanding Timeouts Getting timeouts right is critical for production reliability: - **connectTimeout** controls how long the client waits to establish a TCP connection. Set this low (1-3 seconds) for services that should respond quickly. Set it higher (5-10 seconds) for services behind slow DNS or distant networks. - **requestTimeout** is the maximum total time for the request, from connection initiation to receiving the complete response. For quick API calls, 10-30 seconds is typical. For LLM inference or large file downloads, you may need 60-300 seconds. - **idleTimeout** applies to the gap between data packets. Setting this to `-1` disables it, which is appropriate for long-lived streaming connections. For non-streaming requests, a value like `30` seconds catches stalled connections. - **streamHeaderTimeout** is specific to streaming: it controls how long to wait for the first response headers before giving up. This is separate from `connectTimeout` because some APIs take time to start generating content. ## Using Config with the Builder Pass your config to the builder to create a fully configured client: ```php use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withConfig(new HttpClientConfig( driver: 'guzzle', connectTimeout: 5, requestTimeout: 60, failOnError: true, )) ->create(); // @doctest id="85ed" ``` Or create the client directly: ```php use Cognesy\Http\HttpClient; $client = HttpClient::fromConfig(new HttpClientConfig( driver: 'symfony', requestTimeout: 120, )); // @doctest id="48c1" ``` ## Presets `HttpClientConfig` ships with YAML preset files for common driver configurations. Use `fromPreset()` to load one by name: ```php use Cognesy\Http\Config\HttpClientConfig; $config = HttpClientConfig::fromPreset('guzzle'); // @doctest id="ae4f" ``` Available presets: `curl`, `guzzle`, `symfony`, `http-ollama`. The `HttpClient` facade offers a shorthand: ```php use Cognesy\Http\HttpClient; $client = HttpClient::using('guzzle'); // @doctest id="71c9" ``` You can override individual fields after loading a preset: ```php $config = HttpClientConfig::fromPreset('symfony') ->withOverrides(['requestTimeout' => 120, 'failOnError' => true]); // @doctest id="de15" ``` ## DSN Strings For environments where configuration comes from environment variables or strings, you can use DSN format: ```php $client = (new HttpClientBuilder()) ->withDsn('driver=symfony,connectTimeout=2,requestTimeout=20,streamHeaderTimeout=5,failOnError=true') ->create(); // @doctest id="7e05" ``` DSN values are automatically coerced to the correct types -- integers for timeout fields, booleans for `failOnError`, and strings for `driver`. ## Overriding an Existing Config The `withOverrides()` method creates a new config from an existing one with selective changes: ```php $base = new HttpClientConfig(driver: 'guzzle', requestTimeout: 30); $strict = $base->withOverrides(['failOnError' => true, 'requestTimeout' => 60]); $client = HttpClient::fromConfig($strict); // @doctest id="a956" ``` Only the fields you specify in the override array are changed; everything else carries forward from the base config. ## Creating Config from Arrays When loading configuration from external sources (files, environment, etc.), use the `fromArray()` factory: ```php $config = HttpClientConfig::fromArray([ 'driver' => 'symfony', 'connectTimeout' => 2, 'requestTimeout' => 45, 'failOnError' => true, ]); // @doctest id="1ab7" ``` ## Debug Configuration The `DebugConfig` class controls what gets logged during HTTP interactions. It is separate from `HttpClientConfig` and is passed to the builder independently: ```php use Cognesy\Http\Config\DebugConfig; use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withConfig(new HttpClientConfig(driver: 'guzzle')) ->withDebugConfig(new DebugConfig( httpEnabled: true, httpRequestUrl: true, httpRequestHeaders: true, httpRequestBody: true, httpResponseHeaders: true, httpResponseBody: true, httpResponseStream: true, httpResponseStreamByLine: true, )) ->create(); // @doctest id="b3eb" ``` | Option | Default | Description | |--------|---------|-------------| | `httpEnabled` | `false` | Master switch for debug output | | `httpTrace` | `false` | Dump HTTP trace information | | `httpRequestUrl` | `true` | Log the request URL | | `httpRequestHeaders` | `true` | Log request headers | | `httpRequestBody` | `true` | Log the request body | | `httpResponseHeaders` | `true` | Log response headers | | `httpResponseBody` | `true` | Log the response body | | `httpResponseStream` | `true` | Log streaming response data | | `httpResponseStreamByLine` | `true` | Log stream as complete lines vs. raw chunks | When debug is enabled, the builder automatically prepends an `EventSourceMiddleware` with console and event listeners. You can also load presets from YAML files using `DebugConfig::fromPreset('on')`. ## Configuration Patterns ### Different Profiles for Different Use Cases Create distinct configs for different scenarios: ```php // Quick API calls $quickConfig = new HttpClientConfig( connectTimeout: 1, requestTimeout: 5, failOnError: true, ); // LLM inference (long-running) $llmConfig = new HttpClientConfig( connectTimeout: 3, requestTimeout: 120, idleTimeout: 60, streamChunkSize: 512, ); // File downloads $downloadConfig = new HttpClientConfig( connectTimeout: 5, requestTimeout: 300, idleTimeout: 30, ); // @doctest id="3a8f" ``` ### Environment-Based Configuration Adjust settings based on the runtime environment: ```php $timeout = match (getenv('APP_ENV')) { 'testing' => 1, 'development' => 10, default => 30, }; $config = new HttpClientConfig( requestTimeout: $timeout, failOnError: getenv('APP_ENV') !== 'production', ); // @doctest id="edc8" ``` ## See Also - [Changing Client](7-changing-client.md) -- switch between drivers. - [Making Requests](3-making-requests.md) -- construct and send requests. - [Handling Responses](4-handling-responses.md) -- read response data. ================================================================================ FILE: packages/http/9-1-custom-clients.md ================================================================================ The bundled drivers cover the most common HTTP libraries, but there are situations where you need a custom integration -- perhaps with a proprietary HTTP library, a legacy system, or a specialized transport. This chapter shows how to create a custom driver, register it with the driver registry, and use it through the standard client API. ## The Driver Contract Every driver must implement the `CanHandleHttpRequest` interface, which defines a single method: ```php namespace Cognesy\Http\Contracts; interface CanHandleHttpRequest { public function handle(HttpRequest $request): HttpResponse; } // @doctest id="c341" ``` The method receives an `HttpRequest` and returns an `HttpResponse`. That is the entire contract. The driver is responsible for converting these value objects into whatever the underlying HTTP library expects. ## Creating a Custom Driver Here is a template for a custom driver: ```php namespace App\Http\Drivers; use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\Contracts\CanHandleHttpRequest; use Cognesy\Http\Data\HttpRequest; use Cognesy\Http\Data\HttpResponse; use Cognesy\Http\Exceptions\HttpRequestException; use Cognesy\Events\Contracts\CanHandleEvents; class AcmeHttpDriver implements CanHandleHttpRequest { public function __construct( private HttpClientConfig $config, private CanHandleEvents $events, private ?object $clientInstance = null, ) { // Initialize your vendor client here $this->client = $clientInstance ?? new \Acme\HttpClient([ 'connect_timeout' => $config->connectTimeout, 'timeout' => $config->requestTimeout, ]); } public function handle(HttpRequest $request): HttpResponse { try { $vendorResponse = $this->client->request( method: $request->method(), url: $request->url(), headers: $request->headers(), body: $request->body()->toString(), ); if ($request->isStreamed()) { return HttpResponse::streaming( statusCode: $vendorResponse->status(), headers: $vendorResponse->headers(), stream: $this->adaptStream($vendorResponse), ); } return HttpResponse::sync( statusCode: $vendorResponse->status(), headers: $vendorResponse->headers(), body: $vendorResponse->body(), ); } catch (\Exception $e) { throw new HttpRequestException( message: $e->getMessage(), request: $request, previous: $e, ); } } private function adaptStream($response): \Cognesy\Http\Stream\StreamInterface { return \Cognesy\Http\Stream\BufferedStream::fromStream( (function () use ($response) { foreach ($response->getStream() as $chunk) { yield $chunk; } })() ); } } // @doctest id="3137" ``` The key points are: - Accept `HttpClientConfig`, `CanHandleEvents`, and an optional vendor client instance in the constructor. This matches the signature expected by the driver registry. - Return `HttpResponse::sync()` for buffered responses and `HttpResponse::streaming()` for streamed responses. - Wrap vendor exceptions in `HttpRequestException` to maintain a consistent exception hierarchy. ## Registering the Driver To make your driver available by name (e.g., `'acme'`), register it with the driver registry: ```php use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\Contracts\CanHandleHttpRequest; use Cognesy\Http\Creation\BundledHttpDrivers; use Cognesy\Events\Contracts\CanHandleEvents; $drivers = BundledHttpDrivers::registry()->withDriver( 'acme', static fn(HttpClientConfig $config, CanHandleEvents $events, ?object $clientInstance): CanHandleHttpRequest => new AcmeHttpDriver($config, $events, $clientInstance), ); // @doctest id="2e5a" ``` Then use it through the builder: ```php use Cognesy\Http\Config\HttpClientConfig; use Cognesy\Http\Creation\HttpClientBuilder; $client = (new HttpClientBuilder()) ->withDrivers($drivers) ->withConfig(new HttpClientConfig(driver: 'acme')) ->create(); // @doctest id="7179" ``` The factory function receives the config, events dispatcher, and optional client instance. This lets users pass a pre-configured vendor client through `withClientInstance('acme', $myClient)`. ## Injecting a Driver Directly If you do not need the registry, bypass it entirely by passing a driver instance: ```php use Cognesy\Http\Creation\HttpClientBuilder; $driver = new AcmeHttpDriver($config, $events); $client = (new HttpClientBuilder()) ->withDriver($driver) ->create(); // @doctest id="3e18" ``` Or use the static shorthand: ```php $client = HttpClient::fromDriver($driver); // @doctest id="edd3" ``` ## Reusing Vendor Client Instances When your vendor client requires special setup (custom SSL certificates, proxy configuration, connection pools), create the instance yourself and pass it through: ```php use Cognesy\Http\Creation\HttpClientBuilder; use Symfony\Component\HttpClient\HttpClient as SymfonyHttpClient; $symfony = SymfonyHttpClient::create([ 'proxy' => 'http://proxy.internal:8080', 'verify_peer' => true, 'cafile' => '/etc/ssl/custom-ca.pem', ]); $client = (new HttpClientBuilder()) ->withClientInstance('symfony', $symfony) ->create(); // @doctest id="3eed" ``` This pattern works with any registered driver. The `withClientInstance()` method sets both the driver name and the instance, so the driver factory receives it instead of creating its own. ## Streaming in Custom Drivers The `HttpResponse::streaming()` factory accepts a `StreamInterface` implementation. The simplest approach is to yield chunks from a generator and wrap them with `BufferedStream::fromStream()`: ```php public function handle(HttpRequest $request): HttpResponse { $vendorResponse = $this->client->sendStreaming($request->url(), ...); $stream = (function () use ($vendorResponse) { foreach ($vendorResponse->chunks() as $chunk) { yield $chunk; } })(); return HttpResponse::streaming( statusCode: $vendorResponse->statusCode(), headers: $vendorResponse->headers(), stream: BufferedStream::fromStream($stream), ); } // @doctest id="c56b" ``` The `BufferedStream`, `ArrayStream`, `IterableStream`, and `TransformStream` classes in the `Cognesy\Http\Stream` namespace provide various stream implementations you can use or compose. ## See Also - [Changing Client](7-changing-client.md) -- switch between drivers without custom code. - [Changing Client Config](8-changing-client-config.md) -- configure timeouts and error handling. - [Middleware](10-middleware.md) -- add behaviors around any driver. ================================================================================ FILE: packages/laravel/index.md ================================================================================ # Instructor for Laravel Laravel integration for [Instructor PHP](https://github.com/cognesy/instructor-php) -- the structured output library for LLMs. ## Overview Instructor for Laravel brings the full power of structured LLM output extraction into the Laravel ecosystem. Rather than parsing free-form text responses from language models, Instructor lets you define PHP classes that describe the data you need, and the package takes care of prompting the model, validating its response, and deserializing the result into typed objects. This package provides seamless integration between Instructor PHP and Laravel, giving you: - **Laravel Facades** -- Use `StructuredOutput::`, `Inference::`, `Embeddings::`, and `AgentCtrl::` facades for expressive, framework-native access to LLM capabilities. - **Dependency Injection** -- Inject `StructuredOutput`, `Inference`, or `Embeddings` directly into your classes through Laravel's service container. - **Testing Fakes** -- Mock LLM responses with `StructuredOutput::fake()`, `Inference::fake()`, `Embeddings::fake()`, and `AgentCtrl::fake()`, complete with assertion helpers for verifying extraction calls, connection usage, and model selection. - **Laravel HTTP Client** -- All API calls go through Laravel's `Http::` client under the hood, which means `Http::fake()` works out of the box in your test suite. - **Event Bridge** -- Instructor's internal events are automatically dispatched through Laravel's event system, so you can attach listeners, subscribers, and queued handlers with no extra wiring. - **Artisan Commands** -- Generate response model scaffolding with `make:response-model`, verify your API configuration with `instructor:test`, and bootstrap the package with `instructor:install`. - **Configuration Publishing** -- Laravel-style config file with environment variable support for all settings, from API keys and model selection to HTTP timeouts and logging presets. ## Quick Start ### 1. Install the Package ```bash composer require cognesy/instructor-laravel # @doctest id="ff4e" ``` ### 2. Configure API Key Add to your `.env`: ```env OPENAI_API_KEY=your-openai-api-key // @doctest id="4d78" ``` ### 3. Extract Structured Data ```php use Cognesy\Instructor\Laravel\Facades\StructuredOutput; // Define a response model final class PersonData { public function __construct( public readonly string $name, public readonly int $age, ) {} } // Extract structured data from text $person = StructuredOutput::with( messages: 'John Smith is 30 years old', responseModel: PersonData::class, )->get(); echo $person->name; // "John Smith" echo $person->age; // 30 // @doctest id="63b9" ``` ## Documentation | Guide | Description | |-------|-------------| | [Installation](installation.md) | Detailed installation and setup instructions | | [Configuration](configuration.md) | Complete configuration reference | | [Facades](facades.md) | Using StructuredOutput, Inference, Embeddings, and AgentCtrl facades | | [Response Models](response-models.md) | Creating and using response models | | [Code Agents](agents.md) | Using AgentCtrl for Claude Code, Codex, and OpenCode | | [Testing](testing.md) | Testing with fakes and assertions | | [Events](events.md) | Event handling and Laravel integration | | [Commands](commands.md) | Artisan command reference | | [Advanced](advanced.md) | Streaming, validation, and advanced patterns | | [Troubleshooting](troubleshooting.md) | Common issues and solutions | ## Example: Complete Workflow ```php use Cognesy\Instructor\Laravel\Facades\StructuredOutput; use App\ResponseModels\InvoiceData; class InvoiceProcessor { public function extractFromEmail(string $emailBody): InvoiceData { return StructuredOutput::with( messages: $emailBody, responseModel: InvoiceData::class, system: 'Extract invoice details from the email.', )->get(); } } // In your test public function test_extracts_invoice_data(): void { $fake = StructuredOutput::fake([ InvoiceData::class => new InvoiceData( invoiceNumber: 'INV-001', amount: 150.00, dueDate: '2024-12-31', ), ]); $processor = new InvoiceProcessor(); $invoice = $processor->extractFromEmail('Invoice #INV-001...'); $this->assertEquals('INV-001', $invoice->invoiceNumber); $fake->assertExtracted(InvoiceData::class); } // @doctest id="983e" ``` ## Requirements - PHP 8.2+ - Laravel 10.x, 11.x, or 12.x ## Support - [GitHub Issues](https://github.com/cognesy/instructor-php/issues) - [Documentation](https://docs.instructorphp.com) ================================================================================ FILE: packages/laravel/installation.md ================================================================================ # Installation ## Requirements - PHP 8.2 or higher - Laravel 10.x, 11.x, or 12.x - A valid API key from a supported LLM provider (OpenAI, Anthropic, Google, etc.) ## Install via Composer ```bash composer require cognesy/instructor-laravel # @doctest id="fee6" ``` The package uses Laravel's package auto-discovery mechanism, so the service provider and all four facades (`StructuredOutput`, `Inference`, `Embeddings`, `AgentCtrl`) are registered automatically. No manual registration is required for typical Laravel applications. ## Publish Configuration Publish the configuration file to customize connections, extraction defaults, and other settings: ```bash php artisan vendor:publish --tag=instructor-config # @doctest id="be53" ``` This creates `config/instructor.php` with all available options. The file ships with sensible defaults, so you can start using the package with just an API key and customize later as your needs grow. ## Configure API Keys Add your LLM provider API key to `.env`. You only need the key for the provider you intend to use: ```env # OpenAI (default) OPENAI_API_KEY=sk-... # Or Anthropic ANTHROPIC_API_KEY=sk-ant-... # Or other providers GEMINI_API_KEY=... GROQ_API_KEY=... MISTRAL_API_KEY=... // @doctest id="9839" ``` You can configure multiple providers simultaneously and switch between them at runtime using the `connection()` method on any facade. ## Setup by Package The Laravel package is the Laravel integration layer for four underlying packages. You do not install them separately in a Laravel app. Instead, you configure them through Laravel's native `config/instructor.php` file and use the Laravel facades or container bindings. The standalone `packages/config` YAML loader is not responsible for reading `config/instructor.php`. Under Laravel, the service provider reads Laravel's config repository directly and maps those values into the typed runtime config objects used by Instructor, Polyglot, and the HTTP client. | Underlying package | Laravel surface | What to set up | Continue with | |-------|-------------|----------------|---------------| | `packages/instructor` | `StructuredOutput` facade and `Cognesy\Instructor\StructuredOutput` | Configure your default LLM connection, extraction defaults, and response models | [Facades](facades.md), [Response Models](response-models.md), [Configuration](configuration.md) | | `packages/polyglot` | `Inference` and `Embeddings` facades plus `Cognesy\Polyglot\Inference\Inference` and `Cognesy\Polyglot\Embeddings\Embeddings` | Configure inference connections in `connections` and embedding models in `embeddings.connections` | [Facades](facades.md), [Configuration](configuration.md), [Events](events.md) | | `packages/agent-ctrl` | `AgentCtrl` facade | Install the CLI agent you want to use, ensure its binary is available in `PATH`, then configure timeouts, working directory, and sandbox defaults in `agents` | [Code Agents](agents.md), [Testing](testing.md) | | `packages/http-client` | Internal Laravel-backed transport | No separate install is required; keep `http.driver` set to `laravel` to route requests through Laravel's HTTP client and `Http::fake()` | [Configuration](configuration.md), [Testing](testing.md) | ### `packages/instructor` Under Laravel Structured output uses the default connection from `config/instructor.php`, then applies Laravel-specific extraction defaults such as output mode and retry behavior. In practice, setup means: - publish the config file - set at least one LLM API key - choose a default connection in `connections` - define extraction defaults in `extraction` - create response model classes and call `StructuredOutput` ### `packages/polyglot` Under Laravel Laravel exposes Polyglot through two entry points: - `Inference` for raw text, JSON, tool-calling, and streaming responses - `Embeddings` for vector generation Both use the same published config file. Inference reads from `connections`; embeddings read from `embeddings.connections`. If you already configured providers for `StructuredOutput`, raw inference usually needs no additional setup. ### `packages/agent-ctrl` Under Laravel `AgentCtrl` does not use the LLM HTTP connections from `config/instructor.php`. It runs external agent CLIs from your Laravel application. Setup means: - install the agent CLI you want to use - make sure its executable is available in `PATH` - authenticate that CLI using its own provider workflow - set Laravel defaults for timeout, working directory, model, and sandbox in `agents` ### `packages/http-client` Under Laravel Laravel already wires the HTTP client package into the container. All Instructor, Inference, and Embeddings calls use the Laravel-backed driver by default. Setup usually means only: - keep `http.driver` set to `laravel` - adjust `http.timeout` and `http.connect_timeout` if needed - use `Http::fake()` when you want transport-level HTTP tests ## Verify Installation Run the installation command to verify everything is configured correctly: ```bash php artisan instructor:install # @doctest id="2ba2" ``` This will: 1. Publish the configuration if not already published 2. Check for API key configuration in your `.env` file 3. Show next steps for getting started ## Test Your Connection Test that your API configuration is working by making a real API call: ```bash php artisan instructor:test # @doctest id="967d" ``` This command displays your current configuration (connection name, driver, model, masked API key) and then performs a structured output extraction to confirm the full pipeline is operational. To test a specific connection: ```bash php artisan instructor:test --connection=anthropic # @doctest id="cd7d" ``` To test raw inference (without structured output extraction): ```bash php artisan instructor:test --inference # @doctest id="7851" ``` ## Optional: Publish Stubs If you want to customize the response model templates used by `make:response-model`: ```bash php artisan vendor:publish --tag=instructor-stubs # @doctest id="9e85" ``` This publishes stubs to `stubs/instructor/` in your application root. The command will prefer your custom stubs over the package defaults when generating new response models. ## Manual Registration (Optional) If you have disabled Laravel's package auto-discovery, manually register the service provider. In Laravel 10, add it to `config/app.php`: ```php 'providers' => [ // ... Cognesy\Instructor\Laravel\InstructorServiceProvider::class, ], 'aliases' => [ // ... 'StructuredOutput' => Cognesy\Instructor\Laravel\Facades\StructuredOutput::class, 'Inference' => Cognesy\Instructor\Laravel\Facades\Inference::class, 'Embeddings' => Cognesy\Instructor\Laravel\Facades\Embeddings::class, 'AgentCtrl' => Cognesy\Instructor\Laravel\Facades\AgentCtrl::class, ], // @doctest id="9640" ``` In Laravel 11 and 12, register the provider in `bootstrap/providers.php`. ## Upgrading When upgrading to a new version, republish the configuration if there are new options: ```bash php artisan vendor:publish --tag=instructor-config --force # @doctest id="90aa" ``` Review the [changelog](https://github.com/cognesy/instructor-php/blob/main/CHANGELOG.md) for breaking changes before upgrading major versions. ## Next Steps - [Configuration](configuration.md) -- Configure connections and settings - [Facades](facades.md) -- Learn how to use the facades - [Response Models](response-models.md) -- Create your first response model ================================================================================ FILE: packages/laravel/configuration.md ================================================================================ # Configuration After publishing the configuration file with `php artisan vendor:publish --tag=instructor-config`, you will find it at `config/instructor.php`. This file controls every aspect of the package, from LLM provider connections and extraction behavior to HTTP transport, logging, event bridging, and response caching. This is Laravel-native configuration. The Laravel integration reads `config('instructor.*')` through Laravel's config repository and converts those arrays into typed runtime config objects internally. It does not ask the standalone `packages/config` YAML loader to parse `config/instructor.php`. ## Default Connection ```php 'default' => env('INSTRUCTOR_CONNECTION', 'openai'), // @doctest id="bd42" ``` This determines which LLM connection is used when you call a facade without specifying one explicitly. You can override it at runtime with `->connection('name')` on any facade, or by passing an `LLMConfig` object via `->fromConfig(...)`. ## Connections Configure multiple LLM provider connections. Each connection defines its driver, API credentials, default model, and token limits. You can define as many connections as you need and switch between them at runtime. ```php 'connections' => [ 'openai' => [ 'driver' => 'openai', 'api_url' => env('OPENAI_API_URL', 'https://api.openai.com/v1'), 'api_key' => env('OPENAI_API_KEY'), 'organization' => env('OPENAI_ORGANIZATION'), 'model' => env('OPENAI_MODEL', 'gpt-4o-mini'), 'max_tokens' => env('OPENAI_MAX_TOKENS', 4096), ], 'anthropic' => [ 'driver' => 'anthropic', 'api_url' => env('ANTHROPIC_API_URL', 'https://api.anthropic.com/v1'), 'api_key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-20250514'), 'max_tokens' => env('ANTHROPIC_MAX_TOKENS', 4096), ], 'azure' => [ 'driver' => 'azure', 'api_key' => env('AZURE_OPENAI_API_KEY'), 'resource_name' => env('AZURE_OPENAI_RESOURCE'), 'deployment_id' => env('AZURE_OPENAI_DEPLOYMENT'), 'api_version' => env('AZURE_OPENAI_API_VERSION', '2024-08-01-preview'), 'model' => env('AZURE_OPENAI_MODEL', 'gpt-4o-mini'), 'max_tokens' => env('AZURE_OPENAI_MAX_TOKENS', 4096), ], 'gemini' => [ 'driver' => 'gemini', 'api_url' => env('GEMINI_API_URL', 'https://generativelanguage.googleapis.com/v1beta'), 'api_key' => env('GEMINI_API_KEY'), 'model' => env('GEMINI_MODEL', 'gemini-2.0-flash'), 'max_tokens' => env('GEMINI_MAX_TOKENS', 4096), ], 'ollama' => [ 'driver' => 'ollama', 'api_url' => env('OLLAMA_API_URL', 'http://localhost:11434/v1'), 'api_key' => env('OLLAMA_API_KEY', 'ollama'), 'model' => env('OLLAMA_MODEL', 'llama3.2'), 'max_tokens' => env('OLLAMA_MAX_TOKENS', 4096), ], ], // @doctest id="f930" ``` ### Supported Drivers | Driver | Provider | Description | |--------|----------|-------------| | `openai` | OpenAI | GPT-4, GPT-4o, GPT-4o-mini | | `anthropic` | Anthropic | Claude 3, Claude 3.5, Claude 4 | | `azure` | Azure OpenAI | Azure-hosted OpenAI models | | `gemini` | Google | Gemini 1.5, Gemini 2.0 | | `mistral` | Mistral AI | Mistral, Mixtral models | | `groq` | Groq | Fast inference with Llama, Mixtral | | `cohere` | Cohere | Command models | | `deepseek` | DeepSeek | DeepSeek models | | `ollama` | Ollama | Local open-source models | | `perplexity` | Perplexity | Perplexity models | ### Adding a Custom Connection Any OpenAI-compatible API can be used by setting the `openai` driver and pointing `api_url` to your endpoint. Extra keys beyond the standard set (`driver`, `api_url`, `api_key`, `endpoint`, `model`, `max_tokens`, `options`) are automatically merged into the options array and forwarded with each request. ```php 'connections' => [ // ... existing connections 'my-custom' => [ 'driver' => 'openai', // Use OpenAI-compatible API 'api_url' => 'https://my-custom-api.com/v1', 'api_key' => env('MY_CUSTOM_API_KEY'), 'model' => 'custom-model', 'max_tokens' => 4096, ], ], // @doctest id="84c9" ``` ## Embeddings Connections Configure embedding model connections separately from inference connections. The embeddings section has its own `default` key and connection definitions. ```php 'embeddings' => [ 'default' => env('INSTRUCTOR_EMBEDDINGS_CONNECTION', 'openai'), 'connections' => [ 'openai' => [ 'driver' => 'openai', 'api_url' => env('OPENAI_API_URL', 'https://api.openai.com/v1'), 'api_key' => env('OPENAI_API_KEY'), 'model' => env('OPENAI_EMBEDDINGS_MODEL', 'text-embedding-3-small'), 'dimensions' => env('OPENAI_EMBEDDINGS_DIMENSIONS', 1536), ], 'ollama' => [ 'driver' => 'ollama', 'api_url' => env('OLLAMA_API_URL', 'http://localhost:11434/v1'), 'api_key' => env('OLLAMA_API_KEY', 'ollama'), 'model' => env('OLLAMA_EMBEDDINGS_MODEL', 'nomic-embed-text'), 'dimensions' => env('OLLAMA_EMBEDDINGS_DIMENSIONS', 768), ], ], ], // @doctest id="bf92" ``` ## Extraction Settings Configure defaults for structured output extraction. These values apply to every `StructuredOutput` call unless overridden at runtime. ```php 'extraction' => [ // Output mode: json_schema, json, tools, md_json 'output_mode' => env('INSTRUCTOR_OUTPUT_MODE', 'json_schema'), // Maximum retry attempts when validation fails 'max_retries' => env('INSTRUCTOR_MAX_RETRIES', 2), // Prompt template for retry attempts 'retry_prompt' => 'The response did not pass validation. Please fix the following errors and try again: {errors}', ], // @doctest id="a001" ``` ### Output Modes The output mode controls how the package instructs the LLM to produce structured output. Different providers have varying levels of support for each mode. | Mode | Description | Best For | |------|-------------|----------| | `json_schema` | Uses JSON Schema for structured output | Most reliable; recommended for OpenAI | | `json` | Simple JSON mode without schema enforcement | Fallback for models that lack schema support | | `tools` | Uses tool/function calling to extract structured data | Alternative approach; good cross-provider support | | `md_json` | Markdown-wrapped JSON | Useful for Gemini and similar models | ## HTTP Client Settings Configure the underlying HTTP transport. The Laravel package ships with its own `LaravelDriver` that wraps Laravel's HTTP client (`Illuminate\Http\Client\Factory`), which means `Http::fake()` works transparently in your tests. ```php 'http' => [ // Driver: 'laravel' uses Laravel's HTTP client (enables Http::fake()) 'driver' => env('INSTRUCTOR_HTTP_DRIVER', 'laravel'), // Request timeout in seconds 'timeout' => env('INSTRUCTOR_HTTP_TIMEOUT', 120), // Connection timeout in seconds 'connect_timeout' => env('INSTRUCTOR_HTTP_CONNECT_TIMEOUT', 30), ], // @doctest id="83d4" ``` The service provider binds `Cognesy\Http\Contracts\CanSendHttpRequests` to the Laravel-backed HTTP transport. All higher-level services (Inference, Embeddings, StructuredOutput) depend on that contract, ensuring consistent HTTP behavior across the entire package. ## Logging Settings The package includes a logging pipeline that enriches log entries with Laravel request context (request ID, authenticated user, route, URL) automatically. ```php 'logging' => [ // Enable/disable logging 'enabled' => env('INSTRUCTOR_LOGGING_ENABLED', true), // Log channel (must exist in config/logging.php) 'channel' => env('INSTRUCTOR_LOG_CHANNEL', 'stack'), // Minimum log level 'level' => env('INSTRUCTOR_LOG_LEVEL', 'warning'), // Logging preset: default, production, or custom 'preset' => env('INSTRUCTOR_LOGGING_PRESET', 'production'), // Events to exclude from logging 'exclude_events' => [ Cognesy\Http\Events\DebugRequestBodyUsed::class, Cognesy\Http\Events\DebugResponseBodyReceived::class, ], ], // @doctest id="a2ae" ``` ### Logging Presets | Preset | Description | |--------|-------------| | `default` | Verbose logging suitable for local development; includes message templates for key events and excludes debug-level HTTP body events | | `production` | Minimal logging at `warning` level and above; excludes verbose HTTP and partial-response events for lower overhead | | `custom` | Fully configurable pipeline -- supply your own `channel`, `level`, `exclude_events`, `include_events`, and `templates` arrays | Both the `default` and `production` presets automatically attach lazy enrichers that add the current HTTP request context (request ID, user ID, session ID, route, method, URL) to every log record. ## Events Settings Configure how Instructor's internal events are bridged to Laravel's event dispatcher. ```php 'events' => [ // Bridge Instructor events to Laravel's event dispatcher 'dispatch_to_laravel' => env('INSTRUCTOR_DISPATCH_EVENTS', true), // Specific events to bridge (empty = all events) 'bridge_events' => [ // \Cognesy\Instructor\Events\ExtractionComplete::class, ], ], // @doctest id="08d6" ``` When `bridge_events` is empty (the default), every Instructor event is forwarded to Laravel. To limit traffic, list only the event classes you care about. See the [Events](events.md) guide for the full list of available events and listener examples. ## Cache Settings Configure response caching to avoid redundant API calls for identical inputs. ```php 'cache' => [ // Enable response caching 'enabled' => env('INSTRUCTOR_CACHE_ENABLED', false), // Cache store to use (null = default store) 'store' => env('INSTRUCTOR_CACHE_STORE'), // Default TTL in seconds 'ttl' => env('INSTRUCTOR_CACHE_TTL', 3600), // Cache key prefix 'prefix' => 'instructor', ], // @doctest id="2068" ``` ## Environment Variables Reference | Variable | Default | Description | |----------|---------|-------------| | `INSTRUCTOR_CONNECTION` | `openai` | Default LLM connection | | `INSTRUCTOR_OUTPUT_MODE` | `json_schema` | Output mode for extraction | | `INSTRUCTOR_MAX_RETRIES` | `2` | Max validation retry attempts | | `INSTRUCTOR_HTTP_DRIVER` | `laravel` | HTTP client driver | | `INSTRUCTOR_HTTP_TIMEOUT` | `120` | Request timeout (seconds) | | `INSTRUCTOR_HTTP_CONNECT_TIMEOUT` | `30` | Connection timeout (seconds) | | `INSTRUCTOR_LOGGING_ENABLED` | `true` | Enable logging | | `INSTRUCTOR_LOG_CHANNEL` | `stack` | Laravel log channel | | `INSTRUCTOR_LOG_LEVEL` | `warning` | Minimum log level | | `INSTRUCTOR_LOGGING_PRESET` | `production` | Logging preset | | `INSTRUCTOR_DISPATCH_EVENTS` | `true` | Bridge events to Laravel | | `INSTRUCTOR_CACHE_ENABLED` | `false` | Enable response caching | | `OPENAI_API_KEY` | -- | OpenAI API key | | `ANTHROPIC_API_KEY` | -- | Anthropic API key | ## Runtime Configuration Override any configuration at runtime using the fluent API on the facades: ```php use Cognesy\Instructor\Laravel\Facades\StructuredOutput; use Cognesy\Instructor\StructuredOutputRuntime; $result = StructuredOutput::connection('anthropic') // Switch connection ->withModel('claude-3-opus-20240229') // Override model ->withRuntime( StructuredOutputRuntime::fromDefaults()->withMaxRetries(5) ) // Override retries ->with( messages: 'Extract data...', responseModel: MyModel::class, ) ->get(); // @doctest id="2763" ``` For full programmatic control, build an `LLMConfig` object and pass it directly: ```php use Cognesy\Polyglot\Inference\Config\LLMConfig; $config = LLMConfig::fromArray([ 'driver' => 'openai', 'apiUrl' => 'https://api.openai.com/v1', 'apiKey' => $myKey, 'model' => 'gpt-4o', 'maxTokens' => 8192, ]); $result = StructuredOutput::fromConfig($config) ->with(messages: '...', responseModel: MyModel::class) ->get(); // @doctest id="3639" ``` ================================================================================ FILE: packages/laravel/facades.md ================================================================================ # Facades The package provides four Laravel facades that serve as the primary entry points for interacting with LLMs and code agents. Each facade resolves a fresh instance from the service container, so you can chain methods freely without worrying about shared state between calls. ## StructuredOutput The primary facade for extracting structured data from unstructured text. Given a response model class (a plain PHP DTO with typed properties), the facade prompts the LLM, validates the response against the model's type constraints, and returns a fully typed object. ### Basic Usage ```php use Cognesy\Instructor\Laravel\Facades\StructuredOutput; $person = StructuredOutput::with( messages: 'John Smith is 30 years old', responseModel: PersonData::class, )->get(); // @doctest id="52f7" ``` ### With System Prompt A system prompt steers the LLM's behavior for the extraction task. Use it to provide domain-specific instructions or constraints. ```php $person = StructuredOutput::with( messages: 'Process this text: John, age 30', responseModel: PersonData::class, system: 'You are a data extraction assistant.', )->get(); // @doctest id="4af1" ``` ### With Examples (Few-Shot Learning) Providing input/output examples helps the LLM understand the expected extraction pattern, especially for ambiguous or domain-specific data. ```php $person = StructuredOutput::with( messages: 'Extract: Jane Doe, 25 years', responseModel: PersonData::class, examples: [ ['input' => 'Bob is 40', 'output' => new PersonData(name: 'Bob', age: 40)], ], )->get(); // @doctest id="8431" ``` ### Switching Connections Each call can target a different LLM provider by specifying a connection name that matches an entry in your `config/instructor.php` connections array. ```php $person = StructuredOutput::connection('anthropic')->with( messages: 'Extract person data...', responseModel: PersonData::class, )->get(); // @doctest id="4178" ``` ### Fluent API All configuration can also be set with individual fluent methods. This is useful when you build requests dynamically. ```php use Cognesy\Instructor\StructuredOutputRuntime; $person = StructuredOutput::withMessages('John is 30') ->withResponseModel(PersonData::class) ->withModel('gpt-4o') ->withRuntime( StructuredOutputRuntime::fromDefaults()->withMaxRetries(3) ) ->get(); // @doctest id="f5bf" ``` ### Return Types By default, `get()` returns the deserialized object matching your response model. For simpler extractions, convenience methods cast the result to scalar types. ```php // Get as typed object (default) $person = StructuredOutput::with(...)->get(); // Get as string $name = StructuredOutput::with(...)->getString(); // Get as integer $count = StructuredOutput::with(...)->getInt(); // Get as float $price = StructuredOutput::with(...)->getFloat(); // Get as boolean $valid = StructuredOutput::with(...)->getBoolean(); // Get as array $items = StructuredOutput::with(...)->getArray(); // @doctest id="c0ba" ``` ### Available Methods | Method | Description | |--------|-------------| | `connection(string $name)` | Switch to a different configured connection | | `fromConfig(LLMConfig $config)` | Use an explicit typed LLM config object | | `withRuntime(CanCreateStructuredOutput)` | Replace the runtime directly (advanced) | | `with(...)` | Configure extraction with all parameters at once | | `withMessages(...)` | Set the input messages | | `withInput(string\|array\|object)` | Set arbitrary input data | | `withResponseModel(string\|array\|object)` | Set the response model class, object, or array schema | | `withResponseClass(string)` | Set the response model by class name | | `withResponseObject(object)` | Set the response model by object instance | | `withResponseJsonSchema(array\|CanProvideJsonSchema)` | Set the response model via JSON Schema | | `withSystem(string)` | Set the system prompt | | `withPrompt(string)` | Set the user prompt template | | `withExamples(array)` | Set few-shot examples | | `withModel(string)` | Override the model for this request | | `withOptions(array)` | Set additional provider-specific options | | `withOption(string, mixed)` | Set a single option key | | `withStreaming(bool)` | Enable or disable streaming | | `withCachedContext(...)` | Set a cached context for prompt caching | | `intoArray()` | Deserialize the result as an array | | `intoInstanceOf(string)` | Deserialize into the given class | | `intoObject(CanDeserializeSelf)` | Deserialize using a self-deserializing object | | `get()` | Execute extraction and return the result | | `stream()` | Execute extraction and return a stream | | `response()` | Execute and return the full response wrapper | | `inferenceResponse()` | Execute and return the raw inference response | Runtime policy such as retries, output mode, validators, transformers, deserializers, and extractors is configured on `StructuredOutputRuntime` and then passed via `withRuntime(...)`. --- ## Inference For raw LLM inference without structured output extraction. Use this when you need free-form text generation, JSON responses, or tool-calling capabilities without the overhead of schema validation and deserialization. ### Basic Usage ```php use Cognesy\Instructor\Laravel\Facades\Inference; use Cognesy\Messages\Messages; $response = Inference::with( messages: Messages::fromString('What is the capital of France?'), )->get(); echo $response; // "The capital of France is Paris." // @doctest id="f552" ``` ### With System Message Pass a `Messages` object when you need fine-grained control over the conversation structure. ```php use Cognesy\Messages\Messages; $response = Inference::with( messages: Messages::fromArray([ ['role' => 'system', 'content' => 'You are a helpful assistant.'], ['role' => 'user', 'content' => 'Hello!'], ]), )->get(); // @doctest id="5537" ``` ### JSON Response Request a JSON-formatted response and parse it directly into a PHP array. ```php use Cognesy\Messages\Messages; use Cognesy\Polyglot\Inference\Data\ResponseFormat; $data = Inference::with( messages: Messages::fromString('List 3 colors as JSON'), responseFormat: ResponseFormat::jsonObject(), )->asJsonData(); // ['colors' => ['red', 'green', 'blue']] // @doctest id="964b" ``` ### Switching Connections ```php $response = Inference::connection('groq')->with( messages: Messages::fromString('Explain quantum computing'), )->get(); // @doctest id="9e12" ``` ### Available Methods | Method | Description | |--------|-------------| | `connection(string $name)` | Switch to a different configured connection | | `fromConfig(LLMConfig $config)` | Use an explicit typed LLM config object | | `withRuntime(CanCreateInference)` | Replace the runtime directly (advanced) | | `with(...)` | Configure with all parameters at once | | `withMessages(Messages)` | Set the messages | | `withModel(string)` | Override model | | `withMaxTokens(int)` | Override max tokens | | `withTools(ToolDefinitions)` | Add tool/function definitions | | `withToolChoice(ToolChoice)` | Set tool choice strategy | | `withResponseFormat(ResponseFormat)` | Set response format (e.g., JSON mode) | | `withOptions(array)` | Set provider-specific options | | `withStreaming(bool)` | Enable or disable streaming | | `withCachedContext(...)` | Set a cached context for prompt caching | | `withRetryPolicy(...)` | Set a custom retry policy | | `withResponseCachePolicy(...)` | Set response cache behavior | | `get()` | Execute and return text content | | `asJson()` | Execute and return raw JSON string | | `asJsonData()` | Execute and return parsed array | | `response()` | Return the full response object | | `stream()` | Return a stream iterator | --- ## Embeddings For generating text embeddings (dense vector representations). Embeddings are useful for semantic search, clustering, classification, and similarity comparison. ### Basic Usage ```php use Cognesy\Instructor\Laravel\Facades\Embeddings; // Get single embedding $embedding = Embeddings::withInputs('Hello world')->first(); // [0.123, -0.456, 0.789, ...] // Get multiple embeddings $embeddings = Embeddings::withInputs([ 'First text', 'Second text', ])->vectors(); // @doctest id="787c" ``` ### Switching Connections ```php $embedding = Embeddings::connection('ollama') ->withInputs('Local embedding test') ->first(); // @doctest id="d19c" ``` ### With Custom Model ```php $embedding = Embeddings::withInputs('Test') ->withModel('text-embedding-3-large') ->first(); // @doctest id="23b1" ``` ### Full Response The `get()` method returns the complete response object, which includes both the embedding vectors and usage statistics. ```php $response = Embeddings::withInputs('Test')->get(); $vectors = $response->vectors(); $usage = $response->usage(); // @doctest id="0676" ``` ### Available Methods | Method | Description | |--------|-------------| | `connection(string $name)` | Switch to a different configured embeddings connection | | `fromConfig(EmbeddingsConfig $config)` | Use an explicit typed embeddings config object | | `withRuntime(CanCreateEmbeddings)` | Replace the runtime directly (advanced) | | `withInputs(string\|array)` | Set input text(s) to embed | | `withModel(string)` | Override the embedding model | | `withOptions(array)` | Set provider-specific options | | `with(...)` | Configure with all parameters at once | | `first()` | Get the first embedding vector | | `vectors()` | Get all embedding vectors | | `get()` | Get the full response object with vectors and usage | --- ## AgentCtrl For invoking CLI-based code agents (Claude Code, Codex, OpenCode) that can execute code, modify files, and perform complex multi-step tasks. The facade provides a builder pattern for configuring agent execution and returns a structured `AgentResponse` with the generated output, tool calls, token usage, and cost. ### Basic Usage ```php use Cognesy\Instructor\Laravel\Facades\AgentCtrl; // Execute a task with Claude Code $response = AgentCtrl::claudeCode() ->execute('Generate a Laravel migration for a users table'); if ($response->isSuccess()) { echo $response->text(); } // @doctest id="5efe" ``` ### Agent Selection ```php // Claude Code (Anthropic) $response = AgentCtrl::claudeCode() ->withModel('claude-opus-4-5') ->execute('Refactor the User model'); // Codex (OpenAI) $response = AgentCtrl::codex() ->execute('Write unit tests for UserService'); // OpenCode (Multi-model) $response = AgentCtrl::openCode() ->withModel('anthropic/claude-sonnet-4-5') ->execute('Analyze codebase architecture'); // Dynamic selection use Cognesy\AgentCtrl\Enum\AgentType; $response = AgentCtrl::make(AgentType::ClaudeCode) ->execute('Generate API documentation'); // @doctest id="ea14" ``` ### Configuration The facade automatically applies Laravel configuration defaults from `config/instructor.php` for each agent type. Builder methods override those defaults for a single call. ```php use Cognesy\AgentCtrl\Config\AgentCtrlConfig; use Cognesy\Sandbox\Enums\SandboxDriver; $response = AgentCtrl::claudeCode() ->withConfig(new AgentCtrlConfig( model: 'claude-opus-4-5', timeout: 300, workingDirectory: base_path(), sandboxDriver: SandboxDriver::Host, )) ->execute('Your prompt'); // @doctest id="531d" ``` ### Streaming Process output in real-time with streaming callbacks. The `onText`, `onToolUse`, and `onComplete` callbacks fire as the agent generates output. ```php $response = AgentCtrl::claudeCode() ->onText(function (string $text) { echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) { echo "Tool: $tool\n"; }) ->onComplete(function (AgentResponse $response) { echo "Done! Exit code: " . $response->exitCode; }) ->executeStreaming('Generate a REST API'); // @doctest id="46c4" ``` ### Response Object ```php $response = AgentCtrl::claudeCode()->execute('...'); // Main content $response->text(); // Generated text output $response->isSuccess(); // True if exitCode is 0 // Metadata $response->exitCode; // Process exit code $response->sessionId(); // Session ID for resuming (AgentSessionId|null) $response->agentType; // Which agent was used // Usage & cost $response->usage->input; // Input tokens $response->usage->output; // Output tokens $response->cost; // Cost in USD // Tool calls foreach ($response->toolCalls as $call) { $call->tool; // Tool name $call->input; // Tool input $call->output; // Tool output $call->isError; // If tool failed } // @doctest id="09cb" ``` ### Session Management Resume previous sessions for multi-turn agent interactions. The session ID from a previous response lets you continue where you left off. ```php // First execution $response = AgentCtrl::claudeCode() ->execute('Start refactoring the User model'); $sessionId = $response->sessionId; // Resume later $response = AgentCtrl::claudeCode() ->resumeSession($sessionId) ->execute('Continue with the Address model'); // @doctest id="d887" ``` ### Available Methods | Method | Description | |--------|-------------| | `claudeCode()` | Get Claude Code agent builder | | `codex()` | Get Codex agent builder | | `openCode()` | Get OpenCode agent builder | | `make(AgentType)` | Get agent builder by type | | `fake(array $responses)` | Create a testing fake | | `withConfig(AgentCtrlConfig)` | Apply shared typed config | | `withModel(string)` | Set AI model | | `withTimeout(int)` | Set execution timeout in seconds | | `inDirectory(string)` | Set working directory | | `withSandboxDriver(SandboxDriver)` | Set sandbox isolation driver | | `onText(callable)` | Register streaming text callback | | `onToolUse(callable)` | Register tool use callback | | `onComplete(callable)` | Register completion callback | | `resumeSession(string)` | Resume a previous session | | `execute(string)` | Execute and return response | | `executeStreaming(string)` | Execute with streaming callbacks | --- ## Dependency Injection Instead of facades, you can inject the underlying service classes directly into your constructors or method signatures. Laravel's service container resolves them with the same configuration and HTTP client bindings that the facades use. ```php use Cognesy\Instructor\StructuredOutput; use Cognesy\Polyglot\Inference\Inference; use Cognesy\Polyglot\Embeddings\Embeddings; class MyService { public function __construct( private StructuredOutput $structuredOutput, private Inference $inference, private Embeddings $embeddings, ) {} public function process(string $text): PersonData { return $this->structuredOutput ->with(messages: $text, responseModel: PersonData::class) ->get(); } } // @doctest id="a78e" ``` Dependency injection is particularly useful for: - **Better testability** -- you can mock the injected service or use constructor injection with a fake - **Explicit dependencies** -- the class signature documents exactly which services it needs - **IDE autocompletion** -- your editor can provide method suggestions on the typed property --- ## Facade Behavior All facades proxy to the underlying service classes registered in the container. The `StructuredOutput` facade is registered as a non-singleton (`bind`), so each resolution returns a fresh instance. `Inference` and `Embeddings` are registered as singletons. This means you can chain methods on any facade call without side effects: ```php // Each call gets a fresh StructuredOutput instance StructuredOutput::connection('openai')->with(...)->get(); StructuredOutput::connection('anthropic')->with(...)->get(); // @doctest id="8ad9" ``` ================================================================================ FILE: packages/laravel/response-models.md ================================================================================ # Response Models Response models define the structure of data you want to extract from unstructured text. They are plain PHP classes with typed constructor properties that serve a dual purpose: they tell the LLM what data to produce (via the generated JSON Schema), and they provide a strongly typed container for the extracted result. ## Creating Response Models ### Using Artisan Command The `make:response-model` command generates a ready-to-use response model class with the correct namespace, typed properties, and docblock descriptions. ```bash # Basic response model php artisan make:response-model PersonData # Collection response model (model with an array of child items) php artisan make:response-model ProductList --collection # Nested objects response model (model with child object properties) php artisan make:response-model CompanyProfile --nested # With a custom description in the class docblock php artisan make:response-model Invoice --description="Invoice extracted from PDF" # @doctest id="a468" ``` ### Manual Creation Create a class in `app/ResponseModels/` (or any namespace you prefer): ```php get(); echo $person->name; // "John Smith" echo $person->age; // 30 echo $person->email; // "john@example.com" // @doctest id="330e" ``` ### With Array Schema For quick prototyping or one-off extractions, you can pass a raw JSON Schema array instead of a class. The result is returned as an associative array rather than a typed object. ```php $person = StructuredOutput::with( messages: 'John is 30 years old', responseModel: [ 'type' => 'object', 'properties' => [ 'name' => ['type' => 'string', 'description' => 'Person name'], 'age' => ['type' => 'integer', 'description' => 'Person age'], ], 'required' => ['name', 'age'], ], )->get(); echo $person['name']; // "John" echo $person['age']; // 30 // @doctest id="568b" ``` ### Extracting Collections To extract a list of objects from a single input, wrap the response model class in an array schema descriptor. ```php final class Product { public function __construct( public readonly string $name, public readonly float $price, ) {} } $products = StructuredOutput::with( messages: 'Products: iPhone $999, MacBook $1299, AirPods $199', responseModel: [ 'type' => 'array', 'items' => Product::class, ], )->get(); foreach ($products as $product) { echo "{$product->name}: \${$product->price}\n"; } // @doctest id="4bbf" ``` ## Validation ### Using Symfony Validator Add Symfony Validator constraint attributes to your properties for automatic validation. When the LLM's response violates a constraint, the package sends the validation errors back to the model and retries (up to `max_retries` times). ```php use Symfony\Component\Validator\Constraints as Assert; final class UserRegistration { public function __construct( #[Assert\NotBlank] #[Assert\Length(min: 2, max: 100)] public readonly string $name, #[Assert\NotBlank] #[Assert\Email] public readonly string $email, #[Assert\Range(min: 18, max: 120)] public readonly int $age, ) {} } // @doctest id="51f1" ``` ### Custom Validation Implement the `CanValidateObject` contract for business-rule validation that goes beyond simple type and format checks. The `validate` method must return a `ValidationResult` instance. ```php use Cognesy\Instructor\Validation\Contracts\CanValidateObject; use Cognesy\Instructor\Validation\ValidationResult; class AgeValidator implements CanValidateObject { public function validate(object $dataObject): ValidationResult { if ($dataObject->age < 0) { return ValidationResult::fieldError( field: 'age', value: $dataObject->age, message: 'Age cannot be negative', ); } return ValidationResult::valid(); } } // @doctest id="6918" ``` Custom validators are registered on the `StructuredOutputRuntime`, not on the facade directly: ```php use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\LLMProvider; $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::new()) ->withValidator(new AgeValidator()); $user = StructuredOutput::withRuntime($runtime)->with( messages: 'User: John, age -5', responseModel: UserData::class, )->get(); // @doctest id="1e45" ``` ## Best Practices ### 1. Use Descriptive Property Names Property names are part of the schema the LLM sees. Clear names reduce ambiguity and improve extraction accuracy. ```php // Good public readonly string $customerEmailAddress; // Less clear public readonly string $email; // @doctest id="7e44" ``` ### 2. Add Detailed Descriptions Docblock descriptions are your primary tool for steering the LLM. Be specific about formats, ranges, and edge cases. ```php public function __construct( /** * The product SKU in format XXX-YYYY-ZZZ * Example: ABC-1234-XYZ */ public readonly string $sku, ) {} // @doctest id="49e3" ``` ### 3. Use Appropriate Types Choose the most specific type available. Enums are preferable to free-form strings for fields with a fixed set of values. ```php // Use int for counts public readonly int $quantity; // Use float for prices public readonly float $price; // Use enums for fixed options public readonly Status $status; // @doctest id="f025" ``` ### 4. Make Optional Properties Nullable Distinguish between required and optional fields clearly. Required properties should not have defaults; optional ones should be nullable with a `null` default. ```php // Required public readonly string $name, // Optional public readonly ?string $nickname = null, // @doctest id="947a" ``` ### 5. Use Readonly Properties Readonly properties enforce immutability, which prevents accidental mutation of extracted data. This is the recommended approach for all response models. ```php // Immutable -- recommended public readonly string $name; // Mutable -- avoid unless necessary public string $name; // @doctest id="3b0d" ``` ## Generated Stubs The `make:response-model` command generates from these stub types. Publish them with `php artisan vendor:publish --tag=instructor-stubs` to customize. ### Basic Stub ```php final class {{ class }} { public function __construct( /** The name of the person */ public readonly string $name, /** The age of the person in years */ public readonly int $age, /** Optional email address */ public readonly ?string $email = null, ) {} } // @doctest id="7a6a" ``` ### Collection Stub (`--collection`) ```php final class {{ class }} { public function __construct( /** List of extracted items */ public readonly array $items, ) {} } final class {{ class }}Item { public function __construct( public readonly string $name, public readonly ?string $description = null, ) {} } // @doctest id="d908" ``` ### Nested Stub (`--nested`) ```php final class {{ class }} { public function __construct( public readonly string $title, public readonly {{ class }}Contact $contact, public readonly ?{{ class }}Address $address = null, ) {} } final class {{ class }}Contact { public function __construct( public readonly string $name, public readonly string $email, ) {} } final class {{ class }}Address { public function __construct( public readonly string $street, public readonly string $city, public readonly string $country, ) {} } // @doctest id="2b0f" ``` ================================================================================ FILE: packages/laravel/agents.md ================================================================================ # Code Agents The `AgentCtrl` facade provides a unified interface for invoking CLI-based code agents that can execute code, modify files, and perform complex multi-step tasks. Each agent runs as an external process, and the facade handles process management, output parsing, and response structuring. ## Setup Before using `AgentCtrl`, install the CLI agent you want to run and make sure its executable is available in `PATH`. The Laravel package does not install or authenticate these tools for you. | Agent | Required binary | Setup note | |-------|-----------------|------------| | Claude Code | `claude` | Install Claude Code separately and sign in with its normal workflow | | Codex | `codex` | Install the Codex CLI and ensure `codex` resolves on the server running Laravel | | OpenCode | `opencode` | Install OpenCode and ensure `opencode` resolves on the server running Laravel | After the binary is available, configure Laravel defaults in `config/instructor.php` under `agents` for timeout, working directory, sandbox driver, and per-agent model overrides. ## Supported Agents | Agent | Description | Use Case | |-------|-------------|----------| | **Claude Code** | Anthropic's Claude agent with code execution | General coding tasks, refactoring, file modifications | | **Codex** | OpenAI's Codex agent | Code generation and completion | | **OpenCode** | Multi-model code agent | Research and coding with model flexibility | ## Quick Start ```php use Cognesy\AgentCtrl\Config\AgentCtrlConfig; use Cognesy\Instructor\Laravel\Facades\AgentCtrl; // Execute a task with Claude Code $response = AgentCtrl::claudeCode() ->withConfig(new AgentCtrlConfig( timeout: 300, workingDirectory: base_path(), )) ->execute('Generate a Laravel migration for a users table with name, email, and password fields'); // Check if successful if ($response->isSuccess()) { echo $response->text(); } // @doctest id="c012" ``` ## Agent Selection ### Claude Code Best for general coding tasks with Anthropic's Claude models. Supports sandbox isolation, session resumption, and streaming output. ```php use Cognesy\Instructor\Laravel\Facades\AgentCtrl; $response = AgentCtrl::claudeCode() ->withModel('claude-opus-4-5') ->inDirectory(base_path()) ->withTimeout(300) ->execute('Refactor the User model to use DTOs'); echo $response->text(); echo "Session ID: " . (string) ($response->sessionId() ?? ''); // @doctest id="a7a1" ``` ### Codex Best for OpenAI-powered code generation. ```php $response = AgentCtrl::codex() ->withModel('codex') ->execute('Write unit tests for the UserService class'); // @doctest id="2c02" ``` ### OpenCode Best for multi-model flexibility. Specify the model using the `provider/model` format. ```php $response = AgentCtrl::openCode() ->withModel('anthropic/claude-sonnet-4-5') ->execute('Analyze the codebase architecture'); // @doctest id="b574" ``` ### Dynamic Selection Select agent type at runtime based on configuration or business logic. ```php use Cognesy\AgentCtrl\Enum\AgentType; $agentType = AgentType::from(config('app.default_agent')); $response = AgentCtrl::make($agentType) ->execute('Generate API documentation'); // @doctest id="bde1" ``` ## Configuration ### Builder Methods All agents support the same set of builder methods for configuration. Use `withConfig()` when you want one typed object for the shared options, then layer agent-specific methods on top as needed. Builder methods override any defaults set in the Laravel config file. ```php use Cognesy\AgentCtrl\Config\AgentCtrlConfig; use Cognesy\Sandbox\Enums\SandboxDriver; AgentCtrl::claudeCode() ->withConfig(new AgentCtrlConfig( model: 'claude-opus-4-5', timeout: 300, workingDirectory: '/path/to/project', sandboxDriver: SandboxDriver::Host, )) ->execute('Your prompt'); // @doctest id="c222" ``` `AgentCtrlConfig::fromArray()` also accepts the Laravel config-style keys used in `config/instructor.php`, so `directory` and `sandbox` are mapped automatically. ### Laravel Configuration Configure defaults in `config/instructor.php`. The facade automatically reads these values and applies them when you create a builder. Builder methods then override any defaults for that specific call. ```php 'agents' => [ // Default timeout for all agents 'timeout' => env('INSTRUCTOR_AGENT_TIMEOUT', 300), // Default working directory 'directory' => env('INSTRUCTOR_AGENT_DIRECTORY'), // Default sandbox driver: host, docker, podman, firejail, bubblewrap 'sandbox' => env('INSTRUCTOR_AGENT_SANDBOX', 'host'), // Claude Code specific 'claude_code' => [ 'model' => env('CLAUDE_CODE_MODEL', 'claude-sonnet-4-20250514'), 'timeout' => env('CLAUDE_CODE_TIMEOUT'), 'directory' => env('CLAUDE_CODE_DIRECTORY'), 'sandbox' => env('CLAUDE_CODE_SANDBOX'), ], // Codex specific 'codex' => [ 'model' => env('CODEX_MODEL', 'codex'), 'timeout' => env('CODEX_TIMEOUT'), 'directory' => env('CODEX_DIRECTORY'), 'sandbox' => env('CODEX_SANDBOX'), ], // OpenCode specific 'opencode' => [ 'model' => env('OPENCODE_MODEL', 'anthropic/claude-sonnet-4-20250514'), 'timeout' => env('OPENCODE_TIMEOUT'), 'directory' => env('OPENCODE_DIRECTORY'), 'sandbox' => env('OPENCODE_SANDBOX'), ], ], // @doctest id="1cc0" ``` Agent-specific settings (e.g., `claude_code.timeout`) take precedence over the global defaults (e.g., `agents.timeout`). ### Environment Variables ```env # Default agent settings INSTRUCTOR_AGENT_TIMEOUT=300 INSTRUCTOR_AGENT_DIRECTORY=/path/to/project INSTRUCTOR_AGENT_SANDBOX=host # Claude Code CLAUDE_CODE_MODEL=claude-opus-4-5 # Codex CODEX_MODEL=codex # OpenCode OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514 // @doctest id="966b" ``` ## Streaming Process output in real-time with streaming callbacks. The three callback types fire at different points during execution. ```php $response = AgentCtrl::claudeCode() ->onText(function (string $text) { // Called as text is generated -- use for live output echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) { // Called when agent uses a tool (file read, shell command, etc.) echo "Tool: $tool\n"; echo "Input: " . json_encode($input) . "\n"; }) ->onComplete(function (AgentResponse $response) { // Called once when execution finishes echo "\nDone! Exit code: " . $response->exitCode; }) ->executeStreaming('Generate a REST API for products'); // @doctest id="3cdb" ``` ## Response Object The `AgentResponse` object contains the agent's output along with metadata about the execution. ```php $response = AgentCtrl::claudeCode()->execute('...'); // Main content $response->text(); // string -- Generated text output $response->isSuccess(); // bool -- True if exitCode is 0 // Metadata $response->exitCode; // int -- Process exit code $response->sessionId(); // AgentSessionId|null -- Session ID for resuming $response->agentType; // AgentType -- Which agent was used // Usage (when available) $response->usage; // ?TokenUsage -- Token statistics $response->usage->input; // int -- Input tokens $response->usage->output; // int -- Output tokens $response->usage->total(); // int -- Total tokens // Cost (when available) $response->cost; // ?float -- Cost in USD // Tool calls $response->toolCalls; // array -- Tools used during execution foreach ($response->toolCalls as $call) { $call->tool; // string -- Tool name $call->input; // array -- Tool input parameters $call->output; // ?string -- Tool output $call->isError; // bool -- Whether the tool call failed } // @doctest id="b9c3" ``` ## Session Management Resume previous sessions for continued work. This is useful for multi-turn interactions where the agent needs context from a prior execution. ```php // First execution $response = AgentCtrl::claudeCode() ->execute('Start refactoring the User model'); $sessionId = (string) ($response->sessionId() ?? ''); // Later: Resume the session with full context from the previous run $response = AgentCtrl::claudeCode() ->resumeSession($sessionId) ->execute('Continue with the Address model'); // @doctest id="a6cf" ``` ## Error Handling Always check `isSuccess()` and handle failures gracefully. Agent executions can fail due to timeouts, sandbox errors, or issues in the generated code. ```php use Cognesy\Instructor\Laravel\Facades\AgentCtrl; try { $response = AgentCtrl::claudeCode() ->withTimeout(60) ->execute($prompt); if (!$response->isSuccess()) { // Non-zero exit code Log::error('Agent failed', [ 'exit_code' => $response->exitCode, 'output' => $response->text(), ]); return null; } // Check for tool errors foreach ($response->toolCalls as $call) { if ($call->isError) { Log::warning('Tool error', [ 'tool' => $call->tool, 'error' => $call->output, ]); } } return $response->text(); } catch (\Throwable $e) { // Timeout, sandbox error, etc. Log::error('Agent exception', ['error' => $e->getMessage()]); return null; } // @doctest id="1c17" ``` ## Testing Use `AgentCtrl::fake()` for testing without actual agent execution. See the [Testing](testing.md) guide for full documentation of `AgentCtrlFake`. ```php use Cognesy\Instructor\Laravel\Facades\AgentCtrl; test('generates migration code', function () { // Setup fake with expected responses $fake = AgentCtrl::fake([ 'Generated migration file: 2024_01_01_create_users_table.php', ]); // Execute code under test $result = app(MigrationGenerator::class)->generate('users'); // Assertions $fake->assertExecuted(); $fake->assertExecutedTimes(1); $fake->assertUsedClaudeCode(); $fake->assertExecutedWith('migration'); expect($result)->toContain('users_table'); }); // @doctest id="64d9" ``` ## Real-World Examples ### Code Generation Service ```php namespace App\Services; use Cognesy\Instructor\Laravel\Facades\AgentCtrl; class CodeGenerationService { public function generateMigration(array $schema): string { $prompt = $this->buildMigrationPrompt($schema); $response = AgentCtrl::claudeCode() ->inDirectory(database_path('migrations')) ->execute($prompt); if (!$response->isSuccess()) { throw new \RuntimeException('Failed to generate migration'); } return $response->text(); } public function generateTest(string $className): string { $response = AgentCtrl::claudeCode() ->inDirectory(base_path('tests')) ->execute("Generate comprehensive tests for: $className"); return $response->text(); } private function buildMigrationPrompt(array $schema): string { return "Generate a Laravel migration for:\n" . json_encode($schema, JSON_PRETTY_PRINT); } } // @doctest id="ab24" ``` ### Queued Code Generation For long-running agent tasks, dispatch them to a queue so the user does not have to wait. ```php namespace App\Jobs; use Cognesy\Instructor\Laravel\Facades\AgentCtrl; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; class GenerateCodeJob implements ShouldQueue { use Queueable; public function __construct( public string $prompt, public string $outputPath, ) {} public function handle(): void { $response = AgentCtrl::claudeCode() ->withTimeout(600) // 10 minutes for complex tasks ->inDirectory(dirname($this->outputPath)) ->execute($this->prompt); if ($response->isSuccess()) { file_put_contents($this->outputPath, $response->text()); } } } // Usage GenerateCodeJob::dispatch( 'Generate a complete CRUD controller for Products', app_path('Http/Controllers/ProductController.php') ); // @doctest id="86ad" ``` ### Interactive Code Review ```php use Cognesy\Instructor\Laravel\Facades\AgentCtrl; class CodeReviewer { public function review(string $filePath, callable $onProgress = null): array { $builder = AgentCtrl::claudeCode() ->inDirectory(dirname($filePath)); if ($onProgress) { $builder->onText($onProgress); } $response = $builder->execute( "Review this file for bugs, security issues, and improvements: $filePath" ); return [ 'review' => $response->text(), 'success' => $response->isSuccess(), 'session' => (string) ($response->sessionId() ?? ''), ]; } } // @doctest id="3a3b" ``` ## Sandbox Drivers Control the isolation level of agent execution. The sandbox driver determines whether the agent runs directly on the host or inside a container. | Driver | Description | Use Case | |--------|-------------|----------| | `host` | Direct execution (no isolation) | Development, trusted environments | | `docker` | Docker container isolation | Production, untrusted code | | `podman` | Podman container isolation | Rootless containers | | `firejail` | Linux sandbox | Lightweight isolation | | `bubblewrap` | Minimal sandbox | CI/CD environments | ```php use Cognesy\Sandbox\Enums\SandboxDriver; // Development (direct execution) AgentCtrl::claudeCode() ->withSandboxDriver(SandboxDriver::Host) ->execute('...'); // Production (Docker isolation) AgentCtrl::claudeCode() ->withSandboxDriver(SandboxDriver::Docker) ->execute('...'); // @doctest id="438a" ``` ## Best Practices 1. **Set Timeouts** -- Always set appropriate timeouts for your use case. Complex code generation can take several minutes. 2. **Use Sandbox Isolation** -- In production, use Docker or another container-based sandbox driver to prevent agents from making unintended changes. 3. **Handle Errors** -- Check `isSuccess()` and handle failures gracefully. Agents can fail for many reasons, including API limits, invalid code, and sandbox restrictions. 4. **Log Sessions** -- Store session IDs for debugging and continuation. They let you resume work and trace agent behavior. 5. **Test with Fakes** -- Use `AgentCtrl::fake()` in tests to avoid API calls and process execution. 6. **Queue Long Tasks** -- Use Laravel queues for time-consuming code generation to keep web responses fast. ================================================================================ FILE: packages/laravel/events.md ================================================================================ # Events Instructor dispatches events throughout the extraction and inference lifecycle. These events are automatically bridged to Laravel's event system by the `LaravelEventDispatcher`, allowing you to listen and respond using standard Laravel patterns -- listeners, subscribers, closures, and queued handlers. The bridge is implemented by `Cognesy\Instructor\Laravel\Events\LaravelEventDispatcher`, which lives in the `packages/laravel` package. It wraps Laravel's native `Illuminate\Contracts\Events\Dispatcher` and forwards Instructor events to it based on your configuration. ## Event Bridge Configuration Configure event bridging in `config/instructor.php`: ```php 'events' => [ // Enable bridging to Laravel's event dispatcher 'dispatch_to_laravel' => env('INSTRUCTOR_DISPATCH_EVENTS', true), // Specify which events to bridge (empty = all events) 'bridge_events' => [ // Only bridge specific events \Cognesy\Instructor\Events\Extraction\ExtractionCompleted::class, \Cognesy\Instructor\Events\Extraction\ExtractionFailed::class, ], ], // @doctest id="3d62" ``` When `bridge_events` is empty (the default), every Instructor event is forwarded to Laravel's dispatcher. To reduce overhead in production, list only the event classes your listeners actually need. The bridge uses `instanceof` matching, so listing a parent event class also bridges its subclasses. ## Available Events All events extend `Cognesy\Instructor\Events\StructuredOutputEvent` (which extends `Cognesy\Events\Event`). Events carry data in the `$data` property (an array or mixed value) rather than typed properties. ### Extraction Events Namespace: `Cognesy\Instructor\Events\Extraction` | Event | Description | |-------|-------------| | `ExtractionStarted` | Extraction pipeline has begun processing | | `ExtractionCompleted` | Extraction completed successfully | | `ExtractionFailed` | All extraction strategies failed | | `ExtractionStrategyAttempted` | An extraction strategy was attempted | | `ExtractionStrategyFailed` | An extraction strategy failed | | `ExtractionStrategySucceeded` | An extraction strategy succeeded | ### Response Events Namespace: `Cognesy\Instructor\Events\Response` | Event | Description | |-------|-------------| | `ResponseValidationFailed` | Response failed validation | | `ResponseValidated` | Response passed validation | | `ResponseDeserialized` | Response was deserialized into an object | | `ResponseDeserializationFailed` | Response deserialization failed | | `ResponseTransformed` | Response was transformed | | `ResponseTransformationFailed` | Response transformation failed | | `ResponseGenerationFailed` | Response generation failed | ### Request Events Namespace: `Cognesy\Instructor\Events\Request` | Event | Description | |-------|-------------| | `NewValidationRecoveryAttempt` | A validation recovery retry attempt is being made | | `StructuredOutputRecoveryLimitReached` | Maximum retries exhausted | | `ResponseModelRequested` | Response model was requested | | `ResponseModelBuilt` | Response model schema was built | ### Streaming Events Namespace: `Cognesy\Instructor\Events\PartialsGenerator` | Event | Description | |-------|-------------| | `StreamedResponseReceived` | Streaming response started | | `ChunkReceived` | Received a chunk of streaming data | | `StreamedResponseFinished` | Streaming completed | | `PartialResponseGenerated` | A partial response object was generated | | `StreamedToolCallStarted` | A streamed tool call started | | `StreamedToolCallUpdated` | A streamed tool call was updated | | `StreamedToolCallCompleted` | A streamed tool call completed | ## Listening to Events ### Using Event Listeners Create a dedicated listener class and register it with Laravel's event system. ```php // app/Listeners/LogExtractionCompleted.php namespace App\Listeners; use Cognesy\Instructor\Events\Extraction\ExtractionCompleted; use Illuminate\Support\Facades\Log; class LogExtractionCompleted { public function handle(ExtractionCompleted $event): void { Log::info('Extraction completed', [ 'event' => $event->name(), 'data' => $event->data, ]); } } // @doctest id="fd86" ``` Register in `EventServiceProvider`: ```php // app/Providers/EventServiceProvider.php namespace App\Providers; use App\Listeners\LogExtractionCompleted; use Cognesy\Instructor\Events\Extraction\ExtractionCompleted; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; class EventServiceProvider extends ServiceProvider { protected $listen = [ ExtractionCompleted::class => [ LogExtractionCompleted::class, ], ]; } // @doctest id="55df" ``` ### Using Closures For lightweight listeners, register closures directly in a service provider's `boot` method. ```php // app/Providers/AppServiceProvider.php use Cognesy\Instructor\Events\Extraction\ExtractionCompleted; use Cognesy\Instructor\Events\Extraction\ExtractionFailed; use Illuminate\Support\Facades\Event; public function boot(): void { Event::listen(ExtractionCompleted::class, function ($event) { // Handle successful extraction }); Event::listen(ExtractionFailed::class, function ($event) { // Handle failed extraction }); } // @doctest id="b718" ``` ### Using Event Subscribers Group related event handlers into a single subscriber class. This is convenient when you need to handle multiple Instructor events together. ```php // app/Listeners/InstructorEventSubscriber.php namespace App\Listeners; use Cognesy\Instructor\Events\Extraction\ExtractionCompleted; use Cognesy\Instructor\Events\Extraction\ExtractionFailed; use Cognesy\Instructor\Events\Extraction\ExtractionStarted; use Illuminate\Events\Dispatcher; class InstructorEventSubscriber { public function handleStart(ExtractionStarted $event): void { // Log start } public function handleComplete(ExtractionCompleted $event): void { // Log completion } public function handleFailed(ExtractionFailed $event): void { // Alert on failure } public function subscribe(Dispatcher $events): array { return [ ExtractionStarted::class => 'handleStart', ExtractionCompleted::class => 'handleComplete', ExtractionFailed::class => 'handleFailed', ]; } } // Register in EventServiceProvider protected $subscribe = [ InstructorEventSubscriber::class, ]; // @doctest id="0bc0" ``` ## Common Use Cases ### Logging and Monitoring All events carry data in the `$data` property (typically an array). Use the `name()` method to get the event class short name. ```php use Cognesy\Instructor\Events\Extraction\ExtractionCompleted; use Cognesy\Instructor\Events\Extraction\ExtractionFailed; use Illuminate\Support\Facades\Log; Event::listen(ExtractionCompleted::class, function ($event) { Log::channel('llm')->info('Extraction successful', [ 'event' => $event->name(), 'data' => $event->data, ]); }); Event::listen(ExtractionFailed::class, function ($event) { Log::channel('llm')->error('Extraction failed', [ 'event' => $event->name(), 'data' => $event->data, ]); }); // @doctest id="3e7b" ``` ### Metrics and Analytics ```php use Cognesy\Instructor\Events\Extraction\ExtractionCompleted; use App\Services\MetricsService; Event::listen(ExtractionCompleted::class, function ($event) { app(MetricsService::class)->recordExtraction([ 'event' => $event->name(), 'data' => $event->data, ]); }); // @doctest id="bd15" ``` ### Alerting on Failures ```php use Cognesy\Instructor\Events\Extraction\ExtractionFailed; use Illuminate\Support\Facades\Notification; use App\Notifications\ExtractionFailedNotification; Event::listen(ExtractionFailed::class, function ($event) { Notification::route('slack', config('services.slack.webhook')) ->notify(new ExtractionFailedNotification($event)); }); // @doctest id="c713" ``` ### Queued Event Listeners For CPU-intensive or I/O-heavy processing, implement `ShouldQueue` to push the work onto a queue instead of running it inline. ```php // app/Listeners/ProcessExtractionAnalytics.php namespace App\Listeners; use Cognesy\Instructor\Events\Extraction\ExtractionCompleted; use Illuminate\Contracts\Queue\ShouldQueue; class ProcessExtractionAnalytics implements ShouldQueue { public $queue = 'analytics'; public function handle(ExtractionCompleted $event): void { // Heavy analytics processing runs on the queue } } // @doctest id="ba23" ``` ## Wiretap (Direct Event Handling) The `wiretap` method provides direct access to the raw event stream without going through Laravel's dispatcher. This is useful for low-level debugging or when you need to observe every internal event. ```php use Cognesy\Instructor\Laravel\Facades\StructuredOutput; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\LLMProvider; $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::new()) ->wiretap(function ($event) { // Called for every event in the pipeline logger()->debug('Event: ' . get_class($event)); }); $person = StructuredOutput::withRuntime($runtime)->with( messages: 'Extract person data...', responseModel: PersonData::class, ) ->get(); // @doctest id="17fb" ``` The `LaravelEventDispatcher` itself also supports `wiretap` for registering global listeners that receive every event, regardless of class. These listeners run at the lowest priority after all class-specific and bridged listeners have executed. ## Disabling Event Bridge To disable event bridging entirely (for example, in high-throughput scenarios where the overhead is unacceptable): ```php // config/instructor.php 'events' => [ 'dispatch_to_laravel' => false, ], // @doctest id="74d3" ``` Or via environment variable: ```env INSTRUCTOR_DISPATCH_EVENTS=false // @doctest id="0193" ``` Disabling the bridge only stops events from being forwarded to Laravel's dispatcher. Internal Instructor event listeners and wiretaps continue to work normally. ## Testing Events Use Laravel's `Event::fake()` to assert that specific events were dispatched during a test. ```php use Cognesy\Instructor\Events\Extraction\ExtractionCompleted; use Illuminate\Support\Facades\Event; public function test_dispatches_extraction_event(): void { Event::fake([ExtractionCompleted::class]); StructuredOutput::with( messages: 'John is 30', responseModel: PersonData::class, )->get(); Event::assertDispatched(ExtractionCompleted::class); } // @doctest id="aac5" ``` Assert event data with a closure: ```php Event::assertDispatched(ExtractionCompleted::class, function ($event) { return !empty($event->data); }); // @doctest id="b765" ``` ================================================================================ FILE: packages/laravel/commands.md ================================================================================ # Artisan Commands The package registers three Artisan commands to help with installation, testing, and scaffolding. All commands are registered automatically when the application is running in console mode. ## instructor:install Sets up the Instructor package in your Laravel application. This is the recommended first step after installing the Composer package. ```bash php artisan instructor:install # @doctest id="bda3" ``` ### What It Does 1. **Publishes the configuration file** (`config/instructor.php`) using the `instructor-config` publish tag 2. **Checks for API key configuration** by scanning your `.env` file for `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` entries 3. **Displays next steps** including how to create your first response model and test the installation If no API keys are detected, the command displays a warning with instructions for adding them. ### Options | Option | Description | |--------|-------------| | `--force` | Overwrite existing configuration files (passed through to `vendor:publish`) | ### Example Output ``` Installing Instructor for Laravel... Publishing configuration... done Checking API key configuration... done Next steps: 1. Configure your API keys in .env: OPENAI_API_KEY=your-key-here 2. Create a response model: php artisan make:response-model PersonData 3. Extract structured data: $person = StructuredOutput::with( messages: "John is 30 years old", responseModel: PersonData::class, )->get(); 4. Test your installation: php artisan instructor:test Instructor installed successfully! // @doctest id="7848" ``` --- ## instructor:test Tests your Instructor installation and API configuration by making a real API call. This verifies that your API key is valid, the network connection works, and the full extraction pipeline (or raw inference pipeline) is operational. ```bash php artisan instructor:test # @doctest id="430b" ``` ### What It Does 1. **Displays current configuration** -- connection name, driver, model, and a masked version of the API key 2. **Makes a test API call** -- either a structured output extraction (default) or a raw inference call 3. **Verifies the response** -- confirms the result contains the expected data For the structured output test, the command extracts a simple name-and-age pair from a test sentence. For the inference test, it sends "Reply with just the word 'pong'" and checks the response. ### Options | Option | Description | |--------|-------------| | `--connection=` | Test a specific configured connection instead of the default | | `--inference` | Test raw inference instead of structured output extraction | ### Examples ```bash # Test default connection php artisan instructor:test # Test specific connection php artisan instructor:test --connection=anthropic # Test raw inference php artisan instructor:test --inference # @doctest id="fc9e" ``` ### Example Output ``` Testing Instructor installation... Connection ......................................... openai Driver ............................................. openai Model .............................................. gpt-4o-mini API Key ............................................ sk-a...xyz1 done Testing structured output extraction... done Structured output test completed! // @doctest id="4680" ``` Use the `-v` flag for verbose output, which includes a full stack trace if the test fails. --- ## make:response-model Generates a new response model class with typed constructor properties, docblock descriptions, and the correct namespace. The generated class is ready to use with `StructuredOutput::with(responseModel: ...)` immediately. ```bash php artisan make:response-model {name} # @doctest id="2539" ``` ### Arguments | Argument | Description | |----------|-------------| | `name` | The name of the response model class (e.g., `PersonData`, `InvoiceDetails`) | ### Options | Option | Description | |--------|-------------| | `--collection`, `-c` | Create a collection response model with a parent class containing an array of child item objects | | `--nested`, `-n` | Create a nested response model with child object properties (Contact, Address) | | `--description=`, `-d` | Set the class docblock description (defaults to a TODO placeholder) | | `--force`, `-f` | Overwrite an existing file | ### Examples #### Basic Response Model ```bash php artisan make:response-model PersonData # @doctest id="160d" ``` Creates `app/ResponseModels/PersonData.php`: ```php argument('file')); $invoice = StructuredOutput::with( messages: $content, responseModel: InvoiceData::class, )->get(); $this->info("Invoice Number: {$invoice->number}"); $this->info("Amount: \${$invoice->amount}"); $this->info("Due Date: {$invoice->dueDate}"); return self::SUCCESS; } } // @doctest id="9e74" ``` --- ## Command Reference | Command | Description | |---------|-------------| | `instructor:install` | Install and configure the package | | `instructor:test` | Test API configuration with a real API call | | `make:response-model` | Generate a response model class | ================================================================================ FILE: packages/laravel/testing.md ================================================================================ # Testing The package provides dedicated testing fakes for all four facades, allowing you to mock LLM responses and make assertions about how your code interacts with the services. No real API calls are made when a fake is active, which makes tests fast, deterministic, and free of external dependencies. ## StructuredOutput::fake() The `StructuredOutputFake` intercepts all extraction calls and returns predefined responses. It records every call so you can assert against the response model class, messages, connection, and model that were used. ### Basic Usage ```php use Cognesy\Instructor\Laravel\Facades\StructuredOutput; use App\ResponseModels\PersonData; use Tests\TestCase; class PersonExtractionTest extends TestCase { public function test_extracts_person_data(): void { // Arrange -- setup the fake with expected responses $fake = StructuredOutput::fake([ PersonData::class => new PersonData( name: 'John Smith', age: 30, email: 'john@example.com', ), ]); // Act -- your code calls StructuredOutput $person = StructuredOutput::with( messages: 'John Smith is 30 years old', responseModel: PersonData::class, )->get(); // Assert -- verify the result $this->assertEquals('John Smith', $person->name); $this->assertEquals(30, $person->age); // Assert that extraction was performed $fake->assertExtracted(PersonData::class); } } // @doctest id="0e26" ``` ### Response Mapping Map response model classes to their fake responses. Each class returns its corresponding value when extracted. ```php $fake = StructuredOutput::fake([ PersonData::class => new PersonData(name: 'John', age: 30), AddressData::class => new AddressData(city: 'New York'), OrderData::class => new OrderData(total: 99.99), ]); // Each class returns its mapped response $person = StructuredOutput::with(..., responseModel: PersonData::class)->get(); $address = StructuredOutput::with(..., responseModel: AddressData::class)->get(); // @doctest id="faa3" ``` If you request a response model that has no mapping, the fake throws a `RuntimeException` with a helpful message telling you which class needs a fake response. ### Response Sequences Return different responses for sequential calls to the same response model class. ```php $fake = StructuredOutput::fake(); $fake->respondWithSequence(PersonData::class, [ new PersonData(name: 'First Person', age: 25), new PersonData(name: 'Second Person', age: 30), new PersonData(name: 'Third Person', age: 35), ]); // First call $first = StructuredOutput::with(...)->get(); // First Person // Second call $second = StructuredOutput::with(...)->get(); // Second Person // Third call $third = StructuredOutput::with(...)->get(); // Third Person // @doctest id="445b" ``` ### Available Assertions ```php $fake = StructuredOutput::fake([...]); // Run your code... // Assert extraction was called for a class $fake->assertExtracted(PersonData::class); // Assert extraction count $fake->assertExtractedTimes(PersonData::class, 1); $fake->assertExtractedTimes(PersonData::class, 3); // Assert no extractions were performed $fake->assertNothingExtracted(); // Assert messages contained specific text $fake->assertExtractedWith(PersonData::class, 'John Smith'); // Assert configured connection was used $fake->assertUsedConnection('anthropic'); // Assert model was used $fake->assertUsedModel('gpt-4o'); // @doctest id="9376" ``` ### Accessing Recorded Calls Inspect all recorded extraction calls for custom assertions. ```php $fake = StructuredOutput::fake([...]); // Run your code... // Get all recorded extractions $recorded = $fake->recorded(); foreach ($recorded as $extraction) { echo "Class: " . $extraction['class']; echo "Messages: " . json_encode($extraction['messages']); echo "Model: " . $extraction['model']; echo "Connection: " . $extraction['connection']; } // @doctest id="6a5f" ``` --- ## Inference::fake() The `InferenceFake` intercepts raw inference calls and returns responses based on pattern matching against the input messages. ### Basic Usage ```php use Cognesy\Instructor\Laravel\Facades\Inference; use Cognesy\Messages\Messages; public function test_calls_inference(): void { // Arrange $fake = Inference::fake([ 'What is 2+2?' => 'The answer is 4.', 'default' => 'I don\'t know.', ]); // Act $response = Inference::with( messages: Messages::fromString('What is 2+2?'), )->get(); // Assert $this->assertEquals('The answer is 4.', $response); $fake->assertCalled(); $fake->assertCalledWith('What is 2+2?'); } // @doctest id="ac7e" ``` ### Pattern Matching Responses are matched by checking whether the input message contains the pattern string. The first matching pattern wins. If no pattern matches, the `default` key is used as a fallback; if no `default` exists, an empty string is returned. ```php $fake = Inference::fake([ 'capital' => 'Paris is the capital of France.', 'weather' => 'The weather is sunny.', 'default' => 'I don\'t understand.', ]); // Matches 'capital' (input contains the word) $response1 = Inference::with(messages: Messages::fromString('What is the capital of France?'))->get(); // Matches 'weather' $response2 = Inference::with(messages: Messages::fromString('How is the weather today?'))->get(); // No match, uses 'default' $response3 = Inference::with(messages: Messages::fromString('Random question'))->get(); // @doctest id="e98f" ``` ### Response Sequences Queue ordered responses that are returned regardless of input content. ```php $fake = Inference::fake(); $fake->respondWithSequence([ 'First response', 'Second response', 'Third response', ]); // Returns responses in order $first = Inference::with(...)->get(); // "First response" $second = Inference::with(...)->get(); // "Second response" // @doctest id="f2fa" ``` ### Available Assertions ```php $fake = Inference::fake([...]); // Assert inference was called $fake->assertCalled(); // Assert call count $fake->assertCalledTimes(3); // Assert never called $fake->assertNotCalled(); // Assert called with specific message text $fake->assertCalledWith('What is the capital'); // Assert configured connection was used $fake->assertUsedConnection('groq'); // Assert model was used $fake->assertUsedModel('llama-3.3-70b'); // Assert called with specific tools $fake->assertCalledWithTools(['search', 'calculate']); // @doctest id="0d0b" ``` --- ## Embeddings::fake() The `EmbeddingsFake` intercepts embedding requests and returns predefined or randomly generated vectors. ### Basic Usage ```php use Cognesy\Instructor\Laravel\Facades\Embeddings; public function test_generates_embeddings(): void { // Arrange $fake = Embeddings::fake([ 'hello' => [0.1, 0.2, 0.3, 0.4, 0.5], ]); // Act $embedding = Embeddings::withInputs('hello world')->first(); // Assert $this->assertIsArray($embedding); $fake->assertCalled(); $fake->assertCalledWith('hello world'); } // @doctest id="2805" ``` ### Default Embeddings If no pattern matches, a random normalized embedding vector is generated automatically. This is useful when you need an embedding but do not care about its exact values. ```php $fake = Embeddings::fake(); // Returns random 1536-dimensional embedding (matching OpenAI's default dimensions) $embedding = Embeddings::withInputs('anything')->first(); $this->assertCount(1536, $embedding); // @doctest id="4c88" ``` ### Custom Dimensions Match the dimensionality of your production embedding model. ```php $fake = Embeddings::fake() ->withDimensions(768); // Use 768 dimensions $embedding = Embeddings::withInputs('test')->first(); $this->assertCount(768, $embedding); // @doctest id="4834" ``` ### Available Assertions ```php $fake = Embeddings::fake([...]); // Assert embeddings were called $fake->assertCalled(); // Assert call count $fake->assertCalledTimes(2); // Assert never called $fake->assertNotCalled(); // Assert called with specific input $fake->assertCalledWith('hello world'); // Assert configured connection was used $fake->assertUsedConnection('openai'); // Assert model was used $fake->assertUsedModel('text-embedding-3-large'); // @doctest id="aacc" ``` --- ## AgentCtrl::fake() The `AgentCtrlFake` intercepts code agent executions and returns predefined responses without launching any CLI processes. ### Basic Usage ```php use Cognesy\Instructor\Laravel\Facades\AgentCtrl; public function test_generates_code(): void { // Arrange -- setup fake with expected responses $fake = AgentCtrl::fake([ 'Generated migration file: 2024_01_01_create_users_table.php', ]); // Act -- your code calls AgentCtrl $result = AgentCtrl::claudeCode() ->execute('Generate a users table migration'); // Assert $this->assertEquals(0, $result->exitCode); $this->assertStringContainsString('migration', $result->text()); $fake->assertExecuted(); $fake->assertExecutedWith('migration'); } // @doctest id="059f" ``` ### Response Sequences Return different responses for sequential calls. If more calls are made than responses provided, the last response is repeated. ```php $fake = AgentCtrl::fake([ 'First response', 'Second response', 'Third response', ]); $first = AgentCtrl::claudeCode()->execute('First'); // "First response" $second = AgentCtrl::claudeCode()->execute('Second'); // "Second response" $third = AgentCtrl::claudeCode()->execute('Third'); // "Third response" $fake->assertExecutedTimes(3); // @doctest id="7f9e" ``` ### Custom Responses Create detailed fake responses with specific metadata using the `AgentCtrlFake::response()` factory method. ```php use Cognesy\AgentCtrl\Enum\AgentType; use Cognesy\Instructor\Laravel\Testing\AgentCtrlFake; $customResponse = AgentCtrlFake::response( text: 'Generated code output', exitCode: 0, agentType: AgentType::ClaudeCode, cost: 0.05, ); $fake = AgentCtrl::fake([$customResponse]); $response = AgentCtrl::claudeCode()->execute('Test'); expect($response->cost)->toBe(0.05); expect($response->agentType)->toBe(AgentType::ClaudeCode); // @doctest id="8ea1" ``` ### Fake Tool Calls Simulate agent tool usage in your tests. ```php use Cognesy\Instructor\Laravel\Testing\AgentCtrlFake; $responseWithTools = AgentCtrlFake::response( text: 'Created file', toolCalls: [ AgentCtrlFake::toolCall( tool: 'write_file', input: ['path' => 'app/Models/User.php'], output: 'File created successfully', ), AgentCtrlFake::toolCall( tool: 'run_tests', input: ['path' => 'tests/'], output: 'All tests passed', ), ], ); $fake = AgentCtrl::fake([$responseWithTools]); $response = AgentCtrl::claudeCode()->execute('...'); expect($response->toolCalls)->toHaveCount(2); expect($response->toolCalls[0]->tool)->toBe('write_file'); // @doctest id="27f9" ``` ### Available Assertions ```php $fake = AgentCtrl::fake([...]); // Run your code... // Assert execution occurred $fake->assertExecuted(); $fake->assertNotExecuted(); $fake->assertExecutedTimes(3); // Assert prompt content $fake->assertExecutedWith('Generate a migration'); // Assert agent type $fake->assertAgentType(AgentType::ClaudeCode); $fake->assertUsedClaudeCode(); $fake->assertUsedCodex(); $fake->assertUsedOpenCode(); // Assert streaming was used $fake->assertStreaming(); // Access recorded executions for custom assertions $executions = $fake->getExecutions(); foreach ($executions as $exec) { echo $exec['prompt']; echo $exec['agentType']->name; echo $exec['model']; echo $exec['timeout']; echo $exec['directory']; echo $exec['streaming'] ? 'yes' : 'no'; } // Reset fake state between test scenarios $fake->reset(); // @doctest id="437c" ``` ### Testing Agent Services ```php use Cognesy\Instructor\Laravel\Facades\AgentCtrl; class CodeGeneratorService { public function generateMigration(array $schema): string { $response = AgentCtrl::claudeCode() ->inDirectory(database_path('migrations')) ->execute("Generate migration for: " . json_encode($schema)); if (!$response->isSuccess()) { throw new \RuntimeException('Code generation failed'); } return $response->text(); } } // Test public function test_generates_migration(): void { $fake = AgentCtrl::fake([ 'Migration created successfully', ]); $service = app(CodeGeneratorService::class); $result = $service->generateMigration(['table' => 'users']); $this->assertStringContainsString('Migration', $result); $fake->assertUsedClaudeCode(); $fake->assertExecutedWith('users'); } // @doctest id="bf6e" ``` --- ## HTTP Client Faking Since the package routes all HTTP traffic through Laravel's HTTP client (`Illuminate\Http\Client\Factory`), you can also use `Http::fake()` to intercept requests at the HTTP transport level. This approach is lower-level than facade fakes and is useful when you need to test specific HTTP request/response shapes. ```php use Illuminate\Support\Facades\Http; public function test_with_http_fake(): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'choices' => [ [ 'message' => [ 'content' => '{"name":"John","age":30}', ], ], ], ]), ]); // Your StructuredOutput calls will use the fake HTTP response $person = StructuredOutput::with(...)->get(); Http::assertSent(function ($request) { return $request->url() === 'https://api.openai.com/v1/chat/completions'; }); } // @doctest id="e5a0" ``` This works because the `LaravelDriver` HTTP transport uses the same `Illuminate\Http\Client\Factory` instance that `Http::fake()` instruments. Make sure the `instructor.http.driver` config is set to `'laravel'` (the default). --- ## Testing Services When testing services that use Instructor through dependency injection, the facade fake automatically replaces the container binding. The container will resolve the fake instance for both facade calls and injected dependencies. ```php use Cognesy\Instructor\StructuredOutput; class PersonExtractor { public function __construct( private StructuredOutput $structuredOutput, ) {} public function extract(string $text): PersonData { return $this->structuredOutput ->with(messages: $text, responseModel: PersonData::class) ->get(); } } // In your test public function test_extracts_person(): void { $fake = StructuredOutput::fake([ PersonData::class => new PersonData(name: 'John', age: 30), ]); // The container will resolve the fake $extractor = app(PersonExtractor::class); $person = $extractor->extract('Some text'); $this->assertEquals('John', $person->name); } // @doctest id="e7c0" ``` --- ## Best Practices ### 1. Always Setup Fakes First Call `fake()` before any code that might trigger an extraction. Setting up a fake after the fact has no effect on calls that already happened. ```php public function test_example(): void { // FIRST: Setup fake $fake = StructuredOutput::fake([...]); // THEN: Run your code $result = $this->service->process(); // FINALLY: Assert $fake->assertExtracted(...); } // @doctest id="0622" ``` ### 2. Use Realistic Test Data Realistic fake responses help catch bugs that only surface with production-like data, such as edge cases in string formatting or numeric precision. ```php // Good -- realistic data $fake = StructuredOutput::fake([ InvoiceData::class => new InvoiceData( invoiceNumber: 'INV-2024-001', amount: 1234.56, dueDate: '2024-12-31', ), ]); // Avoid -- placeholder data $fake = StructuredOutput::fake([ InvoiceData::class => new InvoiceData( invoiceNumber: 'test', amount: 0, dueDate: '', ), ]); // @doctest id="fbc7" ``` ### 3. Test Edge Cases Verify that your code handles empty collections, null optional fields, and other boundary conditions correctly. ```php public function test_handles_empty_response(): void { $fake = StructuredOutput::fake([ ItemList::class => new ItemList(items: []), ]); $result = $this->service->getItems(); $this->assertEmpty($result->items); } public function test_handles_null_optional_fields(): void { $fake = StructuredOutput::fake([ PersonData::class => new PersonData( name: 'John', age: 30, email: null, // Optional field ), ]); $person = $this->service->getPerson(); $this->assertNull($person->email); } // @doctest id="bfed" ``` ### 4. Verify Connection and Model Usage Assert that your code routes requests to the correct provider and model, especially when different code paths use different connections. ```php public function test_uses_correct_model(): void { $fake = StructuredOutput::fake([...]); $this->service->processWithClaude(); $fake->assertUsedConnection('anthropic'); $fake->assertUsedModel('claude-3-5-sonnet-20241022'); } // @doctest id="0e4a" ``` ================================================================================ FILE: packages/laravel/advanced.md ================================================================================ # Advanced Usage This guide covers advanced patterns and features for power users who need fine-grained control over extraction behavior, streaming, validation, and multi-provider workflows. ## Streaming Streaming lets you receive partial results as the LLM generates them, rather than waiting for the entire response. This is essential for long-running extractions where you want to show progress, or for real-time UIs that display data as it becomes available. ```php use Cognesy\Instructor\Laravel\Facades\StructuredOutput; $stream = StructuredOutput::with( messages: 'Extract detailed company information from this long document...', responseModel: CompanyData::class, )->withStreaming()->stream(); // Handle partial updates foreach ($stream->partials() as $partial) { echo "Company: " . ($partial->name ?? 'Loading...') . "\n"; echo "Industry: " . ($partial->industry ?? 'Loading...') . "\n"; echo "---\n"; } // Get final complete result $company = $stream->finalValue(); // @doctest id="8aa4" ``` ### Streaming with `partials()` Each partial is a partially populated instance of your response model. Properties that have not been received yet will be `null` or their default value. This is useful for broadcasting live updates via WebSockets. ```php $stream = StructuredOutput::with( messages: 'Extract data...', responseModel: MyModel::class, ) ->withStreaming() ->stream(); foreach ($stream->partials() as $partial) { broadcast(new PartialUpdateEvent($partial)); } $result = $stream->finalValue(); // @doctest id="3937" ``` ### Streaming Sequences When extracting an array of items, the `sequence()` method yields the growing collection as each new item is completed. ```php $stream = StructuredOutput::with( messages: 'Extract all products from this catalog...', responseModel: [ 'type' => 'array', 'items' => ProductData::class, ], ) ->withStreaming() ->stream(); foreach ($stream->sequence() as $items) { echo "Found: {$items->last()->name}\n"; } // @doctest id="534a" ``` --- ## Validation and Retries ### Automatic Validation Response models are automatically validated after deserialization. When validation fails, the package sends the error messages back to the LLM with a retry prompt, asking it to correct the response. This loop continues up to `max_retries` times. ```php use Symfony\Component\Validator\Constraints as Assert; final class UserData { public function __construct( #[Assert\NotBlank] #[Assert\Length(min: 2, max: 100)] public readonly string $name, #[Assert\Email] public readonly string $email, #[Assert\Range(min: 18, max: 120)] public readonly int $age, ) {} } // Extraction will retry if validation fails $user = StructuredOutput::with( messages: 'Extract user from: john doe, email: invalid, age: 5', responseModel: UserData::class, maxRetries: 3, )->get(); // @doctest id="497c" ``` ### Custom Validators Implement the `CanValidateObject` contract for domain-specific validation logic that cannot be expressed with declarative attributes. The `validate` method must return a `ValidationResult` instance. ```php use Cognesy\Instructor\Validation\Contracts\CanValidateObject; use Cognesy\Instructor\Validation\ValidationResult; class BusinessRulesValidator implements CanValidateObject { public function validate(object $dataObject): ValidationResult { if ($dataObject instanceof OrderData) { if ($dataObject->total < $dataObject->minimumOrderValue) { return ValidationResult::fieldError( field: 'total', value: $dataObject->total, message: "Order total must be at least {$dataObject->minimumOrderValue}", ); } } return ValidationResult::valid(); } } // @doctest id="25ff" ``` Custom validators are registered on the `StructuredOutputRuntime`, not on the facade directly: ```php use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\LLMProvider; $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::new()) ->withValidator(new BusinessRulesValidator()); $order = StructuredOutput::withRuntime($runtime)->with( messages: 'Extract order...', responseModel: OrderData::class, )->get(); // @doctest id="febf" ``` ### Custom Retry Prompt Customize the message sent to the LLM when validation fails. The `{errors}` placeholder is replaced with the actual error messages. ```php $result = StructuredOutput::with( messages: 'Extract data...', responseModel: MyModel::class, maxRetries: 3, retryPrompt: 'The extraction failed validation. Errors: {errors}. Please correct and try again.', )->get(); // @doctest id="14dd" ``` --- ## Data Transformation Apply transformations to extracted data after deserialization. Transformers run after validation, so they can normalize, enrich, or restructure the data before it reaches your application code. ```php use Cognesy\Instructor\Transformation\Contracts\CanTransformData; class NormalizePhoneNumbers implements CanTransformData { public function transform(mixed $data): mixed { if ($data instanceof ContactData) { $data->phone = $this->normalize($data->phone); } return $data; } private function normalize(string $phone): string { return preg_replace('/[^0-9+]/', '', $phone); } } // @doctest id="70d7" ``` Custom transformers are registered on the `StructuredOutputRuntime`, not on the facade directly: ```php use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\LLMProvider; $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::new()) ->withTransformer(new NormalizePhoneNumbers()); $contact = StructuredOutput::withRuntime($runtime)->with( messages: 'Contact: John, phone: (555) 123-4567', responseModel: ContactData::class, )->get(); // $contact->phone === '+15551234567' // @doctest id="464f" ``` --- ## Output Modes Different LLMs support different output modes. The output mode controls the mechanism used to extract structured data from the model's response. You can set the default mode in `config/instructor.php` or override it per-request via the runtime. ```php use Cognesy\Instructor\Enums\OutputMode; use Cognesy\Instructor\StructuredOutputRuntime; use Cognesy\Polyglot\Inference\LLMProvider; $jsonSchemaRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::new()) ->withOutputMode(OutputMode::JsonSchema); $toolsRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::new()) ->withOutputMode(OutputMode::Tools); $jsonRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::new()) ->withOutputMode(OutputMode::Json); $mdJsonRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::new()) ->withOutputMode(OutputMode::MdJson); // JSON Schema mode (recommended for OpenAI) $result = StructuredOutput::withRuntime($jsonSchemaRuntime) ->with(...) ->get(); // Tool/Function calling mode $result = StructuredOutput::withRuntime($toolsRuntime) ->with(...) ->get(); // Simple JSON mode $result = StructuredOutput::withRuntime($jsonRuntime) ->with(...) ->get(); // Markdown JSON (for Gemini) $result = StructuredOutput::withRuntime($mdJsonRuntime) ->with(...) ->get(); // @doctest id="6762" ``` --- ## Few-Shot Learning Providing input/output examples significantly improves extraction quality, especially for ambiguous or domain-specific data. Each example pairs an input string with a fully populated response model instance. ```php $person = StructuredOutput::with( messages: 'Extract: Jane Doe, 25 years old, jane@example.com', responseModel: PersonData::class, examples: [ [ 'input' => 'John Smith is 30 years old and works at john@company.com', 'output' => new PersonData( name: 'John Smith', age: 30, email: 'john@company.com', ), ], [ 'input' => 'Mary Johnson, age 45', 'output' => new PersonData( name: 'Mary Johnson', age: 45, email: null, ), ], ], )->get(); // @doctest id="55fb" ``` --- ## System Prompts System prompts set the overall behavior and domain context for the LLM. They are especially valuable when extracting specialized data. ```php $medical = StructuredOutput::with( messages: $patientNotes, responseModel: MedicalRecord::class, system: <<<'PROMPT' You are a medical records extraction specialist. Extract structured data from clinical notes. Use standard medical terminology. If information is unclear, mark as null rather than guessing. PROMPT, )->get(); // @doctest id="5f70" ``` --- ## Tool Descriptions Customize how the response model is described to the LLM in the tool/function calling interface. This is particularly useful when the auto-generated name or description is not descriptive enough for the model to understand the task. ```php $result = StructuredOutput::with( messages: 'Extract invoice details...', responseModel: InvoiceData::class, toolName: 'extract_invoice', toolDescription: 'Extracts structured invoice data including line items, totals, and payment terms.', )->get(); // @doctest id="b911" ``` --- ## Multiple Providers Switch between providers based on the task at hand. Different providers offer different trade-offs in speed, accuracy, cost, and privacy. ```php class AIService { // Fast, cheap extraction for simple tasks public function quickExtract(string $text, string $model): mixed { return StructuredOutput::connection('groq') ->with(messages: $text, responseModel: $model) ->get(); } // High-quality extraction for complex tasks public function precisionExtract(string $text, string $model): mixed { return StructuredOutput::connection('anthropic') ->withModel('claude-3-opus-20240229') ->with(messages: $text, responseModel: $model) ->get(); } // Local extraction for sensitive data public function privateExtract(string $text, string $model): mixed { return StructuredOutput::connection('ollama') ->with(messages: $text, responseModel: $model) ->get(); } } // @doctest id="7e09" ``` --- ## Cached Context (Prompt Caching) For repeated extractions with the same system prompt, examples, or large context, use `withCachedContext()` to signal that the context should be cached by providers that support prompt caching (e.g., Anthropic, OpenAI). This can significantly reduce latency and cost for subsequent calls. ```php $result = StructuredOutput::withCachedContext( system: 'You are a legal document analyzer...', examples: $examples, )->with( messages: $newDocument, responseModel: LegalAnalysis::class, )->get(); // @doctest id="eed9" ``` --- ## Caching Strategies ### Response Caching Cache extraction results for identical inputs to avoid redundant API calls. ```php use Illuminate\Support\Facades\Cache; class CachedExtractor { public function extract(string $text, string $responseModel): mixed { $cacheKey = 'extract:' . md5($text . $responseModel); return Cache::remember($cacheKey, 3600, function () use ($text, $responseModel) { return StructuredOutput::with( messages: $text, responseModel: $responseModel, )->get(); }); } } // @doctest id="1bdd" ``` ### Semantic Caching Use embeddings to find cached results for semantically similar (but not identical) inputs. ```php use Cognesy\Instructor\Laravel\Facades\Embeddings; class SemanticCache { public function extractWithCache(string $text, string $responseModel): mixed { // Generate embedding for input $embedding = Embeddings::withInputs($text)->first(); // Check for similar cached results $cached = $this->findSimilar($embedding); if ($cached) { return $cached; } // Extract and cache $result = StructuredOutput::with( messages: $text, responseModel: $responseModel, )->get(); $this->store($embedding, $result); return $result; } } // @doctest id="59f4" ``` --- ## Batch Processing Process multiple items efficiently, either synchronously or via queued jobs for large batches. ```php use Illuminate\Support\Collection; use Illuminate\Support\Facades\Bus; class BatchExtractor { public function extractBatch(Collection $documents): Collection { return $documents->map(function ($document) { return StructuredOutput::with( messages: $document->content, responseModel: DocumentData::class, )->get(); }); } // Or with queued jobs for large batches public function extractBatchAsync(Collection $documents): void { $jobs = $documents->map(fn ($doc) => new ExtractDocumentJob($doc)); Bus::batch($jobs) ->name('Document Extraction') ->dispatch(); } } // @doctest id="140f" ``` --- ## Error Handling ### Graceful Degradation Wrap extraction calls in try-catch blocks to handle API failures without crashing your application. ```php use Cognesy\Instructor\Laravel\Facades\StructuredOutput; class ResilientExtractor { public function extract(string $text): ?PersonData { try { return StructuredOutput::with( messages: $text, responseModel: PersonData::class, )->get(); } catch (\Throwable $e) { Log::warning('Extraction failed', [ 'error' => $e->getMessage(), 'text' => substr($text, 0, 100), ]); return null; } } } // @doctest id="3822" ``` ### Fallback Providers Automatically try alternative providers when the primary one fails. This pattern provides resilience against provider outages and rate limits. ```php class FallbackExtractor { private array $providers = ['openai', 'anthropic', 'groq']; public function extract(string $text, string $model): mixed { foreach ($this->providers as $provider) { try { return StructuredOutput::connection($provider) ->with(messages: $text, responseModel: $model) ->get(); } catch (\Throwable $e) { Log::warning("Provider {$provider} failed", [ 'error' => $e->getMessage(), ]); continue; } } throw new RuntimeException('All providers failed'); } } // @doctest id="ff84" ``` --- ## Performance Optimization ### Reduce Token Usage ```php // Be concise in system prompts $result = StructuredOutput::with( messages: $text, responseModel: MyModel::class, system: 'Extract data. Be concise.', // Short system prompt )->get(); // Use smaller models for simple extractions $result = StructuredOutput::withModel('gpt-4o-mini') ->with(messages: $text, responseModel: SimpleModel::class) ->get(); // @doctest id="4431" ``` ### Parallel Extraction Use Laravel's concurrency features to run multiple extractions simultaneously. ```php use Illuminate\Support\Facades\Concurrency; $results = Concurrency::run([ fn () => StructuredOutput::with(messages: $text1, responseModel: Model::class)->get(), fn () => StructuredOutput::with(messages: $text2, responseModel: Model::class)->get(), fn () => StructuredOutput::with(messages: $text3, responseModel: Model::class)->get(), ]); // @doctest id="16a6" ``` ================================================================================ FILE: packages/laravel/troubleshooting.md ================================================================================ # Troubleshooting Common issues and their solutions when working with Instructor for Laravel. ## Installation Issues ### Package Not Found **Error:** ``` Package cognesy/instructor-laravel not found // @doctest id="d518" ``` **Solution:** Ensure you have the correct package name and your Composer repository cache is up to date: ```bash composer clear-cache composer require cognesy/instructor-laravel # @doctest id="d493" ``` If you are using a private Packagist mirror, verify the package is available in your configured repositories. ### Service Provider Not Registered **Error:** ``` Class 'Cognesy\Instructor\Laravel\Facades\StructuredOutput' not found // @doctest id="45dc" ``` **Solution:** If auto-discovery is disabled in your `composer.json`, manually register the provider: ```php // config/app.php (Laravel 10) 'providers' => [ Cognesy\Instructor\Laravel\InstructorServiceProvider::class, ], // @doctest id="ea11" ``` If auto-discovery is enabled but the provider is not loading, clear the cached package manifest: ```bash php artisan package:discover php artisan config:clear php artisan cache:clear # @doctest id="3584" ``` --- ## API Key Issues ### API Key Not Configured **Error:** ``` No API key configured for connection 'openai' // @doctest id="0d47" ``` **Solution:** Add your API key to `.env`: ```env OPENAI_API_KEY=sk-your-key-here // @doctest id="7579" ``` Then clear the config cache so Laravel picks up the change: ```bash php artisan config:clear # @doctest id="7b8c" ``` ### Invalid API Key **Error:** ``` 401 Unauthorized: Invalid API key // @doctest id="8821" ``` **Solution:** 1. Verify your API key is correct by checking the provider's dashboard 2. Check that the key has not expired or been revoked 3. Ensure the key has the required permissions (some providers require specific scopes) 4. Verify there are no extra spaces, newlines, or quotes around the key in `.env` 5. Run `php artisan instructor:test` to confirm the key works ### Rate Limiting **Error:** ``` 429 Too Many Requests // @doctest id="2e1a" ``` **Solution:** Rate limiting occurs when you exceed the provider's API call limits. Strategies to mitigate this: 1. Implement rate limiting in your application using Laravel's `RateLimiter` 2. Upgrade your API plan for higher limits 3. Add response caching to reduce redundant API calls 4. Spread requests across multiple providers using the `connection()` method ```php use Illuminate\Support\Facades\RateLimiter; if (RateLimiter::tooManyAttempts('llm-calls', 60)) { throw new TooManyRequestsException(); } RateLimiter::hit('llm-calls'); // @doctest id="b608" ``` --- ## Extraction Issues ### Response Does Not Match Model **Error:** ``` Failed to deserialize response to PersonData // @doctest id="99ba" ``` **Solution:** This usually means the LLM produced JSON that does not conform to your response model's structure. Improve the extraction by: 1. Adding more descriptive property comments (these become schema descriptions) 2. Providing few-shot examples 3. Increasing max retries so the model gets another chance ```php final class PersonData { public function __construct( /** The person's full legal name (first and last) */ public readonly string $name, /** The person's age as a whole number */ public readonly int $age, ) {} } $result = StructuredOutput::with( messages: $text, responseModel: PersonData::class, maxRetries: 5, // Increase retries examples: [...], // Add examples )->get(); // @doctest id="ec1a" ``` ### Validation Failures **Error:** ``` Validation failed after 3 retries // @doctest id="449d" ``` **Solution:** The LLM repeatedly produced output that did not pass your validation constraints. Check whether: 1. Your validation constraints are not too strict for the input data 2. The retry prompt gives the LLM enough context to understand the errors 3. The max retries count is sufficient ```php $result = StructuredOutput::with( messages: $text, responseModel: MyModel::class, maxRetries: 5, retryPrompt: 'Previous response failed: {errors}. Please fix these specific issues.', )->get(); // @doctest id="8bf3" ``` Review your application logs to see the exact validation errors from each retry attempt. ### Null Values for Required Fields **Problem:** The LLM returns `null` for fields you expected to have values. **Solution:** This happens when the input text does not contain enough information for the LLM to populate a field. Strategies: 1. Make the input text clearer or more detailed 2. Add better property descriptions that explain what to look for 3. Use a system prompt that instructs the model to infer values from context 4. Mark fields as nullable if they are truly optional ```php $result = StructuredOutput::with( messages: $text, responseModel: MyModel::class, system: 'Extract all available information. If a field is not found in the text, make a reasonable inference based on context.', )->get(); // @doctest id="b785" ``` --- ## Timeout Issues ### Request Timeout **Error:** ``` cURL error 28: Operation timed out // @doctest id="0965" ``` **Solution:** The API call took longer than the configured timeout. This is common with large inputs, complex response models, or heavily loaded provider APIs. Increase the timeout in configuration: ```php // config/instructor.php 'http' => [ 'timeout' => 300, // 5 minutes 'connect_timeout' => 60, ], // @doctest id="7840" ``` Or override per-request using options: ```php $result = StructuredOutput::withOptions([ 'timeout' => 300, ])->with(...)->get(); // @doctest id="269f" ``` ### Streaming Timeout **Problem:** Streaming requests timeout before the LLM finishes generating. **Solution:** For long-running streaming responses, ensure both the HTTP timeout and PHP's execution time limit are sufficient: ```php set_time_limit(0); // Disable PHP timeout for this request $stream = StructuredOutput::with(...) ->withStreaming() ->stream(); // @doctest id="0465" ``` In production, consider running streaming extractions in a queue worker where time limits are typically more generous. --- ## Testing Issues ### Fake Not Working **Problem:** Real API calls are made despite using `fake()`. **Solution:** Ensure you call `fake()` **before** any code that triggers an extraction. The fake replaces the facade's bound instance, and calls made before the swap reach the real service. ```php // CORRECT $fake = StructuredOutput::fake([...]); $result = $myService->extract(); // Uses fake // WRONG $result = $myService->extract(); // Real API call! $fake = StructuredOutput::fake([...]); // Too late // @doctest id="3264" ``` ### Http::fake() Not Mocking **Problem:** `Http::fake()` does not affect Instructor calls. **Solution:** Ensure the HTTP driver is set to `'laravel'` in your configuration. If a different driver is configured, the package will not route requests through Laravel's HTTP client. ```php // config/instructor.php 'http' => [ 'driver' => 'laravel', ], // @doctest id="16bc" ``` Also verify that your test environment is not overriding this setting via an environment variable. --- ## Performance Issues ### Slow Responses **Solutions:** 1. **Use a faster model** -- `gpt-4o-mini` is significantly faster than `gpt-4o` for simple extractions 2. **Use a fast-inference provider** -- Groq offers very low latency for supported models 3. **Enable response caching** -- avoid redundant calls for identical inputs 4. **Reduce input size** -- truncate long inputs to the minimum necessary context ```php // Use faster provider $result = StructuredOutput::connection('groq') ->with(...)->get(); // Cache responses $result = Cache::remember($cacheKey, 3600, fn () => StructuredOutput::with(...)->get() ); // @doctest id="a1d2" ``` ### High Token Usage **Solutions:** 1. Use concise system prompts -- every token in the prompt counts toward your bill 2. Truncate long inputs to the essential content 3. Use smaller response models with fewer properties 4. Choose a model with a lower per-token cost ```php // Truncate long text $text = Str::limit($longText, 8000); $result = StructuredOutput::with( messages: $text, responseModel: MyModel::class, system: 'Extract data. Be concise.', // Short prompt )->get(); // @doctest id="7aab" ``` --- ## Memory Issues ### Out of Memory **Error:** ``` Allowed memory size exhausted // @doctest id="4949" ``` **Solution:** This can happen when processing many documents in a single request. Strategies: 1. Process documents in chunks and allow garbage collection between batches 2. Use streaming for large responses 3. Dispatch extraction jobs to a queue worker with higher memory limits ```php // Process in chunks $documents->chunk(10)->each(function ($chunk) { foreach ($chunk as $doc) { $result = StructuredOutput::with(...) ->get(); // Process result immediately } gc_collect_cycles(); }); // @doctest id="7644" ``` --- ## Common Error Messages | Error | Cause | Solution | |-------|-------|----------| | `Connection refused` | API endpoint unreachable | Check network, firewall, and API URL | | `Invalid JSON` | LLM returned malformed JSON | Increase retries, simplify response model | | `Model not found` | Wrong model name | Check model name spelling in config | | `Quota exceeded` | API billing limit reached | Upgrade plan or wait for reset | | `Context length exceeded` | Input + output exceeds model limit | Truncate input or use a model with larger context | | `Invalid request` | Malformed API request | Check request parameters and model compatibility | --- ## Getting Help If you are still stuck after trying the solutions above: 1. **Check the logs** for detailed error information: ```bash tail -f storage/logs/laravel.log ``` 2. **Enable debug logging** for maximum visibility into what is happening: ```php // config/instructor.php 'logging' => [ 'enabled' => true, 'level' => 'debug', 'preset' => 'default', ], ``` 3. **Test the API directly** to isolate whether the issue is in your configuration or your code: ```bash php artisan instructor:test --connection=openai php artisan instructor:test --connection=anthropic --inference ``` 4. **Search existing issues** on GitHub: https://github.com/cognesy/instructor-php/issues 5. **Open a new issue** with: - PHP version (`php -v`) - Laravel version (`php artisan --version`) - Package version (`composer show cognesy/instructor-laravel`) - Full error message and stack trace - Minimal reproduction code ================================================================================ FILE: packages/xprompt/01-introduction.md ================================================================================ # Introduction Xprompt turns prompts into ordinary PHP classes. Instead of scattering prompt strings across your codebase, you write a class that returns its content from a `body()` method. Because every prompt implements `Stringable`, it plugs directly into Polyglot, Instructor, and Agents without adapters or glue code. ```php use Cognesy\Xprompt\Prompt; class Persona extends Prompt { public function body(mixed ...$ctx): string { return "You are a {$ctx['role']} expert."; } } echo Persona::with(role: 'security'); // "You are a security expert." // @doctest id="6069" ``` ## Why Classes? Plain strings work until they don't. As prompts grow, you need variables, conditionals, reusable sections, and the ability to swap one version for another without touching calling code. Xprompt gives you these things using the tools you already know — classes, composition, and templates — with no framework overhead. A prompt class can: - **Return a string** — inline text with interpolated context - **Return an array** — compose multiple prompts, strings, and nulls into a single output - **Use a Twig template** — separate markup from logic, add front matter metadata - **Be swapped at runtime** — register variants and override by name via the registry ## How It Fits Xprompt is a leaf package with no opinion about how you call an LLM. Every prompt renders to a string, so it works anywhere a string works: ```php // StructuredOutput (accepts Stringable for system prompt) (new StructuredOutput) ->with( system: Persona::with(role: 'analyst'), responseModel: MyModel::class, )->get(); // Agents (via AgentContext) $context->withSystemPrompt(ReviewSystem::with(content: $doc)); // @doctest id="92b9" ``` ## What You'll Learn 1. **[Getting Started](02-getting-started.md)** — Your first prompt class, context, and rendering 2. **[Composition](03-composition.md)** — Building complex prompts from smaller pieces 3. **[Templates](04-templates.md)** — Twig-backed prompts with front matter 4. **[Structured Data](05-structured-data.md)** — NodeSet for criteria, rubrics, and taxonomies 5. **[Variants & Registry](06-variants-and-registry.md)** — Swapping prompt implementations without changing calling code 6. **[Configuration](07-configuration.md)** — Template engine config and preset loading ================================================================================ FILE: packages/xprompt/02-getting-started.md ================================================================================ # Getting Started ## Your First Prompt Extend `Prompt` and implement `body()`. That's it. ```php use Cognesy\Xprompt\Prompt; class Greeting extends Prompt { public function body(mixed ...$ctx): string { return "Hello, {$ctx['name']}!"; } } // @doctest id="abd2" ``` Render it: ```php echo Greeting::with(name: 'Alice'); // "Hello, Alice!" // @doctest id="c5d9" ``` ## Creating Instances There are two static constructors: ```php // Bare instance — context passed at render time $prompt = Greeting::make(); echo $prompt->render(name: 'Bob'); // Pre-bound context — stored and merged at render time $prompt = Greeting::with(name: 'Charlie'); echo $prompt->render(); // "Hello, Charlie!" // @doctest id="574d" ``` ## Passing Context Context is passed as named arguments. Pre-bound context from `with()` merges with context passed to `render()`, with render-time values taking precedence: ```php class Welcome extends Prompt { public function body(mixed ...$ctx): string { return "{$ctx['greeting']}, {$ctx['name']}!"; } } $prompt = Welcome::with(greeting: 'Hi'); echo $prompt->render(name: 'Dana'); // "Hi, Dana!" echo $prompt->render(name: 'Eve', greeting: 'Hey'); // "Hey, Eve!" // @doctest id="c4d0" ``` ## Stringable Every prompt implements `Stringable`, so you can use it anywhere PHP accepts a string: ```php $persona = Persona::with(role: 'analyst'); // String concatenation $full = "System: " . $persona; // String interpolation echo "Using prompt: {$persona}"; // Pass to any API accepting string|Stringable $inference->withSystem($persona)->create(); // @doctest id="cbec" ``` ## Returning Null A `body()` that returns `null` renders as an empty string. This is intentional — it enables conditional composition, which is covered in [Composition](03-composition.md). ```php class MaybeDisclaimer extends Prompt { public function body(mixed ...$ctx): string|array|null { return ($ctx['strict'] ?? false) ? 'Follow instructions exactly. Do not improvise.' : null; } } // @doctest id="5837" ``` ## Next Steps - [Composition](03-composition.md) — combine prompts into larger structures - [Templates](04-templates.md) — use Twig files for complex prompt content ================================================================================ FILE: packages/xprompt/03-composition.md ================================================================================ # Composition The real power of xprompt is composition. A `body()` method can return an **array** of renderables — strings, other prompts, or nulls. The framework recursively renders each element and joins the results with double newlines. ## Basic Composition ```php class Persona extends Prompt { public function body(mixed ...$ctx): string { return "You are a {$ctx['role']} expert."; } } class Task extends Prompt { public function body(mixed ...$ctx): string { return "Analyze the following document for {$ctx['focus']}."; } } class ReviewSystem extends Prompt { public function body(mixed ...$ctx): array { return [ Persona::with(role: 'code review'), Task::with(focus: 'security vulnerabilities'), "## Document\n\n" . $ctx['content'], ]; } } echo ReviewSystem::with(content: $code); // @doctest id="7ea6" ``` Output: ``` You are a code review expert. Analyze the following document for security vulnerabilities. ## Document // @doctest id="fb79" ``` Array elements are joined with `"\n\n"`. Empty strings and nulls are silently skipped. ## Conditional Sections Return `null` to exclude a section. This keeps conditionals clean: ```php class SystemPrompt extends Prompt { public function body(mixed ...$ctx): array { return [ Persona::with(role: $ctx['role']), Guidelines::make(), ($ctx['strict'] ?? false) ? Constraints::make() : null, "## Input\n\n" . $ctx['input'], ]; } } // @doctest id="a2bc" ``` When `strict` is false, the `Constraints` section is simply absent from the output — no empty lines, no placeholders. ## Context Propagation When a parent prompt renders a child via composition, the parent's context automatically flows down. Children receive the merged context: ```php class Parent extends Prompt { public function body(mixed ...$ctx): array { return [ Child::make(), // receives parent's $ctx ]; } } // Child sees lang: 'en' even though it wasn't explicitly passed echo Parent::with(lang: 'en'); // @doctest id="523f" ``` Children that bind their own context via `with()` merge it with the parent's context — the child's bindings take precedence for shared keys. ## Nesting Composition nests arbitrarily. A prompt can return an array containing prompts that themselves return arrays: ```php class TopLevel extends Prompt { public function body(mixed ...$ctx): array { return [ SectionA::make(), // may return string or array SectionB::make(), // may return string or array ]; } } // @doctest id="7285" ``` The `flatten()` function handles all the recursion. It traverses nested arrays, renders any `Prompt` or `Stringable` objects it finds, filters out nulls and empty strings, and joins everything with `"\n\n"`. ## Mixing Inline and Template-Backed Prompts Composition doesn't care how each piece generates its content. You can freely mix inline prompts, template-backed prompts, and raw strings in the same array: ```php class FullSystem extends Prompt { public function body(mixed ...$ctx): array { return [ Persona::make(), // inline body() ScoringRubric::make(), // Twig template "Be concise in your response.", // plain string ]; } } // @doctest id="920b" ``` ## Next Steps - [Templates](04-templates.md) — use Twig files for content that's easier to maintain as markup - [Structured Data](05-structured-data.md) — render lists, criteria, and taxonomies with NodeSet ================================================================================ FILE: packages/xprompt/04-templates.md ================================================================================ # Templates When prompt content is mostly markup — instructions, rubrics, output format specifications — a Twig template is easier to maintain than a PHP string. Xprompt integrates with the `packages/templates` engine so you can keep your `.twig` files next to your prompt classes. ## Template-Backed Prompt Set `$templateFile` and `$templateDir` on your prompt class: ```php use Cognesy\Xprompt\Prompt; class Analyze extends Prompt { public string $templateFile = 'analyze.twig'; public ?string $templateDir = __DIR__ . '/templates'; } // @doctest id="d0fc" ``` The template file `templates/analyze.twig`: ```twig You will analyze the provided {{ content_type }} for quality issues. ## Criteria - Clarity and readability - Logical consistency - Completeness of arguments ## Input {{ content }} // @doctest id="530f" ``` Render it like any other prompt: ```php echo Analyze::with(content_type: 'essay', content: $essay); // @doctest id="53b6" ``` Context variables map directly to Twig variables. The `body()` method you inherit handles loading, parsing, and rendering automatically. ## Colocating Templates The recommended layout places templates alongside the prompt classes that use them: ``` src/Prompts/ Analyze.php Summarize.php templates/ analyze.twig summarize.twig // @doctest id="0708" ``` Each prompt sets `$templateDir = __DIR__ . '/templates'`, keeping paths relative and portable. ## Front Matter Templates can include YAML front matter for metadata. The front matter is parsed but not rendered — it's available via the `meta()` method: ```twig --- description: Analyze content for quality model: sonnet version: v2 --- Analyze the following {{ topic }} in detail. Focus on {{ aspect }}. // @doctest id="2dbd" ``` ```php $prompt = Analyze::make(); $meta = $prompt->meta(); // ['description' => 'Analyze content for quality', 'model' => 'sonnet', 'version' => 'v2'] // @doctest id="bbd8" ``` This is useful for storing prompt metadata — suggested model, version, author — alongside the prompt content. Your application code can read `meta()` to make decisions about which model to use or to track prompt versions. ## Introspection Template-backed prompts expose their variables and validation state: ```php $prompt = Analyze::make(); // List all template variables $prompt->variables(); // ['content_type', 'content'] // Check for missing or extra variables $prompt->validationErrors(content_type: 'code'); // ['Missing variable: content'] // @doctest id="d0ab" ``` ## Blocks Blocks let you inject pre-rendered prompt content into template variables. Define block classes, list them in `$blocks`, and reference them in your template as `{{ blocks.ClassName }}`: ```php class Header extends Prompt { public bool $isBlock = true; public function body(mixed ...$ctx): string { return "# {$ctx['title']}"; } } class Footer extends Prompt { public bool $isBlock = true; public function body(mixed ...$ctx): string { return "---\nEnd of document."; } } class Document extends Prompt { public string $templateFile = 'document.twig'; public ?string $templateDir = __DIR__ . '/templates'; public array $blocks = [Header::class, Footer::class]; } // @doctest id="669f" ``` The template `document.twig`: ```twig {{ blocks.Header }} ## Content {{ body_text }} {{ blocks.Footer }} // @doctest id="5039" ``` Blocks are rendered before the template, receiving the same context as the parent. The `isBlock = true` flag hides them from registry listings — they're internal building blocks, not standalone prompts. ## Overriding body() If you need to add logic around template rendering, override `body()` and call `renderTemplate()` yourself, or compose the template prompt with other elements: ```php class SmartAnalyze extends Prompt { public function body(mixed ...$ctx): array { return [ Persona::with(role: 'analyst'), AnalyzeTemplate::with(topic: $ctx['topic']), $ctx['examples'] ? Examples::with(items: $ctx['examples']) : null, ]; } } // @doctest id="91f6" ``` ## Next Steps - [Structured Data](05-structured-data.md) — render lists and rubrics with NodeSet - [Variants & Registry](06-variants-and-registry.md) — swap template-backed prompts without changing callers ================================================================================ FILE: packages/xprompt/05-structured-data.md ================================================================================ # Structured Data `NodeSet` is a specialized prompt for rendering structured lists — scoring rubrics, evaluation criteria, taxonomies, classification labels. Instead of hardcoding numbered lists in strings, you define the data and let NodeSet handle formatting. ## Inline Data The simplest approach is inline items: ```php use Cognesy\Xprompt\NodeSet; class ScoringCriteria extends NodeSet { public array $items = [ ['label' => 'Clarity', 'content' => 'Writing is clear and unambiguous'], ['label' => 'Evidence', 'content' => 'Claims are supported by sources'], ['label' => 'Structure', 'content' => 'Argument flows logically'], ]; } echo ScoringCriteria::make(); // @doctest id="db05" ``` Output: ``` 1. **Clarity** -- Writing is clear and unambiguous 2. **Evidence** -- Claims are supported by sources 3. **Structure** -- Argument flows logically // @doctest id="0bf9" ``` ## YAML Data Files For larger datasets, load from a YAML file: ```php class ReviewCriteria extends NodeSet { public string $dataFile = 'criteria.yml'; public ?string $templateDir = __DIR__ . '/data'; public string $sortKey = 'priority'; } // @doctest id="5c69" ``` The file `data/criteria.yml`: ```yaml - id: clarity label: Clarity content: Writing is clear and unambiguous priority: 1 - id: evidence label: Evidence content: Claims are supported by sources priority: 2 children: - id: citations content: All sources are properly cited # @doctest id="e351" ``` Items are sorted by `priority` and children render as indented sub-items: ``` 1. **Clarity** -- Writing is clear and unambiguous 2. **Evidence** -- Claims are supported by sources - All sources are properly cited // @doctest id="8cc8" ``` > **Note:** YAML data files require `symfony/yaml`. Install it with `composer require symfony/yaml`. ## Custom Formatting Override `renderNode()` to change how each item appears: ```php class NumberedLabels extends NodeSet { public array $items = [ ['label' => 'Bug', 'content' => 'Functional defect'], ['label' => 'Style', 'content' => 'Code style issue'], ]; public function renderNode(int $index, array $node, mixed ...$ctx): string { return "- [{$node['label']}] {$node['content']}"; } } // @doctest id="fa7d" ``` Output: ``` - [Bug] Functional defect - [Style] Code style issue // @doctest id="afc7" ``` ## Dynamic Data Override `nodes()` to generate items at runtime: ```php class DynamicLabels extends NodeSet { public function nodes(mixed ...$ctx): array { return array_map( fn(string $label) => ['label' => $label, 'content' => ''], $ctx['labels'] ?? [], ); } } echo DynamicLabels::with(labels: ['Bug', 'Feature', 'Chore']); // @doctest id="4930" ``` ## Using in Composition NodeSet is a regular Prompt, so it composes naturally: ```php class ReviewSystem extends Prompt { public function body(mixed ...$ctx): array { return [ Persona::with(role: 'reviewer'), "## Scoring Criteria", ScoringCriteria::make(), "## Document\n\n" . $ctx['content'], ]; } } // @doctest id="03c8" ``` ## Next Steps - [Variants & Registry](06-variants-and-registry.md) — register and swap prompt implementations - [Configuration](07-configuration.md) — configure template paths and engine settings ================================================================================ FILE: packages/xprompt/06-variants-and-registry.md ================================================================================ # Variants & Registry As your prompt library grows, you'll want to swap implementations without changing the code that uses them. The `PromptRegistry` maps logical names to prompt classes and lets you override which class is used at runtime. ## Registering Prompts ```php use Cognesy\Xprompt\PromptRegistry; $registry = new PromptRegistry(); $registry->register('reviewer.analyze', Analyze::class); $prompt = $registry->get('reviewer.analyze'); echo $prompt->render(content: $doc); // @doctest id="95e3" ``` ## Variants Register multiple classes under the same name. The first registration becomes the default; subsequent ones are stored as variants: ```php $registry->register('reviewer.analyze', Analyze::class); $registry->register('reviewer.analyze', AnalyzeCoT::class); $registry->register('reviewer.analyze', AnalyzeConcise::class); $registry->variants('reviewer.analyze'); // ['Analyze' => Analyze::class, 'AnalyzeCoT' => AnalyzeCoT::class, ...] // @doctest id="5812" ``` ## Overrides Pass overrides to the constructor to swap which variant is returned by `get()`: ```php $registry = new PromptRegistry( overrides: ['reviewer.analyze' => AnalyzeCoT::class], ); $prompt = $registry->get('reviewer.analyze'); // Returns AnalyzeCoT instance // @doctest id="7b34" ``` Overrides are resolved by short class name or fully-qualified class name. This makes it easy to drive prompt selection from configuration files without changing calling code. ## Creating Variants A variant is just a subclass. Override what you need — template, model hint, body logic — and register it under the same name: ```php class Analyze extends Prompt { public string $model = 'sonnet'; public string $templateFile = 'analyze.twig'; public ?string $templateDir = __DIR__ . '/templates'; } class AnalyzeCoT extends Analyze { public string $model = 'opus'; public string $templateFile = 'analyze_cot.twig'; } // @doctest id="fc36" ``` The calling code doesn't change. It asks the registry for `'reviewer.analyze'` and gets whichever variant is configured. ## The AsPrompt Attribute Instead of calling `register()` manually, annotate your class with `#[AsPrompt]`: ```php use Cognesy\Xprompt\Attributes\AsPrompt; #[AsPrompt('reviewer.analyze')] class Analyze extends Prompt { // ... } // @doctest id="a173" ``` Then register via `registerClass()`: ```php $registry->registerClass(Analyze::class); // Registered under 'reviewer.analyze' // @doctest id="ba08" ``` ## Auto-Discovery `PromptDiscovery` scans Composer's classmap and registers all prompt classes automatically: ```php use Cognesy\Xprompt\Discovery\PromptDiscovery; PromptDiscovery::register($registry, namespaces: ['App\\Prompts']); // @doctest id="4edf" ``` Name resolution priority: 1. `#[AsPrompt("name")]` attribute 2. `$promptName` public property 3. Derived from FQCN — `App\Prompts\Reviewer\AnalyzeDocument` becomes `reviewer.analyze_document` ## Listing Prompts ```php // Names only (excludes blocks by default) $registry->names(); // ['reviewer.analyze', 'reviewer.summarize', ...] // Include blocks $registry->names(includeBlocks: true); // Iterate name => class pairs foreach ($registry->all() as $name => $class) { echo "{$name}: {$class}\n"; } // @doctest id="d178" ``` ## Next Steps - [Configuration](07-configuration.md) — configure template paths and engine settings - [Getting Started](02-getting-started.md) — revisit the basics ================================================================================ FILE: packages/xprompt/07-configuration.md ================================================================================ # Configuration By default, xprompt uses Twig with `$templateDir` paths set on individual prompt classes. For centralized control — shared resource paths, cache directories, or preset-based configuration — use `TemplateEngineConfig`. ## Per-Prompt Config The `withConfig()` method returns a clone of the prompt with the given config applied: ```php use Cognesy\Template\Config\TemplateEngineConfig; $config = TemplateEngineConfig::twig( resourcePath: __DIR__ . '/prompts', cachePath: '/tmp/twig-cache', ); $prompt = Analyze::make()->withConfig($config); echo $prompt->render(content: $doc); // @doctest id="82f5" ``` ## Registry-Wide Config Pass config to the `PromptRegistry` constructor. Every prompt retrieved via `get()` will have this config applied: ```php use Cognesy\Xprompt\PromptRegistry; use Cognesy\Template\Config\TemplateEngineConfig; $config = TemplateEngineConfig::twig(resourcePath: __DIR__ . '/prompts'); $registry = new PromptRegistry(config: $config); $registry->register('analyze', Analyze::class); $prompt = $registry->get('analyze'); // Uses the registry's config for template resolution // @doctest id="0e99" ``` ## Config Resolution When a prompt resolves its template engine config, it follows this priority: 1. **Instance config** — set via `withConfig()` on the prompt instance 2. **templateDir compatibility** — if `$templateDir` is set on the class, a Twig config with that resource path is created 3. **Default Twig** — bare `TemplateEngineConfig::twig()` with no resource path This means existing prompts with `$templateDir` continue to work unchanged. ## Presets Load configuration from YAML preset files using `fromPreset()`: ```php $config = TemplateEngineConfig::fromPreset('my-templates'); // @doctest id="88ad" ``` This searches for `my-templates.yaml` in the standard preset directories: ``` config/prompt/presets/ packages/templates/resources/config/prompt/presets/ vendor/cognesy/instructor-php/packages/templates/resources/config/prompt/presets/ vendor/cognesy/instructor-templates/resources/config/prompt/presets/ // @doctest id="c770" ``` You can also provide a specific base path: ```php $config = TemplateEngineConfig::fromPreset('custom', basePath: __DIR__ . '/config'); // @doctest id="c7b4" ``` ## Quick Reference | Method | Purpose | |---|---| | `TemplateEngineConfig::twig($resourcePath, $cachePath)` | Twig engine with paths | | `TemplateEngineConfig::fromPreset($name)` | Load from YAML preset file | | `TemplateEngineConfig::fromArray($data)` | Create from array | | `$config->withOverrides($values)` | Merge overrides into existing config | | `$prompt->withConfig($config)` | Clone prompt with config | ================================================================================ FILE: cookbook/introduction.md ================================================================================ ## Overview Welcome to Instructor cookbooks. The goal of this section is to provide a set of tutorials and examples to help you get started. Instructor comes with a CLI tool that allows you to view and interact with the tutorials and examples and allows you to find the code snippets you may need to get solution to your problem. Examples are only available with Instructor project cloned locally. We did not want to include them in the Composer package to keep it lightweight. ### Step 1: Clone Instructor project from Github To get access to the tutorials and examples, you need to clone the Instructor project from Github: ```bash $ git clone https://github.com/cognesy/instructor-php.git ``` ### Step 2: Create `.env` file Create a `.env` file in the root directory of your copy of Instructor project and set your LLM API key(s). You can use the `.env-dist` file as a template. ### Step 3: Check the available tutorials You can check the available tutorials and examples by running the following command in terminal: ```bash $ ./bin/instructor-hub list ``` ## Available CLI Commands ### List Cookbooks Run `./bin/instructor-hub list` you can see all the available tutorials and examples. ```bash $ ./bin/instructor-hub list ``` ### Reading a Cookbook To read a tutorial, you can run `./bin/instructor-hub show {id}` to see the full tutorial in the terminal. ```bash $ ./bin/instructor-hub show {id} ``` Currently, there is no way to page through the tutorial - feel free to contribute :) ### Running a Cookbook To run a tutorial, you run `./bin/instructor-hub run {id}` in terminal - it will execute the code and show the output. You need to have your OPENAI_API_KEY set in your environment (.env file in root directory of your copy of instructor-php repo). ```bash $ ./bin/instructor-hub run {id} ``` ### Running all Cookbooks This is mostly for testing if cookbooks are executed properly, but you can run `./bin/instructor-hub all {id}` to run all the tutorials and examples in the terminal, starting from the one you specify. ```bash $ ./bin/instructor-hub all {id} ``` ================================================================================ FILE: cookbook/contributing.md ================================================================================ ## We're looking for your help We're looking for a bunch more examples. If you have a tutorial or example you'd like to add, please open a pull request in `docs/hub` and we'll review it. - [ ] Converting the cookbooks to the new format - [ ] Validator examples - [ ] Data extraction examples - [ ] Streaming examples (Iterable and Partial) - [ ] Batch Parsing examples - [ ] Query Expansion examples - [ ] Batch Data Processing examples - [ ] Batch Data Processing examples with Cache We're also looking for help to catch up with the features available in Instructor Hub for Python (see: https://github.com/jxnl/instructor/blob/main/docs/hub/index.md). - [ ] Better viewer with pagination - [ ] Examples database - [ ] Pulling in the code to your own dir, so you can get started with the API ## How to contribute We welcome contributions to the instructor hub, if you have a tutorial or example you'd like to add, please open a pull request in `docs/hub` and we'll review it. 1. The code must be in a single .php file. 2. Please include documentation in the file - check existing examples for the format. 3. Make sure that the code is tested. ```php // @snippet-id=12e5 namespace Cognesy\Polyglot\Examples; use Cognesy\Polyglot\Inference\Inference; echo "Hello, world!\n"; ``` ================================================================================ FILE: cookbook/examples/A01_Basics/basic_use.md ================================================================================ ## Overview Instructor allows you to use large language models to extract information from the text (or content of chat messages), while following the structure you define. LLM does not 'parse' the text to find and retrieve the information. Extraction leverages LLM ability to comprehend provided text and infer the meaning of the information it contains to fill fields of the response object with values that match the types and semantics of the class fields. The simplest way to use the Instructor is to call the `respond` method on the Instructor instance. This method takes a string (or an array of strings in the format of OpenAI chat messages) as input and returns a data extracted from provided text (or chat) using the LLM inference. Returned object will contain the values of fields extracted from the text. The format of the extracted data is defined by the response model, which in this case is a simple PHP class with some public properties. ## Example ```php withMessages($text) ->withResponseClass(User::class) ->get(); // Step 4: Now you can use the extracted data in your application print("Extracted data:\n"); dump($user); assert(isset($user->name)); assert(isset($user->age)); assert($user->name === 'Jason'); assert($user->age === 25); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/constructor_parameters.md ================================================================================ ## Overview Instructor can extract data from the LLM response and use it to instantiate an object via constructor parameters. Instructor will use the constructor parameters nullability and default values to determine which parameters are required and which are optional. ## Example ```php name = $name; $this->age = $age; $this->location = $location ?? ''; $this->password = $password; } public function getAge(): int { return $this->age; } public function getLocation(): string { return $this->location; } public function getPassword(): string { return $this->password; } } $text = <<withMessages($text) ->withResponseClass(UserWithConstructor::class) ->get(); dump($user); assert($user->name === "Jason"); assert($user->getAge() === 25); assert($user->getPassword() === '123admin'); assert($user->getLocation() === ''); // default value for location ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/getters_and_setters.md ================================================================================ ## Overview Instructor can extract data from the LLM response and use it to instantiate an object via setter methods. If given property is not public and has no matching constructor params Instructor will use the setter method parameter nullability and default value to determine if property is required. ## Example ```php name = $name ?: 'Jason'; } public function getName(): string { return $this->name ?? ''; } // `age` is optional (nullable parameter), setter will not be called if LLM does not infer the data public function setAge(int $age): void { $this->age = (int) $age; } public function getAge(): int { return $this->age ?? 0; } public function setLocation(?string $location): void { $this->location = $location; } public function getLocation(): string { return $this->location; } public function setPassword(string|null $password = ''): void { $this->password = $password ?: '123admin'; } public function getPassword(): string { return $this->password; } } $text = <<withMaxRetries(2) ) ->withMessages($text) ->withResponseClass(UserWithSetter::class) ->get(); dump($user); assert($user->getName() === "Jason"); // called - but set to default value as LLM inferred empty name assert($user->getAge() === 0); // not called - property value not inferred by LLM assert($user->getPassword() === '123admin'); // called - but set to default value as LLM inferred empty password assert($user->getLocation() === 'San Francisco'); // called - LLM inferred location from the text ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/public_vs_private.md ================================================================================ ## Overview Instructor only sets accessible fields of the object with the data provided by LLM. Private and protected fields are left unchanged, unless: - class has constructor with parameters matching one or more property names - in such situation object will be hydrated with data from LLM via constructor params, - class has getXxx() and setXxx() methods with xxx matching one of the property names - in such situation object will be hydrated with data from LLM via setter methods If you want to access them directly after extraction, provide default values for them. ## Example ```php withMessages($text) ->withResponseClass(User::class) ->get(); echo "User with public fields\n"; dump($user); assert($user->name === "Jason"); assert($user->age === 25); assert($user->password === '123admin'); // CASE 2: Class with some private fields class UserWithPrivateFields { public string $name; private int $age = 0; private string $password = ''; public function getAge() : int { return $this->age; } public function getPassword(): string { return $this->password; } } $userPriv = StructuredOutput::using('openai') ->withMessages($text) ->withResponseClass(UserWithPrivateFields::class) ->get(); echo "Private 'password' and 'age' fields are not hydrated by Instructor\n"; dump($userPriv); // Private fields keep their default values (not hydrated by LLM) assert($userPriv->getAge() === 0); assert($userPriv->getPassword() === ''); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/basic_use_mixin.md ================================================================================ ## Overview Mixin-based inference was removed in 2.0. Use `StructuredOutput` directly: ```php use Cognesy\Instructor\StructuredOutput; $user = StructuredOutput::using('openai') ->with( messages: 'Jason is 25 years old and works as an engineer.', responseModel: User::class, ) ->getObject(); ``` ## Example ```php with( messages: "Jason is 25 years old and works as an engineer.", responseModel: User::class, ) ->getObject(); dump($user); assert(isset($user->name)); assert(isset($user->age)); assert($user->name === 'Jason'); assert($user->age === 25); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/fluent_api.md ================================================================================ ## Overview ## Example ```php withMessages($text) ->withModel('gpt-3.5-turbo') ->withResponseClass(User::class) ->get(); // Step 4: Now you can use the extracted data in your application print("Extracted data:\n"); dump($user); assert(isset($user->name)); assert(isset($user->age)); assert($user->name === 'Jason'); assert($user->age === 25); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/maybe.md ================================================================================ ## Overview You can create a wrapper class to hold either the result of an operation or an error message. This allows you to remain within a function call even if an error occurs, facilitating better error handling without breaking the code flow. ## Example ```php withOutputMode(OutputMode::MdJson) ))->with( messages: [['role' => 'user', 'content' => $text]], responseModel: Maybe::is(User::class), model: 'gpt-4o-mini', )->get(); echo "\nOUTPUT:\n"; dump($maybeUser->get()); assert($maybeUser->hasValue() === false); assert(!empty($maybeUser->error())); assert($maybeUser->get() === null); $text = "Jason is our new developer, he is 25 years old."; echo "\nINPUT:\n$text\n"; $maybeUser = StructuredOutput::using('openai')->with( messages: [['role' => 'user', 'content' => $text]], responseModel: Maybe::is(User::class) )->get(); echo "\nOUTPUT:\n"; dump($maybeUser->get()); assert($maybeUser->hasValue() === true); assert(empty($maybeUser->error())); assert($maybeUser->get() != null); assert($maybeUser->get() instanceof User); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/messages_api.md ================================================================================ ## Overview Instructor allows you to use `Messages` and `Message` classes to work with chat messages and their sequences. ## Example ```php asSystem('You are a senior PHP8 backend developer.') ->asDeveloper('Be concise and use modern PHP8.2+ features.') // OpenAI developer role is supported and normalized for other providers ->asUser([ 'What is the best way to handle errors in PHP8?', 'Provide a code example.', 'Use modern PHP8.2+ features.', ]) ->asAssistant('I will provide a code example that demonstrates how to handle errors using try-catch. Any specific domain?'); $messages->appendMessage(Message::asUser('Make it insurance related.')); $lastMessageId = $messages->last()->id()->toString(); print("Last message ID: {$lastMessageId}\n"); print("Extracting structured data using LLM...\n\n"); $code = (new StructuredOutput( StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withOutputMode(OutputMode::MdJson) )) ->withMessages($messages) ->withResponseModel(Code::class) ->get(); print("Extracted data:\n"); dump($code); assert(!empty($code->code), 'Expected non-empty code'); assert(Str::contains(strtolower($code->programmingLanguage), 'php'), 'Expected PHP as programming language'); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/mixed_type_property.md ================================================================================ ## Overview ## Example ```php withMessages($text) ->withResponseClass(UserWithMixedTypeProperty::class) ->get(); dump($user); assert(trim($user->name) === "Jason"); assert($user->extraInfo === null || $user->extraInfo !== ''); // optional mixed field ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/modes.md ================================================================================ ## Overview Instructor supports several ways to extract data from the response: - `OutputMode::Tools` - uses OpenAI-style tool calls to get the language model to generate JSON following the schema, - `OutputMode::JsonSchema` - guarantees output matching JSON Schema via Context Free Grammar, does not support optional properties, - `OutputMode::Json` - JSON mode, response follows provided JSON Schema, - `OutputMode::MdJson` - uses prompting to get the language model to generate JSON following the schema. Note: not all modes are supported by all models or providers. Mode can be set via parameter of `StructuredOutput::create()` method. ## Example ```php withOutputMode(OutputMode::Tools) ))->with( messages: $text, responseModel: User::class, )->get(); check($user); dump($user); // CASE 2 - OutputMode::JsonSchema print("\n2. Extracting structured data using LLM - OutputMode::JsonSchema\n"); $user = (new StructuredOutput( StructuredOutputRuntime::fromProvider($provider)->withOutputMode(OutputMode::JsonSchema) ))->with( messages: $text, responseModel: User::class, )->get(); check($user); dump($user); // CASE 3 - OutputMode::Json print("\n3. Extracting structured data using LLM - OutputMode::Json\n"); $user = (new StructuredOutput( StructuredOutputRuntime::fromProvider($provider)->withOutputMode(OutputMode::Json) ))->with( messages: $text, responseModel: User::class, )->get(); check($user); dump($user); // CASE 4 - OutputMode::MdJson print("\n4. Extracting structured data using LLM - OutputMode::MdJson\n"); $user = (new StructuredOutput( StructuredOutputRuntime::fromProvider($provider)->withOutputMode(OutputMode::MdJson) ))->with( messages: $text, responseModel: User::class, )->get(); check($user); dump($user); function check(User $user) { assert(isset($user->name)); assert(isset($user->age)); assert($user->name === 'Jason'); assert($user->age === 25); } ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/optional_fields.md ================================================================================ ## Overview Use PHP's nullable types by prefixing type name with question mark (?) to declare component fields which are optional. Set a default value to prevent undesired defaults like nulls or empty strings. ## Example ```php withMessages('Jason is 25 years old.') ->withResponseClass(UserDetail::class) ->get(); dump($user); assert(!isset($user->lastName) || $user->lastName === ''); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/self_correction.md ================================================================================ ## Overview Instructor uses validation errors to inform LLM on the problems identified in the response, so that LLM can try self-correcting in the next attempt. In case maxRetries parameter is provided and LLM response does not meet validation criteria, Instructor will make subsequent inference attempts until results meet the requirements or maxRetries is reached. ## Example ```php withMaxRetries(3) ->onEvent(HttpRequestSent::class, fn($event) => print("[ ] Requesting LLM response...\n")) ->onEvent(ResponseValidationAttempt::class, fn($event) => print("[?] Validating:\n ".$event."\n")) ->onEvent(ResponseValidationFailed::class, fn($event) => print("[!] Validation failed:\n $event\n")) ->onEvent(ResponseValidated::class, fn($event) => print("[ ] Validation succeeded.\n")); $user = (new StructuredOutput($runtime)) ->with( messages: $text, responseModel: UserDetails::class, )->get(); print("\nOUTPUT:\n"); dump($user); assert($user->email === "jason@wp.pl"); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/attributes.md ================================================================================ ## Overview Instructor supports `Description` and `Instructions` attributes to provide more context to the language model or to provide additional instructions to the model. ## Example ```php with( messages: $text, responseModel: User::class, )->get(); // Step 4: Now you can use the extracted data in your application print("Extracted data:\n"); dump($user); assert(isset($user->name)); assert($user->name === "JASON"); assert(isset($user->age)); assert($user->age === 25); assert(isset($user->job)); assert($user->job === "engineer"); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/using_config.md ================================================================================ ## Overview Core APIs consume typed config objects. At the edge, load raw config data (for example from YAML) and map it to `LLMConfig`. ## Example ```php with( messages: 'Our user Jason is 25 years old.', responseModel: User::class, )->get(); dump($user); assert(isset($user->name)); assert(isset($user->age)); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/validation.md ================================================================================ ## Overview Instructor uses validation to verify if the response generated by LLM meets the requirements of your response model. If the response does not meet the requirements, Instructor will throw an exception. Instructor uses Symfony's Validator component to validate the response, check their documentation for more information on the usage: https://symfony.com/doc/current/components/validator.html Following example demonstrates how to use Symfony Validator's constraints to validate the email field of response. ## Example ```php withResponseClass(UserDetails::class) ->withMessages([['role' => 'user', 'content' => "you can reply to me via mail -- Jason"]]) ->get(); } catch (ValidationException $e) { $caughtException = true; echo "Validation worked.\n"; } catch (Throwable $e) { // Provider/runtime failures are not validation behavior and should not fail the example. $runtimeError = true; echo "Validation failed with unexpected exception: {$e->getMessage()}\n"; } if ($runtimeError) { echo "Skipping strict validation assertions due provider/runtime error.\n"; return; } assert($caughtException === true); assert(!isset($user)); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/custom_validation.md ================================================================================ ## Overview Instructor uses Symfony validation component to validate properties of extracted data. Symfony offers you #[Assert/Callback] annotation to build fully customized validation logic. ## Example ```php name !== strtoupper($this->name)) { $context->buildViolation("Name must be all uppercase.") ->atPath('name') ->setInvalidValue($this->name) ->addViolation(); } } } $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withMaxRetries(2) ->wiretap(fn($e) => $e->print()); $user = (new StructuredOutput($runtime)) ->with( messages: [['role' => 'user', 'content' => 'jason is 25 years old']], responseModel: UserDetails::class, ) ->get(); dump($user); assert($user->name === "JASON"); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/validation_multifield.md ================================================================================ ## Overview Sometimes property level validation is not enough - you may want to check values of multiple properties and based on the combination of them decide to accept or reject the response. Or the assertions provided by Symfony may not be enough for your use case. In such case you can easily add custom validation code to your response model by: - using `ValidationMixin` - and defining validation logic in `validate()` method. In this example LLM should be able to correct typo in the message (graduation year we provided is `1010` instead of `2010`) and respond with correct graduation year. ## Example ```php graduationYear > $this->birthYear) { return ValidationResult::valid(); } return ValidationResult::fieldError( field: 'graduationYear', value: $this->graduationYear, message: "Graduation year has to be bigger than birth year." ); } } $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withMaxRetries(2) ->wiretap(fn($e) => $e->print()); $user = (new StructuredOutput($runtime)) ->withResponseClass(UserDetails::class) ->with( messages: [['role' => 'user', 'content' => 'Jason was born in 2000 and graduated in 18.']], model: 'gpt-4o-mini', )->get(); dump($user); assert($user->graduationYear === 2018); ?> ``` ================================================================================ FILE: cookbook/examples/A01_Basics/validation_with_llm.md ================================================================================ ## Overview You can use LLM capability to semantically process the context to validate the response following natural language instructions. This way you can implement more complex validation logic that would be difficult (or impossible) to achieve using traditional, code-based validation. ## Example ```php hasPII()) { true => ValidationResult::fieldError( field: 'details', value: implode("\n", $this->details), message: "Details contain sensitive PII (phone numbers, SSNs, financial data) - remove those fields from the response." ), false => ValidationResult::valid(), }; } private function hasPII() : bool { $data = implode("\n", $this->details); return StructuredOutput::using('openai') ->with( messages: "Text: {$data}\n\nDoes this text contain a phone number (e.g. +1 123 34 45), SSN (e.g. 123-45-6789), credit card, or bank account number? Answer TRUE only for those specific patterns. Answer FALSE for plain age numbers (e.g. 25), names, or job titles like 'developer'.", responseModel: Scalar::boolean('hasPII', 'True only if the text contains a phone number, SSN, credit card, or bank account number. False for age numbers, names, or job titles.'), ) ->getBoolean(); } } $text = <<withMaxRetries(2) ->wiretap(fn(Event $e) => $e->print()); // let's check the internals of Instructor processing $user = (new StructuredOutput($runtime)) ->with( messages: $text, responseModel: UserDetails::class, )->get(); dump($user); assert(!Str::contains(implode("\n", $user->details), '123-45-6789')); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/config_providers.md ================================================================================ ## Overview This example demonstrates an edge adapter that reads raw config arrays from a custom source and maps them into typed config objects used by runtime/core classes. ## Example ```php dot = new Dot($data); } public function llmConnection(string $name): array { $value = $this->dot->get("llm.connections.$name"); if (!is_array($value)) { throw new RuntimeException("Unknown LLM connection: $name"); } return $value; } } $configSource = new CustomConfigSource([ 'llm' => [ 'connections' => [ 'deepseek' => [ 'driver' => 'deepseek', 'apiUrl' => 'https://api.deepseek.com', 'apiKey' => (string) Env::get('DEEPSEEK_API_KEY', ''), 'endpoint' => '/chat/completions', 'model' => 'deepseek-chat', 'maxTokens' => 128, ], 'openai' => [ 'driver' => 'openai', 'apiUrl' => 'https://api.openai.com/v1', 'apiKey' => (string) Env::get('OPENAI_API_KEY', ''), 'endpoint' => '/chat/completions', 'model' => 'gpt-4.1-nano', 'maxTokens' => 256, ], ], ], ]); $connection = match (true) { (string) Env::get('DEEPSEEK_API_KEY', '') !== '' => 'deepseek', (string) Env::get('OPENAI_API_KEY', '') !== '' => 'openai', default => throw new RuntimeException('Set DEEPSEEK_API_KEY or OPENAI_API_KEY in your environment to run this example.'), }; $events = new EventDispatcher(); $httpClient = (new HttpClientBuilder(events: $events)) ->withConfig(new HttpClientConfig(driver: 'symfony')) ->create(); $llmConfig = LLMConfig::fromArray($configSource->llmConnection($connection)); $provider = LLMProvider::fromLLMConfig($llmConfig); $runtime = StructuredOutputRuntime::fromProvider( provider: $provider, events: $events, httpClient: $httpClient, )->withOutputMode(OutputMode::Tools); class User { public int $age; public string $name; } $runtime->wiretap(fn($e) => $e->print()); $structuredOutput = new StructuredOutput($runtime); $user = $structuredOutput ->withMessages('Our user Jason is 25 years old.') ->withResponseClass(User::class) ->withStreaming() ->get(); dump($user); assert(isset($user->name)); assert(isset($user->age)); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/context_cache_structured.md ================================================================================ ## Overview Instructor offers a simplified way to work with LLM providers' APIs supporting caching, so you can focus on your business logic while still being able to take advantage of lower latency and costs. > **Note 1:** Instructor supports context caching for Anthropic API and OpenAI API. > **Note 2:** Context caching is automatic for all OpenAI API calls. Read more > in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). ## Example When you need to process multiple requests with the same context, you can use context caching to improve performance and reduce costs. In our example we will be analyzing the README.md file of this Github project and generating its structured description for multiple audiences. Let's start by defining the data model for the project details and the properties that we want to extract or generate based on README file. ```php ``` We read the content of the README.md file and cache the context, so it can be reused for multiple requests. ```php "; $mdJsonRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::using('anthropic')) ->withOutputMode(OutputMode::MdJson); $jsonRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::using('anthropic')) ->withOutputMode(OutputMode::Json); ?> ``` At this point we can use Instructor structured output processing to extract the project details from the README.md file into the `Project` data model. Let's start by asking the user to describe the project for a specific audience: P&C insurance CIOs. ```php withCachedContext( system: $cachedSystem, prompt: $cachedPrompt, ) ->with( messages: 'Describe the project in a way compelling to my audience: P&C insurance CIOs.', responseModel: Project::class, options: ['max_tokens' => 4096], )->create(); // get processed value - instance of Project class $project1 = $response1->get(); dump($project1); assert($project1 instanceof Project); assert($project1->name !== ''); assert($project1->description !== ''); // get usage information from inferenceResponse() when you need transport-level metadata $usage1 = $response1->inferenceResponse()->usage(); echo "Usage: {$usage1->inputTokens} prompt tokens, {$usage1->cacheWriteTokens} cache write tokens\n"; ?> ``` Now we can use the same context to ask the user to describe the project for a different audience: boutique CMS consulting company owner. Anthropic API will use the context cached in the previous request to provide the response, which results in faster processing and lower costs. ```php withCachedContext( system: $cachedSystem, prompt: $cachedPrompt, ) ->with( messages: "Describe the project in a way compelling to my audience: boutique CMS consulting company owner.", responseModel: Project::class, options: ['max_tokens' => 4096], )->create(); // get the processed value - instance of Project class $project2 = $response2->get(); dump($project2); assert($project2 instanceof Project); assert($project2->name !== ''); assert($project2->description !== ''); // get usage information from inferenceResponse() when you need transport-level metadata $usage2 = $response2->inferenceResponse()->usage(); echo "Usage: {$usage2->inputTokens} prompt tokens, {$usage2->cacheReadTokens} cache read tokens\n"; if ($usage2->cacheReadTokens === 0) { echo "Note: cacheReadTokens is 0. Cache hits depend on model/provider eligibility.\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/context_cache_structured_oai.md ================================================================================ ## Overview Instructor offers a simplified way to work with LLM providers' APIs supporting caching, so you can focus on your business logic while still being able to take advantage of lower latency and costs. > **Note:** Context caching is automatic for all OpenAI API calls. Read more > in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). ## Example When you need to process multiple requests with the same context, you can use context caching to improve performance and reduce costs. In our example we will be analyzing the README.md file of this Github project and generating its structured description for multiple audiences. Let's start by defining the data model for the project details and the properties that we want to extract or generate based on README file. ```php ``` We read the content of the README.md file and cache the context, so it can be reused for multiple requests. ```php "; $mdJsonRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withOutputMode(OutputMode::MdJson); $jsonRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withOutputMode(OutputMode::Json); ?> ``` At this point we can use Instructor structured output processing to extract the project details from the README.md file into the `Project` data model. Let's start by asking the user to describe the project for a specific audience: P&C insurance CIOs. ```php withCachedContext( system: $cachedSystem, prompt: $cachedPrompt, ) ->with( messages: 'Describe the project in a way compelling to my audience: P&C insurance CIOs.', responseModel: Project::class, options: ['max_tokens' => 4096], )->create(); // get processed value - instance of Project class $project1 = $response1->get(); dump($project1); assert($project1 instanceof Project); assert($project1->name !== ''); assert($project1->description !== ''); // get usage information from inferenceResponse() when you need transport-level metadata $usage1 = $response1->inferenceResponse()->usage(); echo "Usage: {$usage1->inputTokens} prompt tokens, {$usage1->cacheWriteTokens} cache write tokens\n"; ?> ``` Now we can use the same context to ask the user to describe the project for a different audience: boutique CMS consulting company owner. OpenAI API can reuse the cached prefix from the previous request to provide the response, which results in faster processing and lower costs when prompt caching applies. ```php withCachedContext( system: $cachedSystem, prompt: $cachedPrompt, ) ->with( messages: "Describe the project in a way compelling to my audience: boutique CMS consulting company owner.", responseModel: Project::class, options: ['max_tokens' => 4096], )->create(); // get the processed value - instance of Project class $project2 = $response2->get(); dump($project2); assert($project2 instanceof Project); assert($project2->name !== ''); assert($project2->description !== ''); // get usage information from inferenceResponse() when you need transport-level metadata $usage2 = $response2->inferenceResponse()->usage(); echo "Usage: {$usage2->inputTokens} prompt tokens, {$usage2->cacheReadTokens} cache read tokens\n"; if ($usage2->cacheReadTokens === 0) { echo "Note: cacheReadTokens is 0. Prompt caching applies only to eligible models and prompt sizes.\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/context_cache_structured_oai_responses.md ================================================================================ ## Overview Instructor offers a simplified way to work with LLM providers' APIs supporting caching, so you can focus on your business logic while still being able to take advantage of lower latency and costs. ## Example When you need to process multiple requests with the same context, you can use context caching to improve performance and reduce costs. In our example we will be analyzing the README.md file of this Github project and generating its structured description for multiple audiences. Let's start by defining the data model for the project details and the properties that we want to extract or generate based on README file. ```php ``` We read the content of the README.md file and cache the context, so it can be reused for multiple requests. ```php "; $jsonSchemaRuntime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai-responses')) ->withOutputMode(OutputMode::JsonSchema); ?> ``` At this point we can use Instructor structured output processing to extract the project details from the README.md file into the `Project` data model. Let's start by asking the user to describe the project for a specific audience: P&C insurance CIOs. ```php $optionsBase */ $optionsBase = [ 'max_output_tokens' => 4096, // Improve cache routing for identical prefixes between requests. 'prompt_cache_key' => $cacheKey, 'prompt_cache_retention' => 'in_memory', ]; // get StructuredOutputResponse object to get access to usage and other metadata $response1 = (new StructuredOutput($jsonSchemaRuntime)) ->withCachedContext( system: $cachedSystem, prompt: $cachedPrompt, ) ->with( messages: 'Describe the project in a way compelling to my audience: P&C insurance CIOs.', responseModel: Project::class, model: 'gpt-4.1', options: $optionsBase, )->create(); // get processed value - instance of Project class $project1 = $response1->get(); dump($project1); assert($project1 instanceof Project); assert($project1->name !== ''); assert($project1->description !== ''); // Extract response id for Responses API server-side chaining $body1 = json_decode($response1->inferenceResponse()->responseData()->body(), true); $previousResponseId = is_array($body1) ? ($body1['id'] ?? '') : ''; if ($previousResponseId === '') { echo "Warning: previous_response_id not available; chaining will be skipped.\n"; } // get usage information from inferenceResponse() when you need transport-level metadata $usage1 = $response1->inferenceResponse()->usage(); echo "Usage #1: {$usage1->inputTokens} prompt tokens, {$usage1->cacheWriteTokens} cache write tokens\n"; ?> ``` Now we can use the same context to ask the user to describe the project for a different audience: boutique CMS consulting company owner. OpenAI API can reuse the cached prefix from the previous request to provide the response, which results in faster processing and lower costs when prompt caching applies. ```php withCachedContext( system: $cachedSystem, prompt: $cachedPrompt, ) ->with( messages: "Describe the project in a way compelling to my audience: boutique CMS consulting company owner.", responseModel: Project::class, model: 'gpt-4.1', options: $options2, )->create(); // get the processed value - instance of Project class $project2 = $response2->get(); dump($project2); assert($project2 instanceof Project); assert($project2->name !== ''); assert($project2->description !== ''); // get usage information from inferenceResponse() when you need transport-level metadata $usage2 = $response2->inferenceResponse()->usage(); echo "Usage #2: {$usage2->inputTokens} prompt tokens, {$usage2->cacheReadTokens} cache read tokens\n"; if ($usage2->cacheReadTokens === 0) { echo "Note: cacheReadTokens is 0. Cache hits depend on model/provider eligibility.\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/custom_config.md ================================================================================ ## Overview You can provide your own LLM configuration instance to Instructor. This is useful when you want to initialize OpenAI client with custom values - e.g. to call other LLMs which support OpenAI API. ## Example ```php '2.0']); $customClient = (new HttpClientBuilder) ->withEventBus($events) ->withDriver(new SymfonyDriver( config: $httpConfig, clientInstance: $yourClientInstance, events: $events, )) ->create(); // Create instance of LLM connection config initialized with custom parameters $llmConfig = new LLMConfig( apiUrl : 'https://api.deepseek.com', apiKey : (string) Env::get('DEEPSEEK_API_KEY', ''), endpoint: '/chat/completions', model: 'deepseek-chat', maxTokens: 128, driver: 'openai-compatible', ); // Get Instructor with the default client component overridden with your own $runtime = StructuredOutputRuntime::fromConfig( config: $llmConfig, events: $events, httpClient: $customClient, )->withOutputMode(OutputMode::Tools); $runtime->wiretap(fn($e) => $e->print()); $structuredOutput = new StructuredOutput($runtime); $user = $structuredOutput ->with("Our user Jason is 25 years old.") ->withResponseClass(User::class) ->withStreaming() ->get(); dump($user); assert(isset($user->name)); assert(isset($user->age)); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/custom_http_client.md ================================================================================ ## Overview ## Example ```php '2.0']); $yourSymfonyEventDispatcher = new SymfonyEventDispatcher(new EventDispatcher()); $provider = LLMProvider::using('openai') ->withConfigOverrides(['apiUrl' => 'https://api.openai.com/v1']); $customClient = (new HttpClientBuilder(events: $yourSymfonyEventDispatcher)) ->withClientInstance( driverName: 'symfony', clientInstance: $yourSymfonyClientInstance, ) ->create(); $user = (new StructuredOutput( runtime: StructuredOutputRuntime::fromProvider( provider: $provider, events: $yourSymfonyEventDispatcher, httpClient: $customClient, )->withOutputMode(OutputMode::Tools), )) //->wiretap(fn($e) => $e->print()) ->withMessages("Our user Jason is 25 years old.") ->withResponseClass(User::class) //->withStreaming() ->get(); dump($user); assert(isset($user->name)); assert(isset($user->age)); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/custom_http_client_laravel.md ================================================================================ ## Overview ## Example ```php withConfigOverrides(['apiUrl' => 'https://api.openai.com/v1']); $customClient = HttpClient::fromDriver( new LaravelDriver( config: new HttpClientConfig(), events: new EventDispatcher(), clientInstance: $yourLaravelClientInstance, ) ); assert($customClient instanceof CanSendHttpRequests); $user = (new StructuredOutput( runtime: StructuredOutputRuntime::fromProvider( provider: $provider, httpClient: $customClient, )->withOutputMode(OutputMode::Tools), )) //->wiretap(fn($e) => $e->print()) ->withMessages("Our user Jason is 25 years old.") ->withResponseClass(User::class) //->withStreaming() ->get(); dump($user); assert(isset($user->name)); assert(isset($user->age)); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/custom_prompts.md ================================================================================ ## Overview In case you want to take control over the prompts sent by Instructor to LLM for different modes, you can use the `prompt` parameter in the `request()` or `create()` methods. It will override the default Instructor prompts, allowing you to fully customize how LLM is instructed to process the input. ## Example ```php withOutputMode(OutputMode::Tools); $jsonRuntime = StructuredOutputRuntime::fromProvider($provider)->withOutputMode(OutputMode::Json); $mdJsonRuntime = StructuredOutputRuntime::fromProvider($provider)->withOutputMode(OutputMode::MdJson); print("\n# Request for OutputMode::Tools:\n\n"); $toolsRuntime->onEvent(HttpRequestSent::class, fn(HttpRequestSent $event) => dump($event)); $user = (new StructuredOutput($toolsRuntime)) ->with( messages: "Our user Jason is 25 years old.", responseModel: User::class, prompt: "\nYour task is to extract correct and accurate data from the messages using provided tools.\n", )->get(); echo "\nRESPONSE:\n"; dump($user); assert($user->name === 'Jason'); assert($user->age === 25); print("\n# Request for OutputMode::Json:\n\n"); $jsonRuntime->onEvent(HttpRequestSent::class, fn(HttpRequestSent $event) => dump($event)); $user = (new StructuredOutput($jsonRuntime)) ->with( messages: "Our user Jason is 25 years old.", responseModel: User::class, prompt: "\nYour task is to respond correctly with JSON object. Response must follow JSONSchema:\n<|json_schema|>\n", )->get(); echo "\nRESPONSE:\n"; dump($user); assert($user->name === 'Jason'); assert($user->age === 25); print("\n# Request for OutputMode::MdJson:\n\n"); $mdJsonRuntime->onEvent(HttpRequestSent::class, fn(HttpRequestSent $event) => dump($event)); $user = (new StructuredOutput($mdJsonRuntime)) ->with( messages: "Our user Jason is 25 years old.", responseModel: User::class, prompt: "\nYour task is to respond correctly with strict JSON object containing extracted data within a ```json {} ``` codeblock. Object must validate against this JSONSchema:\n<|json_schema|>\n", )->get(); echo "\nRESPONSE:\n"; dump($user); assert($user->name === 'Jason'); assert($user->age === 25); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/custom_llm_with_dsn.md ================================================================================ ## Overview You can provide your own LLM configuration data to `StructuredOutput` object with DSN string. This is useful for inline configuration or for building configuration from admin UI, CLI arguments or environment variables. ## Example ```php wiretap(fn($e) => $e->print()) ->withMessages("Our user Jason is 25 years old.") ->withResponseClass(User::class) ->get(); dump($user); assert(isset($user->name)); assert(isset($user->age)); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/function_calls.md ================================================================================ ## Overview Instructor offers FunctionCall class to extract arguments of a function or method from content. This is useful when you want to build tool use capability, e.g. for AI chatbots or agents. ## Example ```php with( messages: $text, responseModel: FunctionCallFactory::fromMethodName(DataStore::class, 'saveUser'), )->get(); echo "\nCalling the function with the extracted arguments:\n"; (new DataStore)->saveUser(...$args); echo "\nExtracted arguments:\n"; dump($args); assert(count($args) == 3); assert($args['name'] === 'Jason'); assert($args['age'] == 28); assert($args['country'] === 'Germany'); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/manual_schemas.md ================================================================================ ## Overview While InstructorPHP can automatically generate schemas from PHP classes, you can also build schemas manually using the `JsonSchema` API. This provides full control over the JSON Schema structure and is useful for: - Dynamic schemas determined at runtime - Provider-specific optimizations - Legacy JSON Schema integration - Performance-sensitive scenarios See more: [Manual Schema Building](../../../packages/instructor/docs/advanced/manual_schemas.md) ## Example ```php with( messages: $text, responseModel: $userSchema, ) ->get(); print("OUTPUT:\n"); print("Name: " . $user['name'] . "\n"); print("Email: " . $user['email'] . "\n"); print("Age: " . $user['age'] . "\n"); print("Role: " . $user['role'] . "\n"); print("Address: " . $user['address']['city'] . ", " . $user['address']['zip'] . "\n"); print("Skills: " . implode(", ", $user['skills']) . "\n"); print("\n"); print("COMPARISON: Manual vs Reflection\n"); print("=================================\n"); print("✅ Manual schema: Full control, no class needed\n"); print("✅ Reflection (from class): Concise, type-safe, single source of truth\n"); print("\n"); print("Choose manual schemas when:\n"); print("- Schema is determined at runtime\n"); print("- You need provider-specific optimizations\n"); print("- Working with legacy JSON Schema specs\n"); print("- Reflection overhead is a concern\n"); assert($user['name'] === 'John Doe'); assert($user['email'] === 'john.doe@example.com'); assert($user['age'] === 35); assert($user['role'] === 'admin'); assert(!empty($user['address']['city'])); assert(!empty($user['skills'])); ?> ``` ## Advanced Example: Dynamic Schema ```php JsonSchema::string($field['name'], $field['description'] ?? ''), 'int' => JsonSchema::integer($field['name'], $field['description'] ?? ''), 'float' => JsonSchema::number($field['name'], $field['description'] ?? ''), 'bool' => JsonSchema::boolean($field['name'], $field['description'] ?? ''), default => JsonSchema::string($field['name']), }; } return JsonSchema::object( name: 'DynamicData', properties: $properties, requiredProperties: array_column($fields, 'name') ); } // User-defined fields at runtime $userFields = [ ['name' => 'product', 'type' => 'string', 'description' => 'Product name'], ['name' => 'quantity', 'type' => 'int', 'description' => 'Quantity ordered'], ['name' => 'price', 'type' => 'float', 'description' => 'Unit price'], ['name' => 'inStock', 'type' => 'bool', 'description' => 'Is in stock'], ]; $schema = buildDynamicSchema($userFields); $data = StructuredOutput::using('openai') ->with( messages: 'Extract: Laptop, 2 units at $999.99 each, currently in stock', responseModel: $schema, ) ->get(); print("Dynamic extraction result:\n"); print("Product: " . $data['product'] . "\n"); print("Quantity: " . $data['quantity'] . "\n"); print("Price: $" . $data['price'] . "\n"); print("In Stock: " . ($data['inStock'] ? 'Yes' : 'No') . "\n"); assert(is_string($data['product']) && !empty($data['product'])); assert($data['quantity'] === 2); assert(is_float($data['price']) || is_int($data['price'])); assert($data['inStock'] === true); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/partials.md ================================================================================ ## Overview Instructor can process LLM's streamed responses to provide partial updates that you can use to update the model with new data as the response is being generated. You can use it to improve user experience by updating the UI with partial data before the full response is received. ## Example ```php ``` Now we can use this data model to extract arbitrary properties from a text message. As the tokens are streamed from LLM API, the `partialUpdate` function will be called with partially updated object of type `UserDetail` that you can use, usually to update the UI. ```php withConfig(new StructuredOutputConfig( responseCachePolicy: ResponseCachePolicy::None, // use Memory if you need replay )) ->withOutputMode(OutputMode::Json) ))->with( messages: $text, responseModel: UserDetail::class, system: $system, prompt: $prompt, ) ->withStreaming() ->stream(); $partials = []; foreach ($stream->partials() as $partial) { // Streams are one-shot by default. Keep updates if you need to inspect them later. $partials[] = $partial; partialUpdate($partial); } $user = $stream->finalValue(); exitPartialScreen(); echo "All tokens received, fully completed object available in `\$user` variable.\n"; echo '$user = '."\n"; dump($user); assert(!empty($user->roles)); assert(!empty($user->hobbies)); assert($user->location === 'San Francisco'); assert($user->age == 25); assert($user->name === 'Jason'); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/demonstrations.md ================================================================================ ## Overview To improve the results of LLM inference you can provide examples of the expected output. This will help LLM to understand the context and the expected structure of the output. It is typically useful in the `OutputMode::Json` and `OutputMode::MdJson` modes, where the output is expected to be a JSON object. ## Example ```php withOutputMode(OutputMode::Json) ->onEvent(HttpRequestSent::class, fn($event) => dump($event)); $user = (new StructuredOutput($runtime)) ->withMessages("Our user Jason is 25 years old.") ->withResponseClass(User::class) ->withExamples([ new Example( input: "John is 50 and works as a teacher.", output: ['name' => 'John', 'age' => 50] ), new Example( input: "We have recently hired Ian, who is 27 years old.", output: ['name' => 'Ian', 'age' => 27], template: "example input:\n<|input|>\noutput:\n```json\n<|output|>\n```\n", ), ]) ->get(); echo "\nOUTPUT:\n"; dump($user); assert($user->name === 'Jason'); assert($user->age === 25); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/scalars.md ================================================================================ ## Overview Sometimes we just want to get quick results without defining a class for the response model, especially if we're trying to get a straight, simple answer in a form of string, integer, boolean or float. Instructor provides a simplified API for such cases. ## Example ```php with( messages: $text, prompt: 'What is user\'s citizenship?', responseModel: Scalar::enum(CitizenshipGroup::class, name: 'citizenshipGroup'), )->get(); dump($value); assert($value instanceof CitizenshipGroup); assert($value == CitizenshipGroup::US); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/sequences.md ================================================================================ ## Overview Sequences are a special type of response model that can be used to represent a list of objects. It is usually more convenient not create a dedicated class with a single array property just to handle a list of objects of a given class. Additional, unique feature of sequences is that they can be streamed per each completed item in a sequence, rather than on any property update. ## Example ```php wiretap(fn($e) => $e->print()) ->with( messages: $text, responseModel: Sequence::of(Person::class), options: ['stream' => true], ) ->stream(); foreach ($stream->sequence() as $item) { dump($item); } $list = $stream->finalValue(); dump(count($list)); assert(count($list) === 4); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/streaming.md ================================================================================ ## Overview Instructor can process LLM's streamed responses to provide partial response model updates that you can use to update the model with new data as the response is being generated. ## Example ```php ``` Now we can use this data model to extract arbitrary properties from a text message. As the tokens are streamed from LLM API, the `partialUpdate` function will be called with partially updated object of type `UserDetail` that you can use, usually to update the UI. ```php withOutputMode(OutputMode::Json) )) //->wiretap(fn(Event $e) => $e->print()) ->with( messages: $text, responseModel: UserDetail::class, system: $system, prompt: $prompt, ) ->withStreaming() ->stream(); foreach ($stream->partials() as $partial) { partialUpdate($partial); } $user = $stream->lastUpdate(); exitPartialScreen(); echo "All tokens received, fully completed object available in `\$user` variable.\n"; echo '$user = '."\n"; dump($user); assert($user->name === 'Jason'); assert($user->age === 25); ?> ``` ================================================================================ FILE: cookbook/examples/A02_Advanced/structures.md ================================================================================ ## Overview Structures let you define output shape at runtime. Use `SchemaBuilder` to define the schema and pass a `Structure` as response model. If `Structure` is used as response model, Instructor returns a dynamic value that can be converted with `->toArray()`. See more: [Structures](../../structures.md) ## Example ```php string('author', 'Book author', required: false) ->string('title', 'Book title') ->schema(); $schema = SchemaBuilder::define('person', 'A person object') ->string('name', 'Name of the person') ->int('age', 'Age of the person') ->option('gender', ['male', 'female'], 'Gender of the person', required: false) ->shape('address', fn(SchemaBuilder $builder) => $builder ->string('street', 'Street name', required: false) ->string('city', 'City name') ->string('zip', 'Zip code', required: false), 'Address of the person') ->enum('role', Role::class, 'Role of the person') ->collection('favourite_books', $book, 'Favorite books of the person') ->schema(); $structure = Structure::fromSchema($schema); $text = <<with( messages: $text, responseModel: $structure, )->get(); print("OUTPUT:\n"); $personData = match (true) { $person instanceof Structure => $person->toArray(), is_array($person) => $person, default => (array) $person, }; print("Name: " . ($personData['name'] ?? '') . "\n"); print("Age: " . ($personData['age'] ?? '') . "\n"); print("Gender: " . ($personData['gender'] ?? '') . "\n"); print("Address / city: " . ($personData['address']['city'] ?? '') . "\n"); print("Address / ZIP: " . ($personData['address']['zip'] ?? '') . "\n"); print("Role: " . ($personData['role'] ?? '') . "\n"); print("Favourite books:\n"); foreach (($personData['favourite_books'] ?? []) as $bookData) { if (!is_array($bookData)) { continue; } print(" - " . ($bookData['title'] ?? '') . " by " . ($bookData['author'] ?? '') . "\n"); } assert(($personData['name'] ?? '') === 'Jane Doe'); assert(($personData['age'] ?? 0) === 25); assert(!empty($personData['address']['city'])); assert(!empty($personData['address']['zip'])); assert(($personData['role'] ?? '') === 'manager'); assert(!empty($personData['favourite_books'])); ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/cost_calculation.md ================================================================================ ## Overview When using LLM APIs, tracking costs is essential for budgeting and optimization. Cost calculation is decoupled from usage tracking — you use a calculator to compute cost from usage and pricing data. This example demonstrates how to: 1. Define pricing rates ($/1M tokens) with `InferencePricing` 2. Calculate cost using `FlatRateCostCalculator` 3. Compare costs across different models ```php calculate($usage, $pricing); echo "Token Usage:\n"; echo " Input tokens: {$usage->inputTokens}\n"; echo " Output tokens: {$usage->outputTokens}\n"; echo " Cache read: {$usage->cacheReadTokens}\n"; echo " Cache write: {$usage->cacheWriteTokens}\n"; echo " Reasoning: {$usage->reasoningTokens}\n"; echo "\nPricing ($/1M tokens):\n"; echo " Input: \${$pricing->inputPerMToken}\n"; echo " Output: \${$pricing->outputPerMToken}\n"; echo " Cache read: \${$pricing->cacheReadPerMToken}\n"; echo "\nTotal cost: \$" . number_format($cost->total, 6) . "\n"; echo "\nBreakdown:\n"; foreach ($cost->breakdown as $category => $amount) { printf(" %-12s \$%.6f\n", $category, $amount); } } echo "CALCULATING COST WITH EXPLICIT PRICING\n"; echo str_repeat("=", 50) . "\n\n"; $text = "Jason is 25 years old and works as an engineer."; $response = StructuredOutput::using('openai') ->with( messages: $text, responseModel: User::class, )->response(); // Define pricing for default model gpt-4.1-nano $pricing = InferencePricing::fromArray([ 'input' => 0.2, // $0.2 per 1M input tokens 'output' => 0.8, // $0.8 per 1M output tokens 'cacheRead' => 0.05, // $0.05 per 1M cache read tokens ]); echo "TEXT: $text\n\n"; printCostBreakdown($response->usage(), $pricing, $calculator); // COMPARE COSTS ACROSS DIFFERENT MODELS echo "\n\n" . str_repeat("=", 50) . "\n"; echo "COST COMPARISON ACROSS MODELS\n"; echo str_repeat("=", 50) . "\n\n"; $usage = $response->usage(); $models = [ 'GPT-4o' => ['input' => 2.50, 'output' => 10.0], 'GPT-4o-mini' => ['input' => 0.15, 'output' => 0.60], 'Claude 3.5 Sonnet' => ['input' => 3.0, 'output' => 15.0], 'Claude 3.5 Haiku' => ['input' => 0.80, 'output' => 4.0], 'Gemini 2.0 Flash' => ['input' => 0.10, 'output' => 0.40], ]; echo "For {$usage->inputTokens} input + {$usage->outputTokens} output tokens:\n\n"; foreach ($models as $model => $prices) { $pricing = InferencePricing::fromArray($prices); $cost = $calculator->calculate($usage, $pricing); printf(" %-20s \$%.6f\n", $model, $cost->total); } assert($response->value()->name === 'Jason'); assert($response->value()->age === 25); assert($response->usage()->inputTokens > 0); assert($response->usage()->outputTokens > 0); ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/debugging.md ================================================================================ ## Overview The `StructuredOutput` class has a `withDebug()` method that can be used to debug the request and response. It displays detailed information about the request being sent to LLM API and response received from it, including: - request headers, URI, method and body, - response status, headers, and body. This is useful for debugging the request and response when you are not getting the expected results. ## Example ```php withDebugConfig(DebugConfig::fromPreset('on'))->create(); $structuredOutput = new StructuredOutput( StructuredOutputRuntime::fromConfig( config: LLMConfig::fromPreset('openai'), httpClient: $debugHttpClient, ) ); //->wiretap(fn($e) => $e->print()); echo "\n### CASE 1.1 - Debugging sync request\n\n"; $user = $structuredOutput ->with( messages: "Jason is 25 years old.", responseModel: User::class, options: [ 'stream' => false ] ) ->get(); echo "\nResult:\n"; assert(isset($user->name)); assert(isset($user->age)); assert($user->name === 'Jason'); assert($user->age === 25); // CASE 1.2 - normal flow, streaming request echo "\n### CASE 1.2 - Debugging streaming request\n\n"; $user2 = $structuredOutput ->with( messages: "Anna is 21 years old.", responseModel: User::class, options: [ 'stream' => true ] ) ->get(); echo "\nResult:\n"; dump($user2); assert(isset($user2->name)); assert(isset($user2->age)); assert($user2->name === 'Anna'); assert($user2->age === 21); // CASE 2 - forcing API error via empty LLM config // let's initialize the instructor with an incorrect LLM config $structuredOutput = new StructuredOutput( StructuredOutputRuntime::fromConfig( config: new LLMConfig(apiUrl: 'https://example.com'), httpClient: $debugHttpClient, ) ); echo "\n### CASE 2 - Debugging with HTTP exception\n\n"; try { $user = $structuredOutput ->with( messages: "Jason is 25 years old.", responseModel: User::class, options: [ 'stream' => true ] ) ->get(); } catch (Exception $e) { $msg = Str::limit($e->getMessage(), 250); echo "EXCEPTION WE EXPECTED:\n"; echo "\nCaught exception: " . $msg . "\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/structured_output_eventlog_readback.md ================================================================================ ## Overview This example shows the smallest opt-in file logging flow with `EventLog::enable()`. It activates the default JSONL sink, runs one `StructuredOutput` request without passing a custom event bus, then reads the generated log file back and prints the captured entries on screen. Key concepts: - `EventLog::enable()`: turns on the default JSONL sink for the current process - `StructuredOutputRuntime::fromProvider(...)`: uses the default runtime event bus - JSONL readback: parse the generated file after the request completes ## Example ```php with( messages: 'Customer report: checkout returns HTTP 500 after payment. Mark severity and summarize it in one sentence.', responseModel: IncidentSummary::class, ) ->get(); $entries = ExampleEventLog::read($logPath); } finally { EventLog::disable(); } echo "=== StructuredOutput Result ===\n"; echo "Severity: {$incident->severity}\n"; echo "Summary: {$incident->summary}\n"; echo "\n=== EventLog Entries ===\n"; echo "Log file: {$logPath}\n"; echo 'Entries captured: ' . count($entries) . "\n\n"; ExampleEventLog::print($entries, 8); assert($incident->severity !== ''); assert($incident->summary !== ''); assert($entries !== []); ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/logging_laravel.md ================================================================================ ## Overview Laravel integration with Instructor's functional logging pipeline. ## Example ```php headers->set('X-Request-ID', 'req_' . uniqid()); // Create logger $logger = new Logger('instructor'); $logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); // Create pipeline with request context $pipeline = LoggingPipeline::create() ->filter(new LogLevelFilter('debug')) // Changed to debug to capture more events ->enrich(LazyEnricher::framework(fn() => [ 'request_id' => $request->headers->get('X-Request-ID'), 'route' => '/api/extract', ])) ->format(new MessageTemplateFormatter([ \Cognesy\Instructor\Events\StructuredOutput\StructuredOutputStarted::class => '🎯 [LARAVEL] Starting extraction: {responseClass} (Request: {framework.request_id})', \Cognesy\Instructor\Events\StructuredOutput\StructuredOutputResponseGenerated::class => '✅ [LARAVEL] Completed extraction: {responseClass} (Request: {framework.request_id})', ], channel: 'instructor')) ->write(new PsrLoggerWriter($logger)) ->build(); echo "🔧 Laravel logging pipeline configured\n"; echo "📋 About to execute StructuredOutput with logging...\n\n"; class User { public int $age; public string $name; } // Extract data with logging echo "🚀 Starting StructuredOutput extraction...\n"; $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->wiretap($pipeline); $user = (new StructuredOutput($runtime)) ->withMessages("Jason is 25 years old.") ->withResponseClass(User::class) ->get(); echo "\n✅ Extraction completed!\n"; echo "📊 Result: User: {$user->name}, Age: {$user->age}\n"; assert($user->name === 'Jason'); assert($user->age === 25); // TODO: Add "Sample Output" section showing actual log messages // Example format: // ### Sample Output // [2025-12-07 01:18:13] instructor.DEBUG: 🔄 [Laravel] Starting extraction: User // [2025-12-07 01:18:14] instructor.DEBUG: ✅ [Laravel] Completed extraction: User ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/logging_monolog.md ================================================================================ ## Overview Monolog integration with Instructor's functional logging pipeline. ## Example ```php pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); // Create logging pipeline $pipeline = LoggingPipeline::create() ->filter(new LogLevelFilter('debug')) ->format(new MessageTemplateFormatter([ \Cognesy\Instructor\Events\StructuredOutput\StructuredOutputStarted::class => '🎯 Starting extraction: {responseClass}', \Cognesy\Instructor\Events\StructuredOutput\StructuredOutputResponseGenerated::class => '✅ Completed extraction: {responseClass}', ], channel: 'instructor')) ->write(new MonologChannelWriter($logger)) ->build(); echo "📋 About to demonstrate Monolog logging with functional pipeline...\n\n"; class User { public int $age; public string $name; } // Extract data with logging echo "🚀 Starting StructuredOutput extraction...\n"; $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->wiretap($pipeline); $user = (new StructuredOutput($runtime)) ->withMessages("Jason is 25 years old.") ->withResponseClass(User::class) ->get(); echo "\n✅ Extraction completed!\n"; echo "📊 Result: User: {$user->name}, Age: {$user->age}\n"; assert($user->name === 'Jason'); assert($user->age === 25); // TODO: Add "Sample Output" section showing actual log messages // Example format: // ### Sample Output // [2025-12-07T01:18:13.475202+00:00] instructor.DEBUG: 🎯 Starting extraction: User // [2025-12-07T01:18:13.486832+00:00] instructor.DEBUG: HttpRequestSent // [2025-12-07T01:18:14.640213+00:00] instructor.DEBUG: HttpResponseReceived // [2025-12-07T01:18:14.659417+00:00] instructor.DEBUG: ✅ Completed extraction: User ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/logging_psr.md ================================================================================ ## Overview Simple PSR-3 logging integration using Instructor's functional pipeline. ## Example ```php pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); // Create logging pipeline - filters to only StructuredOutput events $pipeline = LoggingPipeline::create() ->filter(new LogLevelFilter('debug')) ->format(new MessageTemplateFormatter([ \Cognesy\Instructor\Events\StructuredOutput\StructuredOutputStarted::class => '🎯 [PSR-3] Starting extraction: {responseClass}', \Cognesy\Instructor\Events\StructuredOutput\StructuredOutputResponseGenerated::class => '✅ [PSR-3] Completed extraction: {responseClass}', ], channel: 'instructor')) ->write(new PsrLoggerWriter($logger)) ->build(); echo "📋 About to demonstrate PSR-3 logging with functional pipeline...\n\n"; echo "🚀 Starting StructuredOutput extraction...\n"; class User { public int $age; public string $name; } // Extract data with logging $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->wiretap($pipeline); $user = (new StructuredOutput($runtime)) ->withMessages("Jason is 25 years old.") ->withResponseClass(User::class) ->get(); echo "\n✅ Extraction completed!\n"; echo "📊 Result: User: {$user->name}, Age: {$user->age}\n"; assert($user->name === 'Jason'); assert($user->age === 25); // TODO: Add "Sample Output" section showing actual log messages // Example format: // ### Sample Output // [2025-12-07T01:18:13.475202+00:00] instructor.DEBUG: 🎯 [PSR-3] Starting extraction: User // [2025-12-07T01:18:14.659417+00:00] instructor.DEBUG: ✅ [PSR-3] Completed extraction: User ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/logging_symfony.md ================================================================================ ## Overview Symfony integration with Instructor's functional logging pipeline. ## Example ```php headers->set('X-Request-ID', 'req_' . uniqid()); $request->attributes->set('_route', 'api.extract'); // Create logger $logger = new Logger('instructor'); $logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); // Create pipeline with Symfony context $pipeline = LoggingPipeline::create() ->filter(new LogLevelFilter('debug')) ->enrich(LazyEnricher::framework(fn() => [ 'request_id' => $request->headers->get('X-Request-ID'), 'route' => $request->attributes->get('_route'), ])) ->format(new MessageTemplateFormatter([ \Cognesy\Instructor\Events\StructuredOutput\StructuredOutputStarted::class => '🎯 [SYMFONY] Starting extraction: {responseClass} (Route: {framework.route})', \Cognesy\Instructor\Events\StructuredOutput\StructuredOutputResponseGenerated::class => '✅ [SYMFONY] Completed extraction: {responseClass}', ], channel: 'instructor')) ->write(new PsrLoggerWriter($logger)) ->build(); echo "🔧 Symfony logging pipeline configured\n"; echo "📋 About to execute StructuredOutput with logging...\n\n"; class User { public int $age; public string $name; } // Extract data with logging echo "🚀 Starting StructuredOutput extraction...\n"; $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->wiretap($pipeline); $user = (new StructuredOutput($runtime)) ->withMessages("Jason is 25 years old.") ->withResponseClass(User::class) ->get(); echo "\n✅ Extraction completed!\n"; echo "📊 Result: User: {$user->name}, Age: {$user->age}\n"; assert($user->name === 'Jason'); assert($user->age === 25); // TODO: Add "Sample Output" section showing actual log messages // Example format: // ### Sample Output // [2025-12-07 01:18:13] instructor.DEBUG: 🔄 [Symfony] Starting extraction: User // [2025-12-07 01:18:14] instructor.DEBUG: ✅ [Symfony] Completed extraction: User ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/on_event.md ================================================================================ ## Overview `StructuredOutputRuntime::fromProvider(...)->onEvent(string $class, callable $callback)` method allows you to receive callback when specified type of event is dispatched by Instructor. This way you can plug into the execution process and monitor it, for example logging or reacting to the events which are of interest to your application. This example demonstrates how you can monitor outgoing requests and received responses via Instructor's events. Check the `Cognesy\Instructor\Events` namespace for the list of available events and their properties. ## Example ```php asLog()."\n"; } }; $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->onEvent(HttpRequestSent::class, fn($event) => $logger->log($event)) ->onEvent(HttpResponseReceived::class, fn($event) => $logger->log($event)); $user = (new StructuredOutput($runtime)) ->with( messages: "Jason is 28 years old", responseModel: User::class, ) ->get(); dump($user); assert($user->name === 'Jason'); assert($user->age === 28); ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/settings.md ================================================================================ ## Overview This example demonstrates edge-level config loading from a custom directory. Core classes receive typed config objects and never read files directly. ## Example ```php load('llm/openai.yaml')->toArray(); $debugData = $config->load('debug/on.yaml')->toArray(); $httpClient = (new HttpClientBuilder()) ->withDebugConfig(DebugConfig::fromArray($debugData)) ->create(); $provider = LLMProvider::fromLLMConfig(LLMConfig::fromArray($llmData)); $runtime = StructuredOutputRuntime::fromProvider( provider: $provider, httpClient: $httpClient, ); $user = (new StructuredOutput($runtime)) ->withMessages('Jason is 25 years old.') ->withResponseClass(UserDetail::class) ->get(); dump($user); assert(!isset($user->lastName) || $user->lastName === ''); ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/structured_telemetry_langfuse.md ================================================================================ ## Overview This example shows how to export Instructor runtime telemetry to Langfuse while keeping the example itself simple. It wires the existing event bus into the telemetry projectors and then runs one small structured extraction. Key concepts: - `RuntimeEventBridge`: attaches telemetry projection to the runtime event bus - `LangfuseExporter`: sends canonical telemetry to Langfuse over HTTP - `InstructorTelemetryProjector`: maps structured output lifecycle events - `PolyglotTelemetryProjector`: captures the nested LLM inference spans - `HttpClientTelemetryProjector`: captures outbound HTTP spans ## Example ```php attachTo($events); $runtime = StructuredOutputRuntime::fromProvider( provider: LLMProvider::using('openai'), events: $events, ); $ticket = (new StructuredOutput($runtime)) ->with( messages: 'Customer report: The checkout page returns a 500 error after payment. Treat this as urgent and summarize it in one sentence.', responseModel: SupportTicket::class, ) ->get(); $hub->flush(); echo "Priority: {$ticket->priority}\n"; echo "Summary: {$ticket->summary}\n"; echo "Telemetry: flushed to Langfuse\n"; assert($ticket->priority !== ''); assert($ticket->summary !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/structured_telemetry_logfire.md ================================================================================ ## Overview This example shows how to export Instructor runtime telemetry to Logfire while keeping the example itself simple. It wires the existing event bus into the telemetry projectors and then runs one small structured extraction. Key concepts: - `RuntimeEventBridge`: attaches telemetry projection to the runtime event bus - `LogfireExporter`: sends canonical telemetry to Logfire via OTLP/HTTP - `InstructorTelemetryProjector`: maps structured output lifecycle events - `PolyglotTelemetryProjector`: captures the nested LLM inference spans - `HttpClientTelemetryProjector`: captures outbound HTTP spans ## Example ```php attachTo($events); $runtime = StructuredOutputRuntime::fromProvider( provider: LLMProvider::using('openai'), events: $events, ); $ticket = (new StructuredOutput($runtime)) ->with( messages: 'Customer report: The checkout page returns a 500 error after payment. Treat this as urgent and summarize it in one sentence.', responseModel: SupportTicket::class, ) ->get(); $hub->flush(); echo "Priority: {$ticket->priority}\n"; echo "Summary: {$ticket->summary}\n"; echo "Telemetry: flushed to Logfire\n"; assert($ticket->priority !== ''); assert($ticket->summary !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/token_usage_events.md ================================================================================ ## Overview Some use cases require tracking the token usage of the API responses. This can be done by getting `InferenceUsage` object from Instructor LLM response object. Code below demonstrates how it can be retrieved for both sync and streamed requests. ## Example ```php inputTokens\n"; echo "Output tokens: $usage->outputTokens\n"; echo "Cache creation tokens: $usage->cacheWriteTokens\n"; echo "Cache read tokens: $usage->cacheReadTokens\n"; echo "Reasoning tokens: $usage->reasoningTokens\n"; } echo "COUNTING TOKENS FOR SYNC RESPONSE\n"; $text = "Jason is 25 years old and works as an engineer."; $response = StructuredOutput::using('openai') ->with( messages: $text, responseModel: User::class, )->response(); echo "\nTEXT: $text\n"; assert($response->usage()->total() > 0); printUsage($response->usage()); echo "\n\nCOUNTING TOKENS FOR STREAMED RESPONSE\n"; $text = "Anna is 19 years old."; $stream = StructuredOutput::using('openai') ->with( messages: $text, responseModel: User::class, options: ['stream' => true], ) ->stream(); $response = $stream->finalValue(); echo "\nTEXT: $text\n"; printUsage($stream->usage()); ?> ``` ================================================================================ FILE: cookbook/examples/A03_Troubleshooting/wiretap.md ================================================================================ ## Overview # Receive all internal events with wiretap() Instructor allows you to receive detailed information at every stage of request and response processing via events. `StructuredOutputRuntime::fromProvider(...)->wiretap(callable $callback)` method allows you to receive all events dispatched by Instructor. Example below demonstrates how `wiretap()` can help you to monitor the execution process and better understand or resolve any processing issues. In this example we use `print()` method available on event classes, which outputs console-formatted information about each event. ## Example ```php wiretap(fn($event) => $event->print()); $user = (new StructuredOutput($runtime)) ->with( messages: [["role" => "user", "content" => "Contact our CTO, Jason is 28 years old -- Best regards, Tom"]], responseModel: UserDetail::class, options: ['stream' => true] ) ->get(); dump($user); assert($user->name === "Jason"); assert($user->role === Role::CTO); assert($user->age === 28); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/a21.md ================================================================================ ## Overview Support for A21 Jamba - MAMBA architecture models, very strong at handling long context. Mode compatibility: - OutputMode::Tools (supported) - OutputMode::Json (supported) - OutputMode::JsonSchema (supported) - OutputMode::MdJson (fallback) ## Example ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/anthropic.md ================================================================================ ## Overview Instructor supports Anthropic API - you can find the details on how to configure the client in the example below. Mode compatibility: - OutputMode::MdJson, OutputMode::Json - supported - OutputMode::Tools - not supported yet ## Example ```php withOutputMode(OutputMode::Tools) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'claude-3-haiku-20240307', )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/azure_openai.md ================================================================================ ## Overview You can connect to Azure OpenAI instance using a dedicated client provided by Instructor. Please note it requires setting up your own model deployment using Azure OpenAI service console. ## Example ```php withOutputMode(OutputMode::Json) ); // Call with your model name and preferred execution mode $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'gpt-4o-mini', // set your own value/source )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/cerebras.md ================================================================================ ## Overview Support for Cerebras API which uses custom hardware for super fast inference. Cerebras provides Llama models. Mode compatibility: - OutputMode::Tools (supported) - OutputMode::Json (supported) - OutputMode::JsonSchema (supported) - OutputMode::MdJson (fallback) ## Example ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, model: 'llama3.1-8b', // set your own value/source examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/cohere.md ================================================================================ ## Overview Instructor supports Cohere API - you can find the details on how to configure the client in the example below. Mode compatibility: - OutputMode::MdJson - supported, recommended as a fallback from JSON mode - OutputMode::Json - supported, recommended - OutputMode::Tools - partially supported, not recommended Reasons OutputMode::Tools is not recommended: - Cohere does not support JSON Schema, which only allows to extract very simple, flat data schemas. - Performance of the currently available versions of Cohere models in tools mode for Instructor use case (data extraction) is extremely poor. ## Example ```php withDebugConfig(DebugConfig::fromPreset('on'))->create(); $structuredOutput = new StructuredOutput( StructuredOutputRuntime::fromConfig( config: LLMConfig::fromPreset('cohere'), httpClient: $debugHttpClient, )->withOutputMode(OutputMode::Json) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'command-r-plus-08-2024', )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/deepseek.md ================================================================================ ## Overview Support for DeepSeek API which provides strong models at affordable price. Mode compatibility: - OutputMode::Tools (supported) - OutputMode::Json (supported) - OutputMode::JsonSchema (supported) - OutputMode::MdJson (fallback) ## Example ```php withOutputMode(OutputMode::JsonSchema) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, model: 'deepseek-chat', // set your own value/source examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/fireworks.md ================================================================================ ## Overview Please note that the larger Mistral models support OutputMode::Json, which is much more reliable than OutputMode::MdJson. Mode compatibility: - OutputMode::Tools - selected models - OutputMode::Json - selected models - OutputMode::MdJson ## Example ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput ->with( messages: 'Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.', responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'developer', 'hobbies' => ['playing drums']], ]], model: 'accounts/fireworks/models/deepseek-v3p1', )->get(); echo "Completed response model:\n\n"; dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/google_gemini.md ================================================================================ ## Overview Google offers Gemini models which perform well in benchmarks. Supported modes: - OutputMode::MdJson - fallback mode - OutputMode::Json - recommended - OutputMode::Tools - supported Here's how you can use Instructor with Gemini API. ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput ->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], //options: ['stream' => true], ) ->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/google_gemini_oai.md ================================================================================ ## Overview Google offers Gemini models which perform well in benchmarks. Supported modes: - OutputMode::MdJson - fallback mode - OutputMode::Json - recommended - OutputMode::Tools - supported Here's how you can use Instructor with Gemini's OpenAI compatible API. ```php withOutputMode(OutputMode::MdJson) ); $user = $structuredOutput ->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], //options: ['stream' => true], ) ->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/groq.md ================================================================================ ## Overview Groq is LLM providers offering a very fast inference thanks to their custom hardware. They provide a several models - Llama2, Mixtral and Gemma. Supported modes depend on the specific model, but generally include: - OutputMode::MdJson - fallback mode - OutputMode::Json - recommended - OutputMode::Tools - supported Here's how you can use Instructor with Groq API. ## Example ```php withOutputMode(OutputMode::Json) ->withMaxRetries(2) ); $user = $structuredOutput ->with( messages: "Jason (@jxnlco) is 25 years old. He is the admin of this project. He likes playing football and reading books.", responseModel: User::class, prompt: 'Parse the user data to JSON, respond using following JSON Schema: <|json_schema|>', examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'user', 'hobbies' => ['playing drums'],], ],[ 'input' => 'We have a meeting with John, our new admin who likes surfing. He is 19 years old - check his profile: @jx90.', 'output' => ['name' => 'John', 'role' => 'admin', 'hobbies' => ['surfing'], 'username' => 'jx90', 'age' => 19], ]], model: 'llama-3.3-70b-versatile', //'gemma2-9b-it', options: ['temperature' => 0.5], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/huggingface.md ================================================================================ ## Overview You can use Instructor to parse structured output from LLMs using Hugging Face API. This example demonstrates how to parse user data into a structured model using JSON Schema. ## Example ```php withOutputMode(OutputMode::Json) ->withMaxRetries(2) ); $user = $structuredOutput ->with( messages: "Jason (@jxnlco) is 25 years old. He is the admin of this project. He likes playing football and reading books.", responseModel: User::class, prompt: 'Parse the user data to JSON, respond using following JSON Schema: <|json_schema|>', examples: [[ 'input' => 'I\'ve got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['firstName' => 'Frank', 'age' => 30, 'username' => 'frank@hk.ch', 'role' => 'user', 'hobbies' => ['playing drums'],], ],[ 'input' => 'We have a meeting with John, our new admin who likes surfing. He is 19 years old - check his profile: @jx90.', 'output' => ['firstName' => 'John', 'role' => 'admin', 'hobbies' => ['surfing'], 'username' => 'jx90', 'age' => 19], ]], //model: 'deepseek-ai/DeepSeek-R1-0528-Qwen3-8B', options: ['temperature' => 0.5], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->firstName)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->firstName === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/inception.md ================================================================================ ## Overview Inception API provides OpenAI-compatible endpoints for chat completions. Mode compatibility: - OutputMode::Tools (supported) - OutputMode::Json (supported) - OutputMode::JsonSchema (supported) - OutputMode::MdJson (fallback) ## Example ```php withOutputMode(OutputMode::MdJson) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'mercury', // set your own value/source )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/meta.md ================================================================================ ## Overview Instructor supports Meta LLM inference API. You can find the details on how to configure below. ## Example ```php withOutputMode(OutputMode::JsonSchema) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old. He is the admin of this project. He likes playing football and reading books.", responseModel: User::class, prompt: 'Parse the user data to JSON, respond using following JSON Schema: <|json_schema|>', examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'user', 'hobbies' => ['playing drums'],], ],[ 'input' => 'We have a meeting with John, our new admin who likes surfing. He is 19 years old - check his profile: @jig.', 'output' => ['age' => 19, 'name' => 'John', 'username' => 'jig', 'role' => 'admin', 'hobbies' => ['surfing'],], ]], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/minimaxi.md ================================================================================ ## Overview Support for Minimaxi's API. Mode compatibility: - OutputMode::MdJson (supported) - OutputMode::Tools (not supported) - OutputMode::Json (not supported) - OutputMode::JsonSchema (not supported) ## Example ```php withDebugConfig(DebugConfig::fromPreset('on'))->create(); $structuredOutput = new StructuredOutput( StructuredOutputRuntime::fromConfig( config: LLMConfig::fromPreset('minimaxi'), httpClient: $debugHttpClient, )->withOutputMode(OutputMode::MdJson) ); $user = $structuredOutput ->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'MiniMax-Text-01', // set your own value/source ) ->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/mistralai.md ================================================================================ ## Overview Mistral.ai is a company that builds OS language models, but also offers a platform hosting those models. You can use Instructor with Mistral API by configuring the client as demonstrated below. Please note that the larger Mistral models support OutputMode::Json, which is much more reliable than OutputMode::MdJson. Mode compatibility: - OutputMode::Tools - supported (Mistral-Small / Mistral-Medium / Mistral-Large) - OutputMode::Json - recommended (Mistral-Small / Mistral-Medium / Mistral-Large) - OutputMode::MdJson - fallback mode (Mistral 7B / Mixtral 8x7B) ## Example ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ],[ 'input' => 'We have a meeting with John, our new user. He is 30 years old - check his profile: @jx90.', 'output' => ['name' => 'John', 'role' => 'admin', 'hobbies' => [], 'username' => 'jx90', 'age' => 30], ]], model: 'mistral-small-latest', //'open-mixtral-8x7b', )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/moonshotai.md ================================================================================ ## Overview Support for MoonshotAI's API. Mode compatibility: - OutputMode::MdJson (supported) - OutputMode::Tools (supported) - OutputMode::Json (supported) - OutputMode::JsonSchema (supported) ## Example ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'kimi-latest', // set your own value/source )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/ollama.md ================================================================================ ## Overview You can use Instructor with local Ollama instance. Please note that, at least currently, OS models do not perform on par with OpenAI (GPT-3.5 or GPT-4) model for complex data schemas. Supported modes: - OutputMode::MdJson - fallback mode, works with any capable model - OutputMode::Json - recommended - OutputMode::Tools - supported (for selected models - check Ollama docs) ## Example ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer. Asked to connect via Twitter @frankch. Btw, he plays on drums!', 'output' => ['name' => 'Frank', 'role' => 'developer', 'hobbies' => ['playing drums'], 'username' => 'frankch', 'age' => null], ],[ 'input' => 'We have a meeting with John, our new user. He is 30 years old - check his profile: @j90.', 'output' => ['name' => 'John', 'role' => 'admin', 'hobbies' => [], 'username' => 'j90', 'age' => 30], ]], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/openai.md ================================================================================ ## Overview This is the default client used by Instructor. Mode compatibility: - OutputMode::Tools (supported) - OutputMode::Json (supported) - OutputMode::JsonSchema (recommended for new models) - OutputMode::MdJson (fallback) ## Example ```php withOutputMode(OutputMode::JsonSchema) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'gpt-4o-mini', // set your own value/source )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/openai-responses.md ================================================================================ ## Overview OpenAI's Responses API is their new recommended API for inference, offering improved performance and features compared to Chat Completions. Key features: - 3% better performance on reasoning tasks - 40-80% improved cache utilization - Built-in tools: web search, file search, code interpreter - Server-side conversation state via `previous_response_id` - Semantic streaming events Mode compatibility: - OutputMode::Tools (supported) - OutputMode::Json (supported) - OutputMode::JsonSchema (recommended) - OutputMode::MdJson (fallback) ## Example ```php withOutputMode(OutputMode::JsonSchema) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'gpt-4o-mini', // set your own value/source )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/openrouter.md ================================================================================ ## Overview You can use Instructor with OpenRouter API. OpenRouter provides easy, unified access to multiple open source and commercial models. Read OpenRouter docs to learn more about the models they support. Please note that OS models are in general weaker than OpenAI ones, which may result in lower quality of responses or extraction errors. You can mitigate this (partially) by using validation and `maxRetries` option to make Instructor automatically reattempt the extraction in case of extraction issues. ## Example ```php withOutputMode(OutputMode::JsonSchema) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old. He is the admin of this project. He likes playing football and reading books.", responseModel: User::class, prompt: 'Parse the user data to JSON, respond using following JSON Schema: <|json_schema|>', examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'user', 'hobbies' => ['playing drums'],], ],[ 'input' => 'We have a meeting with John, our new admin who likes surfing. He is 19 years old - check his profile: @jig.', 'output' => ['age' => 19, 'name' => 'John', 'username' => 'jig', 'role' => 'admin', 'hobbies' => ['surfing'],], ]], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/perplexity.md ================================================================================ ## Overview You can use Instructor with Perplexity API. Perplexity is an API that provides access to a large language model (LLM) for various tasks, including search and text generation. ## Example ```php withOutputMode(OutputMode::MdJson) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old. He is the admin of this project. He likes playing football and reading books.", responseModel: User::class, prompt: 'Parse the user data to JSON, respond using following JSON Schema: <|json_schema|>', examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'user', 'hobbies' => ['playing drums'],], ],[ 'input' => 'We have a meeting with John, our new admin who likes surfing. He is 19 years old - check his profile: @jig.', 'output' => ['age' => 19, 'name' => 'John', 'username' => 'jig', 'role' => 'admin', 'hobbies' => ['surfing'],], ]], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/sambanova.md ================================================================================ ## Overview Support for SambaNova's API, which provide fast inference endpoints for Llama and Qwen LLMs. Mode compatibility: - OutputMode::MdJson (supported) - OutputMode::Tools (not supported) - OutputMode::Json (not supported) - OutputMode::JsonSchema (not supported) ## Example ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], model: 'Meta-Llama-3.1-8B-Instruct', // set your own value/source )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/togetherai.md ================================================================================ ## Overview Together.ai hosts a number of language models and offers inference API with support for chat completion, JSON completion, and tools call. You can use Instructor with Together.ai as demonstrated below. Please note that some Together.ai models support OutputMode::Tools or OutputMode::Json, which are much more reliable than OutputMode::MdJson. Mode compatibility: - OutputMode::Tools - supported for selected models - OutputMode::Json - supported for selected models - OutputMode::MdJson - fallback mode ## Example ```php withOutputMode(OutputMode::Json) ); $user = $structuredOutput->with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. He asked to come back to him frank@hk.ch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => 'frank@hk.ch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ],[ 'input' => 'We have a meeting with John, our new user. He is 30 years old - check his profile: @jx90.', 'output' => ['name' => 'John', 'role' => 'admin', 'hobbies' => [], 'username' => 'jx90', 'age' => 30], ]], //model: 'meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo', //options: ['stream' => true ] )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A04_APISupport/xai.md ================================================================================ ## Overview Support for xAI's API, which offers access to X.com's Grok model. Mode compatibility: - OutputMode::Tools (supported) - OutputMode::Json (supported) - OutputMode::JsonSchema (supported) - OutputMode::MdJson (fallback) ## Example ```php with( messages: "Jason (@jxnlco) is 25 years old and is the admin of this project. He likes playing football and reading books.", responseModel: User::class, examples: [[ 'input' => 'Ive got email Frank - their developer, who\'s 30. His Twitter handle is @frankch. Btw, he plays on drums!', 'output' => ['age' => 30, 'name' => 'Frank', 'username' => '@frankch', 'role' => 'developer', 'hobbies' => ['playing drums'],], ]], )->get(); print("Completed response model:\n\n"); dump($user); assert(isset($user->name)); assert(isset($user->role)); assert(isset($user->age)); assert(isset($user->hobbies)); assert(isset($user->username)); assert(is_array($user->hobbies)); assert(count($user->hobbies) > 0); assert($user->role === UserType::Admin); assert($user->age === 25); assert($user->name === 'Jason'); assert(in_array($user->username, ['jxnlco', '@jxnlco'])); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/complex_extraction.md ================================================================================ ## Overview This is an example of extraction of a very complex structure from the provided text. ## Example ```php withOutputMode(OutputMode::Json) ); echo "PROJECT EVENTS:\n\n"; $stream = $structuredOutput ->with( messages: $report, responseModel: Sequence::of(ProjectEvent::class), model: 'gpt-4o-mini', options: [ 'max_tokens' => 16000, ], examples: [['input' => 'Acme Insurance project to implement SalesTech CRM solution is currently in RED status due to delayed delivery of document production system, led by 3rd party vendor - Alfatech. Customer (Acme) is discussing the resolution with the vendor. Production deployment plan has been finalized on Aug 15th and awaiting customer approval.', 'output' => [["type" => "object", "title" => "sequenceOfProjectEvent", "description" => "A sequence of ProjectEvent", "properties" => ["list" => [["title" => "Absorbing delay by deploying extra resources", "description" => "System integrator (SysCorp) are working to absorb some of the delay by deploying extra resources to speed up development when the doc production is done.", "type" => "action", "status" => "open", "stakeholders" => [["name" => "SysCorp", "role" => "system integrator", "details" => "System integrator",],], "date" => "2021-09-01",], ["title" => "Finalization of production deployment plan", "description" => "Production deployment plan has been finalized on Aug 15th and awaiting customer approval.", "type" => "progress", "status" => "open", "stakeholders" => [["name" => "Acme", "role" => "customer", "details" => "Customer",],], "date" => "2021-08-15",],],]]]]], ) ->withStreaming() ->stream(); foreach ($stream->sequence() as $item) { displayEvent($item); } $events = $stream->finalValue(); echo "TOTAL EVENTS: " . count($events) . "\n"; assert(count($events) > 0, 'Expected events to be extracted'); function displayEvent(ProjectEvent $event) : void { echo "Event: {$event->title}\n"; echo " - Descriptions: {$event->description}\n"; echo " - Type: {$event->type->value}\n"; echo " - Status: {$event->status->value}\n"; echo " - Date: {$event->date}\n"; if (empty($event->stakeholders)) { echo " - Stakeholders: none\n"; } else { echo " - Stakeholders:\n"; foreach($event->stakeholders as $stakeholder) { echo " - {$stakeholder->name} ({$stakeholder->role->value})\n"; } } echo "\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/complex_extraction_claude.md ================================================================================ ## Overview This is an example of extraction of a very complex structure from the provided text with Anthropic Claude 3 model. ## Example ```php withOutputMode(OutputMode::Tools) ); echo "PROJECT EVENTS:\n\n"; $stream = $structuredOutput ->with( messages: $report, responseModel: Sequence::of(ProjectEvent::class), model: 'claude-haiku-4-5', // 'claude-3-haiku-20240307' prompt: 'Extract a list of project events with all the details from the provided input in JSON format using schema: <|json_schema|>', examples: [['input' => 'Acme Insurance project to implement SalesTech CRM solution is currently in RED status due to delayed delivery of document production system, led by 3rd party vendor - Alfatech. Customer (Acme) is discussing the resolution with the vendor. Production deployment plan has been finalized on Aug 15th and awaiting customer approval.', 'output' => [["type" => "object", "title" => "sequenceOfProjectEvent", "description" => "A sequence of ProjectEvent", "properties" => ["list" => [["title" => "Absorbing delay by deploying extra resources", "description" => "System integrator (SysCorp) are working to absorb some of the delay by deploying extra resources to speed up development when the doc production is done.", "type" => "action", "status" => "open", "stakeholders" => [["name" => "SysCorp", "role" => "system integrator", "details" => "System integrator",],], "date" => "2021-09-01",], ["title" => "Finalization of production deployment plan", "description" => "Production deployment plan has been finalized on Aug 15th and awaiting customer approval.", "type" => "progress", "status" => "open", "stakeholders" => [["name" => "Acme", "role" => "customer", "details" => "Customer",],], "date" => "2021-08-15",],],]]]]], options: [ 'max_tokens' => 4096, 'stream' => true, ]) ->stream(); foreach ($stream->sequence() as $item) { displayEvent($item); } $events = $stream->finalValue(); echo "TOTAL EVENTS: " . count($events) . "\n"; assert(count($events) > 0, 'Expected events to be extracted'); //dump($events->list); function displayEvent(ProjectEvent $event) : void { echo "Event: {$event->title}\n"; echo " - Descriptions: {$event->description}\n"; echo " - Type: {$event->type->value}\n"; echo " - Status: {$event->status->value}\n"; echo " - Date: {$event->date}\n"; if (empty($event->stakeholders)) { echo " - Stakeholders: none\n"; } else { echo " - Stakeholders:\n"; foreach($event->stakeholders as $stakeholder) { echo " - {$stakeholder->name} ({$stakeholder->role->value})\n"; } } echo "\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/complex_extraction_cohere.md ================================================================================ ## Overview This is an example of extraction of a very complex structure from the provided text with Cohere R models. ## Example ```php withOutputMode(OutputMode::JsonSchema) ); echo "PROJECT EVENTS:\n\n"; $stream = $structuredOutput ->with( messages: $report, responseModel: Sequence::of(ProjectEvent::class), model: 'command-r-plus-08-2024', examples: [['input' => 'Acme Insurance project to implement SalesTech CRM solution is currently in RED status due to delayed delivery of document production system, led by 3rd party vendor - Alfatech. Customer (Acme) is discussing the resolution with the vendor. Production deployment plan has been finalized on Aug 15th and awaiting customer approval.', 'output' => [["type" => "object", "title" => "sequenceOfProjectEvent", "description" => "A sequence of ProjectEvent", "properties" => ["list" => [["title" => "Absorbing delay by deploying extra resources", "description" => "System integrator (SysCorp) are working to absorb some of the delay by deploying extra resources to speed up development when the doc production is done.", "type" => "action", "status" => "open", "stakeholders" => [["name" => "SysCorp", "role" => "system integrator", "details" => "System integrator",],], "date" => "2021-09-01",], ["title" => "Finalization of production deployment plan", "description" => "Production deployment plan has been finalized on Aug 15th and awaiting customer approval.", "type" => "progress", "status" => "open", "stakeholders" => [["name" => "Acme", "role" => "customer", "details" => "Customer",],], "date" => "2021-08-15",],],]]]]], options: [ 'max_tokens' => 2048, 'stream' => true, ]) ->stream(); foreach ($stream->sequence() as $item) { displayEvent($item); } $events = $stream->finalValue(); echo "TOTAL EVENTS: " . count($events) . "\n"; assert(count($events) > 0, 'Expected events to be extracted'); function displayEvent(ProjectEvent $event) : void { echo "Event: {$event->title}\n"; echo " - Descriptions: {$event->description}\n"; echo " - Type: {$event->type->value}\n"; echo " - Status: {$event->status->value}\n"; echo " - Date: {$event->date}\n"; if (empty($event->stakeholders)) { echo " - Stakeholders: none\n"; } else { echo " - Stakeholders:\n"; foreach($event->stakeholders as $stakeholder) { echo " - {$stakeholder->name} ({$stakeholder->role->value})\n"; } } echo "\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/complex_extraction_gemini.md ================================================================================ ## Overview This is an example of extraction of a very complex structure from the provided text with Google Gemini model. ## Example ```php withOutputMode(OutputMode::Json) ); echo "PROJECT EVENTS:\n\n"; $stream = $structuredOutput //->onEvent(StructuredOutputResponseUpdated::class, fn(StructuredOutputResponseUpdated $e) => print "---\n".$e->response->content()."---\n") ->with( messages: $report, responseModel: Sequence::of(ProjectEvent::class), examples: [['input' => 'Acme Insurance project to implement SalesTech CRM solution is currently in RED status due to delayed delivery of document production system, led by 3rd party vendor - Alfatech. Customer (Acme) is discussing the resolution with the vendor. Production deployment plan has been finalized on Aug 15th and awaiting customer approval.', 'output' => [["type" => "object", "title" => "sequenceOfProjectEvent", "description" => "A sequence of ProjectEvent", "properties" => ["list" => [["title" => "Absorbing delay by deploying extra resources", "description" => "System integrator (SysCorp) are working to absorb some of the delay by deploying extra resources to speed up development when the doc production is done.", "type" => "action", "status" => "open", "stakeholders" => [["name" => "SysCorp", "role" => "system integrator", "details" => "System integrator",],], "date" => "2021-09-01",], ["title" => "Finalization of production deployment plan", "description" => "Production deployment plan has been finalized on Aug 15th and awaiting customer approval.", "type" => "progress", "status" => "open", "stakeholders" => [["name" => "Acme", "role" => "customer", "details" => "Customer",],], "date" => "2021-08-15",],],]]]]], //model: 'gemini-1.5-flash', //model: 'gemini-2.0-flash-exp', //model: 'gemini-2.0-flash-thinking-exp', options: [ 'max_tokens' => 2048, 'stream' => true, ], )->stream(); foreach ($stream->sequence() as $item) { displayEvent($item); } $events = $stream->finalValue(); echo "TOTAL EVENTS: " . count($events) . "\n"; assert(count($events) > 0, 'Expected events to be extracted'); function displayEvent(ProjectEvent $event) : void { echo "Event: {$event->title}\n"; echo " - Descriptions: {$event->description}\n"; echo " - Type: {$event->type->value}\n"; echo " - Status: {$event->status->value}\n"; echo " - Date: {$event->date}\n"; if (empty($event->stakeholders)) { echo " - Stakeholders: none\n"; } else { echo " - Stakeholders:\n"; foreach($event->stakeholders as $stakeholder) { echo " - {$stakeholder->name} ({$stakeholder->role->value})\n"; } } echo "\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/cost_calculation_structured.md ================================================================================ ## Overview Calculate the cost of a StructuredOutput extraction call. The response provides `InferenceUsage` with token counts. Pair it with `InferencePricing` rates and a `FlatRateCostCalculator` to get a `Cost` breakdown. ## Example ```php with( messages: $text, responseModel: Company::class, )->response(); $company = $response->value(); $usage = $response->usage(); echo "Extracted:\n"; echo " Name: {$company->name}\n"; echo " Industry: {$company->industry}\n"; echo " Founded: {$company->foundedYear}\n"; echo " Products: " . implode(', ', $company->products) . "\n\n"; echo "Token usage:\n"; echo " Input: {$usage->inputTokens}\n"; echo " Output: {$usage->outputTokens}\n\n"; // 2. Calculate cost $calculator = new FlatRateCostCalculator(); $pricing = InferencePricing::fromArray([ 'input' => 0.2, // gpt-4.1-nano 'output' => 0.8, ]); $cost = $calculator->calculate($usage, $pricing); echo "Cost: {$cost->toString()}\n"; echo "Breakdown:\n"; foreach ($cost->breakdown as $category => $amount) { if ($amount > 0) { printf(" %-12s \$%.6f\n", $category, $amount); } } assert($company->name === 'Apple Inc.' || $company->name === 'Apple'); assert($company->foundedYear === 1976); assert(count($company->products) >= 3); assert($cost->total > 0); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/custom_extractor.md ================================================================================ ## Overview Instructor uses a pluggable extraction system to parse structured content from LLM responses. Different LLMs and output modes may return content in various formats - wrapped in markdown, embedded in explanatory text, or with trailing commas. You can create custom extractors to handle specific response formats from your LLM or API. Extractors are tried in order until one succeeds. ## Built-in Extractors Instructor provides these content extractors: - `DirectJsonExtractor` - Parses content directly as JSON (fastest) - `BracketMatchingExtractor` - Finds JSON by matching first `{` to last `}` - `MarkdownBlockExtractor` - Extracts from markdown code blocks - `ResilientJsonExtractor` - Handles trailing commas, missing braces - `SmartBraceExtractor` - Smart brace matching with string escaping ## Example: Custom XML Wrapper Extractor ```php {"name":"John"} */ class XmlJsonExtractor implements CanExtractResponse { public function __construct( private string $tagName = 'json', ) {} #[\Override] public function extract(ExtractionInput $input): array { // Match: {"key": "value"} $pattern = sprintf('/<%s>(.*?)<\/%s>/s', $this->tagName, $this->tagName); if (!preg_match($pattern, $input->content, $matches)) { throw new ExtractionException("No <{$this->tagName}> wrapper found"); } $json = trim($matches[1]); if ($json === '') { throw new ExtractionException("Empty <{$this->tagName}> wrapper"); } try { $decoded = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); } catch (\JsonException $e) { throw new ExtractionException("Invalid JSON in <{$this->tagName}>: {$e->getMessage()}", $e); } if (!is_array($decoded)) { throw new ExtractionException("Expected object or array in <{$this->tagName}>"); } return $decoded; } #[\Override] public function name(): string { return 'xml_json_extractor'; } } // Define schema class Person { public string $name; public int $age; public string $city; } // Simulate an LLM response with XML-wrapped JSON $xmlWrappedResponse = << { "name": "Alice Johnson", "age": 28, "city": "San Francisco" } The data has been successfully extracted from the input. EOT; echo "=== Example 1: Custom extractor for XML-wrapped JSON (sync) ===\n\n"; echo "Raw LLM response:\n"; echo str_repeat('-', 50) . "\n"; echo $xmlWrappedResponse . "\n"; echo str_repeat('-', 50) . "\n\n"; // Use custom extractors // DirectJson is tried first (will fail), then XmlJsonExtractor (will succeed) $person = new StructuredOutput( StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withExtractor(ResponseExtractor::fromExtractors( new DirectJsonExtractor(), new XmlJsonExtractor('json'), )) ) ->withResponseClass(Person::class) ->withMessages("Extract: Alice Johnson, 28 years old, lives in San Francisco") ->get(); dump($person); echo "\nExtracted data:\n"; echo "Name: {$person->name}\n"; echo "Age: {$person->age}\n"; echo "City: {$person->city}\n"; assert($person->name === 'Alice Johnson'); assert($person->age === 28); assert($person->city === 'San Francisco'); ?> ``` ## Expected Output ``` === Demonstrating Custom Extraction Strategy === Raw LLM response: -------------------------------------------------- Here is the extracted information: { "name": "Alice Johnson", "age": 28, "city": "San Francisco" } The data has been successfully extracted from the input. -------------------------------------------------- Person { +name: "Alice Johnson" +age: 28 +city: "San Francisco" } Extracted data: Name: Alice Johnson Age: 28 City: San Francisco ``` ## Streaming with Custom Extractors Custom extractors are automatically used for both sync and streaming modes. The `ResponseExtractor` handles buffer creation internally, using a subset of extractors optimized for streaming (fast extractors by default). ```php withExtractor(ResponseExtractor::fromExtractors( new DirectJsonExtractor(), new XmlJsonExtractor('json'), )) ) ->withResponseClass(Person::class) ->withMessages("Extract person data...") ->stream(); foreach ($stream->responses() as $partial) { $value = $partial->value(); $name = is_object($value) ? ($value->name ?? '...') : '...'; echo "Partial: " . $name . "\n"; } $person = $stream->finalValue(); echo "Final: {$person->name}\n"; ?> ``` ## Creating Your Own Extractor Implement `CanExtractResponse` interface: ```php use Cognesy\Instructor\Extraction\Contracts\CanExtractResponse; use Cognesy\Instructor\Extraction\Data\ExtractionInput; use Cognesy\Instructor\Extraction\Exceptions\ExtractionException; class MyCustomExtractor implements CanExtractResponse { public function extract(ExtractionInput $input): array { // Your extraction logic here // Return decoded array on success // Throw ExtractionException on failure } public function name(): string { return 'my_custom'; // For logging/debugging } } ``` ## Extractor Chain Behavior Extractors are tried in order until one succeeds: 1. First extractor is called with ExtractionInput 2. If it returns an array, extraction is complete 3. If it throws ExtractionException, next extractor is tried 4. If all fail, an error is raised This allows graceful degradation - try fast/simple extractors first, fall back to more complex ones only when needed. ================================================================================ FILE: cookbook/examples/A05_Extras/structured_input.md ================================================================================ ## Overview Instructor offers a way to use structured data as an input. This is useful when you want to use object data as input and get another object with a result of LLM inference. The `input` field of Instructor's `create()` method can be an object, but also an array or just a string. ## Example ```php withInput($email) ->withResponseClass(Email::class) ->withPrompt('Return an Email object. Copy the address field exactly as provided. Translate only the subject and body fields to natural Spanish.') ->withModel('gpt-4o-mini') ->withOptions(['temperature' => 0]) ->get(); print_r($translatedEmail); if ($translatedEmail->address !== $email->address) { echo "ERROR: Address was modified during translation\n"; exit(1); } if ($translatedEmail->subject === $email->subject) { echo "ERROR: Subject was not translated\n"; exit(1); } if ($translatedEmail->body === $email->body) { echo "ERROR: Body was not translated\n"; exit(1); } ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/image_car_damage.md ================================================================================ ## Overview This is an example of how to extract structured data from an image using Instructor. The image is loaded from a file and converted to base64 format before sending it to OpenAI API. In this example we will be extracting structured data from an image of a car with visible damage. The response model will contain information about the location of the damage and the type of damage. ## Scanned image Here's the image we're going to extract data from. ![Car Photo](../../../images/car-damage.jpg) ## Example ```php toData( responseModel: DamageAssessment::class, prompt: 'Identify and assess each car damage location and severity separately.', structuredOutput: StructuredOutputRuntime::fromProvider( provider: LLMProvider::using('openai'), ), model: 'gpt-4o-mini', options: ['max_tokens' => 4096] ); dump($assessment); assert(strtolower(trim($assessment->make)) === 'toyota'); assert(strtolower(trim($assessment->model)) === 'prius'); assert(strtolower(trim($assessment->bodyColor)) === 'white'); assert(count($assessment->damages) > 0); assert(trim($assessment->summary) !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/image_to_data.md ================================================================================ ## Overview This is an example of how to extract structured data from an image using Instructor. The image is loaded from a file and converted to base64 format before sending it to OpenAI API. The response model is a PHP class that represents the structured receipt information with data of vendor, items, subtotal, tax, tip, and total. ## Scanned image Here's the image we're going to extract data from. ![Receipt](../../../images/receipt.png) ## Example ```php with( messages: Image::fromFile(__DIR__ . '/receipt.png')->toMessage(), responseModel: Receipt::class, prompt: 'Extract structured data from the receipt.', options: ['max_tokens' => 4096] )->get(); dump($receipt); assert(is_numeric($receipt->total)); assert(number_format((float) $receipt->total, 2, '.', '') === '169.82'); assert(count($receipt->items) > 0); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/image_to_data_anthropic.md ================================================================================ ## Overview This is an example of how to extract structured data from an image using Instructor. The image is loaded from a file and converted to base64 format before sending it to OpenAI API. The response model is a PHP class that represents the structured receipt information with data of vendor, items, subtotal, tax, tip, and total. ## Scanned image Here's the image we're going to extract data from. ![Receipt](../../../images/receipt.png) ## Example ```php withOutputMode(OutputMode::Json) )->with( messages: Image::fromFile(__DIR__ . '/receipt.png')->toMessage(), responseModel: Receipt::class, prompt: 'Extract structured data from the receipt. Return result as JSON following this schema: <|json_schema|>', model: 'claude-haiku-4-5', options: ['max_tokens' => 4096] )->get(); dump($receipt); assert(is_numeric($receipt->total)); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/image_to_data_gemini.md ================================================================================ ## Overview This is an example of how to extract structured data from an image using Instructor. The image is loaded from a file and converted to base64 format before sending it to OpenAI API. The response model is a PHP class that represents the structured receipt information with data of vendor, items, subtotal, tax, tip, and total. ## Scanned image Here's the image we're going to extract data from. ![Receipt](../../../images/receipt.png) ## Example ```php withOutputMode(OutputMode::Json) )->with( messages: Image::fromFile(__DIR__ . '/receipt.png')->toMessage(), responseModel: Receipt::class, prompt: 'Extract structured data from the receipt. Return result as JSON following this schema: <|json_schema|>', options: ['max_tokens' => 4096] )->get(); dump($receipt); assert(is_numeric($receipt->total)); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/json_schema.md ================================================================================ ## Overview Instructor has a built-in support for dynamically constructing JSON Schema using `JsonSchema` class. It is useful when you want to shape the structures during runtime. ## Example ```php withDefaultToStdClass() ) ->withMessages("Jason is 25 years old and works as an engineer") ->withResponseJsonSchema($schema) ->get(); dump($user); assert(gettype($user) === 'object'); assert(get_class($user) === 'stdClass'); assert(isset($user->name)); assert(isset($user->age)); assert($user->name === 'Jason'); assert($user->age === 25); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/streaming_structured_openai_responses.md ================================================================================ ## Overview A minimal structured-output streaming example using the `openai-responses` connection config. The example verifies that streaming yields partial updates and that we receive the expected final fields. ## Example ```php withOutputMode(OutputMode::JsonSchema); $stream = (new StructuredOutput($runtime)) // ->withHttpClient(...) // pass a debug-enabled HTTP client when needed ->withResponseClass(PersonProfile::class) ->withMessages($text) ->withOptions(['max_output_tokens' => 384]) ->withStreaming() ->stream(); foreach ($stream->partials() as $partial) { $onPartialUpdate($partial); } $profile = $stream->finalValue(); exitPartialScreen(); echo "All tokens received. Final structured profile:\n"; dump($profile); assert($partialsCount > 0, 'Expected at least one partial update'); assert(Str::contains($profile->name, 'Jason'), 'Expected name Jason'); assert($profile->age === 25, 'Expected age 25'); $hobbiesLower = array_map(static fn(string $hobby): string => strtolower($hobby), $profile->hobbies); assert(in_array('soccer', $hobbiesLower, true), 'Expected hobby soccer'); assert(in_array('climbing', $hobbiesLower, true), 'Expected hobby climbing'); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/output_format_array.md ================================================================================ ## Overview By default, Instructor deserializes extracted data into PHP objects. Sometimes you may want to work with raw associative arrays instead - for example, when storing data in a database, passing to a JSON API, or when you don't need the overhead of object instantiation. The `intoArray()` method allows you to use a PHP class to define the schema (structure and validation sent to the LLM) while receiving the result as a plain associative array. ## Example ```php withResponseClass(Person::class) // Schema definition ->intoArray() // Return as array ->withMessages("Jason is 25 years old and works as a software engineer.") ->get(); dump($personArray); // Result is a plain associative array assert(is_array($personArray)); assert($personArray['name'] === 'Jason'); assert($personArray['age'] === 25); assert($personArray['occupation'] === 'software engineer'); // No object instantiation occurred assert(!is_object($personArray)); echo "\nExtracted data as array:\n"; echo "Name: {$personArray['name']}\n"; echo "Age: {$personArray['age']}\n"; echo "Occupation: {$personArray['occupation']}\n"; ?> ``` ## Expected Output ``` array(3) { 'name' => string(5) "Jason" 'age' => int(25) 'occupation' => string(18) "software engineer" } Extracted data as array: Name: Jason Age: 25 Occupation: software engineer ``` ================================================================================ FILE: cookbook/examples/A05_Extras/output_format_instance_of.md ================================================================================ ## Overview Sometimes you want to define the extraction schema using one class but receive the result as a different class. This is useful when: - You have a rich domain model for the LLM schema but want a simpler DTO for output - You want to separate API contracts from internal representations - You need different validation rules for input vs output The `intoInstanceOf()` method allows you to specify a different target class for deserialization while keeping the original class for schema generation. ## Example ```php withResponseClass(UserProfile::class) // Schema sent to LLM ->intoInstanceOf(UserDTO::class) // Output class ->with( messages: "Extract: John Smith, 30 years old, john@example.com, phone: 555-1234, lives at 123 Main St", ) ->get(); dump($user); // Result is UserDTO instance (not UserProfile) assert($user instanceof UserDTO); assert(!($user instanceof UserProfile)); // UserDTO has only the fields it needs assert($user->fullName === 'John Smith'); assert($user->email === 'john@example.com'); // UserDTO doesn't have the extra fields from UserProfile assert(!property_exists($user, 'age')); assert(!property_exists($user, 'phoneNumber')); assert(!property_exists($user, 'address')); echo "\nExtracted as UserDTO:\n"; echo "Name: {$user->fullName}\n"; echo "Email: {$user->email}\n"; ?> ``` ## Expected Output ``` object(UserDTO)#123 (2) { ["fullName"]=> string(10) "John Smith" ["email"]=> string(17) "john@example.com" } Extracted as UserDTO: Name: John Smith Email: john@example.com ``` ## Note The LLM receives the UserProfile schema (with 5 fields: name, age, email, phone, address), but the result is deserialized into UserDTO (with only 2 fields: name, email). Extra fields that don't exist in UserDTO are ignored during deserialization. ================================================================================ FILE: cookbook/examples/A05_Extras/output_format_streaming.md ================================================================================ ## Overview When streaming responses, you often want real-time updates as objects (for validation and deduplication), but the final result as an array (for database storage or API responses). The `intoArray()` method works seamlessly with streaming - partial updates are objects during streaming, but the final value is returned as an array. ## Example ```php withResponseClass(Article::class) ->intoArray() ->withMessages("Extract: 'Introduction to PHP 8.4' by Jane Doe, 1500 words, tags: php, tutorial, programming") ->stream(); // During streaming, responses are StructuredOutputResponse snapshots foreach ($stream->responses() as $response) { dump($response); } // Final result is an array (not object) $finalArticle = $stream->finalValue(); dump($finalArticle); assert(is_array($finalArticle)); assert(is_string($finalArticle['title']) && $finalArticle['title'] !== ''); assert(is_string($finalArticle['author']) && $finalArticle['author'] !== ''); assert(is_int($finalArticle['wordCount']) && $finalArticle['wordCount'] > 0); assert(is_array($finalArticle['tags'])); assert(in_array('php', $finalArticle['tags'])); echo "\nFinal result (array):\n"; echo "Title: {$finalArticle['title']}\n"; echo "Author: {$finalArticle['author']}\n"; echo "Words: {$finalArticle['wordCount']}\n"; echo "Tags: " . implode(', ', $finalArticle['tags']) . "\n"; ?> ``` ## Expected Output ``` Streaming article extraction... Update #1: Article Update #2: Article Update #3: Article array(4) { ["title"]=> string(26) "Introduction to PHP 8.4" ["author"]=> string(8) "Jane Doe" ["wordCount"]=> int(1500) ["tags"]=> array(3) { [0]=> string(3) "php" [1]=> string(8) "tutorial" [2]=> string(11) "programming" } } Final result (array): Title: Introduction to PHP 8.4 Author: Jane Doe Words: 1500 Tags: php, tutorial, programming ``` ## How It Works 1. **During streaming**: Partial updates are deserialized as objects for real-time validation and deduplication 2. **After streaming completes**: The final result is re-extracted and returned as an array (respecting `intoArray()`) 3. **Best of both worlds**: Object validation during streaming, array convenience for the final result ================================================================================ FILE: cookbook/examples/A05_Extras/pure_array_processing.md ================================================================================ ## Overview This example demonstrates extraction using ONLY arrays - no PHP classes, no serialization, no deserialization. Just JSON Schema definition and array output. This is useful when: - Working with dynamic schemas defined at runtime - Avoiding class creation overhead - Integrating with systems that expect plain arrays - Building schema-driven extraction pipelines ## Example ```php with( messages: "Extract: 'Introduction to PHP 8.4' by Jane Doe, 1500 words, tags: php, tutorial, programming", responseModel: $articleSchema, // <-- JsonSchema object, no class! ) ->intoArray() // Output as pure array ->get(); echo "=== RESULT (PURE ARRAY) ===\n"; dump($article); // Verify it's a pure array - no objects involved assert(is_array($article), 'Result must be array'); assert(!is_object($article), 'Result must NOT be object'); assert(is_string($article['title']), 'Title must be string'); assert(is_string($article['author']), 'Author must be string'); assert(is_int($article['wordCount']), 'WordCount must be int'); assert(is_array($article['tags']), 'Tags must be array'); // All array values are primitives echo "\nField types:\n"; foreach ($article as $key => $value) { $type = gettype($value); echo " $key: $type\n"; assert( in_array($type, ['string', 'integer', 'array', 'double', 'boolean']), "All values must be primitives, got $type for $key" ); } echo "\n✓ Pure array processing verified - no classes, no serialization!\n"; ?> ``` ## Expected Output ``` === PURE ARRAY PROCESSING (NO CLASSES) === === RESULT (PURE ARRAY) === array:4 [ "title" => "Introduction to PHP 8.4" "author" => "Jane Doe" "wordCount" => 1500 "tags" => array:3 [ 0 => "php" 1 => "tutorial" 2 => "programming" ] ] Field types: title: string author: string wordCount: integer tags: array ✓ Pure array processing verified - no classes, no serialization! ``` ## How It Works 1. **Schema definition**: `JsonSchema` fluent API defines the expected structure 2. **No PHP classes**: No `Article` class, no `@var` annotations, no type hints 3. **intoArray()**: Forces output to be plain PHP array 4. **Result**: Pure associative array with primitive values ## JsonSchema API ```php // Basic types JsonSchema::string('name', 'description') JsonSchema::integer('age', 'description') JsonSchema::number('price', 'description') JsonSchema::boolean('active', 'description') // Arrays JsonSchema::array('tags', JsonSchema::string(), 'description') // Objects (nested) JsonSchema::object('address', [ JsonSchema::string('street'), JsonSchema::string('city'), ], requiredProperties: ['street', 'city']) // Enums JsonSchema::enum('status', ['pending', 'active', 'closed']) ``` ## Use Cases - **Dynamic schemas**: Define extraction shapes at runtime - **API-driven extraction**: Schema comes from external API/database - **No-class pipelines**: Avoid PHP class overhead entirely - **Array-first architectures**: When your system expects arrays throughout ================================================================================ FILE: cookbook/examples/A05_Extras/schema.md ================================================================================ ## Overview Instructor has a built-in support for generating JSON Schema from the classes or objects. This is useful as it helps you avoid writing the JSON Schema manually, which can be error-prone and time-consuming. ## Example ```php schema(City::class); $city = StructuredOutput::using('openai')->with( messages: "Provide details about Paris, the capital city of France.", responseModel: $schema, )->get(); dump($city); assert(gettype($city) === 'object'); assert(get_class($city) === 'City'); assert(str_contains($city->name, 'Paris')); assert(is_int($city->population)); assert(is_int($city->founded)); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/schema_dynamic.md ================================================================================ ## Overview Instructor can generate JSON Schema from runtime schemas. Use `SchemaBuilder` to build the schema, then wrap it in `Structure`. ## Example ```php string('name', 'City name') ->int('population', 'City population') ->int('founded', 'Founding year') ->schema(); $city = Structure::fromSchema($citySchema); $runtime = StructuredOutputRuntime::fromProvider( LLMProvider::using('openai'), )->withOutputMode(OutputMode::JsonSchema); $data = (new StructuredOutput($runtime)) ->intoArray() ->withMessages([['role' => 'user', 'content' => 'What is capital of France? \ Respond with JSON data.']]) ->withResponseJsonSchema($city->toJsonSchema()) ->withOptions(['max_tokens' => 64]) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT:\n"; dump($data); assert(is_array($data), 'Response should be an array'); assert(isset($data['name']), 'Response should have "name" field'); assert(strpos($data['name'], 'Paris') !== false, 'City name should be Paris'); assert(isset($data['population']), 'Response should have "population" field'); assert(isset($data['founded']), 'Response should have "founded" field'); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/transcription_to_tasks.md ================================================================================ ## Overview This example demonstrates how you can create task assignments based on a transcription of meeting recording. ## Example ```php withOutputMode(OutputMode::Json) ) ->with( messages: $text, responseModel: Tasks::class, //model: 'gpt-4o-mini', ) ->get(); // Step 4: Now you can use the extracted data in your application print("Extracted data:\n"); dump($tasks); assert($tasks->meetingDate->format('Y-m-d') === '2024-01-15'); assert(count($tasks->tasks) === 3); // Index tasks by due date — LLM ordering is non-deterministic $byDate = []; foreach ($tasks->tasks as $task) { $byDate[$task->dueDate->format('Y-m-d')] = $task; } assert(isset($byDate['2024-01-20'])); assert($byDate['2024-01-20']->status === TaskStatus::Pending); assert(isset($byDate['2024-01-18'])); assert($byDate['2024-01-18']->status === TaskStatus::Pending); assert($byDate['2024-01-18']->owner === Role::PM); assert(isset($byDate['2024-01-16'])); assert($byDate['2024-01-16']->status === TaskStatus::Pending); ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/translate_ui_fields.md ================================================================================ ## Overview You can use Instructor to translate text fields in your UI. We can instruct the model to translate only the text fields from one language to another, but leave the other fields, like emails or URLs, unchanged. This example demonstrates how to translate text fields from English to German using structure-to-structure processing with LLM. ## Example ```php This is some WYSIWYG HTML content.

' ); $transformedModel = new StructuredOutput( StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withMaxRetries(2) ->withValidator(new \Cognesy\Instructor\Validation\Validators\SymfonyValidator()) ) ->withInput($sourceModel) ->withResponseClass(get_class($sourceModel)) ->withPrompt('Translate the headline and text fields to German. Keep HTML tags unchanged. Keep the url field unchanged.') ->withModel('gpt-4o-mini') ->withOptions(['temperature' => 0]) ->get(); print_r($transformedModel); $hasGermanHeadline = str_contains($transformedModel->headline, 'Überschrift') || str_contains($transformedModel->headline, 'Schlagzeile'); if (!$hasGermanHeadline) { echo "ERROR: Headline not translated to German\n"; exit(1); } if (!str_contains($transformedModel->text, '

')) { echo "ERROR: HTML tags not preserved in text\n"; exit(1); } $url = str_replace('\/', '/', $transformedModel->url); if (!str_contains($url, 'https://translation.com/')) { echo "ERROR: URL was modified during translation\n"; exit(1); } ?> ``` ================================================================================ FILE: cookbook/examples/A05_Extras/web_to_objects.md ================================================================================ ## Overview This example demonstrates how to extract structured data from a web page and get it as PHP object. ## Example In this example we will be extracting list of Laravel companies from The Manifest website. The result will be a list of `Company` objects. We use Webpage extractor to get the content of the page and specify 'none' scraper, which means that we will be using built-in `file_get_contents` function to get the content of the page. In production environment you might want to use one of the supported scrapers: - `browsershot` - `scrapingbee` - `scrapfly` - `jinareader` Commercial scrapers require API key, which can be set in the auxiliary web configuration files (`/packages/auxiliary/config/web/default.yaml` and `/packages/auxiliary/config/web/scrapers/*.yaml`). ```php get(BasePath::get('examples/A05_Extras/WebToObjects/companies.html')) ->select('.directory-providers__list') ->selectMany( selector: '.provider-card', callback: fn($item) => $item->asMarkdown(), limit: 3 ); $companies = []; echo "Extracting company data from:\n\n"; foreach($companyGen as $companyDiv) { /** @var string $companyDiv */ echo " > " . substr($companyDiv, 0, 32) . "...\n\n"; $company = new StructuredOutput( StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withOutputMode(OutputMode::Json) ) ->with( messages: $companyDiv, responseModel: Company::class, )->get(); $companies[] = $company; dump($company); } assert(count($companies) === 3); ?> ``` ================================================================================ FILE: cookbook/examples/B01_LLM/inference.md ================================================================================ ## Overview `Inference` class offers access to LLM APIs and convenient methods to execute model inference, incl. chat completions, tool calling or JSON output generation. LLM providers access details can be found and modified via `LLMConfig` objects. ## Example ```php with(messages: Messages::fromString('What is capital of Germany')) ->get(); echo "USER: What is capital of Germany\n"; echo "ASSISTANT: $answer\n\n"; assert(Str::contains($answer, 'Berlin')); // EXAMPLE 2: customize inference options using fluent API $response = Inference::using('openai') ->withMessages(Messages::fromString('What is capital of France')) ->withOptions(['max_tokens' => 64]) ->create(); $answer = $response->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n\n"; assert(Str::contains($answer, 'Paris')); // EXAMPLE 3: streaming response $stream = Inference::using('openai') ->withMessages(Messages::fromString('Describe capital of Brasil')) ->withOptions(['max_tokens' => 128]) ->withStreaming() ->stream() ->deltas(); echo "USER: Describe capital of Brasil\n"; echo 'ASSISTANT: '; foreach ($stream as $delta) { echo $delta->contentDelta; } echo "\n"; ?> ``` ================================================================================ FILE: cookbook/examples/B01_LLM/llm_json.md ================================================================================ ## Overview While working with `Inference` class, you can also generate JSON output from the model inference. This is useful for example when you need to process the response in a structured way or when you want to store the elements of the response in a database. `Inference` supports explicit response shaping through `responseFormat`, plus `tools` and `toolChoice` for tool calling. ## Example In this example we will use OpenAI JSON mode, which guarantees that the response will be in a JSON format. It does not guarantee compliance with a specific schema (for some providers including OpenAI). We can try to work around it by providing an example of the expected JSON output in the prompt. > NOTE: Some model providers allow to specify a JSON schema for model to follow via `schema` parameter of `response_format`. OpenAI does not support this feature in JSON mode (only in JSON Schema mode). ```php with( messages: Messages::fromString('What is capital of France? \ Respond with JSON data containing name", population and year of founding. \ Example: {"name": "Berlin", "population": 3700000, "founded": 1237}'), responseFormat: ResponseFormat::jsonObject(), options: ['max_tokens' => 64], ) ->asJsonData(); echo "USER: What is capital of France\n"; echo "ASSISTANT:\n"; dump($data); assert(is_array($data), 'Response should be an array'); assert(isset($data['name']), 'Response should have "name" field'); assert(strpos($data['name'], 'Paris') !== false, 'City name should be Paris'); assert(isset($data['population']), 'Response should have "population" field'); ?> ``` ================================================================================ FILE: cookbook/examples/B01_LLM/llm_json_schema.md ================================================================================ ## Overview While working with `Inference` class, you can also generate JSON output from the model inference. This is useful for example when you need to process the response in a structured way or when you want to store the elements of the response in a database. ## Example In this example we will use OpenAI JSON Schema mode, which guarantees that the response will be in a JSON format that matches the provided schema. > NOTE: Json Schema mode with guaranteed structured outputs is not supported by all language model providers. ```php with( messages: Messages::fromString('What is capital of France? Respond with JSON data.'), responseFormat: ResponseFormat::jsonSchema( schema: [ 'type' => 'object', 'description' => 'City information', 'properties' => [ 'name' => [ 'type' => 'string', 'description' => 'City name', ], 'founded' => [ 'type' => 'integer', 'description' => 'Founding year', ], 'population' => [ 'type' => 'integer', 'description' => 'Current population', ], ], 'additionalProperties' => false, 'required' => ['name', 'founded', 'population'], ], name: 'city_data', strict: true, ), options: ['max_tokens' => 64], ) ->asJsonData(); echo "USER: What is capital of France\n"; echo "ASSISTANT:\n"; dump($data); assert(is_array($data), 'Response should be an array'); assert(isset($data['name']), 'Response should have "name" field'); assert(strpos($data['name'], 'Paris') !== false, 'City name should be Paris'); assert(isset($data['population']), 'Response should have "population" field'); assert(isset($data['founded']), 'Response should have "founded" field'); ?> ``` ================================================================================ FILE: cookbook/examples/B01_LLM/llm_md_json.md ================================================================================ ## Overview While working with `Inference` class, you can also generate JSON output from the model inference. This is useful for example when you need to process the response in a structured way or when you want to store the elements of the response in a database. ## Example In this example we explicitly ask the model to generate a JSON output by responding with a JSON object within a Markdown code block. This is useful for the models which do not support JSON output directly. We will also provide an example of the expected JSON output in the prompt to guide the model in generating the correct response. ```php with( messages: Messages::fromString('What is capital of France? \ Respond with a JSON object in a ```json``` code block containing "name", "population", and "founded". \ Use integer values for population and founded year (negative for BC). Do not include extra text. \ Example: {"name":"Paris","population":2139000,"founded":-250}'), options: ['max_tokens' => 64, 'temperature' => 0], ) ->asJsonData(); echo "USER: What is capital of France\n"; echo "ASSISTANT:\n"; dump($data); assert(is_array($data), 'Response should be an array'); assert(isset($data['name']), 'Response should have "name" field'); assert(strpos($data['name'], 'Paris') !== false, 'City name should be Paris'); assert(isset($data['population']), 'Response should have "population" field'); assert(isset($data['founded']), 'Response should have "founded" field'); ?> ``` ================================================================================ FILE: cookbook/examples/B01_LLM/llm_tools.md ================================================================================ ## Overview While working with `Inference` class, you can also generate JSON output from the model inference. This is useful for example when you need to process the response in a structured way or when you want to store the elements of the response in a database. ## Example In this example we will use OpenAI tool calling, in which model will generate a JSON containing arguments for a function call. This way we can make the model generate a JSON object with specific structure of parameters. ```php with( messages: Messages::fromString('What is capital of France? Respond with function call.'), tools: ToolDefinitions::fromArray([[ 'type' => 'function', 'function' => [ 'name' => 'extract_data', 'description' => 'Extract city data', 'parameters' => [ 'type' => 'object', 'description' => 'City information', 'properties' => [ 'name' => [ 'type' => 'string', 'description' => 'City name', ], 'founded' => [ 'type' => 'integer', 'description' => 'Founding year', ], 'population' => [ 'type' => 'integer', 'description' => 'Current population', ], ], 'required' => ['name', 'founded', 'population'], 'additionalProperties' => false, ], ], ]]), toolChoice: ToolChoice::specific('extract_data'), options: ['max_tokens' => 64], ) ->response(); $data = $response->toolCalls()->first()?->args() ?? []; echo "USER: What is capital of France\n"; echo "ASSISTANT:\n"; dump($data); assert(is_array($data), 'Response should be an array'); assert(isset($data['name']), 'Response should have "name" field'); assert(is_string($data['name']) && $data['name'] !== '', 'City name should be a non-empty string'); assert(array_key_exists('population', $data), 'Response should have "population" field'); assert(array_key_exists('founded', $data), 'Response should have "founded" field'); ?> ``` ================================================================================ FILE: cookbook/examples/B01_LLM/llm_with_schema_helper.md ================================================================================ ## Overview Polyglot has a built-in support for dynamically constructing JSON Schema using `JsonSchema` class. It is useful when you want to shape the structures during runtime. ## Example ```php with( messages: Messages::fromString('What is capital of France? Respond with JSON data.'), responseFormat: ResponseFormat::fromArray($schema->toResponseFormat( schemaName: 'city_data', schemaDescription: 'City data', strict: true, )), options: ['max_tokens' => 64], ) ->asJsonData(); echo "USER: What is capital of France\n"; echo "ASSISTANT:\n"; dump($data); assert(is_array($data)); assert(is_string($data['name'])); assert(is_int($data['population'])); assert(is_int($data['founded'])); ?> ``` ================================================================================ FILE: cookbook/examples/B01_LLM/llm_with_tools_helper.md ================================================================================ ## Overview Polyglot has a built-in support for dynamically constructing tool calling schema using `JsonSchema` class. ## Example ```php with( messages: Messages::fromString('What is capital of France? Respond with function call.'), tools: ToolDefinitions::fromArray([ $schema->toFunctionCall( functionName: 'provide_data', functionDescription: 'Provide city data' ), ]), toolChoice: ToolChoice::specific('provide_data'), options: ['max_tokens' => 64], ) ->asToolCallJsonData(); echo "USER: What is capital of France\n"; echo "ASSISTANT:\n"; dump($data); assert(is_array($data)); assert(is_string($data['name'])); assert(is_int($data['population'])); assert(is_int($data['founded'])); ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/llm_config_providers.md ================================================================================ ## Overview This example demonstrates an edge adapter that reads raw config arrays from a custom source and maps them into typed config objects used by `InferenceRuntime`. ## Example ```php dot = new Dot($data); } public function llmConnection(string $name): array { $value = $this->dot->get("llm.connections.$name"); if (! is_array($value)) { throw new RuntimeException("Unknown LLM connection: $name"); } return $value; } } $configSource = new CustomConfigSource([ 'llm' => [ 'connections' => [ 'deepseek' => [ 'driver' => 'deepseek', 'apiUrl' => 'https://api.deepseek.com', 'apiKey' => (string) Env::get('DEEPSEEK_API_KEY', ''), 'endpoint' => '/chat/completions', 'model' => 'deepseek-chat', 'maxTokens' => 128, ], 'openai' => [ 'driver' => 'openai', 'apiUrl' => 'https://api.openai.com/v1', 'apiKey' => (string) Env::get('OPENAI_API_KEY', ''), 'endpoint' => '/chat/completions', 'model' => 'gpt-4.1-nano', 'maxTokens' => 256, ], ], ], ]); $connection = match (true) { (string) Env::get('DEEPSEEK_API_KEY', '') !== '' => 'deepseek', (string) Env::get('OPENAI_API_KEY', '') !== '' => 'openai', default => throw new RuntimeException('Set DEEPSEEK_API_KEY or OPENAI_API_KEY in your environment to run this example.'), }; $events = new EventDispatcher; $httpClient = (new HttpClientBuilder(events: $events)) ->withConfig(new HttpClientConfig(driver: 'symfony')) ->withClientInstance( driverName: 'symfony', clientInstance: SymfonyHttpClient::create(['http_version' => '2.0']), ) ->create(); $llmConfig = LLMConfig::fromArray($configSource->llmConnection($connection)); $provider = LLMProvider::fromLLMConfig($llmConfig); $runtime = InferenceRuntime::fromProvider( provider: $provider, events: $events, httpClient: $httpClient, ); $events->addListener(Event::class, fn (Event $e) => $e->print()); $answer = Inference::fromRuntime($runtime) ->withMessages(Messages::fromString('What is the capital of France')) ->withMaxTokens(256) ->withStreaming() ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/context_cache_llm.md ================================================================================ ## Overview Instructor offers a simplified way to work with LLM providers' APIs supporting caching (currently only Anthropic API), so you can focus on your business logic while still being able to take advantage of lower latency and costs. > **Note 1:** Instructor supports context caching for Anthropic API and OpenAI API. > **Note 2:** Context caching is automatic for all OpenAI API calls. Read more > in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). ## Example When you need to process multiple requests with the same context, you can use context caching to improve performance and reduce costs. In our example we will be analyzing the README.md file of this Github project and generating its summary for 2 target audiences. ```php withCachedContext( messages: Messages::fromArray([ ['role' => 'user', 'content' => 'Here is content of README.md file'], ['role' => 'user', 'content' => $data], ['role' => 'user', 'content' => 'Generate a short, very domain specific pitch of the project described in README.md. List relevant, domain specific problems that this project could solve. Use domain specific concepts and terminology to make the description resonate with the target audience.'], ['role' => 'assistant', 'content' => "For whom do you want to generate the pitch?\nCache nonce: {$cacheNonce}"], ]), ); $response = $inference ->with( messages: Messages::fromString('founder of lead gen SaaS startup'), model: $model, options: ['max_tokens' => 512], ) ->response(); echo "----------------------------------------\n"; echo "\n# Summary for CTO of lead gen vendor\n"; echo " ({$response->usage()->cacheReadTokens} tokens read from cache)\n\n"; echo "----------------------------------------\n"; echo $response->content()."\n"; assert(! empty($response->content())); assert(Str::contains($response->content(), 'lead', false)); if ($response->usage()->cacheWriteTokens === 0) { echo "Note: cacheWriteTokens is 0. Prompt caching depends on provider/model token thresholds.\n"; } $response2 = $inference ->with( messages: Messages::fromString('CIO of insurance company'), model: $model, options: ['max_tokens' => 512], ) ->response(); echo "----------------------------------------\n"; echo "\n# Summary for CIO of insurance company\n"; echo " ({$response2->usage()->cacheReadTokens} tokens read from cache)\n\n"; echo "----------------------------------------\n"; echo $response2->content()."\n"; assert(! empty($response2->content())); assert(Str::contains($response2->content(), 'insurance', false)); if ($response2->usage()->cacheReadTokens === 0) { echo "Note: cacheReadTokens is 0. Prompt caching depends on provider/model token thresholds.\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/context_cache_llm_oai.md ================================================================================ ## Overview Instructor offers a simplified way to work with LLM providers' APIs supporting caching, so you can focus on your business logic while still being able to take advantage of lower latency and costs. > **Note:** Context caching is automatic for all OpenAI API calls. Read more > in the [OpenAI API documentation](https://platform.openai.com/docs/guides/prompt-caching). ## Example When you need to process multiple requests with the same context, you can use context caching to improve performance and reduce costs. In our example we will be analyzing the README.md file of this Github project and generating its summary for 2 target audiences. ```php withCachedContext( messages: Messages::fromArray([ ['role' => 'user', 'content' => 'Here is content of README.md file'], ['role' => 'user', 'content' => $data], ['role' => 'user', 'content' => 'Generate a short, very domain specific pitch of the project described in README.md. List relevant, domain specific problems that this project could solve. Use domain specific concepts and terminology to make the description resonate with the target audience.'], ['role' => 'assistant', 'content' => 'For whom do you want to generate the pitch?'], ]), ); $response = $inference ->with( messages: Messages::fromString('founder of lead gen SaaS startup'), options: ['max_tokens' => 512], ) ->response(); echo "----------------------------------------\n"; echo "\n# Summary for CTO of lead gen vendor\n"; echo " ({$response->usage()->cacheReadTokens} tokens read from cache)\n\n"; echo "----------------------------------------\n"; echo $response->content()."\n"; assert(! empty($response->content())); assert(Str::contains($response->content(), 'lead', false)); if ($response->usage()->cacheReadTokens === 0 && $response->usage()->cacheWriteTokens === 0) { echo "Note: cacheReadTokens/cacheWriteTokens are 0. Prompt caching applies only to eligible models and prompt sizes.\n"; } $response2 = $inference ->with( messages: Messages::fromString('CIO of insurance company'), options: ['max_tokens' => 512], ) ->response(); echo "----------------------------------------\n"; echo "\n# Summary for CIO of insurance company\n"; echo " ({$response2->usage()->cacheReadTokens} tokens read from cache)\n\n"; echo "----------------------------------------\n"; echo $response2->content()."\n"; assert(! empty($response2->content())); assert(Str::contains($response2->content(), 'insurance', false)); if ($response2->usage()->cacheReadTokens === 0) { echo "Note: cacheReadTokens is 0. Prompt caching applies only to eligible models and prompt sizes.\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/llm_custom_config.md ================================================================================ ## Overview You can provide your own LLM configuration instance to `Inference` object. This is useful when you want to initialize LLM client with custom values. ## Example ```php '2.0']); $customClient = (new HttpClientBuilder) ->withEventBus($events) ->withDriver(new SymfonyDriver( config: $httpConfig, clientInstance: $yourClientInstance, events: $events, )) ->create(); // Create instance of LLM client initialized with custom parameters $config = new LLMConfig( apiUrl : 'https://api.deepseek.com', apiKey : (string) Env::get('DEEPSEEK_API_KEY', ''), endpoint: '/chat/completions', model: 'deepseek-chat', maxTokens: 128, driver: 'deepseek', ); // Call inference API with custom client and configuration $events->addListener(Event::class, fn (Event $e) => $e->print()); $runtime = InferenceRuntime::fromConfig( config: $config, events: $events, httpClient: $customClient, ); $answer = Inference::fromRuntime($runtime) ->with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->withStreaming() ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/custom_embeddings_config.md ================================================================================ ## Overview ## Example ```php withConfig($config); $bestMatches = EmbedUtils::findSimilar( embeddings: EmbeddingsRuntime::fromProvider($provider), query: $query, documents: $documents, topK: 3 ); dump($bestMatches); assert(!empty($bestMatches)); ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/custom_llm_driver.md ================================================================================ ## Overview You can register and use your own LLM driver, either using a new driver name or overriding an existing driver bundled with Polyglot. ## Example ```php withDriver( name: 'custom-driver', driver: fn ($config, $httpClient, $events) => new class($config, $httpClient, $events) extends OpenAIDriver { #[\Override] protected function makeHttpResponse(HttpRequest $request): HttpResponse { // some extra functionality to demonstrate our driver is being used echo ">>> Handling request...\n"; return parent::makeHttpResponse($request); } }, ); // Create instance of LLM client initialized with custom parameters $config = new LLMConfig( apiUrl : 'https://api.openai.com/v1', apiKey : (string) Env::get('OPENAI_API_KEY', ''), endpoint : '/chat/completions', model: 'gpt-4o-mini', maxTokens: 128, driver : 'custom-driver', ); $answer = Inference::fromRuntime(InferenceRuntime::fromConfig($config, drivers: $drivers)) ->withMessages(Messages::fromString('What is the capital of France')) ->withOptions(['max_tokens' => 64]) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/custom_llm_via_dsn.md ================================================================================ ## Overview You can provide your own LLM configuration data to `Inference` object with DSN string. This is useful for inline configuration or for building configuration from admin UI, CLI arguments or environment variables. ## Example ```php toArray())) ->with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/embed_utils.md ================================================================================ ## Overview `EmbedUtils` class offers convenient methods to find top K vectors or documents most similar to provided query. Check out the `EmbedUtils` class for more details. - `EmbedUtils::findTopK()` - `EmbedUtils::findSimilar()` Embeddings provider access details are supplied via `EmbeddingsConfig` objects. ## Example ```php ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/embeddings.md ================================================================================ ## Overview `Embeddings` class offers access to embeddings APIs which allows to generate vector representations of inputs. These embeddings can be used to compare semantic similarity between inputs, e.g. to find relevant documents based on a query. `Embeddings` class supports following embeddings providers: - Azure - Cohere - Gemini - Jina - Mistral - OpenAI Embeddings provider access details are supplied via `EmbeddingsConfig` objects. To store and search across large sets of vector embeddings you may want to use one of the popular vector databases: PGVector, Chroma, Pinecone, Weaviate, Milvus, etc. ## Example ```php withInputs($inputs) ->get(); // get query and doc vectors from the response [$queryVectors, $docVectors] = $response->split(1); $queryVector = $queryVectors[0] ?? throw new \InvalidArgumentException('Query vector not found'); // calculate cosine similarities $similarities = EmbedUtils::findTopK($queryVector, $docVectors, $topK); // print documents most similar to the query echo 'Query: '.$query.PHP_EOL; $count = 1; foreach ($similarities as $index => $similarity) { echo $count++; echo ': '.$documents[$index]; echo ' - cosine similarity to query = '.$similarities[$index]; echo PHP_EOL; } assert(! empty($similarities)); ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/http_client.md ================================================================================ ## Overview ## Example ```php toArray()), httpClient: $httpClient, )) ->withMessages(Messages::fromString('What is the capital of France')) ->withMaxTokens(64) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/parallel_calls.md ================================================================================ ## Overview Work in progress. ## Example ```php ``` ================================================================================ FILE: cookbook/examples/B02_LLMAdvanced/reasoning_content.md ================================================================================ ## Overview Deepseek API allows to access reasoning content, which is a detailed explanation of how the response was generated. This feature is useful for debugging and understanding the reasoning behind the response. ## Example ```php withMessages(Messages::fromString('What is the capital of France? Answer with just the city name, nothing else.')) ->withMaxTokens(1024) ->response(); echo "\nCASE #1: Sync response\n"; echo "USER: What is capital of France\n"; echo "ASSISTANT: {$response->content()}\n"; echo "REASONING: {$response->reasoningContent()}\n"; assert($response->content() !== ''); assert($response->reasoningContent() !== ''); // EXAMPLE 2: streaming response $stream = Inference::using('deepseek-r') ->with( messages: Messages::fromString('What is the capital of Brasil? Answer with just the city name, nothing else.'), options: ['max_tokens' => 1024] ) ->withStreaming() ->stream(); echo "\nCASE #2: Streamed response\n"; echo "USER: What is capital of Brasil\n"; echo 'ASSISTANT: '; foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; } echo "\n"; echo "REASONING: {$stream->final()->reasoningContent()}\n"; assert($stream->final()->reasoningContent() !== ''); assert($stream->final()->content() !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/inference_eventlog_readback.md ================================================================================ ## Overview This example enables the default `EventLog` JSONL sink for a plain inference request, then reads the resulting log file and prints the captured entries. Key concepts: - `EventLog::enable()`: programmatic opt-in for default runtime logging - `InferenceRuntime::fromProvider(...)`: uses the built-in runtime event wiring - JSONL readback: inspect emitted inference and HTTP lifecycle events after the call ## Example ```php with( messages: Messages::fromString('Answer in one sentence: what is the capital of France?'), options: ['max_tokens' => 48], ) ->get(); $entries = ExampleEventLog::read($logPath); } finally { EventLog::disable(); } echo "=== Inference Result ===\n"; echo "Response: {$response}\n"; echo "\n=== EventLog Entries ===\n"; echo "Log file: {$logPath}\n"; echo 'Entries captured: ' . count($entries) . "\n\n"; ExampleEventLog::print($entries, 8); assert($response !== ''); assert($entries !== []); ?> ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/http_debug.md ================================================================================ ## Overview Instructor PHP provides a way to debug HTTP calls made to LLM APIs by using an HTTP client configured with `withDebugConfig(DebugConfig::fromPreset(...))` and passing it into `InferenceRuntime`. When HTTP debug mode is enabled, the HTTP middleware stack prints request and response details (including streaming data) to the console and dispatches HTTP debug events. ## Example ```php withDebugConfig(DebugConfig::fromPreset('on'))->create(); $response = Inference::fromRuntime(InferenceRuntime::fromConfig( config: LLMConfig::fromPreset('openai'), httpClient: $http, )) ->with( messages: Messages::fromString('What is the capital of Brasil?'), options: ['max_tokens' => 128] ) ->get(); echo "USER: What is capital of Brasil\n"; echo "ASSISTANT: $response\n"; assert(!empty($response)); ?> ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/llm_logging_laravel_embeddings.md ================================================================================ ## Overview Simple Embeddings operation logging with Laravel-style context. ## Example ```php headers->set('X-Request-ID', 'req_'.uniqid()); // Create logger $logger = new Logger('embeddings'); $logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); // Create pipeline with Laravel context $pipeline = LoggingPipeline::create() ->filter(new LogLevelFilter('debug')) ->enrich(LazyEnricher::framework(fn () => [ 'request_id' => $request->headers->get('X-Request-ID'), ])) ->format(new MessageTemplateFormatter([ \Cognesy\Polyglot\Embeddings\Events\EmbeddingsRequested::class => '🔤 Embeddings requested: {provider}/{model} (Request: {framework.request_id})', \Cognesy\Polyglot\Embeddings\Events\EmbeddingsResponseReceived::class => '✅ Embeddings generated: {dimensions}D vectors', ], channel: 'embeddings')) ->write(new PsrLoggerWriter($logger)) ->build(); echo "📋 About to demonstrate Embeddings logging with Laravel context...\n\n"; // Attach wiretap listener to the runtime event bus $events = new EventDispatcher; $events->wiretap($pipeline); $embeddings = Embeddings::fromRuntime( EmbeddingsRuntime::fromConfig(config: EmbeddingsConfig::fromPreset('openai'), events: $events) ); echo "🚀 Starting Embeddings generation...\n"; $vectors = $embeddings ->withInputs([ 'The quick brown fox', 'Jumps over the lazy dog', ]) ->get(); echo "\n✅ Embeddings completed!\n"; echo '📊 Generated '.count($vectors->vectors())." embedding vectors\n"; echo '📊 Vector dimensions: '.count($vectors->first()?->values() ?? [])."\n"; assert(!empty($vectors->vectors())); assert(count($vectors->first()->values()) > 0); // TODO: Add "Sample Output" section showing actual log messages // Example format: // ### Sample Output // 📋 About to demonstrate Embeddings logging with Laravel... // 🚀 Starting Embeddings request... // [2025-12-07 01:18:13] embeddings.DEBUG: 🔄 [Laravel] Embeddings requested: openai/text-embedding-3-small // [2025-12-07 01:18:14] embeddings.DEBUG: ✅ [Laravel] Embeddings completed: openai/text-embedding-3-small // ✅ Embeddings completed! // 📊 Generated 2 embedding vectors // 📊 Vector dimensions: 1536 ?> ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/llm_logging_laravel_inference.md ================================================================================ ## Overview Simple Inference operation logging using Monolog. ## Example ```php pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); // Create logging pipeline $pipeline = LoggingPipeline::create() ->filter(new LogLevelFilter('debug')) ->format(new MessageTemplateFormatter([ \Cognesy\Polyglot\Inference\Events\InferenceRequested::class => '🤖 Inference requested: {provider}/{model}', \Cognesy\Polyglot\Inference\Events\InferenceResponseCreated::class => '✅ Inference completed: {provider}/{model}', ], channel: 'inference')) ->write(new MonologChannelWriter($logger)) ->build(); echo "📋 About to demonstrate Inference logging with Monolog...\n\n"; // Create inference with logging $events = new EventDispatcher; $events->wiretap($pipeline); $inference = Inference::fromRuntime(InferenceRuntime::fromConfig( config: LLMConfig::fromPreset('openai'), events: $events, )); echo "🚀 Starting Inference request...\n"; $response = $inference ->withMessages(Messages::fromString('What is the capital of France?')) ->withMaxTokens(50) ->get(); echo '📊 Response: '.($response ?: 'Empty response')."\n"; assert(!empty($response)); ?> ``` ``` // TODO: Add "Sample Output" section showing actual log messages // Example format: // ### Sample Output // 📋 About to demonstrate Inference logging with Monolog... // 🚀 Starting Inference request... // [2025-12-07T01:18:13.475202+00:00] inference.DEBUG: 🤖 Inference requested: openai/gpt-4o-mini // [2025-12-07T01:18:14.659417+00:00] inference.DEBUG: ✅ Inference completed: openai/gpt-4o-mini // ✅ Inference completed! // 📊 Response: The capital of France is Paris. ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/llm_logging_monolog.md ================================================================================ ## Overview Simple Inference operation logging using Monolog. ## Example ```php pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); // Create logging pipeline $pipeline = LoggingPipeline::create() ->filter(new LogLevelFilter('debug')) ->format(new MessageTemplateFormatter([ \Cognesy\Polyglot\Inference\Events\InferenceRequested::class => '🤖 Inference requested: {provider}/{model}', \Cognesy\Polyglot\Inference\Events\InferenceResponseCreated::class => '✅ Inference completed: {provider}/{model}', ], channel: 'inference')) ->write(new MonologChannelWriter($logger)) ->build(); echo "📋 About to demonstrate Inference logging with Monolog...\n\n"; // Create inference with logging $events = new EventDispatcher; $events->wiretap($pipeline); $inference = Inference::fromRuntime(InferenceRuntime::fromConfig( config: LLMConfig::fromPreset('openai'), events: $events, )); echo "🚀 Starting Inference request...\n"; $response = $inference ->withMessages(Messages::fromString('What is the capital of France?')) ->withMaxTokens(50) ->get(); echo '📊 Response: '.($response ?: 'Empty response')."\n"; assert(!empty($response)); ?> ``` ``` // TODO: Add "Sample Output" section showing actual log messages // Example format: // ### Sample Output // 📋 About to demonstrate Inference logging with Monolog... // 🚀 Starting Inference request... // [2025-12-07T01:18:13.475202+00:00] inference.DEBUG: 🤖 Inference requested: openai/gpt-4o-mini // [2025-12-07T01:18:14.659417+00:00] inference.DEBUG: ✅ Inference completed: openai/gpt-4o-mini // ✅ Inference completed! // 📊 Response: The capital of France is Paris. ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/llm_logging_symfony.md ================================================================================ ## Overview Inference operation logging with Symfony-style context. ## Example ```php attributes->set('_route', 'api.stream'); // Create logger $logger = new Logger('inference'); $logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG)); // Create pipeline with Symfony context $pipeline = LoggingPipeline::create() ->filter(new LogLevelFilter('debug')) ->enrich(LazyEnricher::framework(fn () => [ 'route' => $request->attributes->get('_route'), ])) ->format(new MessageTemplateFormatter([ \Cognesy\Polyglot\Inference\Events\InferenceRequested::class => '🤖 [SYMFONY] Inference requested: {provider}/{model} (Route: {framework.route})', \Cognesy\Polyglot\Inference\Events\InferenceResponseCreated::class => '✅ [SYMFONY] Inference completed: {provider}/{model}', ], channel: 'inference')) ->write(new PsrLoggerWriter($logger)) ->build(); echo "📋 About to demonstrate Inference logging with Symfony...\n\n"; // Create inference with logging $events = new EventDispatcher; $events->wiretap($pipeline); $inference = Inference::fromRuntime(InferenceRuntime::fromConfig( config: LLMConfig::fromPreset('openai'), events: $events, )); echo "🚀 Starting simple Inference to demonstrate logging...\n"; $response = $inference ->withMessages(Messages::fromString('What is the capital of France?')) ->withMaxTokens(50) ->get(); echo "\n✅ Inference completed!\n"; // Handle response properly - it might be a string or object if (is_string($response)) { echo '📊 Response: '.($response ?: 'Empty response')."\n"; } else { echo '📊 Response: '.($response->content ?? 'Response object has no content property')."\n"; } assert(!empty($response)); ?> ``` ``` // TODO: Add "Sample Output" section showing actual log messages // Example format: // ### Sample Output // 📋 About to demonstrate Inference logging with Symfony... // 🚀 Starting Inference request... // [2025-12-07 01:18:13] inference.DEBUG: 🔄 [Symfony] Inference requested: openai/gpt-4o-mini // [2025-12-07 01:18:14] inference.DEBUG: ✅ [Symfony] Inference completed: openai/gpt-4o-mini // ✅ Inference completed! // 📊 Response: The capital of France is Paris. ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/llm_telemetry_langfuse.md ================================================================================ ## Overview This example uses `InferenceRuntime` directly and sends the LLM and HTTP lifecycle to Langfuse. It is useful when you want visibility into the raw LLM call path without the additional StructuredOutput layer. Key concepts: - `InferenceRuntime`: direct Polyglot runtime for inference calls - `PolyglotTelemetryProjector`: maps inference lifecycle events - `HttpClientTelemetryProjector`: captures transport spans - `Telemetry::flush()`: pushes the final batch to Langfuse ## Example ```php attachTo($events); $runtime = InferenceRuntime::fromProvider( provider: LLMProvider::using('openai'), events: $events, ); $response = Inference::fromRuntime($runtime) ->with( messages: Messages::fromString('Summarize why observability matters for LLM applications in exactly 3 bullet points.'), options: ['max_tokens' => 180], ) ->response(); $hub->flush(); echo "Response:\n"; echo $response->content() . "\n\n"; if ($response->usage() !== null) { echo "Tokens: {$response->usage()->inputTokens} in / {$response->usage()->outputTokens} out\n"; } echo "Telemetry: flushed to Langfuse\n"; assert($response->content() !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/llm_telemetry_logfire.md ================================================================================ ## Overview This example uses `InferenceRuntime` directly and sends the LLM and HTTP lifecycle to Logfire. It is useful when you want visibility into the raw LLM call path without the additional StructuredOutput layer. Key concepts: - `InferenceRuntime`: direct Polyglot runtime for inference calls - `PolyglotTelemetryProjector`: maps inference lifecycle events - `HttpClientTelemetryProjector`: captures transport spans - `Telemetry::flush()`: pushes the final batch to Logfire ## Example ```php attachTo($events); $runtime = InferenceRuntime::fromProvider( provider: LLMProvider::using('openai'), events: $events, ); $response = Inference::fromRuntime($runtime) ->with( messages: Messages::fromString('Summarize why observability matters for LLM applications in exactly 3 bullet points.'), options: ['max_tokens' => 180], ) ->response(); $hub->flush(); echo "Response:\n"; echo $response->content() . "\n\n"; if ($response->usage() !== null) { echo "Tokens: {$response->usage()->inputTokens} in / {$response->usage()->outputTokens} out\n"; } echo "Telemetry: flushed to Logfire\n"; assert($response->content() !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/llm_telemetry_streaming_langfuse.md ================================================================================ ## Overview This example uses `InferenceRuntime` with **streaming** enabled and sends the full LLM and HTTP lifecycle — including the complete response body — to Langfuse. For streaming responses the HTTP span stays open while chunks arrive and closes only when the stream is exhausted (`HttpStreamCompleted`). By enabling `captureStreamingChunks: true` each SSE chunk is also recorded as a log event under the `http.client.request` span, which is useful for debugging but should be left off in production. Key concepts: - `withStreaming()`: requests a server-sent-events stream from the LLM provider - `HttpClientTelemetryProjector($hub, captureStreamingChunks: true)`: records each chunk and closes the HTTP span with the full body on stream completion - `HttpStreamCompleted`: new event fired when the stream generator is exhausted - `Telemetry::flush()`: must be called **after** the stream is consumed ## Example ```php attachTo($events); $runtime = InferenceRuntime::fromProvider( provider: LLMProvider::using('openai'), events: $events, ); $stream = Inference::fromRuntime($runtime) ->with( messages: Messages::fromString('Explain in 3 bullet points why distributed tracing matters for streaming AI responses.'), options: ['max_tokens' => 200], ) ->withStreaming() ->stream(); echo "Response (streaming):\n"; $fullContent = ''; foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; $fullContent .= $delta->contentDelta; } echo "\n\n"; // Flush AFTER the stream is fully consumed: HttpStreamCompleted fires when // the generator above is exhausted, carrying the full raw HTTP response body. $hub->flush(); echo "Telemetry: flushed to Langfuse\n"; assert($fullContent !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/B03_LLMTroubleshooting/llm_telemetry_streaming_logfire.md ================================================================================ ## Overview This example uses `InferenceRuntime` with **streaming** enabled and sends the full LLM and HTTP lifecycle — including the complete response body — to Logfire. For streaming responses the HTTP span stays open while chunks arrive and closes only when the stream is exhausted (`HttpStreamCompleted`). By enabling `captureStreamingChunks: true` each SSE chunk is also recorded as a log event under the `http.client.request` span, which is useful for debugging but should be left off in production. Key concepts: - `withStreaming()`: requests a server-sent-events stream from the LLM provider - `HttpClientTelemetryProjector($hub, captureStreamingChunks: true)`: records each chunk and closes the HTTP span with the full body on stream completion - `HttpStreamCompleted`: new event fired when the stream generator is exhausted - `Telemetry::flush()`: must be called **after** the stream is consumed ## Example ```php attachTo($events); $runtime = InferenceRuntime::fromProvider( provider: LLMProvider::using('openai'), events: $events, ); $stream = Inference::fromRuntime($runtime) ->with( messages: Messages::fromString('Explain in 3 bullet points why distributed tracing matters for streaming AI responses.'), options: ['max_tokens' => 200], ) ->withStreaming() ->stream(); echo "Response (streaming):\n"; $fullContent = ''; foreach ($stream->deltas() as $delta) { echo $delta->contentDelta; $fullContent .= $delta->contentDelta; } echo "\n\n"; // Flush AFTER the stream is fully consumed: HttpStreamCompleted fires when // the generator above is exhausted, carrying the full raw HTTP response body. $hub->flush(); echo "Telemetry: flushed to Logfire\n"; assert($fullContent !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_a21.md ================================================================================ ## Overview Support for A21 Jamba - MAMBA architecture models, very strong at handling long context. ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_anthropic.md ================================================================================ ## Overview Instructor supports Anthropic API - you can find the details on how to configure the client in the example below. Inference feature compatibility: - Instructor markdown-JSON fallback, native JSON object response_format - supported - tool calling - not supported yet ## Example ```php withHttpClientPreset('guzzle') // ->wiretap(fn($e) => $e->print()) ->with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 128] ) ->withStreaming() ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_azure_openai.md ================================================================================ ## Overview You can connect to Azure OpenAI instance using a dedicated client provided by Instructor. Please note it requires setting up your own model deployment using Azure OpenAI service console. ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_cerebras.md ================================================================================ ## Overview Support for Cerebras API which uses custom hardware for super fast inference. Cerebras provides Llama models. Inference feature compatibility: - tool calling (supported) - native JSON object response_format (supported) - native JSON schema response_format (supported) - Instructor markdown-JSON fallback (fallback) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_cohere.md ================================================================================ ## Overview Instructor supports Cohere API - you can find the details on how to configure the client in the example below. Inference feature compatibility: - Instructor markdown-JSON fallback - supported, recommended as a fallback from JSON mode - native JSON object response_format - supported, recommended - tool calling - partially supported, not recommended Reasons tool calling is not recommended: - Cohere does not support JSON Schema, which only allows to extract very simple, flat data schemas. - Performance of the currently available versions of Cohere models in tools mode for Instructor use case (data extraction) is extremely poor. ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_deepseek.md ================================================================================ ## Overview Support for DeepSeek API which provides strong models at affordable price. Inference feature compatibility: - tool calling (supported) - native JSON object response_format (supported) - native JSON schema response_format (supported) - Instructor markdown-JSON fallback (fallback) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_fireworks.md ================================================================================ ## Overview Please note that the larger Mistral models support native JSON object response_format, which is much more reliable than Instructor markdown-JSON fallback. Inference feature compatibility: - tool calling - selected models - native JSON object response_format - selected models - Instructor markdown-JSON fallback ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_google_gemini.md ================================================================================ ## Overview Google offers Gemini models which perform well in benchmarks. Supported modes: - Instructor markdown-JSON fallback - fallback mode - native JSON object response_format - recommended - tool calling - supported Here's how you can use Instructor with Gemini API. ```php withDebugConfig(DebugConfig::fromPreset('detailed'))->create(); $answer = Inference::fromRuntime(InferenceRuntime::fromConfig( config: LLMConfig::fromPreset('gemini'), httpClient: $http, )) ->with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_google_gemini_oai.md ================================================================================ ## Overview Google offers Gemini models which perform well in benchmarks. Supported modes: - Instructor markdown-JSON fallback - fallback mode - native JSON object response_format - recommended - tool calling - supported Here's how you can use Instructor with Gemini API in OpenAI-compatible mode. ```php withDebugConfig(DebugConfig::fromPreset('detailed'))->create(); $answer = Inference::fromRuntime(InferenceRuntime::fromConfig( config: LLMConfig::fromPreset('gemini-oai'), // use OpenAI-compatible Gemini config (v1beta/openai) httpClient: $http, )) ->with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_groq.md ================================================================================ ## Overview Groq is LLM providers offering a very fast inference thanks to their custom hardware. They provide a several models - Llama2, Mixtral and Gemma. Supported modes depend on the specific model, but generally include: - Instructor markdown-JSON fallback - fallback mode - native JSON object response_format - recommended - tool calling - supported Here's how you can use Instructor with Groq API. ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_inception.md ================================================================================ ## Overview Inception API provides OpenAI-compatible endpoints for chat completions. Inference feature compatibility: - tool calling (supported) - native JSON object response_format (supported) - native JSON schema response_format (supported) - Instructor markdown-JSON fallback (fallback) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_meta.md ================================================================================ ## Overview Instructor supports Meta LLM inference API. You can find the details on how to configure ## Example ```php withDebugConfig(DebugConfig::fromPreset('on'))->create(); $answer = Inference::fromRuntime(InferenceRuntime::fromConfig( config: LLMConfig::fromPreset('meta'), httpClient: $http, )) ->with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_minimaxi.md ================================================================================ ## Overview Support for Minimaxi's API. Inference feature compatibility: - Instructor markdown-JSON fallback (supported) - tool calling (not supported) - native JSON object response_format (not supported) - native JSON schema response_format (not supported) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 256] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_mistralai.md ================================================================================ ## Overview Mistral.ai is a company that builds OS language models, but also offers a platform hosting those models. You can use Instructor with Mistral API by configuring the client as demonstrated below. Please note that the larger Mistral models support native JSON object response_format, which is much more reliable than Instructor markdown-JSON fallback. Inference feature compatibility: - tool calling - supported (Mistral-Small / Mistral-Medium / Mistral-Large) - native JSON object response_format - recommended (Mistral-Small / Mistral-Medium / Mistral-Large) - Instructor markdown-JSON fallback - fallback mode (Mistral 7B / Mixtral 8x7B) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 256] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_moonshotai.md ================================================================================ ## Overview Support for MoonshotAI's API. Inference feature compatibility: - Instructor markdown-JSON fallback (supported) - tool calling (supported) - native JSON object response_format (supported) - native JSON schema response_format (not supported) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_ollama.md ================================================================================ ## Overview You can use Instructor with local Ollama instance. Please note that, at least currently, OS models do not perform on par with OpenAI (GPT-3.5 or GPT-4) model for complex data schemas. Supported modes: - Instructor markdown-JSON fallback - fallback mode, works with any capable model - native JSON object response_format - recommended - tool calling - supported (for selected models - check Ollama docs) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_openai.md ================================================================================ ## Overview This is the default client used by Instructor. Inference feature compatibility: - tool calling (supported) - native JSON object response_format (supported) - native JSON schema response_format (recommended for new models) - Instructor markdown-JSON fallback (fallback) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_openai-responses.md ================================================================================ ## Overview OpenAI's Responses API is their new recommended API for inference, offering improved performance and features compared to Chat Completions. Key features: - 3% better performance on reasoning tasks - 40-80% improved cache utilization - Built-in tools: web search, file search, code interpreter - Server-side conversation state via `previous_response_id` - Semantic streaming events Inference feature compatibility: - tool calling (supported) - native JSON object response_format (supported) - native JSON schema response_format (recommended) - Instructor markdown-JSON fallback (fallback) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_openrouter.md ================================================================================ ## Overview You can use Instructor with OpenRouter API. OpenRouter provides easy, unified access to multiple open source and commercial models. Read OpenRouter docs to learn more about the models they support. Please note that OS models are in general weaker than OpenAI ones, which may result in lower quality of responses or extraction errors. You can mitigate this (partially) by using validation and `maxRetries` option to make Instructor automatically reattempt the extraction in case of extraction issues. ## Example ```php withDebugConfig(DebugConfig::fromPreset('on'))->create(); $answer = Inference::fromRuntime(InferenceRuntime::fromConfig( config: LLMConfig::fromPreset('openrouter'), httpClient: $http, )) ->with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_perplexity.md ================================================================================ ## Overview Perplexity is a search engine that provides an API for generating text. It is designed to be used in a variety of applications, including chatbots, content generation, and more. ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 256] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_sambanova.md ================================================================================ ## Overview Support for SambaNova's API, which provide fast inference endpoints for Llama and Qwen LLMs. Inference feature compatibility: - Instructor markdown-JSON fallback (supported) - tool calling (not supported) - native JSON object response_format (not supported) - native JSON schema response_format (not supported) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_togetherai.md ================================================================================ ## Overview Together.ai hosts a number of language models and offers inference API with support for chat completion, JSON completion, and tools call. You can use Instructor with Together.ai as demonstrated below. Please note that some Together.ai models support tool calling or native JSON object response_format, which are much more reliable than Instructor markdown-JSON fallback. Inference feature compatibility: - tool calling - supported for selected models - native JSON object response_format - supported for selected models - Instructor markdown-JSON fallback - fallback mode ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B04_LLMApiSupport/llm_xai.md ================================================================================ ## Overview Support for xAI's API, which offers access to X.com's Grok model. Inference feature compatibility: - tool calling (supported) - native JSON object response_format (supported) - native JSON schema response_format (supported) - Instructor markdown-JSON fallback (fallback) ## Example ```php with( messages: Messages::fromString('What is the capital of France'), options: ['max_tokens' => 64] ) ->get(); echo "USER: What is capital of France\n"; echo "ASSISTANT: $answer\n"; assert(Str::contains($answer, 'Paris')); ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/chat_with_many_participants.md ================================================================================ ## Overview This example demonstrates a sophisticated multi-participant chat system featuring: - **System prompt isolation** - each AI participant has their own persona - **Role normalization** - proper LLM role mapping for multi-participant conversations - **AI-powered moderation** - LLM coordinator decides who should speak next based on context - **Clean state management** - everything configured in immutable ChatState - **Type-safe participant selection** - StructuredOutput for decision making ## Example ```php $state->stepCount()), new ResponseContentCheck( fn(ChatState $state): ?Messages => $state->currentStep()?->outputMessages(), static fn(Messages $lastResponse): bool => $lastResponse->last()->content()->toString() !== '', ), ), ); //->wiretap(fn(Event $e) => $e->print()); $participantNames = [ 'moderator' => '🎙️ Moderator', 'dr_chen' => '🔬 Dr. Chen', 'marcus' => '⚙️ Marcus', ]; $state = new ChatState(); while ($chat->hasNextStep($state)) { $state = $chat->nextStep($state); $step = $state->currentStep(); if ($step) { $participantName = $step->participantName(); $content = trim($step->outputMessages()->toString()); // Only display if there's actual content if (!empty($content)) { $displayName = $participantNames[$participantName] ?? "🤖 $participantName"; echo "\n$displayName:\n"; echo str_repeat('-', strlen($displayName)) . "\n"; echo "$content\n\n"; } } } echo "🎬 Panel discussion concluded!\n"; assert($state->stepCount() > 0, 'Expected at least one step in the chat'); ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/chat_with_summary.md ================================================================================ ## Overview ## Example ```php $state->stepCount()), new ResponseContentCheck( fn(ChatState $state): ?Messages => $state->currentStep()?->outputMessages(), static fn(Messages $lastResponse): bool => $lastResponse->toString() !== '', ), ), processors: new StateProcessors( new AccumulateTokenUsage(), new AppendStepMessages(), new MoveMessagesToBuffer( maxTokens: 128, bufferSection: 'buffer', events: $events ), new SummarizeBuffer( maxBufferTokens: 128, maxSummaryTokens: 512, bufferSection: 'buffer', summarySection: 'summary', summarizer: new SummarizeMessages( inference: InferenceRuntime::fromProvider( provider: LLMProvider::using('openai'), ), ), events: $events, ), ), events: $events, );//->wiretap(fn(Event $e) => $e->printDebug()); $context = "# CONTEXT\n\n" . file_get_contents(__DIR__ . '/summary.md'); $state = (new ChatState)->withMessages( Messages::fromString(content: $context, role: 'system') ); while ($chat->hasNextStep($state)) { $state = $chat->nextStep($state); $step = $state->currentStep(); $name = $step?->participantName() ?? 'unknown'; $content = trim($step?->outputMessages()->toString() ?? ''); echo "\n--- Step " . ($state->stepCount()) . " ($name) ---\n"; echo ($content ?: '[eot]'). "\n"; // echo "---------------------\n"; // echo "SUMMARY:\n" . $state->store()->section('summary')->get()?->toString(); // echo "---------------------\n"; // echo "BUFFER:\n" . $state->store()->section('buffer')->get()?->toString(); // echo "---------------------\n"; // echo "MESSAGES:\n" . $state->store()->section('messages')->get()?->toString(); // echo "=====================\n"; } assert($state->stepCount() > 0, 'Expected at least one step in the chat'); ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/cost_calculation_inference.md ================================================================================ ## Overview Calculate the cost of an LLM inference call using `FlatRateCostCalculator`. Pricing is decoupled from usage — you define rates with `InferencePricing`, get token counts from the response's `InferenceUsage`, and the calculator produces a `Cost` value object with total and per-category breakdown. ## Example ```php withMessages(Messages::fromString($prompt)) ->withOptions(['max_tokens' => 100]) ->response(); $usage = $response->usage(); echo "Prompt: {$prompt}\n"; echo "Response: {$response->content()}\n\n"; echo "Tokens — input: {$usage->inputTokens}, output: {$usage->outputTokens}\n\n"; // 2. Define pricing rates ($/1M tokens) and calculate cost $calculator = new FlatRateCostCalculator(); $pricing = InferencePricing::fromArray([ 'input' => 0.2, // gpt-4.1-nano input 'output' => 0.8, // gpt-4.1-nano output ]); $cost = $calculator->calculate($usage, $pricing); echo "Cost breakdown:\n"; foreach ($cost->breakdown as $category => $amount) { if ($amount > 0) { printf(" %-12s \$%.6f\n", $category, $amount); } } echo " Total: {$cost->toString()}\n\n"; // 3. Compare across models using the same usage $models = [ 'GPT-4.1-nano' => ['input' => 0.2, 'output' => 0.8], 'GPT-4.1-mini' => ['input' => 0.4, 'output' => 1.6], 'GPT-4.1' => ['input' => 2.0, 'output' => 8.0], 'Claude 4 Haiku' => ['input' => 0.8, 'output' => 4.0], 'Claude 4 Sonnet' => ['input' => 3.0, 'output' => 15.0], 'Gemini 2.5 Flash' => ['input' => 0.15, 'output' => 0.60], ]; echo "Cost comparison ({$usage->inputTokens} in + {$usage->outputTokens} out tokens):\n"; foreach ($models as $model => $rates) { $modelCost = $calculator->calculate($usage, InferencePricing::fromArray($rates)); printf(" %-20s %s\n", $model, $modelCost->toString()); } // 4. Accumulate costs across multiple calls $cost2 = $calculator->calculate($usage, InferencePricing::fromArray(['input' => 3.0, 'output' => 15.0])); $totalCost = $cost->withAccumulated($cost2); echo "\nAccumulated (nano + sonnet pricing): {$totalCost->toString()}\n"; assert($usage->inputTokens > 0); assert($usage->outputTokens > 0); assert($cost->total > 0); assert($totalCost->total > $cost->total); ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/image_data.md ================================================================================ ## Overview `Image` class in Instructor PHP provides an easy way to include images in your prompts. It supports loading images from files, URLs, or base64 encoded strings. The image can be sent as part of the message content to the LLM. ## Example ```php asSystem('You are an expert in car damage assessment.') ->asUser(Content::empty() ->addContentPart(ContentPart::text('Describe the car damage in the image.')) ->addContentPart(Image::fromFile(__DIR__ . '/car-damage.jpg')->toContentPart()) ); $response = Inference::using('openai') ->withModel('gpt-4o-mini') ->withMessages($messages) ->get(); echo "Response: " . $response . "\n"; assert(!empty($response), 'Expected non-empty response from image inference'); ?> ================================================================================ FILE: cookbook/examples/B05_LLMExtras/metrics_streaming.md ================================================================================ ## Overview Collect simple streaming metrics from Polyglot inference events: time to first chunk, stream duration, chunk count (streamed deltas), and average output tokens per second. ## Example ```php 'onFirstChunk', PartialInferenceDeltaCreated::class => 'onChunk', InferenceCompleted::class => 'onCompleted', ]; } public function onFirstChunk(StreamFirstChunkReceived $event): void { $this->timer('llm.stream.ttfc_ms', $event->timeToFirstChunkMs, [ 'model' => $this->modelTag($event->model), ]); } public function onChunk(PartialInferenceDeltaCreated $event): void { $this->chunkCount += 1; } public function onCompleted(InferenceCompleted $event): void { $durationMs = $event->data['durationMs'] ?? 1; $durationSeconds = max(0.001, $durationMs / 1000); $outputTokens = $event->data['outputTokens'] ?? 0; $tokensPerSecond = $outputTokens / $durationSeconds; $this->timer('llm.stream.duration_ms', $durationMs); $this->gauge('llm.stream.chunk_count', (float) $this->chunkCount); $this->gauge('llm.stream.output_tokens', (float) $outputTokens); $this->gauge('llm.stream.output_tokens_per_second', $tokensPerSecond); $this->chunkCount = 0; } private function modelTag(?string $model): string { if ($model !== null && $model !== '') { return $model; } return 'default'; } } $events = new EventDispatcher(); $metrics = new Metrics($events); $metrics->collect(new StreamMetricsCollector()); $metrics->exportTo(new CallbackExporter(function (iterable $metrics): void { $aggregates = aggregateMetrics($metrics); foreach ($aggregates as $aggregate) { $tagsOutput = formatTags($aggregate['tags']); $value = aggregatedValue($aggregate); printf("[%s] %s%s = %.2f\n", $aggregate['type'], $aggregate['name'], $tagsOutput, $value); } })); $prompt = 'In one sentence, explain why streaming responses help UX.'; $runtime = InferenceRuntime::fromConfig(config: LLMConfig::fromPreset('openai'), events: $events); $stream = Inference::fromRuntime($runtime) ->withMessages(Messages::fromString($prompt)) ->withOptions(['max_tokens' => 64]) ->withStreaming() ->stream() ->deltas(); echo "USER: {$prompt}\n"; echo "ASSISTANT: "; foreach ($stream as $delta) { echo $delta->contentDelta; } echo "\n\n"; $exportedMetrics = []; $metrics->exportTo(new CallbackExporter(function (iterable $m) use (&$exportedMetrics): void { foreach ($m as $metric) { $exportedMetrics[] = $metric; } })); $metrics->export(); assert(count($exportedMetrics) > 0, 'Expected non-empty metrics collection'); function formatTags(array $tags): string { if ($tags === []) { return ''; } $keys = array_keys($tags); $values = array_values($tags); $tagList = array_map( static fn (string $key, mixed $value): string => "{$key}=\"{$value}\"", $keys, $values, ); return ' {' . implode(', ', $tagList) . '}'; } /** * @param iterable $metrics * @return array */ function aggregateMetrics(iterable $metrics): array { $aggregates = []; foreach ($metrics as $metric) { $key = $metric->type() . '|' . $metric->name() . '|' . $metric->tags()->toKey(); if (!array_key_exists($key, $aggregates)) { $aggregates[$key] = [ 'type' => $metric->type(), 'name' => $metric->name(), 'tags' => $metric->tags()->toArray(), 'count' => 0, 'sum' => 0.0, 'last' => 0.0, ]; } $aggregates[$key]['count'] += 1; $aggregates[$key]['sum'] += $metric->value(); $aggregates[$key]['last'] = $metric->value(); } return array_values($aggregates); } /** * @param array{type: string, count: int, sum: float, last: float} $aggregate */ function aggregatedValue(array $aggregate): float { return match ($aggregate['type']) { 'counter' => $aggregate['sum'], 'timer', 'histogram' => $aggregate['sum'] / max(1, $aggregate['count']), default => $aggregate['last'], }; } ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/streaming_inference_openai_responses.md ================================================================================ ## Overview A minimal streaming example using explicit typed config: `Inference::using('openai-responses')`. The example verifies that streaming produces deltas and that the final response contains the expected marker. ## Example ```php withMessages(Messages::fromString($prompt)) ->withOptions(['max_output_tokens' => 256]) ->withStreaming() ->stream() ->onDelta(fn($delta) => print($delta->contentDelta)); $assembled = ''; $deltaCount = 0; foreach ($stream->deltas() as $delta) { $contentDelta = $delta->contentDelta; if ($contentDelta === '') { continue; } $deltaCount += 1; $assembled .= $contentDelta; } $final = $stream->final(); assert($final !== null, 'Expected a final response'); $finalContent = $final->content(); echo "\nFinal response:\n{$finalContent}\n"; assert($deltaCount > 0, 'Expected at least one streamed delta'); assert($assembled !== '', 'Expected non-empty assembled content'); assert(Str::contains($assembled, $expectedPhrase, false), 'Expected phrase in streamed content'); assert(Str::contains($final->content(), $expectedPhrase, false), 'Expected phrase in final content'); assert(trim($assembled) === trim($finalContent), 'Expected assembled content to match final content'); ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/prompt_templates.md ================================================================================ ## Overview `Template` class in Instructor PHP provides a way to define and use prompt templates using Twig, Blade or custom 'arrowpipe' template syntax. ## Example ```php from('What is capital of {{country}}') ->with(['country' => 'Germany']) ->toText(); $answer = Inference::using('openai')->withMessages(Messages::fromString($prompt))->get(); echo "EXAMPLE 1: prompt = $prompt\n"; echo "ASSISTANT: $answer\n"; echo "\n"; assert(Str::contains($answer, 'Berlin')); // EXAMPLE 2: Load prompt from file // DSN now uses engine aliases (twig/blade/arrowpipe), so point twig at a concrete template path $prompt = Template::text( pathOrDsn: 'twig:packages/templates/resources/prompts/demo-twig/capital', variables: ['country' => 'Germany'], ); $answer = Inference::using('openai')->withMessages(Messages::fromString($prompt))->get(); echo "EXAMPLE 2: prompt = $prompt\n"; echo "ASSISTANT: $answer\n"; echo "\n"; assert(Str::contains($answer, 'Berlin')); ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/summary_with_llm.md ================================================================================ ## Overview This is an example of a simple summarization. ## Example ```php with(messages: Messages::fromArray([ ['role' => 'user', 'content' => 'Content to summarize:'], ['role' => 'user', 'content' => $report], ['role' => 'user', 'content' => 'Concise summary of project report in 2-3 sentences:'], ])) ->get(); dump($summary); assert(!empty($summary), 'Expected non-empty summary'); ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/tool_use.md ================================================================================ ## Overview `ToolUse` class automates the process of using tools by LLM, i.e.: - calling LLM with provided context (message sequence), - extracting tool calls requested by LLM from the response, - calling the requested tool and storing its results, - constructing message sequence with the result of call, - sending updated message sequence back to LLM. This cycle is repeated until one of the exit criteria is met: - LLM no longer requests any tool calls, - specified maximum number of iterations is reached, - specified token usage limit is reached - there are any errors during the process (e.g. LLM requested a tool that is not available). `ToolUse` class provides 3 ways to iterate through the process: - manual control - code is responsible for checking `hasNextStep()` and calling `nextStep()` in a loop, - using iterator - code uses foreach loop to iterate through the steps (internally it checks `hasNextStep()` and calls `nextStep()`), - just get final step - you only get the final step, iteration process is done internally. ## Example This example demonstrates 3 ways to use `ToolUse` class to allow LLM call functions if needed to answer simple math question. We provide 2 functions (`add_numbers` and `subtract_numbers`) as tools available to LLM and specify the task in plain language. The LLM is expected to call the functions in the correct order to get the final result. ```php $state->stepCount()), new TokenUsageLimit(8192, fn(ToolUseState $state) => $state->usage()->total()), new ExecutionTimeLimit(60, fn(ToolUseState $state) => $state->startedAt()), new RetryLimit(2, fn(ToolUseState $state) => $state->steps(), fn(ToolUseStep $step) => $step->hasErrors()), new StopOnFinalDecision(), ), ); // // PATTERN #1 - manual control // echo "\nPATTERN #1 - manual control\n"; $state = (new ToolUseState) ->withMessages(Messages::fromString('Add 2455 and 3558 then subtract 4344 from the result.')); // iterate until no more steps while ($toolUse->hasNextStep($state)) { $state = $toolUse->nextStep($state); $step = $state->currentStep(); print("STEP - tokens used: " . ($step->usage()?->total() ?? 0) . ' [' . $step->toString() . ']' . "\n"); } // print final response $result = $state->currentStep()->outputMessages()->toString(); print("RESULT: " . $result . "\n"); // // PATTERN #2 - using iterator // echo "\nPATTERN #2 - using iterator\n"; $state = (new ToolUseState) ->withMessages(Messages::fromString('Add 2455 and 3558 then subtract 4344 from the result.')); // iterate until no more steps foreach ($toolUse->iterator($state) as $currentState) { $step = $currentState->currentStep(); print("STEP - tokens used: " . ($step->usage()?->total() ?? 0) . ' [' . $step->toString() . ']' . "\n"); $state = $currentState; // keep the latest state } // print final response $result = $state->currentStep()->outputMessages()->toString(); print("RESULT: " . $result . "\n"); // // PATTERN #3 - just get final step (fast forward to it) // echo "\nPATTERN #3 - get only final result\n"; $state = (new ToolUseState) ->withMessages(Messages::fromString('Add 2455 and 3558 then subtract 4344 from the result.')); // print final response $finalState = $toolUse->finalStep($state); $result = $finalState->currentStep()->outputMessages()->toString(); print("RESULT: " . $result . "\n"); assert(!empty($result), 'Expected non-empty result from tool use'); assert($finalState->stepCount() > 0, 'Expected at least one step in tool use'); ?> ``` ================================================================================ FILE: cookbook/examples/B05_LLMExtras/tool_use_react.md ================================================================================ ## Overview ### Example ```php $state->stepCount()), new TokenUsageLimit(8192, fn(ToolUseState $state) => $state->usage()->total()), new ExecutionTimeLimit(60, fn(ToolUseState $state) => $state->startedAt()), new RetryLimit(2, fn(ToolUseState $state) => $state->steps(), fn(ToolUseStep $step) => $step->hasErrors()), new StopOnFinalDecision(), ), driver: $driver ); // // PATTERN #1 - manual control // echo "\nReAct PATTERN #1 - manual control\n"; $state = (new ToolUseState) ->withMessages(Messages::fromString('Add 2455 and 3558 then subtract 4344 from the result.')); while ($toolUse->hasNextStep($state)) { $state = $toolUse->nextStep($state); $step = $state->currentStep(); print("STEP - tokens used: " . ($step->usage()?->total() ?? 0) . ' [' . $step->toString() . ']' . "\n"); } $result = $state->currentStep()->outputMessages()->toString(); print("RESULT: " . $result . "\n"); // // PATTERN #2 - using iterator // echo "\nReAct PATTERN #2 - using iterator\n"; $state = (new ToolUseState) ->withMessages(Messages::fromString('Add 2455 and 3558 then subtract 4344 from the result.')); foreach ($toolUse->iterator($state) as $currentState) { $step = $currentState->currentStep(); print("STEP - tokens used: " . ($step->usage()?->total() ?? 0) . ' [' . $step->toString() . ']' . "\n"); $state = $currentState; // keep the latest state } $result = $state->currentStep()->outputMessages()->toString(); print("RESULT: " . $result . "\n"); // // PATTERN #3 - just get final step (fast forward to it) // echo "\nReAct PATTERN #3 - final via Inference (optional)\n"; $state = (new ToolUseState) ->withMessages(Messages::fromString('Add 2455 and 3558 then subtract 4344 from the result.')); $finalState = $toolUse->finalStep($state); $result = $finalState->currentStep()->outputMessages()->toString(); print("RESULT: " . $result . "\n"); assert(!empty($result), 'Expected non-empty result from ReAct tool use'); assert($finalState->stepCount() > 0, 'Expected at least one step in ReAct tool use'); ?> ``` ================================================================================ FILE: cookbook/examples/C01_Http/http_client_basics.md ================================================================================ ## Overview Demonstrates basic HTTP client usage: building a client, sending a request, and reading status/headers/body from the response. Uses a Mock driver so this example runs without network. ## Example ```php withMock(function ($mock) { // Respond to a specific request shape $mock->addResponse( HttpResponse::sync( statusCode: 200, headers: ['Content-Type' => 'application/json'], body: json_encode(['ok' => true, 'message' => 'Welcome!']), ), url: 'https://api.example.local/welcome', method: 'GET' ); }) ->create(); $request = new HttpRequest( url: 'https://api.example.local/welcome', method: 'GET', headers: ['Accept' => 'application/json'], body: '', options: [], ); $response = $client->send($request)->get(); echo "Status: " . $response->statusCode() . "\n"; echo "Headers: " . json_encode($response->headers()) . "\n"; echo "Body: " . $response->body() . "\n"; assert($response->statusCode() === 200, 'Expected status code 200'); assert(!empty($response->body()), 'Expected non-empty response body'); ?> ``` ================================================================================ FILE: cookbook/examples/C01_Http/http_client_streaming_basics.md ================================================================================ ## Overview Demonstrates requesting a streamed response and iterating over chunks. Uses a Mock driver to simulate SSE-like stream. ## Example ```php withMock(function ($mock) { $mock->addResponse( HttpResponse::streaming( statusCode: 200, headers: ['Content-Type' => 'text/event-stream'], stream: ArrayStream::from(["hello\n", "from\n", "stream\n"]), ), url: 'https://api.example.local/stream', method: 'GET' ); }) ->create(); $request = new HttpRequest( url: 'https://api.example.local/stream', method: 'GET', headers: ['Accept' => 'text/event-stream'], body: '', options: ['stream' => true], ); $chunkCount = 0; foreach ($client->send($request)->stream() as $chunk) { echo $chunk; // handle streamed data incrementally $chunkCount++; } assert($chunkCount === 3, 'Expected 3 streamed chunks'); ?> ``` ================================================================================ FILE: cookbook/examples/C01_Http/http_middleware_hooks.md ================================================================================ ## Overview Practical `BaseMiddleware` example: enrich request headers in `beforeRequest()` and perform lightweight side effects in `afterRequest()`. ## Example ```php withHeader('X-Request-ID', 'req-demo-123'); } protected function afterRequest(HttpRequest $request, HttpResponse $response): HttpResponse { fwrite(STDOUT, "status=" . $response->statusCode() . "\n"); return $response; } } $driver = new MockHttpDriver(); $driver->addResponse( HttpResponse::sync( statusCode: 200, headers: ['Content-Type' => 'application/json'], body: json_encode(['ok' => true]), ), url: 'https://api.example.local/ping', method: 'GET' ); $client = HttpClient::fromDriver($driver) ->withMiddleware(new RequestIdMiddleware()); $request = new HttpRequest( url: 'https://api.example.local/ping', method: 'GET', headers: ['Accept' => 'application/json'], body: '', options: [], ); $response = $client->send($request)->get(); echo $response->body() . "\n"; assert($response->statusCode() === 200, 'Expected status code 200'); assert(!empty($response->body()), 'Expected non-empty response body'); ?> ``` ================================================================================ FILE: cookbook/examples/C01_Http/http_middleware_stream.md ================================================================================ ## Overview Simple streaming middleware example that tags every emitted chunk. ## Example ```php withStreaming(true); $response = $next->handle($request); return BaseResponseDecorator::decorate( $response, fn(string $chunk): string => "[STREAM] " . $chunk, ); } } $driver = new MockHttpDriver(); $driver->addResponse( HttpResponse::streaming( statusCode: 200, headers: ['Content-Type' => 'text/event-stream'], stream: new ArrayStream(["one\n", "two\n", "three\n"]), ), url: 'https://api.example.local/stream', method: 'GET', ); $client = HttpClient::fromDriver($driver) ->withMiddleware(new TagStreamChunks()); $request = new HttpRequest( url: 'https://api.example.local/stream', method: 'GET', headers: ['Accept' => 'text/event-stream'], body: '', options: ['stream' => true], ); $chunkCount = 0; foreach ($client->send($request)->stream() as $chunk) { echo $chunk; $chunkCount++; } assert($chunkCount > 0, 'Expected at least one streamed chunk'); assert(str_contains($chunk, '[STREAM]'), 'Expected chunks to be tagged by middleware'); ?> ``` ================================================================================ FILE: cookbook/examples/C01_Http/http_middleware_sync.md ================================================================================ ## Overview Demonstrates synchronous HTTP middleware that adds a request header and transforms the response body by uppercasing a JSON field. ## Example ```php withHeader('X-Trace', 'sync-demo'); } protected function afterRequest(HttpRequest $request, HttpResponse $response): HttpResponse { // transform JSON body by uppercasing the "message" field $body = $response->body(); $data = json_decode($body, true) ?? []; if (isset($data['message']) && is_string($data['message'])) { $data['message'] = strtoupper($data['message']); return HttpResponse::sync( statusCode: $response->statusCode(), headers: $response->headers(), body: json_encode($data), ); } return $response; } } // Mock driver returns a simple JSON payload $driver = new MockHttpDriver(); $driver->addResponse( HttpResponse::sync( statusCode: 200, headers: ['Content-Type' => 'application/json'], body: json_encode(['message' => 'hello']), ), url: 'https://api.example.local/echo', method: 'POST' ); $client = HttpClient::fromDriver($driver); $client = $client->withMiddleware(new UppercaseBodyMiddleware()); $request = new HttpRequest( url: 'https://api.example.local/echo', method: 'POST', headers: ['Accept' => 'application/json'], body: ['message' => 'hello'], options: [], ); $response = $client->send($request)->get(); echo "Status: {$response->statusCode()}\n"; echo "Body: {$response->body()}\n"; assert($response->statusCode() === 200, 'Expected status code 200'); $decoded = json_decode($response->body(), true); assert($decoded['message'] === 'HELLO', 'Expected uppercased message in response body'); ?> ``` ================================================================================ FILE: cookbook/examples/C01_Http/http_client_pool_basics.md ================================================================================ ## Overview Deterministic pool example using an in-memory pool handler and the dedicated `packages/http-pool` API. ## Example ```php 'application/json'], body: json_encode(['url' => $request->url(), 'ok' => true]), )); } return HttpResponseList::fromArray($results); } } $requests = HttpRequestList::of( new HttpRequest('https://api.example.local/a', 'GET', [], '', []), new HttpRequest('https://api.example.local/b', 'GET', [], '', []), new HttpRequest('https://api.example.local/c', 'GET', [], '', []), ); $pool = new PendingHttpPool($requests, new InMemoryPool()); $results = $pool->all(maxConcurrent: 2); echo "success={$results->successCount()} failures={$results->failureCount()}\n"; foreach ($results->successful() as $response) { echo $response->body() . "\n"; } assert($results->successCount() === 3, 'Expected 3 successful responses'); assert($results->failureCount() === 0, 'Expected 0 failures'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_execute.md ================================================================================ ## Overview The simplest way to run an agent: `AgentLoop::execute()` runs the loop to completion and returns the final `AgentState`. The loop sends messages to the LLM, processes any tool calls, and repeats until the LLM produces a final response with no pending tool calls. Key concepts: - `AgentLoop::default()`: Creates a minimal agent loop with sensible defaults - `AgentState::empty()`: Creates an empty immutable state container - `execute()`: Runs the full loop and returns the final state - `AgentEventConsoleObserver`: Attach via `wiretap()` to see execution lifecycle events - `finalResponse()`: Access the agent's final text output ## Example ```php wiretap($logger->wiretap()); // Prepare initial state with a user message $state = AgentState::empty()->withMessages( Messages::fromString('What are the three primary colors? Answer in one sentence.') ); // Execute the loop to completion echo "=== Agent Execution ===\n\n"; $finalState = $loop->execute($state); // Read the result echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Agent ID: {$finalState->agentId()->toString()}\n"; echo "Execution ID: {$finalState->execution()?->executionId()->toString()}\n"; echo "Last Step ID: {$finalState->lastStep()?->stepId()->toString()}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens used: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert($finalState->status() === \Cognesy\Agents\Enums\ExecutionStatus::Completed); assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_iterate.md ================================================================================ ## Overview `AgentLoop::iterate()` yields the agent state after each step, giving you fine-grained control over the execution. This is useful for streaming progress, implementing custom stop logic, or inspecting intermediate states between LLM calls. You can combine `iterate()` with `AgentEventConsoleObserver` to get detailed event output (tool calls, inference, continuation decisions) alongside your own step-by-step logic. The logger hooks into the event system and prints as events fire during iteration. Key concepts: - `iterate()`: Returns an iterable that yields `AgentState` after each step - `AgentEventConsoleObserver`: Attach via `wiretap()` for detailed execution logging - Step-by-step inspection of tool calls, token usage, and agent decisions - Early termination by breaking out of the loop ## Example ```php withTool(ReadFileTool::inDirectory($workDir)) ->wiretap($logger->wiretap()); $state = AgentState::empty()->withMessages( Messages::fromString('Read the composer.json file and tell me the project name.') ); echo "=== Stepping through agent loop ===\n\n"; // iterate() yields state after each step; the logger prints events as they fire $stepNum = 0; foreach ($loop->iterate($state) as $stepState) { $stepNum++; // At yield time the step has been completed, so use lastStep() $step = $stepState->lastStep(); $hasToolCalls = $step?->hasToolCalls() ?? false; $tokens = $stepState->usage()->total(); $status = $stepState->status()->value; // Custom per-step output alongside the logger's event output $toolsLabel = $hasToolCalls ? 'yes' : 'no'; echo " >> Step {$stepNum}: status={$status}, has_tools={$toolsLabel}, tokens={$tokens}\n\n"; // Early termination example: stop after 5 steps regardless if ($stepNum >= 5) { echo "\n[Breaking early after {$stepNum} steps]\n"; break; } } echo "\n=== Final Result ===\n"; $response = $stepState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Total steps: {$stepState->stepCount()}\n"; echo "Total tokens: {$stepState->usage()->total()}\n"; if ($stepState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$stepState->status()->value}.\n"; exit(1); } // Assertions assert($stepNum >= 1, 'Expected at least 1 step from iterate()'); assert(!empty($stepState->currentResponse()->toString()), 'Expected non-empty response'); assert($stepState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_bash_tool.md ================================================================================ ## Overview Attaching `BashTool` directly to `AgentLoop` gives the agent the ability to execute shell commands. This is useful for system administration tasks, running scripts, or gathering system information. The agent decides which commands to run based on the task. Key concepts: - `AgentLoop::withTool()`: Adds a tool directly to the loop - `BashTool`: Executes shell commands with configurable sandboxing - The agent autonomously decides which commands to run - `AgentEventConsoleObserver`: Shows tool arguments including executed commands ## Example ```php withTool(BashTool::inDirectory(getcwd())) ->wiretap($logger->wiretap()); $state = AgentState::empty()->withUserMessage( 'What is the current date and time? Also show the hostname and working directory. Be concise.' ); echo "=== Agent Execution ===\n\n"; $finalState = $loop->execute($state); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?? 'No response'; echo "Answer: {$response}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_custom_tool.md ================================================================================ ## Overview Build a custom tool by extending `BaseTool`. Override `__invoke(mixed ...$args)` to implement the tool logic, use `$this->arg()` to extract named parameters, and override `toToolSchema()` to define the parameter schema for the LLM. This example creates a `SystemInfoTool` that reports memory usage, PHP version, and other runtime information. The agent calls it when asked about the system. Key concepts: - `BaseTool`: Abstract base class for custom tools - `__invoke(mixed ...$args)`: The method the agent calls - `$this->arg()`: Extract named or positional parameters from args - `toToolSchema()`: Define the JSON Schema the LLM sees for this tool - `$this->agentState`: Access current agent state from within a tool ## Example ```php arg($args, 'category', 0, 'all'); $info = []; if ($category === 'memory' || $category === 'all') { $memUsage = memory_get_usage(true); $memPeak = memory_get_peak_usage(true); $info[] = sprintf("Memory: %.2f MB (peak: %.2f MB)", $memUsage / 1048576, $memPeak / 1048576); } if ($category === 'php' || $category === 'all') { $info[] = "PHP Version: " . PHP_VERSION; $info[] = "OS: " . PHP_OS; $info[] = "SAPI: " . PHP_SAPI; } if ($category === 'all') { $info[] = "PID: " . getmypid(); $info[] = "Uptime: " . (int)(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT']) . "s"; // Show agent context if available if ($this->agentState !== null) { $info[] = "Agent step: " . $this->agentState->stepCount(); $info[] = "Agent tokens: " . $this->agentState->usage()->total(); } } return implode("\n", $info); } #[\Override] public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') ->withProperties([ JsonSchema::string('category', 'What to check: "memory", "php", or "all"'), ]) ->withRequiredProperties([]) )->toArray()); } } // AgentEventConsoleObserver shows execution lifecycle events on the console $logger = new AgentEventConsoleObserver( useColors: true, showTimestamps: true, showContinuation: true, showToolArgs: true, ); // Create loop with the custom tool $loop = AgentLoop::default() ->withTool(new SystemInfoTool()) ->wiretap($logger->wiretap()); $state = AgentState::empty()->withMessages( Messages::fromString('Check the current memory usage and PHP version. Report back concisely.') ); echo "=== Agent Execution ===\n\n"; $finalState = $loop->execute($state); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_hooks.md ================================================================================ ## Overview Hooks intercept agent lifecycle events to observe or modify state. Each hook receives a `HookContext` and returns a (potentially modified) `HookContext`. Hooks can: - **Observe**: Log events, collect metrics without changing state - **Modify**: Inject metadata, adjust system prompts, transform messages - **Block**: Prevent tool execution (for `BeforeToolUse` hooks) Key concepts: - `CallableHook`: Wraps a closure as a `HookInterface` - `HookTriggers`: Specifies when the hook fires (e.g., `beforeStep()`, `afterStep()`) - `HookContext`: Carries `AgentState`, tool call info, and trigger type - `HookStack`: Registers hooks directly on `AgentLoop` via `withInterceptor()` ## Example ```php with( hook: new CallableHook(function (HookContext $ctx) use (&$timings): HookContext { $step = $ctx->state()->stepCount() + 1; $timings[$step] = microtime(true); return $ctx->withState( $ctx->state()->withMetadata('step_started_at', microtime(true)) ); }), triggerTypes: HookTriggers::beforeStep(), name: 'timing:start', ) // Hook 2: After each step — calculate duration ->with( hook: new CallableHook(function (HookContext $ctx) use (&$timings): HookContext { $step = $ctx->state()->stepCount(); $started = $timings[$step] ?? null; $duration = $started ? round((microtime(true) - $started) * 1000) : 0; $tokens = $ctx->state()->usage()->total(); echo " [timing] Step {$step}: {$duration}ms (total tokens: {$tokens})\n"; return $ctx; }), triggerTypes: HookTriggers::afterStep(), name: 'timing:end', ) // Hook 3: Before tool use — log which tool is about to run ->with( hook: new CallableHook(function (HookContext $ctx): HookContext { $toolName = $ctx->toolCall()?->name() ?? 'unknown'; echo " [audit] About to execute: {$toolName}\n"; return $ctx; }), triggerTypes: HookTriggers::beforeToolUse(), name: 'audit:tool', ) // Hook 4: After tool use — log tool result status ->with( hook: new CallableHook(function (HookContext $ctx): HookContext { $exec = $ctx->toolExecution(); if ($exec !== null) { $status = $exec->wasBlocked() ? 'BLOCKED' : 'OK'; echo " [audit] Tool {$exec->name()} -> {$status}\n"; } return $ctx; }), triggerTypes: HookTriggers::afterToolUse(), name: 'audit:result', ) // Hook 5: On stop — final summary ->with( hook: new CallableHook(function (HookContext $ctx): HookContext { $state = $ctx->state(); echo " [summary] Agent stopping after {$state->stepCount()} steps\n"; return $ctx; }), triggerTypes: HookTriggers::onStop(), name: 'summary', ); $agent = AgentLoop::default() ->withTool(BashTool::inDirectory(getcwd())) ->withInterceptor($hooks) ->wiretap($logger->wiretap()); // Run the agent $state = AgentState::empty()->withUserMessage( 'What is the current date? Use bash to find out. Be concise.' ); echo "=== Agent Execution with Hooks ===\n\n"; $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert(!empty($timings), 'Expected timing data from hooks'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_context_compiler.md ================================================================================ ## Overview Context compilers control which messages the LLM sees at each step. By default, `ConversationWithCurrentToolTrace` includes all conversation messages plus only the current execution's tool traces. You can swap in a different compiler to change what context the agent reasons over. This example builds a custom compiler that wraps the default one and applies two transformations: - **Filtering**: Truncates long tool results so they don't overwhelm the context - **Enrichment**: Injects a dynamic system message with execution progress info The compiler logs what it does at each step, so you can see exactly what the LLM receives. Key concepts: - `CanCompileMessages`: Interface for message compilers - `ConversationWithCurrentToolTrace`: Default — includes conversation + current tool traces - Custom compilers can filter, truncate, enrich, or transform the message list ## Example ```php inner->compile($state); $originalCount = $messages->count(); // 2. FILTER: truncate long tool results to keep context lean $messages = $messages->filter(function (Message $msg) { if ($msg->role()->value !== 'tool') { return true; // keep non-tool messages as-is } $content = $msg->content()->toString(); if (strlen($content) <= $this->maxToolResultLength) { return true; // short enough, keep it } return true; // keep but we'll truncate below }); // Apply truncation $truncated = []; $truncatedCount = 0; foreach ($messages->all() as $msg) { if ($msg->role()->value === 'tool') { $content = $msg->content()->toString(); if (strlen($content) > $this->maxToolResultLength) { $msg = $msg->withContent(Content::fromAny( substr($content, 0, $this->maxToolResultLength) . '... [truncated]', )); $truncatedCount++; } } $truncated[] = $msg; } $messages = Messages::fromMessages($truncated); // 3. ENRICH: inject execution context as a user instruction $step = $state->stepCount() + 1; $tokens = $state->usage()->total(); $context = "[System note] You are on step {$step}. Tokens used so far: {$tokens}. Be concise."; $messages = $messages->appendMessage( new Message(role: 'user', content: $context) ); // 4. LOG: show what the LLM will see echo " [compiler] Compiled {$messages->count()} messages (from {$originalCount} original"; if ($truncatedCount > 0) { echo ", {$truncatedCount} tool results truncated"; } echo ")\n"; foreach ($messages->all() as $msg) { $role = $msg->role()->value; $content = $msg->content()->toString(); $len = strlen($content); if ($len === 0) { echo " [{$role}] (tool calls only)\n"; } else { $preview = substr(str_replace("\n", ' ', $content), 0, 72); echo " [{$role}] ({$len}ch) {$preview}" . ($len > 72 ? '...' : '') . "\n"; } } return $messages; } } // Wrap the default compiler with our instrumented one $compiler = new InstrumentedCompiler( inner: new ConversationWithCurrentToolTrace(), maxToolResultLength: 200, ); $agent = AgentLoop::default(); $agent = $agent ->withTool(BashTool::inDirectory(getcwd() ?: __DIR__)) ->withDriver($agent->driver()->withMessageCompiler($compiler)) ->wiretap($logger->wiretap()); $state = AgentState::empty()->withUserMessage( 'List all files in the current directory, then tell me how many there are.' ); echo "=== Agent with Custom Context Compiler ===\n\n"; $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Steps: {$finalState->stepCount()}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_stop_conditions.md ================================================================================ ## Overview The agent loop stops when `AgentState::shouldStop()` returns true. You can trigger stops from within tools using `AgentStopException`, or apply guard hooks directly (`StepsLimitHook`, `TokenUsageLimitHook`, `ExecutionTimeLimitHook`). When a tool stops the agent, the last step is a `ToolExecution` — not a `FinalResponse`. This means `finalResponse()` returns empty. Use `currentResponse()` to get the best available output regardless of how the agent stopped: - `finalResponse()` — strict: only returns text when the LLM completed naturally (no pending tool calls) - `currentResponse()` — pragmatic: returns `finalResponse()` if available, otherwise the last step's output Key concepts: - `AgentStopException`: Throw from a tool to stop the loop immediately - `StopSignal` / `StopReason`: Describes why the loop stopped - `finalResponse()` vs `currentResponse()`: Strict vs pragmatic response access - Guard hooks: step/token/time limits implemented via lifecycle interception ## Example ```php = $this->stopAt) { // Throw AgentStopException to halt the loop throw new AgentStopException( signal: new StopSignal( reason: StopReason::StopRequested, message: "Counter reached target: {$this->stopAt}", ), context: ['final_count' => self::$count], source: self::class, ); } return "Counter is at " . self::$count . ". Keep going — call counter again."; } #[\Override] public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters'), )->toArray()); } } $logger = new AgentEventConsoleObserver( useColors: true, showTimestamps: true, showContinuation: true, showToolArgs: true, ); // Create loop with the counter tool $loop = AgentLoop::default() ->withTool(new CounterTool(stopAt: 3)) ->wiretap($logger->wiretap()); $state = AgentState::empty()->withMessages( Messages::fromString('Call the counter tool repeatedly until it stops you.') ); echo "=== Agent with Stop Condition ===\n\n"; $finalState = $loop->execute($state); // ========================================================================= // Reading the response after a forced stop // ========================================================================= // // When a tool throws AgentStopException, the last step is a ToolExecution // (the LLM was requesting tool calls when the stop happened). This means: // // finalResponse() -> empty (no FinalResponse step exists) // currentResponse() -> last step's LLM output (best available text) // // For stop-exception scenarios the real "answer" is typically in the stop // signal context or agent metadata — not in the LLM's text output. echo "\n=== Result ===\n"; // finalResponse() is empty because the agent was stopped mid-tool-execution $final = $finalState->finalResponse()->toString(); echo "finalResponse(): " . ($final !== '' ? $final : '(empty — agent was stopped, not completed)') . "\n"; // currentResponse() falls back to the last step's output $current = $finalState->currentResponse()->toString(); echo "currentResponse(): " . ($current !== '' ? $current : '(empty)') . "\n"; // hasFinalResponse() lets you branch on how the agent ended echo "hasFinalResponse(): " . ($finalState->hasFinalResponse() ? 'true' : 'false') . "\n"; // The stop signal carries the reason and context set by the tool $stopSignal = $finalState->stopSignal(); echo "Stop reason: " . ($stopSignal?->toString() ?? 'unknown') . "\n"; echo "Stop context: " . json_encode($stopSignal?->context ?? []) . "\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value === 'failed') { echo "Skipping assertions because execution status is failed.\n"; exit(1); } // Assertions assert($finalState->hasFinalResponse() === false, 'Expected no final response (agent was stopped)'); assert($finalState->stopSignal() !== null, 'Expected a stop signal'); assert($finalState->stopSignal()->context['final_count'] === 3, 'Expected counter to reach 3'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_events.md ================================================================================ ## Overview The agent emits events throughout its lifecycle. Events are read-only observations — they cannot modify agent behavior (use hooks for that). Two ways to observe events: - `wiretap(callable)`: Receives **every** event — `AgentEventConsoleObserver` uses this internally - `onEvent(EventClass, callable)`: Subscribes to a **specific** event type for custom logic Both can be used together. The logger provides general visibility while `onEvent()` lets you collect metrics, trigger side effects, or react to specific events. Key concepts: - `AgentEventConsoleObserver`: Built-in wiretap that formats all events for console output - `onEvent()`: Targeted listener for a single event class - Events include: `AgentStepCompleted`, `ToolCallStarted`, `ToolCallCompleted`, `InferenceResponseReceived`, `AgentExecutionCompleted`, `ContinuationEvaluated`, and more ## Example ```php withTool(BashTool::inDirectory(getcwd())) ->wiretap($logger->wiretap()); // onEvent(): subscribe to specific event types for custom logic // This runs alongside the logger — use it to collect metrics, trigger // side effects, or react to specific events the logger doesn't cover. $totalInferenceMs = 0; $agent->onEvent(InferenceResponseReceived::class, function (InferenceResponseReceived $event) use (&$totalInferenceMs) { $ms = $event->receivedAt->getTimestamp() * 1000 + (int)($event->receivedAt->format('u') / 1000) - $event->requestStartedAt->getTimestamp() * 1000 - (int)($event->requestStartedAt->format('u') / 1000); $totalInferenceMs += $ms; }); $agent->onEvent(AgentExecutionCompleted::class, function (AgentExecutionCompleted $event) use (&$totalInferenceMs) { echo "\n [custom] Execution summary:\n"; echo " Steps: {$event->totalSteps}\n"; echo " Total tokens: {$event->totalUsage->total()}\n"; echo " LLM time: {$totalInferenceMs}ms\n"; }); // Run the agent $state = AgentState::empty()->withUserMessage( 'What is today\'s date? Use bash to find out. Be concise.' ); echo "=== Agent Events Demo ===\n\n"; $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($totalInferenceMs > 0, 'Expected inference time to be tracked'); ?> ``` ================================================================================ FILE: cookbook/examples/D01_Agents/agent_loop_multi_execution.md ================================================================================ ## Overview An agent can handle multiple rounds of execution, where each round builds on the previous conversation history. Just add a new user message to the returned state and call `execute()` again — `AgentLoop` automatically resets completed executions before starting a new one. This enables multi-turn interactions where the agent reasons over past tool results to answer follow-up questions without re-executing tools. Key concepts: - `withUserMessage()`: Appends a follow-up user message to the existing conversation - The agent sees all prior messages including tool calls and results from previous executions - Follow-up questions can reference data gathered in earlier rounds - `AgentLoop` auto-resets terminal execution state (completed/failed) on entry ## Example ```php withTool(ReadFileTool::inDirectory($workDir)) ->wiretap($logger->wiretap()); // === Execution 1: Ask the agent to read composer.json === $query1 = 'Read the composer.json file and tell me the project name and its PHP version requirement.'; echo "=== Execution 1 ===\n"; echo "Query: {$query1}\n\n"; $state = AgentState::empty()->withUserMessage($query1); $state = $loop->execute($state); $response1 = $state->finalResponse()->toString() ?: 'No response'; echo "\nResponse: {$response1}\n\n"; // === Execution 2: Follow-up question using context from Execution 1 === $query2 = 'Based on what you read, does the project use PSR-4 autoloading? What are the namespace prefixes?'; echo "=== Execution 2 ===\n"; echo "Query: {$query2}\n\n"; // Just add a new message — AgentLoop auto-resets the completed execution $state = $state->withUserMessage($query2); $state = $loop->execute($state); $response2 = $state->finalResponse()->toString() ?: 'No response'; echo "\nResponse: {$response2}\n\n"; // === Execution 3: Another follow-up — agent reasons without tools === $query3 = 'Given what you know about this project, what type of project is it — a library, framework, or application? Explain briefly.'; echo "=== Execution 3 ===\n"; echo "Query: {$query3}\n\n"; $state = $state->withUserMessage($query3); $state = $loop->execute($state); $response3 = $state->finalResponse()->toString() ?: 'No response'; echo "\nResponse: {$response3}\n"; if ($state->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$state->status()->value}.\n"; exit(1); } // Assertions assert(!empty($response1) && $response1 !== 'No response', 'Expected non-empty response from execution 1'); assert(!empty($response2) && $response2 !== 'No response', 'Expected non-empty response from execution 2'); assert(!empty($response3) && $response3 !== 'No response', 'Expected non-empty response from execution 3'); assert($state->stepCount() >= 1, 'Expected at least 1 step in final execution'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_basic.md ================================================================================ ## Overview The simplest use of an Agent - a straightforward Q&A without tools. The agent uses the LLM directly to answer questions. This demonstrates the core agent loop: receiving a message, processing it through the LLM, and returning a response. Key concepts: - `AgentBuilder`: Constructs configured agent instances - `AgentState`: Immutable state container for messages and metadata - `AgentLoop::execute()`: Executes the agent loop until completion - `UseGuards`: Adds step/token/time safety limits - `AgentEventConsoleObserver`: Provides visibility into agent execution stages ## Example ```php withCapability(new UseLLMConfig(llm: LLMProvider::using('anthropic'))) ->withCapability(new UseGuards(maxSteps: 3, maxTokens: 4096, maxExecutionTime: 30)) ->build() ->wiretap($logger->wiretap()); // Create initial state with user question $state = AgentState::empty()->withMessages( Messages::fromString('What is the capital of France? Answer in one sentence.') ); echo "=== Agent Execution Log ===\n\n"; // Execute agent until completion $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert($finalState->status() === \Cognesy\Agents\Enums\ExecutionStatus::Completed); assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_file_system.md ================================================================================ ## Overview Agents can be equipped with file system capabilities to read, write, search, and edit files within a specified working directory. This enables code analysis, documentation generation, refactoring assistance, and other file-based operations. The agent determines which file operations to perform based on the task. Key concepts: - `UseFileTools`: Capability that adds core file tools (`read_file`, `write_file`, `edit_file`) - `UseTools`: Adds extra tools explicitly when needed (`list_dir`, `search_files`) - Working directory: Root path for all file operations (security boundary) - Available tools: `read_file`, `write_file`, `edit_file`, `list_dir`, `search_files` - `AgentEventConsoleObserver`: Provides visibility into agent execution stages ## Example ```php withCapability(new UseFileTools($workDir)) ->withCapability(new UseTools( ListDirTool::inDirectory($workDir), SearchFilesTool::inDirectory($workDir), )) ->withCapability(new UseGuards(maxSteps: 8, maxTokens: 8192, maxExecutionTime: 45)) ->build() ->wiretap($logger->wiretap()); // Create task that requires file access $task = <<withMessages( Messages::fromString($task) ); echo "=== Agent Execution Log ===\n\n"; // Execute agent until completion $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_structured_output.md ================================================================================ ## Overview Demonstrates how agents can extract structured data from unstructured text using the `UseStructuredOutputs` capability powered by Instructor. This pattern enables: - **Form autofill**: Extract lead/contact data from pasted text or web content - **Data transformation**: Convert unstructured text into validated PHP objects - **Multi-step workflows**: Chain extraction with API calls using metadata storage - **Validation with retry**: Automatic retry on validation failures Key concepts: - `UseStructuredOutputs`: Capability for LLM-powered data extraction - `SchemaRegistry`: Pre-registered extraction schemas - `structured_output`: Tool to extract data into schema - `AgentEventConsoleObserver`: Provides visibility into agent execution stages ## Example ```php agentState === null) { return 'Error: Agent state not available'; } $leadData = $this->agentState->metadata()->get($metadataKey); if ($leadData === null) { return "Error: No lead data found at metadata key '{$metadataKey}'"; } // Extract lead info for the response $name = match (true) { is_object($leadData) && property_exists($leadData, 'name') => $leadData->name, is_array($leadData) && isset($leadData['name']) => $leadData['name'], default => 'Unknown', }; $email = match (true) { is_object($leadData) && property_exists($leadData, 'email') => $leadData->email, is_array($leadData) && isset($leadData['email']) => $leadData['email'], default => 'Unknown', }; // Simulate API call - in real implementation, call actual CRM API $leadId = 'LEAD-' . strtoupper(substr(md5((string) time()), 0, 8)); return "Lead created successfully!\n" . " ID: {$leadId}\n" . " Name: {$name}\n" . " Email: {$email}\n" . " Source: metadata key '{$metadataKey}'"; } #[\Override] public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray([ 'type' => 'function', 'function' => [ 'name' => $this->name(), 'description' => $this->description(), 'parameters' => [ 'type' => 'object', 'properties' => [ 'metadata_key' => [ 'type' => 'string', 'description' => 'The metadata key where lead data is stored (e.g., "current_lead")', ], ], 'required' => ['metadata_key'], ], ], ]); } } // ============================================================================= // 3. Build the agent with structured output and API capabilities // ============================================================================= // Create console logger for execution visibility $logger = new AgentEventConsoleObserver( useColors: true, showTimestamps: true, showContinuation: true, showToolArgs: true, ); // Register extraction schemas $schemas = new SchemaRegistry([ 'lead' => new SchemaDefinition( class: Lead::class, description: 'Business lead with contact information', prompt: 'Extract lead information from the text. Look for names, emails, ' . 'phone numbers, company names, job titles, and addresses.', ), ]); // Build agent $agent = AgentBuilder::base() ->withCapability(new UseStructuredOutputs( schemas: $schemas, structuredOutput: StructuredOutputRuntime::fromProvider( provider: LLMProvider::using('openai'), ), policy: new StructuredOutputPolicy( llm: LLMProvider::using('openai'), defaultMaxRetries: 3, ), )) ->withCapability(new UseMetadataTools()) ->withCapability(new UseTools(new CreateLeadTool())) ->withCapability(new UseGuards(maxSteps: 10, maxTokens: 12288, maxExecutionTime: 90)) ->build() ->wiretap($logger->wiretap()); // ============================================================================= // 4. Prepare input data (unstructured text with lead information) // ============================================================================= $inputText = <<withMessages( Messages::fromString($task) ); // ============================================================================= // 6. Execute agent // ============================================================================= echo "=== Agent Execution Log ===\n"; echo "Input text:\n{$inputText}\n\n"; // Execute agent until completion $finalState = $agent->execute($state); // ============================================================================= // 7. Show final results // ============================================================================= echo "\n=== Result ===\n"; // Get the extracted lead from metadata $extractedLead = $finalState->metadata()->get('current_lead'); $fields = match(true) { is_object($extractedLead) => get_object_vars($extractedLead), is_array($extractedLead) => $extractedLead, default => [], }; if ($extractedLead !== null) { echo "Extracted Lead (from metadata):\n"; foreach ($fields as $key => $value) { if ($value !== null && $value !== '') { echo " {$key}: {$value}\n"; } } echo "\n"; } $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert($extractedLead !== null, 'Expected extracted lead in metadata'); assert(!empty($fields), 'Expected non-empty lead fields'); assert(!empty($fields['name'] ?? null), 'Expected lead to have a name'); assert(!empty($fields['email'] ?? null), 'Expected lead to have an email'); assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_hooks.md ================================================================================ ## Overview Hooks allow you to intercept tool calls before and after execution. This example demonstrates using a `BeforeToolUse` hook to block dangerous bash commands - a practical security pattern for agentic applications. Key concepts: - `CallableHook`: Wraps a closure as a hook - `HookContext`: Provides access to tool call and agent state - `HookTriggers`: Defines when the hook fires (e.g., `beforeToolUse()`) - `UseHook`: Registers a hook capability with explicit trigger/priority - `AgentEventConsoleObserver`: Provides visibility into agent execution stages ## Example ```php /dev/sda', 'mkfs', 'dd if=', ':(){:|:&};:', // Fork bomb ]; $blockedPatterns = array_map( static fn(string $pattern): string => strtolower(trim($pattern)), $blockedPatterns, ); // Build agent with bash capability and security hook $agent = AgentBuilder::base() ->withCapability(new UseBash()) ->withCapability(new UseHook( hook: new CallableHook(function (HookContext $ctx) use ($blockedPatterns): HookContext { $toolCall = $ctx->toolCall(); if ($toolCall === null) { return $ctx; } $args = $toolCall->args(); $rawCommand = match (true) { is_array($args) && isset($args['command']) && is_string($args['command']) => $args['command'], default => '', }; $command = strtolower(trim((string) preg_replace('/\s+/', ' ', $rawCommand))); if ($command === '') { return $ctx; } // Check for dangerous patterns foreach ($blockedPatterns as $pattern) { if (str_contains($command, $pattern)) { echo " [HOOK] BLOCKED - Dangerous pattern detected: {$pattern}\n"; return $ctx->withToolExecutionBlocked("Dangerous command: {$pattern}"); } } echo " [HOOK] ALLOWED - {$rawCommand}\n"; return $ctx; }), triggers: HookTriggers::beforeToolUse(), priority: 100, // High priority = runs first )) ->withCapability(new UseGuards(maxSteps: 8, maxTokens: 4096, maxExecutionTime: 30)) ->build() ->wiretap($logger->wiretap()); // Test with safe commands $state = AgentState::empty()->withUserMessage( 'List the files in the current directory and show the date' ); echo "=== Test 1: Safe Commands ===\n\n"; $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Status: {$finalState->status()->value}\n"; // Test with dangerous command (simulated prompt) echo "\n=== Test 2: Dangerous Command Detection ===\n\n"; $state2 = AgentState::empty()->withUserMessage( 'Delete all files with: rm -rf /' ); $finalState2 = $agent->execute($state2); echo "\n=== Result ===\n"; $hasErrors = $finalState2->currentStep()?->hasErrors() ?? false; echo "Command was " . ($hasErrors ? "BLOCKED (security hook worked!)" : "executed") . "\n"; echo "Steps: {$finalState2->stepCount()}\n"; echo "Status: {$finalState2->status()->value}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because safe-command execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response from safe commands'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step for safe commands'); assert($finalState2->stepCount() >= 1, 'Expected at least 1 step for dangerous command test'); ?> ``` ## How It Works 1. **Hook Registration**: `UseHook` registers a `CallableHook` with `HookTriggers::beforeToolUse()` 2. **Context Access**: `HookContext` provides `toolCall()` and `state()` accessors 3. **Priority**: Higher priority (100) ensures this security check runs before other hooks 4. **Blocking**: `$ctx->withToolExecutionBlocked($reason)` blocks the tool call with a reason 5. **Allowing**: Returning `$ctx` unchanged allows execution to proceed ## Other Hook Types ```php // After tool execution - for logging/metrics ->withCapability(new UseHook( hook: new CallableHook(function (HookContext $ctx): HookContext { $exec = $ctx->toolExecution(); if ($exec !== null) { echo "Tool {$exec->name()} completed\n"; } return $ctx; }), triggers: HookTriggers::afterToolUse(), )) // Before each step - modify state ->withCapability(new UseHook( hook: new CallableHook(function (HookContext $ctx): HookContext { $state = $ctx->state()->withMetadata('step_started', microtime(true)); return $ctx->withState($state); }), triggers: HookTriggers::beforeStep(), )) // After each step ->withCapability(new UseHook( hook: new CallableHook(function (HookContext $ctx): HookContext { $started = $ctx->state()->metadata()->get('step_started'); if ($started !== null) { $duration = microtime(true) - $started; echo "Step took {$duration}s\n"; } return $ctx; }), triggers: HookTriggers::afterStep(), )) ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_self_critique.md ================================================================================ ## Overview Self-critique enables agents to evaluate their own outputs and request revisions when answers are incomplete or incorrect. This pattern uses a critic subagent that reviews each final response and decides whether it meets quality standards or needs refinement. This significantly improves accuracy by: - Catching incomplete answers - Detecting logical errors - Ensuring answers match the original question - Forcing deeper investigation when initial responses are superficial Key concepts: - `UseSelfCritique`: Capability that adds self-evaluation after each response - `maxIterations`: Maximum number of critique-revision cycles (default: 2) - `AgentEventConsoleObserver`: Provides visibility into continuation decisions showing SelfCritic evaluations ## Example ```php withCapability(new UseFileTools($workDir)) ->withCapability(new UseTools( ListDirTool::inDirectory($workDir), SearchFilesTool::inDirectory($workDir), )) ->withCapability(new UseSelfCritique( structuredOutput: StructuredOutputRuntime::fromProvider( provider: LLMProvider::using('openai'), ), maxIterations: 2, // Allow up to 2 critique iterations )) ->withCapability(new UseGuards(maxSteps: 12, maxTokens: 12288, maxExecutionTime: 90)) ->build() ->wiretap($logger->wiretap()); // Ask a question where the agent might give a superficial answer $question = "What testing framework does this project use? Be specific. Provide fragments of files as evidence."; $state = AgentState::empty()->withMessages( Messages::fromString($question) ); echo "=== Agent Execution Log ===\n"; echo "Question: {$question}\n\n"; // Execute agent until completion $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $answer = $finalState->finalResponse()->toString() ?: 'No answer'; echo "Answer: {$answer}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_search.md ================================================================================ ## Overview Demonstrates how agents can autonomously search codebases by: - Searching for files matching patterns - Reading relevant files - Synthesizing information into answers - Using subagents for specialized tasks This example shows the agent determining search strategy, executing searches, and analyzing results without predefined workflows. The agent decides which files to read based on search results. Key concepts: - `SearchFilesTool`: Search for files by filename/path pattern - `ReadFileTool`: Read file contents - `UseSubagents`: Spawn specialized subagents for subtasks - `AgentEventConsoleObserver`: Provides visibility into agent execution stages ## Example ```php register(new AgentDefinition( name: 'reader', description: 'Reads files and extracts relevant information', systemPrompt: 'You read files and extract relevant information. Be thorough and precise.', tools: NameList::fromArray(['read_file']), )); $registry->register(new AgentDefinition( name: 'searcher', description: 'Searches for files by filename/path patterns', systemPrompt: 'You search for files by filename/path patterns. Use glob patterns effectively.', tools: NameList::fromArray(['search_files']), )); // Build main orchestration agent $agent = AgentBuilder::base() ->withCapability(new UseFileTools($workDir)) ->withCapability(new UseTools( ListDirTool::inDirectory($workDir), SearchFilesTool::inDirectory($workDir), )) ->withCapability(new UseSubagents(provider: $registry)) ->withCapability(new UseGuards(maxSteps: 12, maxTokens: 12288, maxExecutionTime: 90)) ->build() ->wiretap($logger->wiretap()); // Ask a question that requires search + file reading $question = "Find all tool classes (files matching *Tool.php) under packages/agents/src/Capability/File/ and briefly describe what each tool does based on its code."; $state = AgentState::empty()->withMessages( Messages::fromString($question) ); echo "=== Agent Execution Log ===\n\n"; // Execute agent until completion $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $answer = $finalState->finalResponse()->toString() ?: 'No answer'; echo "Answer: {$answer}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value === 'failed') { echo "Skipping assertions because execution status is failed.\n"; exit(1); } // Assertions $hasAnswer = trim($finalState->finalResponse()->toString()) !== ''; $isStopped = $finalState->status() === ExecutionStatus::Stopped; assert($hasAnswer || $isStopped, 'Expected non-empty response or stopped status'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_subagents.md ================================================================================ ## Overview Subagents enable decomposition of complex tasks into isolated subtasks. The main agent orchestrates multiple subagents, each with specialized roles and tools. This pattern provides: - **Context isolation**: Each subagent has clean context without cross-contamination - **Isolated execution**: Each subagent runs independently with its own state - **Specialized capabilities**: Each subagent has specific tools for its role - **Scalability**: Handle many independent subtasks without context overflow - **Result aggregation**: Main agent synthesizes subagent outputs Key concepts: - `UseSubagents`: Capability that enables subagent spawning - `AgentDefinitionRegistry`: Registry of available subagent definitions - `AgentDefinition`: Defines subagent role, tools, and behavior - `AgentEventConsoleObserver`: Shows parent/child agent IDs for tracking orchestration ## Example ```php register(new AgentDefinition( name: 'reviewer', description: 'Reviews code files and identifies issues', systemPrompt: 'You review code files and identify issues. Read the file and provide a concise assessment focusing on code quality, potential bugs, and improvements.', tools: NameList::fromArray(['read_file']), )); // Register documentation generator subagent $registry->register(new AgentDefinition( name: 'documenter', description: 'Generates documentation for code', systemPrompt: 'You generate documentation for code. Read the file and create brief, clear documentation explaining what the code does and how to use it.', tools: NameList::fromArray(['read_file']), )); // Build main orchestration agent $agent = AgentBuilder::base() ->withCapability(new UseFileTools($workDir)) ->withCapability(new UseSubagents(provider: $registry)) ->withCapability(new UseGuards(maxSteps: 10, maxTokens: 12288, maxExecutionTime: 90)) ->build() ->wiretap($logger->wiretap()); // Task requiring multiple isolated reviews (small files to keep token usage low) $task = <<withMessages( Messages::fromString($task) ); echo "=== Agent Execution Log ===\n"; echo "Task: Review multiple files using subagents\n\n"; // Execute agent until completion $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $summary = $finalState->finalResponse()->toString() ?: 'No summary'; echo "Answer: {$summary}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert(!empty($finalState->finalResponse()->toString()), 'Expected non-empty response'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_retrospective.md ================================================================================ ## Overview Execution retrospective lets an agent "rewind" its conversation to an earlier checkpoint when it realizes it has been going in circles or took a wrong path. Inspired by kimi-cli's D-Mail mechanism, this capability injects visible `[CHECKPOINT N]` markers before each step. When the agent calls `execution_retrospective(checkpoint_id, guidance)`, the message context is truncated to before that checkpoint and the guidance is injected as a message from the agent's "future self". Key properties: - **Only the message buffer is rewound** — execution history (steps, token usage) is preserved - **Side effects are NOT undone** — file changes, API calls remain; guidance should account for them - **Checkpoint markers are visible to the LLM** — the agent can reference them by ID - **`onRewind` callback** — extension point for user-defined self-improvement (logging, memory, prompt tuning) This significantly reduces wasted steps by: - Cutting dead-end exploration from the context window - Providing focused guidance to the agent's "past self" - Preserving full execution history for observability Key concepts: - `UseExecutionRetrospective`: Capability that adds checkpoint markers, rewind logic, and system prompt instructions - `RetrospectivePolicy`: Configuration (maxRewinds, systemPromptInstructions) - `onRewind`: User callback invoked on every rewind with the result and agent state - `AgentEventConsoleObserver`: Shows checkpoint injection, tool calls, and step progression ## Example ```php withCapability(new UseLLMConfig( llm: LLMProvider::using('anthropic')->withConfigOverrides([ 'model' => 'claude-sonnet-4-5-20250929', 'maxTokens' => 8192, ]), )) ->withCapability(new UseContextConfig( systemPrompt: <<<'SYSTEM' You are a CLI automation agent. You accomplish tasks using bash commands. Always limit command output — use --limit, | head -20, etc. MANDATORY TWO-PASS WORKFLOW — you MUST follow this exactly: Pass 1 (EXPLORE): Discover the right command via --help, trial runs, etc. Once you know the correct command that produces the answer, STOP. Do NOT answer the user's question yet. You MUST call execution_retrospective to rewind to checkpoint 1 with the exact working command as guidance. This is REQUIRED even if you already have the answer — the purpose is to clean the context of exploration noise. Pass 2 (EXECUTE): After rewind, you will see guidance from your future self. Trust it completely. Run the command from guidance. Answer. Done. Do NOT explore again. Do NOT call execution_retrospective again. IMPORTANT: You must ALWAYS rewind after exploration. Never skip the rewind. Answering directly after exploration without rewinding is a workflow violation. SYSTEM, )) ->withCapability(new UseBash(baseDir: $workDir)) ->withCapability(new UseExecutionRetrospective( policy: new RetrospectivePolicy( maxRewinds: 1, systemPromptInstructions: <<<'PROMPT' ## Execution Retrospective (MANDATORY) [CHECKPOINT N] markers appear before each step. You have `execution_retrospective`. RULES: 1. After ANY exploration (--help, trial commands, learning), you MUST call execution_retrospective to rewind to checkpoint 1 with the working command as guidance. This is not optional — it cleans exploration noise from the context. 2. After a rewind, guidance from your future self appears as an assistant message. Trust it completely. Run the command it specifies. Answer. Done. Do NOT explore again. Do NOT call execution_retrospective again. 3. NEVER answer the user's question directly after exploration without rewinding first. PROMPT, ), onRewind: function (ExecutionRetrospectiveResult $result, AgentState $state) use (&$rewindLog) { $rewindLog[] = [ 'checkpoint' => $result->checkpointId, 'guidance' => $result->guidance, 'step' => $state->stepCount(), ]; echo "\n ** REWIND to checkpoint {$result->checkpointId}: {$result->guidance}\n\n"; }, )) ->withCapability(new UseGuards(maxSteps: 20, maxTokens: 65536, maxExecutionTime: 180)) ->build() ->wiretap($logger->wiretap()); // Task: List issues using the `bd` CLI — with zero prior knowledge. // The agent has no idea what `bd` is. It must explore via --help and trial/error. // // Expected flow: // Phase 1 (steps 1-3): Agent explores `bd` (--help, list --help, maybe a wrong attempt) // → Context now polluted with massive help output // Phase 2 (step 4): Agent successfully runs `bd list` // Phase 3 (step 5): Agent recognizes exploration waste → calls execution_retrospective // → Rewinds to checkpoint 1 with guidance: "Run `bd list` to list issues" // Phase 4 (step 6): With clean context, agent one-shots `bd list` and responds // ~6 steps total, but context is clean after rewind $question = <<<'QUESTION' List the 5 most recent open issues tracked in this project. I believe the command is `bd issues --open --limit 5`. QUESTION; $state = AgentState::empty()->withMessages( Messages::fromString($question) ); echo "=== Agent Execution Log ===\n"; echo "Task: List issues using unknown CLI tool (bd)\n\n"; // Execute agent until completion $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $answer = $finalState->finalResponse()->toString() ?: 'No answer'; echo "Answer: {$answer}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($rewindLog !== []) { echo "\n=== Rewind Log ===\n"; foreach ($rewindLog as $i => $entry) { echo "Rewind #{$i}: checkpoint={$entry['checkpoint']}, at step={$entry['step']}\n"; echo " Guidance: {$entry['guidance']}\n"; } } else { echo "\nNo rewinds occurred — agent completed on first attempt.\n"; } if ($finalState->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$finalState->status()->value}.\n"; exit(1); } // Assertions assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_planning_subagent.md ================================================================================ ## Overview `UsePlanningSubagent` exposes planning as a tool (`plan_with_subagent`) that the parent agent can call before implementation. The parent generates a task specification, the planner subagent can use an isolated tool set, and it returns a dense markdown plan back to the parent for execution. This pattern provides: - **Planner isolation**: planning runs in a separate subagent context - **Tool scoping**: planner tools can differ from the parent tool set - **No recursion**: planner toolset automatically removes `spawn_subagent` and `plan_with_subagent` - **Prompt-level guidance**: capability appends instructions describing required specification sections Key concepts: - `UsePlanningSubagent`: installs `plan_with_subagent` and planning instructions - `parentInstructions`: system prompt fragment telling the parent when/how to call planner - parent tool constraints: listed in system prompt so plans stay executable - `plannerSystemPrompt`: specialist prompt for the planning subagent - `plannerTools`: optional allowlist of tools available to planner subagent - `plannerAdditionalTools`: planner-only tools not available to parent execution - `plannerBudget`: optional guard budget for the planner execution ## Example ```php withCapability(new UseTools( ReadFileTool::inDirectory($workDir), )) ->withCapability(new UsePlanningSubagent( parentInstructions: <<withCapability(new UseGuards(maxSteps: 8, maxTokens: 12288, maxExecutionTime: 90)) ->build() ->wiretap($logger->wiretap()); $task = <<<'TASK' Prepare a realistic plan for a small docs update about planning capability in this repository. First, create a plan. Then execute only the analysis part of the plan and provide a concise proposal: 1. Capability purpose section outline 2. Configuration options outline 3. Example usage outline Constraints: - planner subagent may use bash/search for discovery - parent execution may use only read_file - do not modify any files - keep the final response under 180 words TASK; $state = AgentState::empty()->withMessages( Messages::fromString($task) ); echo "=== Agent Execution Log ===\n"; echo "Task: Plan first, then execute\n\n"; $finalState = $agent->execute($state); echo "\n=== Result ===\n"; $answer = $finalState->finalResponse()->toString() ?: 'No answer'; echo "Answer: {$answer}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tokens: {$finalState->usage()->total()}\n"; echo "Status: {$finalState->status()->value}\n"; if ($finalState->status()->value === 'failed') { echo "Skipping assertions because execution status is failed.\n"; exit(1); } $hasAnswer = trim($finalState->finalResponse()->toString()) !== ''; $isStopped = $finalState->status() === ExecutionStatus::Stopped; assert($hasAnswer || $isStopped, 'Expected non-empty response or stopped status'); assert($finalState->stepCount() >= 1, 'Expected at least 1 step'); assert($finalState->usage()->total() > 0, 'Expected token usage > 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D02_AgentBuilder/agent_skills.md ================================================================================ ## Overview Skills extend agents with reusable instruction modules. Each skill is a `SKILL.md` file with YAML frontmatter and markdown instructions, following the Agent Skills Open Standard (agentskills.io) — compatible with 30+ AI tools. This example demonstrates: - `SkillLibrary`: discovering and loading skills from a directory - `UseSkills`: wiring skills into an agent via `AgentBuilder` - Argument substitution (`$ARGUMENTS`, `$0`, `$1`) - Shell preprocessing for dynamic content injection - Invocation control (`disable-model-invocation`, `user-invocable`) - `LoadSkillTool`: the tool the LLM uses to load skills on demand The example uses `FakeAgentDriver` for deterministic execution — no real LLM calls. ## Example ```php renderSkillList() . "\n\n"; echo "=== Model-invocable only (excludes disable-model-invocation: true) ===\n"; echo $library->renderSkillList(modelInvocable: true) . "\n\n"; echo "=== User-invocable only ===\n"; echo $library->renderSkillList(userInvocable: true) . "\n\n"; // -- Step 2: Load a skill with argument substitution -------------------- $skill = $library->getSkill('fix-issue'); echo "=== fix-issue skill rendered with arguments ===\n"; echo $skill->render('42') . "\n\n"; $skill = $library->getSkill('code-review'); echo "=== code-review skill rendered with arguments ===\n"; echo $skill->render('src/Auth/LoginController.php') . "\n\n"; // -- Step 3: Shell preprocessing (command substitution) ---------------- $preprocessor = new SkillPreprocessor(timeoutSeconds: 5); $skill = $library->getSkill('project-context'); echo "=== project-context skill (raw body, before preprocessing) ===\n"; echo $skill->body . "\n\n"; echo "=== project-context skill (after preprocessing + argument substitution) ===\n"; // Preprocessing is automatic when LoadSkillTool has a preprocessor, // but we can also call it directly for demonstration: $processedBody = $preprocessor->process($skill->body); echo $processedBody . "\n\n"; // -- Step 4: Wire skills into an agent ---------------------------------- $agent = AgentBuilder::base() ->withCapability(new UseGuards(maxSteps: 3)) ->withCapability(new UseSkills($library, $preprocessor)) ->build(); echo "=== Agent built with skills capability ===\n"; echo "Tools registered: " . implode(', ', $agent->tools()->names()) . "\n"; echo "Done.\n"; ?> ``` ================================================================================ FILE: cookbook/examples/D03_AgentTemplates/template_from_definition.md ================================================================================ ## Overview Instantiate an agent directly from an in-memory `AgentDefinition`. Key concepts: - `AgentDefinition`: template data object - `DefinitionLoopFactory`: builds executable loop from template - `DefinitionStateFactory`: builds initial state from template - `llmConfig`: selects real LLM provider config for execution - `AgentEventConsoleObserver`: execution visibility ## Example ```php instantiateAgentLoop($definition) ->wiretap($logger->wiretap()); $seed = AgentState::empty()->withUserMessage('What is the capital of France?'); $state = (new DefinitionStateFactory())->instantiateAgentState($definition, $seed); echo "=== Agent Execution Log ===\n\n"; $final = $loop->execute($state); echo "\n=== Result ===\n"; echo 'Answer: ' . ($final->finalResponse()->toString() ?: 'No response') . "\n"; echo 'Steps: ' . $final->stepCount() . "\n"; echo 'Status: ' . $final->status()->value . "\n"; assert($final->status()->value === 'completed', 'Expected completed status, got: ' . $final->status()->value); assert($final->stepCount() >= 1, 'Expected at least 1 step'); ?> ``` ================================================================================ FILE: cookbook/examples/D03_AgentTemplates/template_from_markdown.md ================================================================================ ## Overview Load `AgentDefinition` from a markdown file and execute it. The agent definition declares a tool allow-list in its frontmatter; tools are resolved from a `ToolRegistry` passed to `DefinitionLoopFactory`. ## Example ```php arg($args, 'a', 0, 0); $b = (float) $this->arg($args, 'b', 1, 0); $op = (string) $this->arg($args, 'operation', 2, 'add'); return (string) match ($op) { 'add' => $a + $b, 'subtract' => $a - $b, 'multiply' => $a * $b, 'divide' => $b !== 0.0 ? $a / $b : 'Error: division by zero', default => "Error: unknown operation '{$op}'", }; } #[\Override] public function toToolSchema(): ToolDefinition { return ToolDefinition::fromArray(ToolSchema::make( name: $this->name(), description: $this->description(), parameters: JsonSchema::object('parameters') ->withProperties([ JsonSchema::number('a', 'First operand'), JsonSchema::number('b', 'Second operand'), JsonSchema::string('operation', 'Operation: add, subtract, multiply, or divide'), ]) ->withRequiredProperties(['a', 'b', 'operation']) )->toArray()); } } $logger = new AgentEventConsoleObserver(useColors: true, showTimestamps: true, showContinuation: true, showToolArgs: true); $definition = (new AgentDefinitionLoader()) ->loadFile('examples/D03_AgentTemplates/TemplateFromMarkdown/agent.md'); $capabilities = new AgentCapabilityRegistry(); $tools = new ToolRegistry(); $tools->register(new CalculatorTool()); $loop = (new DefinitionLoopFactory($capabilities, $tools)) ->instantiateAgentLoop($definition) ->wiretap($logger->wiretap()); $question = 'What is 1337 multiplied by 42?'; $seed = AgentState::empty()->withUserMessage($question); $state = (new DefinitionStateFactory())->instantiateAgentState($definition, $seed); echo "=== Agent Execution Log ===\n\n"; $final = $loop->execute($state); echo "\n=== Result ===\n"; echo "Template: {$definition->name}\n"; echo "Question: {$question}\n"; echo 'Answer: ' . ($final->finalResponse()->toString() ?: 'No response') . "\n"; echo 'Status: ' . $final->status()->value . "\n"; assert($definition->name === 'md-agent'); assert($final->status()->value === 'completed', 'Expected completed status, got: ' . $final->status()->value); ?> ``` ================================================================================ FILE: cookbook/examples/D03_AgentTemplates/template_from_yaml.md ================================================================================ ## Overview Load `AgentDefinition` from YAML and run it with the same factories. ## Example ```php loadFile('examples/D03_AgentTemplates/TemplateFromYaml/agent.yaml'); $capabilities = new AgentCapabilityRegistry(); $loop = (new DefinitionLoopFactory($capabilities)) ->instantiateAgentLoop($definition) ->wiretap($logger->wiretap()); $state = (new DefinitionStateFactory())->instantiateAgentState( $definition, AgentState::empty()->withUserMessage('What is 7 multiplied by 8?'), ); echo "=== Agent Execution Log ===\n\n"; $final = $loop->execute($state); echo "\n=== Result ===\n"; echo "Template: {$definition->name}\n"; echo 'Answer: ' . ($final->finalResponse()->toString() ?: 'No response') . "\n"; echo 'Status: ' . $final->status()->value . "\n"; assert($definition->name === 'yaml-agent'); assert($final->status()->value === 'completed', 'Expected completed status, got: ' . $final->status()->value); ?> ``` ================================================================================ FILE: cookbook/examples/D03_AgentTemplates/template_with_tools_and_capabilities.md ================================================================================ ## Overview Template declares both a capability (`guards.basic`) and a tool allow-list. The loop factory resolves both from registries while using a real LLM driver. ## Example ```php register('guards.basic', new UseGuards(maxSteps: 4, maxTokens: 2000, maxExecutionTime: 30)); $tools = new ToolRegistry(); $tools->register(FakeTool::returning( 'city_fact', 'Returns one city fact', 'Paris has a population of about 2.1 million residents.', )); $definition = new AgentDefinition( name: 'tool-agent', description: 'Template-declared tools and capabilities.', systemPrompt: 'Use tools when needed.', llmConfig: 'openai', capabilities: NameList::fromArray(['guards.basic']), tools: NameList::fromArray(['city_fact']), ); $loop = (new DefinitionLoopFactory($capabilities, $tools)) ->instantiateAgentLoop($definition) ->wiretap($logger->wiretap()); $final = $loop->execute(AgentState::empty()->withUserMessage( 'Use city_fact and answer with one short fact about Paris.', )); echo "=== Result ===\n"; echo 'Steps: ' . $final->stepCount() . "\n"; echo 'Final answer: ' . ($final->finalResponse()->toString() ?: 'No response') . "\n"; if ($final->status()->value !== 'completed') { echo "Skipping assertions because execution status is {$final->status()->value}.\n"; exit(1); } assert($final->status()->value === 'completed'); assert($final->finalResponse()->toString() !== ''); ?> ``` ================================================================================ FILE: cookbook/examples/D03_AgentTemplates/template_override_seed_state.md ================================================================================ ## Overview Show how `DefinitionStateFactory` merges template data with a provided seed state. ## Example ```php 'gold', 'region' => 'eu']), ); $seed = AgentState::empty() ->withUserMessage('Keep this message from seed state.') ->withSystemPrompt('Seed prompt should be replaced.') ->withMetadata('session', 'abc-123'); $state = (new DefinitionStateFactory())->instantiateAgentState($definition, $seed); echo "=== Result ===\n"; echo 'System prompt: ' . $state->context()->systemPrompt() . "\n"; echo 'Messages count: ' . $state->messages()->count() . "\n"; echo 'Metadata tier: ' . $state->metadata()->get('tier') . "\n"; echo 'Metadata session: ' . $state->metadata()->get('session') . "\n"; assert($state->context()->systemPrompt() === 'Template prompt overrides seed prompt.'); assert($state->messages()->count() === 1); assert($state->metadata()->get('tier') === 'gold'); assert($state->metadata()->get('session') === 'abc-123'); ?> ``` ================================================================================ FILE: cookbook/examples/D04_AgentSessions/session_create_and_persist.md ================================================================================ ## Overview Create a session from `AgentDefinition`, run one `SendMessage` turn, then persist and reload updated state. ## Example ```php create($definition); $sessionId = SessionId::from($created->sessionId()); $worked = $runtime->execute( $sessionId, new SendMessage('Explain in one sentence why persisted sessions are useful.', $loopFactory), ); $loaded = $repo->load($sessionId); $updated = $repo->save($worked->withState($worked->state()->withMetadata('phase', 'saved'))); echo "=== Result ===\n"; echo 'Session ID: ' . $created->sessionId() . "\n"; echo 'Version after create: ' . $created->version() . "\n"; echo 'Version after send message: ' . $worked->version() . "\n"; echo 'Version after save: ' . $updated->version() . "\n"; echo 'Last response: ' . ($worked->state()->finalResponse()->toString() ?: 'No response') . "\n"; echo 'Metadata phase: ' . ($updated->state()->metadata()->get('phase') ?? 'missing') . "\n"; echo 'Loaded from store: ' . ($loaded !== null ? 'yes' : 'no') . "\n"; assert(!empty($created->sessionId()->toString()), 'Session ID should not be empty'); assert($created->version() >= 1, 'Created session should have version >= 1'); assert($worked->version() > $created->version(), 'Version should increment after SendMessage'); assert($updated->version() > $worked->version(), 'Version should increment after save'); assert(!empty($worked->state()->finalResponse()->toString()), 'SendMessage should produce a response'); assert($updated->state()->metadata()->get('phase') === 'saved', 'Metadata phase should be saved'); assert($loaded !== null, 'Session should be loadable from store'); ?> ``` ================================================================================ FILE: cookbook/examples/D04_AgentSessions/session_runtime_execute_action.md ================================================================================ ## Overview Run one `SendMessage` turn, then lifecycle actions through `SessionRuntime::execute()`. ## Example ```php create(new AgentDefinition( name: 'runtime-agent', description: 'Runtime action demo', systemPrompt: 'You are helpful. Reply in one short sentence.', llmConfig: 'openai', )); $sessionId = SessionId::from($created->sessionId()); $worked = $runtime->execute($sessionId, new SendMessage('Confirm that one work turn was executed.', $loopFactory)); $suspended = $runtime->execute($sessionId, new SuspendSession()); $resumed = $runtime->execute($sessionId, new ResumeSession()); echo "=== Result ===\n"; echo 'Initial status: ' . $created->status()->value . "\n"; echo 'After work turn response: ' . ($worked->state()->finalResponse()->toString() ?: 'No response') . "\n"; echo 'After suspend: ' . $suspended->status()->value . "\n"; echo 'After resume: ' . $resumed->status()->value . "\n"; echo 'Current version: ' . $resumed->version() . "\n"; assert($created->status()->value === 'active', 'Initial status should be active'); assert(!empty($worked->state()->finalResponse()->toString()), 'Work turn should produce a response'); assert($suspended->status()->value === 'suspended', 'Status after suspend should be suspended'); assert($resumed->status()->value === 'active', 'Status after resume should be active'); assert($resumed->version() > $created->version(), 'Version should increment through lifecycle'); ?> ``` ================================================================================ FILE: cookbook/examples/D04_AgentSessions/session_runtime_read_apis.md ================================================================================ ## Overview Use runtime read APIs after real session work: `getSession`, `getSessionInfo`, and `listSessions`. ## Example ```php create(new AgentDefinition( name: 'agent-one', description: 'first', systemPrompt: 'You are one.', llmConfig: 'openai', )); $two = $runtime->create(new AgentDefinition( name: 'agent-two', description: 'second', systemPrompt: 'You are two.', llmConfig: 'openai', )); $runtime->execute($one->sessionId(), new SendMessage('Say one sentence about session one.', $loopFactory)); $runtime->execute($two->sessionId(), new SendMessage('Say one sentence about session two.', $loopFactory)); $sessionId = $one->sessionId(); $session = $runtime->getSession($sessionId); $info = $runtime->getSessionInfo($sessionId); $list = $runtime->listSessions(); echo "=== Result ===\n"; echo 'Loaded session: ' . $session->sessionId() . "\n"; echo 'Session info status: ' . $info->status()->value . "\n"; echo 'Session message count: ' . $session->state()->messages()->count() . "\n"; echo 'Session last response: ' . ($session->state()->finalResponse()->toString() ?: 'No response') . "\n"; echo 'List count: ' . $list->count() . "\n"; assert($session !== null, 'getSession should return a session'); assert($session->sessionId()->toString() === $sessionId->toString(), 'Loaded session ID should match requested ID'); assert($info->status()->value === 'active', 'Session info status should be active'); assert($session->state()->messages()->count() > 0, 'Session should have messages after SendMessage'); assert(!empty($session->state()->finalResponse()->toString()), 'Session should have a last response'); assert($list->count() === 2, 'listSessions should return both sessions'); ?> ``` ================================================================================ FILE: cookbook/examples/D04_AgentSessions/session_send_message_action.md ================================================================================ ## Overview Use `SendMessage` action to wake up the agent loop from persisted session state, execute turns, and persist updated state between wake-ups. ## Example ```php create(new AgentDefinition( name: 'message-agent', description: 'Executes SendMessage action.', systemPrompt: 'You are a geography assistant. Answer in one short sentence.', llmConfig: 'openai', )); $sessionId = SessionId::from($created->sessionId()); $loopFactoryWithLogger = new class($loopFactory, $logger) implements \Cognesy\Agents\Template\Contracts\CanInstantiateAgentLoop { public function __construct( private readonly DefinitionLoopFactory $factory, private readonly AgentEventConsoleObserver $logger, ) {} public function instantiateAgentLoop(AgentDefinition $definition): \Cognesy\Agents\CanControlAgentLoop { echo "[runtime] Rebuilding agent loop from saved definition: {$definition->name}\n"; return $this->factory->instantiateAgentLoop($definition)->wiretap($this->logger->wiretap()); } }; echo "=== Agent Execution Log ===\n\n"; echo "[runtime] Wake-up #1: loading persisted session {$sessionId->toString()}\n"; $beforeFirstWakeUp = $runtime->getSession($sessionId); echo "[runtime] Wake-up #1: loaded version {$beforeFirstWakeUp->version()}, messages={$beforeFirstWakeUp->state()->messages()->count()}\n"; $afterFirstWakeUp = $runtime->execute( $sessionId, new SendMessage('What is the capital of France?', $loopFactoryWithLogger), ); echo "[runtime] Wake-up #2: loading persisted session {$sessionId->toString()}\n"; $beforeSecondWakeUp = $runtime->getSession($sessionId); echo "[runtime] Wake-up #2: loaded version {$beforeSecondWakeUp->version()}, messages={$beforeSecondWakeUp->state()->messages()->count()}\n"; $afterSecondWakeUp = $runtime->execute( $sessionId, new SendMessage('What is the closest major river to that city?', $loopFactoryWithLogger), ); echo "\n=== Result ===\n"; echo 'Version after first wake-up: ' . $afterFirstWakeUp->version() . "\n"; echo 'Version after second wake-up: ' . $afterSecondWakeUp->version() . "\n"; echo 'Conversation messages count: ' . $afterSecondWakeUp->state()->messages()->count() . "\n"; echo 'Last response: ' . ($afterSecondWakeUp->state()->finalResponse()->toString() ?: 'No response') . "\n"; echo "\nConversation transcript:\n"; echo $afterSecondWakeUp->state()->messages()->toString() . "\n"; assert($afterFirstWakeUp->version() > $created->version(), 'Version should increment after first wake-up'); assert($afterSecondWakeUp->version() > $afterFirstWakeUp->version(), 'Version should increment after second wake-up'); assert($afterSecondWakeUp->state()->messages()->count() > $afterFirstWakeUp->state()->messages()->count(), 'Message count should grow after second wake-up'); assert(!empty($afterFirstWakeUp->state()->finalResponse()->toString()), 'First wake-up should produce a response'); assert(!empty($afterSecondWakeUp->state()->finalResponse()->toString()), 'Second wake-up should produce a response'); ?> ``` ================================================================================ FILE: cookbook/examples/D04_AgentSessions/session_fork_action.md ================================================================================ ## Overview Fork an existing session into a new one, then continue each branch independently. ## Example ```php name}\n"; return $this->factory->instantiateAgentLoop($definition)->wiretap($this->logger->wiretap()); } }; $repo = new SessionRepository(new InMemorySessionStore()); $runtime = new SessionRuntime($repo, new EventDispatcher('session-runtime-example')); $parent = $runtime->create(new AgentDefinition( name: 'parent-agent', description: 'Travel planner session', systemPrompt: 'You are a travel planner. Answer in one short sentence.', llmConfig: 'openai', )); $parentId = $parent->sessionId(); echo "=== Agent Execution Log ===\n\n"; echo "[runtime] Seed parent session {$parentId->toString()}\n"; $parentWithContext = $runtime->execute( $parentId, new SendMessage('Suggest 3 attractions in Paris.', $loopFactoryWithLogger), ); $forkedId = SessionId::from('forked-session-demo'); $forked = (new ForkSession($forkedId))->executeOn($parentWithContext); $storedFork = $repo->create($forked); echo "[runtime] Fork created {$storedFork->sessionId()} from parent {$parentId->toString()}\n"; $parentBranch = $runtime->execute( $parentId, new SendMessage('Now add one low-budget food recommendation.', $loopFactoryWithLogger), ); $forkBranch = $runtime->execute( $forkedId, new SendMessage('Now add one luxury dining recommendation.', $loopFactoryWithLogger), ); echo "\n=== Result ===\n"; echo 'Parent session: ' . $parentId->toString() . "\n"; echo 'Forked session: ' . $storedFork->sessionId() . "\n"; echo 'Fork parent ID: ' . ($storedFork->info()->parentId()?->value ?? 'none') . "\n"; echo "\nParent branch last response:\n"; echo ($parentBranch->state()->finalResponse()->toString() ?: 'No response') . "\n"; echo "\nFork branch last response:\n"; echo ($forkBranch->state()->finalResponse()->toString() ?: 'No response') . "\n"; echo "\nParent transcript:\n"; echo $parentBranch->state()->messages()->toString() . "\n"; echo "\nFork transcript:\n"; echo $forkBranch->state()->messages()->toString() . "\n"; assert($storedFork->sessionId()->toString() === $forkedId->toString(), 'Forked session should have the requested ID'); assert($storedFork->info()->parentId() !== null, 'Forked session should reference a parent'); assert($storedFork->info()->parentId()->value === $parentId->toString(), 'Fork parent ID should match original session'); assert(!empty($parentBranch->state()->finalResponse()->toString()), 'Parent branch should have a response'); assert(!empty($forkBranch->state()->finalResponse()->toString()), 'Fork branch should have a response'); ?> ``` ================================================================================ FILE: cookbook/examples/D04_AgentSessions/session_conflict_handling.md ================================================================================ ## Overview Conflicts are explicit exceptions. This example runs one work turn, then simulates a stale write conflict. ## Example ```php create(new AgentDefinition( name: 'conflict-agent', description: 'Conflict demo', systemPrompt: 'You are helpful. Reply in one sentence.', llmConfig: 'openai', )); $sessionId = SessionId::from($created->sessionId()); $worked = $runtime->execute( $sessionId, new SendMessage('Do one short task before conflict simulation.', $loopFactory), ); $copyA = $repo->load($sessionId); $copyB = $repo->load($sessionId); if ($copyA === null || $copyB === null) { throw new RuntimeException('Expected both copies to be loaded'); } $repo->save($copyA->withState($copyA->state()->withMetadata('writer', 'A'))); echo "=== Result ===\n"; echo 'Version after work turn: ' . $worked->version() . "\n"; echo 'Work response: ' . ($worked->state()->finalResponse()->toString() ?: 'No response') . "\n"; try { $repo->save($copyB->withState($copyB->state()->withMetadata('writer', 'B'))); echo "Unexpected: no conflict raised\n"; } catch (SessionConflictException $e) { echo 'Conflict detected as expected: ' . $e->getMessage() . "\n"; } assert($worked->version() > $created->version(), 'Version should increment after work turn'); assert(!empty($worked->state()->finalResponse()->toString()), 'Work turn should produce a response'); $conflictCaught = false; try { $repo->save($copyB->withState($copyB->state()->withMetadata('writer', 'B2'))); } catch (SessionConflictException) { $conflictCaught = true; } assert($conflictCaught, 'Stale write should throw SessionConflictException'); ?> ``` ================================================================================ FILE: cookbook/examples/D04_AgentSessions/session_runtime_hooks.md ================================================================================ ## Overview Intercept `SessionRuntime` lifecycle stages with `SessionHookStack` to apply cross-cutting session policies without replacing runtime flow. ## Example ```php */ public array $stages = []; }; $hook = new class($trace) implements CanControlAgentSession { public function __construct(private object $trace) {} public function onStage(AgentSessionStage $stage, AgentSession $session): AgentSession { $this->trace->stages[] = $stage->value; return match ($stage) { AgentSessionStage::AfterLoad => $session->withState( $session->state()->withMetadata('hook.after_load', true) ), AgentSessionStage::AfterAction => $session->withState( $session->state()->withMetadata('hook.after_action', true) ), AgentSessionStage::BeforeSave => $session->suspended(), default => $session, }; } }; $hooks = SessionHookStack::empty()->with($hook, priority: 100); $runtime = new SessionRuntime($repo, $events, $hooks); $created = $runtime->create(new AgentDefinition( name: 'hooks-agent', description: 'Session hooks demo', systemPrompt: 'You are helpful. Reply in one short sentence.', llmConfig: 'openai', )); $sessionId = SessionId::from($created->sessionId()); $updated = $runtime->execute( $sessionId, new SendMessage('Do one short task while hooks are active.', $loopFactory), ); $loaded = $runtime->getSession($sessionId); echo "=== Result ===\n"; echo 'Status after execute: ' . $updated->status()->value . "\n"; echo 'Persisted status: ' . $loaded->status()->value . "\n"; echo 'Hook stage trace: ' . implode(', ', $trace->stages) . "\n"; echo 'Metadata hook.after_load: ' . (($loaded->state()->metadata()->get('hook.after_load') ?? false) ? 'true' : 'false') . "\n"; echo 'Last response: ' . ($loaded->state()->finalResponse()->toString() ?: 'No response') . "\n"; assert(!empty($trace->stages), 'Hook stage trace should not be empty'); assert(in_array('after_load', $trace->stages), 'Hook should have fired after_load stage'); assert(in_array('after_action', $trace->stages), 'Hook should have fired after_action stage'); assert(in_array('before_save', $trace->stages), 'Hook should have fired before_save stage'); assert($loaded->status()->value === 'suspended', 'BeforeSave hook should have suspended the session'); assert($loaded->state()->metadata()->get('hook.after_load') === true, 'AfterLoad hook should have set metadata'); assert(!empty($loaded->state()->finalResponse()->toString()), 'Session should have a response'); ?> ``` ================================================================================ FILE: cookbook/examples/D05_AgentTroubleshooting/agent_eventlog_readback.md ================================================================================ ## Overview This example enables `EventLog` file logging for the default agent runtime, executes one simple agent run, then reads the generated JSONL file and prints the captured agent lifecycle entries on screen. Key concepts: - `EventLog::enable()`: activates the default JSONL sink - `AgentLoop::default()`: uses the default agent runtime event bus - JSONL readback: inspect execution lifecycle after the run completes ## Example ```php withMessages( Messages::fromString('What are the three primary colors? Answer in one sentence.') ); $finalState = $loop->execute($state); $entries = ExampleEventLog::read($logPath); } finally { EventLog::disable(); } $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "=== Agent Result ===\n"; echo "Answer: {$response}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Status: {$finalState->status()->value}\n"; echo "\n=== EventLog Entries ===\n"; echo "Log file: {$logPath}\n"; echo 'Entries captured: ' . count($entries) . "\n\n"; ExampleEventLog::print($entries, 8); assert($finalState->status() === ExecutionStatus::Completed); assert($response !== ''); assert($entries !== []); ?> ``` ================================================================================ FILE: cookbook/examples/D05_AgentTroubleshooting/agent_subagent_telemetry_langfuse.md ================================================================================ ## Overview This example extends the existing agent telemetry pattern with delegated work. The parent agent stays visible in the console, but the same event stream is also projected to Langfuse so you can inspect the full parent and subagent trace tree. Key concepts: - `UseSubagents`: lets the parent delegate work through `spawn_subagent` - `AgentDefinitionRegistry`: defines the available delegated workers - `RuntimeEventBridge`: projects the full parent and child event stream into telemetry - `AgentsTelemetryProjector`: maps agent execution, tool, and subagent lifecycle events - `UseBash`: gives the delegated subagent a real tool boundary to emit nested telemetry ## Example ```php attachTo($events); $logger = new AgentEventConsoleObserver( useColors: true, showTimestamps: true, showContinuation: true, showToolArgs: true, ); $workDir = dirname(__DIR__, 3); $registry = new AgentDefinitionRegistry(); $registry->register(new AgentDefinition( name: 'repo_inspector', description: 'Inspects repository paths with bash and reports concise evidence', systemPrompt: <<<'SYSTEM' You inspect repositories with the bash tool. Every factual claim must come from bash output. Use bash at least 3 separate times. Never combine commands with && or ;. SYSTEM, tools: NameList::fromArray(['bash']), )); $subagentSpawns = []; $subagentCompletions = []; $agent = AgentBuilder::base($events) ->withCapability(new UseContextConfig(systemPrompt: <<<'SYSTEM' You are an orchestration agent. If `repo_inspector` is available, you must delegate repository inspection to it. Your first tool call must be `spawn_subagent`. Do not use bash directly when `repo_inspector` can do the work. If you skip delegation, the task is incomplete. Summarize delegated findings clearly and briefly. SYSTEM)) ->withCapability(new UseLLMConfig(llm: LLMProvider::using('openai'))) ->withCapability(new UseBash(baseDir: $workDir)) ->withCapability(new UseSubagents(provider: $registry)) ->withCapability(new UseGuards(maxSteps: 8, maxTokens: 12288, maxExecutionTime: 60)) ->build() ->wiretap($logger->wiretap()) ->wiretap(static function (object $event) use (&$subagentSpawns, &$subagentCompletions): void { if ($event instanceof SubagentSpawning) { $subagentSpawns[] = $event; } if ($event instanceof SubagentCompleted) { $subagentCompletions[] = $event; } }); $task = <<<'TASK' You must call `spawn_subagent` as your first tool call. Use subagent `repo_inspector`. Do not call bash yourself. Pass this task to the subagent: Inspect this repository with bash. Requirements: - Use bash at least 3 separate times. - Do not combine commands with && or ;. - Run these as separate bash calls: 1. pwd 2. ls examples/D05_AgentTroubleshooting 3. rg -n "UseSubagents|SpawnSubagentTool|SubagentSpawning" packages/agents/src/Capability/Subagent/*.php packages/agents/src/Events/Subagent*.php Return exactly 3 short bullets: 1. What kind of repository this is 2. Where the agent telemetry examples live 3. What the subagent telemetry path records After the subagent returns, provide only its 3 bullets as the final answer. TASK; $state = AgentState::empty()->withMessages(Messages::fromString($task)); echo "=== Agent Execution Log ===\n\n"; $finalState = $agent->execute($state); $hub->flush(); $collectToolNames = function (AgentState $state) use (&$collectToolNames): array { return array_reduce( $state->stepExecutions()->all(), static function (array $names, $stepExecution) use (&$collectToolNames): array { $stepNames = []; foreach ($stepExecution->step()->toolExecutions()->all() as $toolExecution) { $stepNames[] = $toolExecution->name(); $value = $toolExecution->value(); if ($value instanceof AgentState) { $stepNames = [...$stepNames, ...$collectToolNames($value)]; } } return [...$names, ...$stepNames]; }, [], ); }; $toolNames = $collectToolNames($finalState); $toolCallCount = count($toolNames); $bashCallCount = count(array_filter($toolNames, static fn(string $name): bool => $name === 'bash')); $spawnSubagentCount = count(array_filter($toolNames, static fn(string $name): bool => $name === 'spawn_subagent')); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Status: {$finalState->status()->value}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tools used: " . implode(' > ', $toolNames) . "\n"; echo "Total tool calls: {$toolCallCount}\n"; echo "Bash calls across parent/child runs: {$bashCallCount}\n"; echo "spawn_subagent calls: {$spawnSubagentCount}\n"; echo "Subagents spawned: " . count($subagentSpawns) . "\n"; echo "Subagents completed: " . count($subagentCompletions) . "\n"; echo "Telemetry: flushed to Langfuse\n"; assert($finalState->status() === ExecutionStatus::Completed); assert($response !== ''); assert($spawnSubagentCount >= 1); assert(count($subagentSpawns) >= 1); assert(count($subagentCompletions) >= 1); assert($bashCallCount >= 3); ?> ``` ================================================================================ FILE: cookbook/examples/D05_AgentTroubleshooting/agent_subagent_telemetry_logfire.md ================================================================================ ## Overview This example extends the existing agent telemetry pattern with delegated work. The parent agent stays visible in the console, but the same event stream is also projected to Logfire so you can inspect the full parent and subagent trace tree. Key concepts: - `UseSubagents`: lets the parent delegate work through `spawn_subagent` - `AgentDefinitionRegistry`: defines the available delegated workers - `RuntimeEventBridge`: projects the full parent and child event stream into telemetry - `AgentsTelemetryProjector`: maps agent execution, tool, and subagent lifecycle events - `UseBash`: gives the delegated subagent a real tool boundary to emit nested telemetry ## Example ```php attachTo($events); $logger = new AgentEventConsoleObserver( useColors: true, showTimestamps: true, showContinuation: true, showToolArgs: true, ); $workDir = dirname(__DIR__, 3); $registry = new AgentDefinitionRegistry(); $registry->register(new AgentDefinition( name: 'repo_inspector', description: 'Inspects repository paths with bash and reports concise evidence', systemPrompt: <<<'SYSTEM' You inspect repositories with the bash tool. Every factual claim must come from bash output. Use bash at least 3 separate times. Never combine commands with && or ;. SYSTEM, tools: NameList::fromArray(['bash']), )); $subagentSpawns = []; $subagentCompletions = []; $agent = AgentBuilder::base($events) ->withCapability(new UseContextConfig(systemPrompt: <<<'SYSTEM' You are an orchestration agent. When repository inspection is needed and a suitable subagent exists, delegate the inspection. Do not use bash directly when `repo_inspector` can do the work. Summarize delegated findings clearly and briefly. SYSTEM)) ->withCapability(new UseLLMConfig(llm: LLMProvider::using('openai'))) ->withCapability(new UseBash(baseDir: $workDir)) ->withCapability(new UseSubagents(provider: $registry)) ->withCapability(new UseGuards(maxSteps: 8, maxTokens: 12288, maxExecutionTime: 60)) ->build() ->wiretap($logger->wiretap()) ->wiretap(static function (object $event) use (&$subagentSpawns, &$subagentCompletions): void { if ($event instanceof SubagentSpawning) { $subagentSpawns[] = $event; } if ($event instanceof SubagentCompleted) { $subagentCompletions[] = $event; } }); $task = <<<'TASK' Inspect this repository by delegating the inspection to the `repo_inspector` subagent. Requirements for the delegated subagent work: - Use bash at least 3 separate times. - Do not combine commands with && or ;. - Run these as separate bash calls: 1. pwd 2. ls examples/D05_AgentTroubleshooting 3. rg -n "UseSubagents|SpawnSubagentTool|SubagentSpawning" packages/agents/src/Capability/Subagent/*.php packages/agents/src/Events/Subagent*.php Then answer in 3 short bullets: 1. What kind of repository this is 2. Where the agent telemetry examples live 3. What the subagent telemetry path records TASK; $state = AgentState::empty()->withMessages(Messages::fromString($task)); echo "=== Agent Execution Log ===\n\n"; $finalState = $agent->execute($state); $hub->flush(); $collectToolNames = function (AgentState $state) use (&$collectToolNames): array { return array_reduce( $state->stepExecutions()->all(), static function (array $names, $stepExecution) use (&$collectToolNames): array { $stepNames = []; foreach ($stepExecution->step()->toolExecutions()->all() as $toolExecution) { $stepNames[] = $toolExecution->name(); $value = $toolExecution->value(); if ($value instanceof AgentState) { $stepNames = [...$stepNames, ...$collectToolNames($value)]; } } return [...$names, ...$stepNames]; }, [], ); }; $toolNames = $collectToolNames($finalState); $toolCallCount = count($toolNames); $bashCallCount = count(array_filter($toolNames, static fn(string $name): bool => $name === 'bash')); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Status: {$finalState->status()->value}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tools used: " . implode(' > ', $toolNames) . "\n"; echo "Total tool calls: {$toolCallCount}\n"; echo "Bash calls across parent/child runs: {$bashCallCount}\n"; echo "Subagents spawned: " . count($subagentSpawns) . "\n"; echo "Subagents completed: " . count($subagentCompletions) . "\n"; echo "Telemetry: flushed to Logfire\n"; assert($finalState->status() === ExecutionStatus::Completed); assert($response !== ''); assert(count($subagentSpawns) >= 1); assert(count($subagentCompletions) >= 1); assert($bashCallCount >= 3); ?> ``` ================================================================================ FILE: cookbook/examples/D05_AgentTroubleshooting/agent_telemetry_langfuse.md ================================================================================ ## Overview This example keeps the normal agent console event output, but also exports the same execution lifecycle to Langfuse. It is a practical pattern for debugging agent behavior locally while still keeping a remote execution trace. Key concepts: - `AgentEventConsoleObserver`: local event visibility during execution - `RuntimeEventBridge`: turns the agent event stream into telemetry - `AgentsTelemetryProjector`: maps agent lifecycle events - `PolyglotTelemetryProjector`: captures the nested LLM work done by the agent - `UseBash`: forces the example to emit telemetry for real tool execution, not just one LLM response ## Example ```php attachTo($events); $logger = new AgentEventConsoleObserver( useColors: true, showTimestamps: true, showContinuation: true, showToolArgs: true, ); $workDir = dirname(__DIR__, 3); $agent = AgentBuilder::base($events) ->withCapability(new UseContextConfig(systemPrompt: <<<'SYSTEM' You inspect repositories with the bash tool. Every factual claim must come from a bash command. Use bash at least 3 separate times. Never combine commands with && or ;. SYSTEM)) ->withCapability(new UseLLMConfig(llm: LLMProvider::using('openai'))) ->withCapability(new UseBash(baseDir: $workDir)) ->withCapability(new UseGuards(maxSteps: 6, maxTokens: 8192, maxExecutionTime: 45)) ->build() ->wiretap($logger->wiretap()); $task = <<<'TASK' Inspect this repository with the bash tool. Requirements: - Use the bash tool at least 3 separate times. - Do not combine commands with && or ;. - Run these as separate bash calls: 1. pwd 2. ls examples/D05_AgentTroubleshooting 3. rg -n "UseBash|BashTool" packages/agents/src/Capability/Bash/*.php Then answer in 3 short bullets: 1. What kind of repository this is 2. Where the agent telemetry examples live 3. How bash tooling is enabled for agents TASK; $state = AgentState::empty()->withMessages(Messages::fromString($task)); echo "=== Agent Execution Log ===\n\n"; $finalState = $agent->execute($state); $hub->flush(); $toolNames = array_reduce( $finalState->stepExecutions()->all(), static function (array $names, $stepExecution): array { $stepNames = array_map( static fn($toolExecution): string => $toolExecution->name(), $stepExecution->step()->toolExecutions()->all(), ); return [...$names, ...$stepNames]; }, [], ); $toolCallCount = count($toolNames); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Status: {$finalState->status()->value}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tools used: " . implode(' > ', $toolNames) . "\n"; echo "Total tool calls: {$toolCallCount}\n"; echo "Telemetry: flushed to Langfuse\n"; assert($finalState->status() === ExecutionStatus::Completed); assert($response !== ''); assert($toolCallCount >= 3); ?> ``` ================================================================================ FILE: cookbook/examples/D05_AgentTroubleshooting/agent_telemetry_logfire.md ================================================================================ ## Overview This example keeps the normal agent console event output, but also exports the same execution lifecycle to Logfire. It is a practical pattern for debugging agent behavior locally while still keeping a remote execution trace. Key concepts: - `AgentEventConsoleObserver`: local event visibility during execution - `RuntimeEventBridge`: turns the agent event stream into telemetry - `AgentsTelemetryProjector`: maps agent lifecycle events - `PolyglotTelemetryProjector`: captures the nested LLM work done by the agent - `UseBash`: forces the example to emit telemetry for real tool execution, not just one LLM response ## Example ```php attachTo($events); $logger = new AgentEventConsoleObserver( useColors: true, showTimestamps: true, showContinuation: true, showToolArgs: true, ); $workDir = dirname(__DIR__, 3); $agent = AgentBuilder::base($events) ->withCapability(new UseContextConfig(systemPrompt: <<<'SYSTEM' You inspect repositories with the bash tool. Every factual claim must come from a bash command. Use bash at least 3 separate times. Never combine commands with && or ;. SYSTEM)) ->withCapability(new UseLLMConfig(llm: LLMProvider::using('openai'))) ->withCapability(new UseBash(baseDir: $workDir)) ->withCapability(new UseGuards(maxSteps: 6, maxTokens: 8192, maxExecutionTime: 45)) ->build() ->wiretap($logger->wiretap()); $task = <<<'TASK' Inspect this repository with the bash tool. Requirements: - Use the bash tool at least 3 separate times. - Do not combine commands with && or ;. - Run these as separate bash calls: 1. pwd 2. ls examples/D05_AgentTroubleshooting 3. rg -n "UseBash|BashTool" packages/agents/src/Capability/Bash/*.php Then answer in 3 short bullets: 1. What kind of repository this is 2. Where the agent telemetry examples live 3. How bash tooling is enabled for agents TASK; $state = AgentState::empty()->withMessages(Messages::fromString($task)); echo "=== Agent Execution Log ===\n\n"; $finalState = $agent->execute($state); $hub->flush(); $toolNames = array_reduce( $finalState->stepExecutions()->all(), static function (array $names, $stepExecution): array { $stepNames = array_map( static fn($toolExecution): string => $toolExecution->name(), $stepExecution->step()->toolExecutions()->all(), ); return [...$names, ...$stepNames]; }, [], ); $toolCallCount = count($toolNames); echo "\n=== Result ===\n"; $response = $finalState->finalResponse()->toString() ?: 'No response'; echo "Answer: {$response}\n"; echo "Status: {$finalState->status()->value}\n"; echo "Steps: {$finalState->stepCount()}\n"; echo "Tools used: " . implode(' > ', $toolNames) . "\n"; echo "Total tool calls: {$toolCallCount}\n"; echo "Telemetry: flushed to Logfire\n"; assert($finalState->status() === ExecutionStatus::Completed); assert($response !== ''); assert($toolCallCount >= 3); ?> ``` ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/agent_ctrl_basic.md ================================================================================ ## Overview AgentCtrl provides a unified interface for executing prompts against CLI-based code agents (like Claude Code, OpenCode, Codex, etc.). This example demonstrates the simplest possible usage: sending a prompt and receiving a structured response with metadata. Key concepts: - `AgentCtrl::make()`: Factory for creating agent instances - `AgentType`: Enum specifying which CLI agent to use - `AgentResponse`: Structured response with text, session info, usage stats, and cost - `AgentCtrlConsoleLogger`: Provides visibility into agent execution stages ## Example ```php wiretap($logger->wiretap()) ->withConfig(new AgentCtrlConfig( timeout: 300, workingDirectory: getcwd() ?: null, )) ->execute('Explain the SOLID principles in software design. List each principle with a one-line explanation.'); echo "\n=== Result ===\n"; if ($response->isSuccess()) { echo "Answer: " . $response->text() . "\n"; echo "Agent: {$response->agentType->value}\n"; if ($response->sessionId()) { echo "Session: {$response->sessionId()}\n"; } if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } } else { echo "ERROR: Request failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [opencode] [EXEC] Execution started [prompt=Explain the SOLID principles...] 14:32:16.456 [opencode] [DONE] Execution completed [exit=0, tools=0, tokens=198] === Result === Answer: The SOLID principles are: 1. Single Responsibility Principle (SRP): A class should have only one reason to change 2. Open/Closed Principle (OCP): Open for extension but closed for modification 3. Liskov Substitution Principle (LSP): Derived classes must be substitutable for base classes 4. Interface Segregation Principle (ISP): Don't force clients to depend on unused interfaces 5. Dependency Inversion Principle (DIP): Depend on abstractions, not concretions Agent: opencode Session: session-abc123 Tokens: 42 in / 156 out Cost: $0.0023 ``` ## Key Points - **Unified interface**: Same API works across different CLI agents - **Agent selection**: Use `AgentType` enum to specify which agent to use - **Console logger**: `AgentCtrlConsoleLogger` shows execution stages with color-coded labels - **Response metadata**: Access session IDs, token usage, and cost information - **Typed config**: `AgentCtrlConfig` packages shared builder options into one object - **Error handling**: Check `isSuccess()` before accessing response data - **Simple execution**: One method call (`execute()`) handles the entire interaction ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/agent_ctrl_events.md ================================================================================ ## Overview AgentCtrl provides a comprehensive event system for monitoring agent execution. Use the built-in `AgentCtrlConsoleLogger` for formatted output, or attach custom listeners for targeted monitoring. Events fire in real-time during execution. Key concepts: - `AgentCtrlConsoleLogger`: Built-in wiretap that formats events for console output - `wiretap()`: Observe ALL events with a single callback - `onEvent()`: Listen to specific event types (started, completed, text received, etc.) - Event types: `AgentExecutionStarted`, `AgentTextReceived`, `AgentToolUsed`, `AgentExecutionCompleted`, `AgentErrorOccurred` ## Example ```php wiretap($logger->wiretap()); // 2. Targeted listeners: subscribe to specific event types $agent->onEvent(AgentToolUsed::class, function (AgentToolUsed $event) { echo "\n >>> Tool used: {$event->tool}\n\n"; }); $agent->onEvent(AgentExecutionCompleted::class, function (AgentExecutionCompleted $event) { echo "\n=== Execution Complete ===\n"; echo " Tools: {$event->toolCallCount}\n"; if ($event->cost !== null) { echo " Cost: $" . number_format($event->cost, 4) . "\n"; } $tokens = ($event->inputTokens ?? 0) + ($event->outputTokens ?? 0); if ($tokens > 0) { echo " Tokens: {$tokens}\n"; } }); // Run the agent echo "=== Agent Execution Log ===\n\n"; $response = $agent->executeStreaming('List files in current directory and explain what you see.'); echo "\n=== Result ===\n"; if ($response->isSuccess()) { echo "Answer: " . $response->text() . "\n"; } else { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [opencode] [EXEC] Execution started [prompt=List files in current directory...] 14:32:15.234 [opencode] [PROC] Process started [commands=1] 14:32:15.456 [opencode] [TEXT] Text received [length=48] 14:32:16.234 [opencode] [TOOL] bash {command=ls -la} >>> Tool used: bash 14:32:16.567 [opencode] [TEXT] Text received [length=156] 14:32:17.890 [opencode] [DONE] Execution completed [exit=0, tools=1, cost=$0.0034, tokens=198] === Execution Complete === Tools: 1 Cost: $0.0034 Tokens: 198 === Result === Answer: The directory contains several PHP project files including composer.json for dependencies, a src/ directory with source code, and configuration files. ``` ## Key Points - **Console logger**: `AgentCtrlConsoleLogger` provides clean, color-coded event output with configurable toggles - **Wiretap pattern**: Observe all events with `wiretap()` for comprehensive logging - **Targeted listening**: Use `onEvent()` for specific event types when you only care about certain events - **Composable**: Combine the console logger with targeted listeners - **Real-time monitoring**: Events fire as execution progresses, not after completion - **Rich metadata**: Events include timestamps, model info, token usage, costs, and tool details - **Use cases**: Logging, telemetry, progress bars, debugging, analytics ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/agent_ctrl_streaming.md ================================================================================ ## Overview Streaming execution provides real-time visibility into agent operations. Instead of waiting for completion, you see text output and tool calls as they happen. Combine streaming callbacks with the `AgentCtrlConsoleLogger` for full execution introspection. Key concepts: - `executeStreaming()`: Execute with real-time output instead of waiting for completion - `onText()`: Callback for each text chunk as it arrives - `onToolUse()`: Callback for each tool call with inputs and outputs - `AgentCtrlConsoleLogger`: Shows execution lifecycle alongside streaming output - `withMaxTurns()`: Limit the number of agent loop iterations ## Example ```php wiretap($logger->wiretap()) ->withMaxTurns(10) ->onText(function (string $text) { // Stream text as it arrives echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) use (&$toolCalls) { // Show each tool call as it happens $target = $input['pattern'] ?? $input['file_path'] ?? $input['command'] ?? ''; if (strlen($target) > 40) { $target = '...' . substr($target, -37); } $toolCalls[] = $tool; echo "\n >> [{$tool}] {$target}\n"; }) ->executeStreaming('Find the AgentCtrl class and explain the make() factory method. Be concise.'); echo "\n=== Result ===\n"; if (!$response->isSuccess()) { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } echo "Tools used: " . implode(' > ', $toolCalls) . "\n"; echo "Total tool calls: " . count($toolCalls) . "\n"; if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [claude-code] [EXEC] Execution started [model=claude-sonnet-4-5-20250514, prompt=Find the AgentCtrl class...] 14:32:15.234 [claude-code] [PROC] Process started [commands=1] I'll search for the AgentCtrl class to understand the factory pattern. 14:32:15.456 [claude-code] [TOOL] Glob {pattern=src/**/*AgentCtrl*.php} >> [Glob] src/**/*AgentCtrl*.php I found the AgentCtrl class. Let me examine the make() method. 14:32:16.234 [claude-code] [TOOL] Read {file_path=src/AgentCtrl/AgentCtrl.php} >> [Read] src/AgentCtrl/AgentCtrl.php The make() factory method provides a clean way to instantiate agents by: 1. Accepting an AgentType enum to specify which CLI agent to use 2. Returning a configured AgentCtrl instance 3. Allowing method chaining for further configuration 14:32:17.890 [claude-code] [DONE] Execution completed [exit=0, tools=2, cost=$0.0021, tokens=214] === Result === Tools used: Glob > Read Total tool calls: 2 Tokens: 125 in / 89 out Cost: $0.0021 ``` ## Key Points - **Real-time output**: Text appears as the agent generates it, not after completion - **Console logger**: `AgentCtrlConsoleLogger` shows execution lifecycle alongside streaming output - **Tool visibility**: See each tool call with its arguments as it executes - **Progress tracking**: Know what the agent is doing at any moment - **Same response**: Final response object has same structure as non-streaming execution - **Fluent interface**: Combine `wiretap()`, `onText()`, `onToolUse()`, and configuration methods - **Use cases**: Progress bars, interactive UIs, debugging, long-running tasks ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/agent_ctrl_switching.md ================================================================================ ## Overview AgentCtrl provides a unified API that works across multiple CLI-based code agents. This enables runtime switching between different backends (Claude Code, OpenCode, Codex, Pi) without changing your application code. Useful for comparing agent performance, failover scenarios, or A/B testing. Key concepts: - `AgentType` enum: Specify which agent backend to use - Unified API: Same methods work across all agent types - Runtime selection: Choose agent dynamically based on configuration or logic - `AgentCtrlConsoleLogger`: Shared logger works across all agent types ## Example ```php 'OpenCode', 'claude-code' => 'Claude Code', 'codex' => 'Codex', 'pi' => 'Pi', 'gemini' => 'Gemini', ]; foreach ($agents as $agentId => $agentName) { echo "=== Testing: {$agentName} ===\n\n"; $startTime = microtime(true); try { $builder = AgentCtrl::make(AgentType::from($agentId)) ->wiretap($logger->wiretap()) ->withConfig(new AgentCtrlConfig( timeout: 120, workingDirectory: getcwd() ?: null, )); // Apply agent-specific configuration if ($agentId === 'claude-code') { $builder->withMaxTurns(1); } if ($agentId === 'pi') { $builder->ephemeral(); } $response = $builder->execute($prompt); $elapsed = round((microtime(true) - $startTime) * 1000); echo "\n=== Result ({$elapsed}ms) ===\n"; if ($response->isSuccess()) { echo "Answer: " . $response->text() . "\n"; if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } } else { echo "Failed (exit code: {$response->exitCode})\n"; exit(1); } } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; exit(1); } echo "\n"; } ?> ``` ## Expected Output ``` === Testing: OpenCode === 14:32:15.123 [opencode] [EXEC] Execution started [prompt=What design pattern does a class...] 14:32:16.357 [opencode] [DONE] Execution completed [exit=0, tools=0, tokens=62] === Result (1234ms) === Answer: The Factory Method pattern, which uses a static method to create and return instances of different subclasses based on parameters. Tokens: 38 in / 24 out Cost: $0.0008 === Testing: Claude Code === 14:32:17.123 [claude-code] [EXEC] Execution started [prompt=What design pattern does a class...] 14:32:19.279 [claude-code] [DONE] Execution completed [exit=0, tools=0, cost=$0.0015, tokens=64] === Result (2156ms) === Answer: This is the Factory Method pattern, where a static factory method determines which concrete class to instantiate based on input. Tokens: 38 in / 26 out Cost: $0.0015 === Testing: Codex === 14:32:20.123 [codex] [EXEC] Execution started [prompt=What design pattern does a class...] 14:32:21.110 [codex] [DONE] Execution completed [exit=0, tools=0, tokens=60] === Result (987ms) === Answer: A class using static make() for conditional instantiation typically implements the Factory Method or Static Factory pattern. Tokens: 38 in / 22 out Cost: $0.0012 === Testing: Pi === 14:32:22.123 [pi] [EXEC] Execution started [prompt=What design pattern does a class...] 14:32:23.456 [pi] [DONE] Execution completed [exit=0, tools=0, cost=$0.0003, tokens=56] === Result (1333ms) === Answer: A class with a static make() method implements the Static Factory Method pattern for object creation. Tokens: 34 in / 22 out Cost: $0.0003 ``` ## Key Points - **Unified API**: Same interface across all agent backends - **Shared logger**: One `AgentCtrlConsoleLogger` works across all agent types, prefixing output with `[opencode]`, `[claude-code]`, etc. - **Runtime selection**: Choose agent dynamically based on requirements - **Agent comparison**: Run the same prompt across multiple agents to compare - **Failover capability**: Try alternative agents if primary fails - **Agent-specific tuning**: Apply configuration based on agent characteristics - **Performance comparison**: Measure response time and cost across agents - **Use cases**: A/B testing, load balancing, fallback strategies, feature parity testing ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/claude_code_basic.md ================================================================================ ## Overview This example demonstrates how to use the Claude Code CLI integration to execute simple prompts. The `AgentCtrl` facade provides a clean API for invoking the `claude` CLI in headless mode with full event observability via `AgentCtrlConsoleLogger`. Key concepts: - `AgentCtrl::claudeCode()`: Factory for Claude Code agent builder - `withMaxTurns()`: Limit agentic loop iterations - `AgentCtrlConsoleLogger`: Shows execution lifecycle with color-coded labels ## Example ```php wiretap($logger->wiretap()) ->withMaxTurns(1) ->execute('What is the capital of France? Answer briefly.'); echo "\n=== Result ===\n"; if ($response->isSuccess()) { echo "Answer: " . $response->text() . "\n"; if ($response->sessionId()) { echo "Session: {$response->sessionId()}\n"; } if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } } else { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [claude-code] [EXEC] Execution started [prompt=What is the capital of France? Answer briefly.] 14:32:15.124 [claude-code] [REQT] Request built [type=ClaudeRequest, duration=0ms] 14:32:15.125 [claude-code] [CMD ] Command spec created [args=8, duration=0ms] 14:32:15.126 [claude-code] [SBOX] Policy configured [driver=host, timeout=120s, network=on] 14:32:15.127 [claude-code] [SBOX] Ready [driver=host, setup=1ms] 14:32:15.128 [claude-code] [PROC] Process started [commands=8] 14:32:16.234 [claude-code] [RESP] Parsing started [format=stream-json, size=456] 14:32:16.235 [claude-code] [RESP] Data extracted [events=3, tools=0, text=42 chars, duration=1ms] 14:32:16.236 [claude-code] [RESP] Parsing completed [duration=2ms] 14:32:16.237 [claude-code] [DONE] Execution completed [exit=0, tools=0] === Result === Answer: The capital of France is Paris. Session: session-abc123 ``` ## Key Points - **Simple execution**: One method call handles the entire interaction - **Full observability**: Console logger shows request building, sandbox setup, and response parsing - **Pipeline visibility**: Enable `showPipeline` to see request/response internals ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/claude_code_search.md ================================================================================ ## Overview This example demonstrates the agentic capabilities of Claude Code CLI by having it search through the codebase to find and explain validation examples. The `AgentCtrlConsoleLogger` provides full visibility into tool calls, streaming, and the agent's decision-making process. Key concepts: - `AgentCtrl::claudeCode()`: Factory for Claude Code agent builder - `withMaxTurns()`: Allow multiple turns for exploration - `onText()` / `onToolUse()`: Real-time streaming callbacks - `AgentCtrlConsoleLogger`: Shows execution lifecycle alongside streaming output ## Example ```php wiretap($logger->wiretap()) ->withMaxTurns(10) ->onText(function (string $text) { echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) use (&$toolCalls) { $target = $input['pattern'] ?? $input['file_path'] ?? $input['command'] ?? ''; if (strlen($target) > 50) { $target = '...' . substr($target, -47); } $toolCalls[] = $tool; echo "\n >> [{$tool}] {$target}\n"; }) ->executeStreaming(<<<'PROMPT' Complete this task in steps: 1. Use Glob or find command to locate PHP files with "validation" in the filename under ./examples 2. Read the contents of relevant PHP file 3. Analyze the code and provide a concise explanation (under 200 words) covering: - What validation is being performed - What validation constraints/attributes are used - How validation is triggered - What happens when validation fails Provide your final explanation as a clear, structured response. PROMPT); echo "\n=== Result ===\n"; echo "Tools used: " . implode(' > ', $toolCalls) . "\n"; echo "Total tool calls: " . count($toolCalls) . "\n"; echo "Exit code: {$response->exitCode}\n"; if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } if (!$response->isSuccess()) { echo "Error: Agent search failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [claude-code] [EXEC] Execution started [prompt=Complete this task in steps:...] 14:32:15.234 [claude-code] [SBOX] Policy configured [driver=host, timeout=120s, network=on] 14:32:15.235 [claude-code] [SBOX] Ready [driver=host, setup=1ms] 14:32:15.236 [claude-code] [PROC] Process started [commands=12] I'll search for PHP files with "validation" in the filename under ./examples. 14:32:16.456 [claude-code] [TOOL] Glob {pattern=./examples/**/*validation*.php} >> [Glob] ./examples/**/*validation*.php Found a validation example. Let me read it. 14:32:17.234 [claude-code] [TOOL] Read {file_path=./examples/A01_Basics/Validation/run.php} >> [Read] ./examples/A01_Basics/Validation/run.php The validation example demonstrates... 14:32:19.890 [claude-code] [DONE] Execution completed [exit=0, tools=2] === Result === Tools used: Glob > Read Total tool calls: 2 Exit code: 0 ``` ## Key Points - **Agentic search**: Agent autonomously explores files and synthesizes answers - **Full visibility**: Console logger shows every tool call alongside streaming output - **Sandbox awareness**: Enable `showSandbox` to see sandbox initialization details - **Multi-turn**: `withMaxTurns(10)` allows the agent to explore iteratively ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/codex_basic.md ================================================================================ ## Overview This example demonstrates how to use the OpenAI Codex CLI integration to execute simple prompts. The `AgentCtrl` facade provides a clean API for invoking the `codex exec` command with full event observability. Key concepts: - `AgentCtrl::codex()`: Factory for Codex agent builder - `withSandbox()`: Configure sandbox mode for file/network access - `AgentCtrlConsoleLogger`: Shows execution lifecycle with color-coded labels ## Example ```php wiretap($logger->wiretap()) ->withSandbox(SandboxMode::ReadOnly) ->execute('What is the capital of France? Answer briefly.'); echo "\n=== Result ===\n"; if ($response->isSuccess()) { echo "Answer: " . $response->text() . "\n"; if ($response->sessionId()) { echo "Thread ID: {$response->sessionId()}\n"; } if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } } else { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [codex] [EXEC] Execution started [prompt=What is the capital of France? Answer briefly.] 14:32:15.124 [codex] [REQT] Request built [type=CodexRequest, duration=0ms] 14:32:15.125 [codex] [CMD ] Command spec created [args=6, duration=0ms] 14:32:15.126 [codex] [SBOX] Policy configured [driver=host, timeout=120s, network=on] 14:32:15.127 [codex] [SBOX] Ready [driver=host, setup=1ms] 14:32:15.128 [codex] [PROC] Process started [commands=6] 14:32:16.234 [codex] [RESP] Parsing started [format=json, size=312] 14:32:16.235 [codex] [RESP] Data extracted [events=2, tools=0, text=32 chars, duration=1ms] 14:32:16.236 [codex] [RESP] Parsing completed [duration=2ms, session=thread-abc123] 14:32:16.237 [codex] [DONE] Execution completed [exit=0, tools=0, tokens=62] === Result === Answer: The capital of France is Paris. Thread ID: thread-abc123 Tokens: 38 in / 24 out ``` ## Key Points - **Simple execution**: One method call handles the entire interaction - **Full observability**: Console logger shows request building, sandbox, and response parsing - **Sandbox control**: Use `withSandbox()` for file/network access control ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/codex_streaming.md ================================================================================ ## Overview This example demonstrates real-time streaming output from the Codex CLI. Text and tool calls are displayed as they arrive, with `AgentCtrlConsoleLogger` providing execution lifecycle visibility alongside the streaming output. Key concepts: - `executeStreaming()`: Execute with real-time output - `onText()` / `onToolUse()`: Callbacks for streaming events - `AgentCtrlConsoleLogger`: Shows execution lifecycle alongside streaming - `inDirectory()`: Set working directory for sandbox access ## Example ```php wiretap($logger->wiretap()) ->withSandbox(SandboxMode::ReadOnly) ->inDirectory(getcwd()) ->onText(function (string $text) { echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) use (&$toolCalls) { $target = $input['command'] ?? $input['pattern'] ?? ''; if (strlen($target) > 40) { $target = '...' . substr($target, -37); } $toolCalls[] = $tool; echo "\n >> [{$tool}] {$target}\n"; }) ->executeStreaming('List the files in the current directory and explain what you see.'); echo "\n=== Result ===\n"; echo "Tools used: " . implode(' > ', $toolCalls) . "\n"; echo "Total tool calls: " . count($toolCalls) . "\n"; if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } if (!$response->isSuccess()) { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [codex] [EXEC] Execution started [prompt=List the files in the current directory...] 14:32:15.234 [codex] [PROC] Process started [commands=8] I'll list the files in the current directory. 14:32:16.234 [codex] [TOOL] bash {command=ls -la} >> [bash] ls -la The directory contains several PHP project files including: - composer.json for dependency management - src/ directory with source code 14:32:17.890 [codex] [DONE] Execution completed [exit=0, tools=1, tokens=98] === Result === Tools used: bash Total tool calls: 1 Tokens: 42 in / 56 out ``` ## Key Points - **Real-time output**: Text appears as the agent generates it - **Tool visibility**: See each tool call with arguments as it executes - **Console logger**: Execution lifecycle events interleaved with streaming output - **Working directory**: Use `inDirectory()` to set the sandbox working directory ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/agent_ctrl_eventlog_readback.md ================================================================================ ## Overview This example shows how to activate `EventLog` for an `AgentCtrl` execution, run one Codex request through the builder path, then read the generated JSONL file and print the captured entries on screen. Key concepts: - `EventLog::enable()`: activates the default JSONL sink for the current process - `AgentCtrl::codex()`: uses the builder path, which currently honors `EventLog` - JSONL readback: inspect execution, pipeline, and process events after the run ## Example ```php withSandbox(SandboxMode::ReadOnly) ->execute('What is the capital of France? Answer briefly.'); $entries = ExampleEventLog::read($logPath); } finally { EventLog::disable(); } echo "=== AgentCtrl Result ===\n"; if (!$response->isSuccess()) { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } echo "Answer: " . $response->text() . "\n"; if ($response->sessionId()) { echo "Thread ID: {$response->sessionId()}\n"; } if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } echo "\n=== EventLog Entries ===\n"; echo "Log file: {$logPath}\n"; echo 'Entries captured: ' . count($entries) . "\n\n"; ExampleEventLog::print($entries, 10); assert($response->isSuccess()); assert($response->text() !== ''); assert($entries !== []); ?> ``` ## Expected Output ``` === AgentCtrl Result === Answer: The capital of France is Paris. Thread ID: thread-abc123 Tokens: 38 in / 24 out === EventLog Entries === Log file: /tmp/examples-d10-agentctrl-eventlog-xxxx.jsonl Entries captured: 8 1. [2026-03-18T13:00:00+00:00] INFO agent-ctrl.bridge-builder AgentExecutionStarted 2. [2026-03-18T13:00:00+00:00] INFO agent-ctrl.bridge-builder RequestBuilt 3. [2026-03-18T13:00:00+00:00] INFO agent-ctrl.bridge-builder CommandSpecCreated ... ``` ## Key Points - **Builder path**: `AgentCtrl::codex()` flows through the bridge builder, which is currently wired to `EventLog` - **Opt-in activation**: the JSONL sink is off until `EventLog::enable()` is called - **Post-run inspection**: reading the JSONL file is a simple troubleshooting workflow for CLI-based agent runs - **Current limitation**: direct bridge constructors still need separate wiring work to match this behavior ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/gemini_basic.md ================================================================================ ## Overview This example demonstrates how to use the Gemini CLI integration to execute simple prompts. The `AgentCtrl` facade provides a clean API for invoking the `gemini` CLI in headless mode with full event observability. Key concepts: - `AgentCtrl::gemini()`: Factory for Gemini agent builder - `planMode()`: Run in read-only analysis mode - `AgentCtrlConsoleLogger`: Shows execution lifecycle with color-coded labels ## Example ```php wiretap($logger->wiretap()) ->withModel('flash') ->planMode() ->execute('What is the capital of France? Answer briefly.'); echo "\n=== Result ===\n"; if ($response->isSuccess()) { echo "Answer: " . $response->text() . "\n"; if ($response->sessionId()) { echo "Session: {$response->sessionId()}\n"; } if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } } else { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [gemini] [EXEC] Execution started [prompt=What is the capital of France? Answer briefly.] 14:32:15.124 [gemini] [REQT] Request built [type=GeminiRequest, duration=0ms] 14:32:15.125 [gemini] [CMD ] Command spec created [args=8, duration=0ms] 14:32:15.126 [gemini] [SBOX] Policy configured [driver=host, timeout=120s, network=on] 14:32:15.127 [gemini] [SBOX] Ready [driver=host, setup=1ms] 14:32:15.128 [gemini] [PROC] Process started [commands=8] 14:32:16.234 [gemini] [RESP] Parsing started [format=stream-json, size=456] 14:32:16.235 [gemini] [RESP] Data extracted [events=4, tools=0, text=42 chars, duration=1ms] 14:32:16.236 [gemini] [RESP] Parsing completed [duration=2ms] 14:32:16.237 [gemini] [DONE] Execution completed [exit=0, tools=0, tokens=58] === Result === Answer: The capital of France is Paris. Session: sess-abc123 Tokens: 34 in / 24 out ``` ## Key Points - **Simple execution**: One method call handles the entire interaction - **Full observability**: Console logger shows request building, sandbox setup, and response parsing - **Plan mode**: Use `planMode()` for read-only analysis without file modifications - **Free tier**: Gemini CLI offers a free tier with Google account authentication ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/gemini_streaming.md ================================================================================ ## Overview This example demonstrates real-time streaming output from the Gemini CLI. Text and tool calls are displayed as they arrive, with `AgentCtrlConsoleLogger` providing execution lifecycle visibility alongside the streaming output. Key concepts: - `executeStreaming()`: Execute with real-time output - `onText()` / `onToolUse()`: Callbacks for streaming events - `withApprovalMode()`: Control tool approval behavior - `AgentCtrlConsoleLogger`: Shows execution lifecycle alongside streaming ## Example ```php wiretap($logger->wiretap()) ->withModel('flash') ->withApprovalMode(ApprovalMode::AutoEdit) ->inDirectory(getcwd()) ->onText(function (string $text) { echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) use (&$toolCalls) { $target = $input['command'] ?? $input['path'] ?? $input['pattern'] ?? ''; if (strlen($target) > 40) { $target = '...' . substr($target, -37); } $toolCalls[] = $tool; echo "\n >> [{$tool}] {$target}\n"; }) ->executeStreaming('Read the first 5 lines of composer.json in this directory and describe what you see.'); echo "\n=== Result ===\n"; echo "Tools used: " . implode(' > ', $toolCalls) . "\n"; echo "Total tool calls: " . count($toolCalls) . "\n"; if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if (!$response->isSuccess()) { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [gemini] [EXEC] Execution started [prompt=Read the first 5 lines of composer.json...] 14:32:15.234 [gemini] [PROC] Process started [commands=8] I'll read the first 5 lines of composer.json. 14:32:16.234 [gemini] [TOOL] read_file {path=composer.json} >> [read_file] composer.json The first 5 lines of composer.json show: - The project name and description - The license type - Autoload configuration 14:32:17.890 [gemini] [DONE] Execution completed [exit=0, tools=1, tokens=104] === Result === Tools used: read_file Total tool calls: 1 Tokens: 42 in / 62 out ``` ## Key Points - **Real-time output**: Text appears as the agent generates it - **Tool visibility**: See each tool call with arguments as it executes - **Approval modes**: Use `withApprovalMode()` to control tool approval (auto_edit auto-approves edits) - **Console logger**: Execution lifecycle events interleaved with streaming output ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/opencode_basic.md ================================================================================ ## Overview This example demonstrates how to use the OpenCode CLI integration to execute simple prompts. The `AgentCtrl` facade provides a clean API for invoking the `opencode run` command with full event observability. Key concepts: - `AgentCtrl::openCode()`: Factory for OpenCode agent builder - `AgentCtrlConsoleLogger`: Shows execution lifecycle with color-coded labels - `AgentResponse`: Structured response with text, session info, usage stats, and cost ## Example ```php wiretap($logger->wiretap()) ->execute('What is the capital of France? Answer briefly.'); echo "\n=== Result ===\n"; if ($response->isSuccess()) { echo "Answer: " . $response->text() . "\n"; if ($response->sessionId()) { echo "Session ID: {$response->sessionId()}\n"; } if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } } else { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [opencode] [EXEC] Execution started [prompt=What is the capital of France? Answer briefly.] 14:32:15.124 [opencode] [REQT] Request built [type=OpenCodeRequest, duration=0ms] 14:32:15.125 [opencode] [CMD ] Command spec created [args=5, duration=0ms] 14:32:15.126 [opencode] [SBOX] Policy configured [driver=host, timeout=120s, network=on] 14:32:15.127 [opencode] [SBOX] Ready [driver=host, setup=1ms] 14:32:15.128 [opencode] [PROC] Process started [commands=5] 14:32:16.234 [opencode] [RESP] Parsing started [format=json, size=289] 14:32:16.235 [opencode] [RESP] Data extracted [events=2, tools=0, text=32 chars, duration=1ms] 14:32:16.236 [opencode] [RESP] Parsing completed [duration=2ms, session=session-abc123] 14:32:16.237 [opencode] [DONE] Execution completed [exit=0, tools=0, cost=$0.0008, tokens=62] === Result === Answer: The capital of France is Paris. Session ID: session-abc123 Tokens: 38 in / 24 out Cost: $0.0008 ``` ## Key Points - **Simple execution**: One method call handles the entire interaction - **Full observability**: Console logger shows request building, sandbox, and response parsing - **Cost tracking**: OpenCode exposes cost information in the response ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/opencode_streaming.md ================================================================================ ## Overview This example demonstrates real-time streaming output from the OpenCode CLI. Text and tool calls are displayed as they arrive, with `AgentCtrlConsoleLogger` providing execution lifecycle visibility alongside the streaming output. Key concepts: - `executeStreaming()`: Execute with real-time output - `onText()` / `onToolUse()`: Callbacks for streaming events - `AgentCtrlConsoleLogger`: Shows execution lifecycle alongside streaming - OpenCode exposes cost and session information ## Example ```php wiretap($logger->wiretap()) ->onText(function (string $text) { echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) use (&$toolCalls) { $target = $input['command'] ?? $input['file_path'] ?? $input['pattern'] ?? ''; if (strlen($target) > 40) { $target = '...' . substr($target, -37); } $toolCalls[] = $tool; echo "\n >> [{$tool}] {$target}\n"; }) ->executeStreaming('Read the first 5 lines of composer.json in this directory and describe what you see.'); echo "\n=== Result ===\n"; echo "Tools used: " . implode(' > ', $toolCalls) . "\n"; echo "Total tool calls: " . count($toolCalls) . "\n"; if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } if (!$response->isSuccess()) { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [opencode] [EXEC] Execution started [prompt=Read the first 5 lines of composer.json...] 14:32:15.234 [opencode] [PROC] Process started [commands=5] I'll read the first 5 lines of composer.json. 14:32:16.234 [opencode] [TOOL] Read {file_path=composer.json} >> [Read] composer.json The first 5 lines of composer.json show: - The project name and description - The license type - Autoload configuration 14:32:17.890 [opencode] [DONE] Execution completed [exit=0, tools=1, cost=$0.0012, tokens=98] === Result === Tools used: Read Total tool calls: 1 Tokens: 42 in / 56 out Cost: $0.0012 ``` ## Key Points - **Real-time output**: Text appears as the agent generates it - **Tool visibility**: See each tool call with arguments as it executes - **Console logger**: Execution lifecycle events interleaved with streaming output - **Cost visibility**: OpenCode provides cost tracking in the response ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/pi_basic.md ================================================================================ ## Overview This example demonstrates how to use the Pi CLI integration to execute simple prompts. The `AgentCtrl` facade provides a clean API for invoking the `pi` CLI in JSON mode with full event observability. Key concepts: - `AgentCtrl::pi()`: Factory for Pi agent builder - `ephemeral()`: Run without saving session state - `withThinking()`: Control reasoning depth (6 levels from off to xhigh) - `AgentCtrlConsoleLogger`: Shows execution lifecycle with color-coded labels ## Example ```php wiretap($logger->wiretap()) ->ephemeral() ->execute('What is the capital of France? Answer briefly.'); echo "\n=== Result ===\n"; if ($response->isSuccess()) { echo "Answer: " . $response->text() . "\n"; if ($response->sessionId()) { echo "Session: {$response->sessionId()}\n"; } if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } } else { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [pi] [EXEC] Execution started [prompt=What is the capital of France? Answer briefly.] 14:32:15.124 [pi] [REQT] Request built [type=PiRequest, duration=0ms] 14:32:15.125 [pi] [CMD ] Command spec created [args=4, duration=0ms] 14:32:15.126 [pi] [SBOX] Policy configured [driver=host, timeout=120s, network=on] 14:32:15.127 [pi] [SBOX] Ready [driver=host, setup=1ms] 14:32:15.128 [pi] [PROC] Process started [commands=4] 14:32:16.234 [pi] [RESP] Parsing started [format=jsonl, size=512] 14:32:16.235 [pi] [RESP] Data extracted [events=6, tools=0, text=42 chars, duration=1ms] 14:32:16.236 [pi] [RESP] Parsing completed [duration=2ms] 14:32:16.237 [pi] [DONE] Execution completed [exit=0, tools=0, cost=$0.0003, tokens=58] === Result === Answer: The capital of France is Paris. Session: abc123-def456 Tokens: 34 in / 24 out Cost: $0.0003 ``` ## Key Points - **Simple execution**: One method call handles the entire interaction - **Full observability**: Console logger shows request building, sandbox setup, and response parsing - **Ephemeral mode**: Use `ephemeral()` for one-off prompts that don't need session persistence - **Cost and usage**: Pi provides both token usage and cost data in the response ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/pi_streaming.md ================================================================================ ## Overview This example demonstrates real-time streaming output from the Pi CLI. Text and tool calls are displayed as they arrive, with `AgentCtrlConsoleLogger` providing execution lifecycle visibility alongside the streaming output. Key concepts: - `executeStreaming()`: Execute with real-time output - `onText()` / `onToolUse()`: Callbacks for streaming events - `withTools()`: Control which tools the agent can use - `AgentCtrlConsoleLogger`: Shows execution lifecycle alongside streaming ## Example ```php wiretap($logger->wiretap()) ->ephemeral() ->withTools(['read', 'grep', 'find', 'ls']) ->inDirectory(getcwd()) ->onText(function (string $text) { echo $text; }) ->onToolUse(function (string $tool, array $input, ?string $output) use (&$toolCalls) { $target = $input['command'] ?? $input['file_path'] ?? $input['pattern'] ?? ''; if (strlen($target) > 40) { $target = '...' . substr($target, -37); } $toolCalls[] = $tool; echo "\n >> [{$tool}] {$target}\n"; }) ->executeStreaming('Read the first 5 lines of composer.json in this directory and describe what you see.'); echo "\n=== Result ===\n"; echo "Tools used: " . implode(' > ', $toolCalls) . "\n"; echo "Total tool calls: " . count($toolCalls) . "\n"; if ($response->usage()) { echo "Tokens: {$response->usage()->input} in / {$response->usage()->output} out\n"; } if ($response->cost()) { echo "Cost: $" . number_format($response->cost(), 4) . "\n"; } if (!$response->isSuccess()) { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } ?> ``` ## Expected Output ``` === Agent Execution Log === 14:32:15.123 [pi] [EXEC] Execution started [prompt=Read the first 5 lines of composer.json...] 14:32:15.234 [pi] [PROC] Process started [commands=8] I'll read the first 5 lines of composer.json. 14:32:16.234 [pi] [TOOL] read {file_path=composer.json} >> [read] composer.json The first 5 lines of composer.json show: - The project name and description - The license type - Autoload configuration 14:32:17.890 [pi] [DONE] Execution completed [exit=0, tools=1, cost=$0.0005, tokens=104] === Result === Tools used: read Total tool calls: 1 Tokens: 42 in / 62 out Cost: $0.0005 ``` ## Key Points - **Real-time output**: Text appears as the agent generates it - **Tool visibility**: See each tool call with arguments as it executes - **Read-only mode**: Use `withTools()` to restrict to safe, read-only tools - **Console logger**: Execution lifecycle events interleaved with streaming output - **Cost visibility**: Pi provides cost tracking in the response ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/agent_ctrl_telemetry_langfuse.md ================================================================================ ## Overview This example combines the built-in `AgentCtrlConsoleLogger` with Langfuse export. You still see the live execution locally, and the same run is also emitted as correlated telemetry through the `AgentCtrl` projector. Key concepts: - `AgentCtrlConsoleLogger`: local execution visibility - `AgentCtrlTelemetryProjector`: correlates the full run by `executionId` - `executeStreaming()`: shows progress as the CLI agent works - `bash` tool calls: make the telemetry trace reflect multi-step repository inspection - `Telemetry::flush()`: sends the final telemetry batch ## Example ```php wiretap($logger->wiretap()) ->wiretap($bridge->handle(...)) ->withSandbox(SandboxMode::ReadOnly) ->inDirectory($workDir) ->executeStreaming($prompt); $hub->flush(); $toolNames = array_map( static fn($toolCall): string => $toolCall->tool, $response->toolCalls, ); $toolCallCount = count($toolNames); echo "\n=== Result ===\n"; if (!$response->isSuccess()) { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } echo "Answer: {$response->text()}\n"; echo "Execution ID: {$response->executionId()}\n"; echo "Tools used: " . implode(' > ', $toolNames) . "\n"; echo "Total tool calls: {$toolCallCount}\n"; if ($response->sessionId() !== null) { echo "Session ID: {$response->sessionId()}\n"; } echo "Telemetry: flushed to Langfuse\n"; assert($response->text() !== ''); assert($toolCallCount >= 3); ?> ``` ================================================================================ FILE: cookbook/examples/D10_AgentCtrl/agent_ctrl_telemetry_logfire.md ================================================================================ ## Overview This example combines the built-in `AgentCtrlConsoleLogger` with Logfire export. You still see the live execution locally, and the same run is also emitted as correlated telemetry through the `AgentCtrl` projector. Key concepts: - `AgentCtrlConsoleLogger`: local execution visibility - `AgentCtrlTelemetryProjector`: correlates the full run by `executionId` - `executeStreaming()`: shows progress as the CLI agent works - `bash` tool calls: make the telemetry trace reflect multi-step repository inspection - `Telemetry::flush()`: sends the final telemetry batch ## Example ```php wiretap($logger->wiretap()) ->wiretap($bridge->handle(...)) ->withSandbox(SandboxMode::ReadOnly) ->inDirectory($workDir) ->executeStreaming($prompt); $hub->flush(); $toolNames = array_map( static fn($toolCall): string => $toolCall->tool, $response->toolCalls, ); $toolCallCount = count($toolNames); echo "\n=== Result ===\n"; if (!$response->isSuccess()) { echo "Error: Command failed with exit code {$response->exitCode}\n"; exit(1); } echo "Answer: {$response->text()}\n"; echo "Execution ID: {$response->executionId()}\n"; echo "Tools used: " . implode(' > ', $toolNames) . "\n"; echo "Total tool calls: {$toolCallCount}\n"; if ($response->sessionId() !== null) { echo "Session ID: {$response->sessionId()}\n"; } echo "Telemetry: flushed to Logfire\n"; assert($response->text() !== ''); assert($toolCallCount >= 3); ?> ``` ================================================================================ FILE: cookbook/examples/D20_Sandbox/sandbox_host_ls.md ================================================================================ ## Overview Run a simple command through `Sandbox::host()`. This is the quickest way to verify sandbox execution in your environment. ## Example ```php withTimeout(3); $result = Sandbox::host($policy)->execute(['ls', '-Al']); echo "Exit: {$result->exitCode()}\n"; echo "--- stdout ---\n"; echo $result->stdout() . "\n"; if ($result->stderr() !== '') { echo "--- stderr ---\n"; echo $result->stderr() . "\n"; } assert($result->exitCode() === 0, 'ls command should exit with code 0'); assert(!empty($result->stdout()), 'ls command should produce output'); ?> ``` ================================================================================ FILE: cookbook/examples/D20_Sandbox/sandbox_run_php_script.md ================================================================================ ## Overview Run inline PHP in sandboxed execution. Useful when tools need controlled script execution with predictable policy limits. ## Example ```php withTimeout(5); $sandbox = Sandbox::fromPolicy($policy)->using(SandboxDriver::Host); $result = $sandbox->execute([ 'php', '-r', 'echo "sandbox says hi\\n"; echo "cwd=" . getcwd() . "\\n";', ]); echo $result->stdout(); assert($result->exitCode() === 0, 'PHP script should exit with code 0'); assert(str_contains($result->stdout(), 'sandbox says hi'), 'Output should contain expected greeting'); ?> ``` ================================================================================ FILE: cookbook/examples/D20_Sandbox/sandbox_streaming_output.md ================================================================================ ## Overview Stream command output as it arrives. This is useful for long-running tool calls where users need live feedback. ## Example ```php withTimeout(10); $command = [ 'php', '-r', 'for ($i = 1; $i <= 3; $i++) { echo "tick {$i}\\n"; usleep(300000); } fwrite(STDERR, "stderr line\\n");', ]; $result = Sandbox::host($policy)->execute( argv: $command, onOutput: function (string $type, string $chunk): void { $stream = $type === Process::ERR ? 'ERR' : 'OUT'; echo "[{$stream}] {$chunk}"; }, ); echo "\nDone in {$result->duration()}s, exit={$result->exitCode()}\n"; assert($result->exitCode() === 0, 'Streaming command should exit with code 0'); ?> ``` ================================================================================ FILE: cookbook/examples/D20_Sandbox/sandbox_timeout_guard.md ================================================================================ ## Overview Show policy-based timeout control. This protects your app from hanging commands. ## Example ```php withTimeout(1); $result = Sandbox::host($policy)->execute([ 'php', '-r', 'sleep(3); echo "done\\n";', ]); echo "Exit: {$result->exitCode()}\n"; echo "Timed out: " . ($result->timedOut() ? 'yes' : 'no') . "\n"; echo "Stdout: " . $result->stdout() . "\n"; assert($result->timedOut() === true, 'Command should have timed out'); ?> ``` ================================================================================ FILE: cookbook/examples/D21_SandboxAPIs/sandbox_api_bubblewrap_echo.md ================================================================================ ## Overview Minimal bubblewrap driver API check. ## Example ```php withTimeout(10); try { $result = Sandbox::bubblewrap($policy) ->execute(['sh', '-lc', 'echo "hello from bubblewrap sandbox"']); assert($result->exitCode() === 0, 'Bubblewrap echo should exit with code 0'); echo "Exit: {$result->exitCode()}\n"; echo $result->stdout(); } catch (Throwable $e) { echo "Bubblewrap sandbox unavailable: {$e->getMessage()}\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/D21_SandboxAPIs/sandbox_api_docker_echo.md ================================================================================ ## Overview Minimal docker driver API check. ## Example ```php withTimeout(10); try { $result = Sandbox::docker($policy, image: 'alpine:3') ->execute(['sh', '-lc', 'echo "hello from docker sandbox"']); assert($result->exitCode() === 0, 'Docker echo should exit with code 0'); echo "Exit: {$result->exitCode()}\n"; echo $result->stdout(); } catch (Throwable $e) { echo "Docker sandbox unavailable: {$e->getMessage()}\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/D21_SandboxAPIs/sandbox_api_firejail_echo.md ================================================================================ ## Overview Minimal firejail driver API check. ## Example ```php withTimeout(10); try { $result = Sandbox::firejail($policy) ->execute(['sh', '-lc', 'echo "hello from firejail sandbox"']); assert($result->exitCode() === 0, 'Firejail echo should exit with code 0'); echo "Exit: {$result->exitCode()}\n"; echo $result->stdout(); } catch (Throwable $e) { echo "Firejail sandbox unavailable: {$e->getMessage()}\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/D21_SandboxAPIs/sandbox_api_host_echo.md ================================================================================ ## Overview Minimal host driver API check. ## Example ```php execute(['echo', 'hello from host sandbox']); assert($result->exitCode() === 0, 'Host echo should exit with code 0'); echo "Exit: {$result->exitCode()}\n"; echo $result->stdout(); ?> ``` ================================================================================ FILE: cookbook/examples/D21_SandboxAPIs/sandbox_api_podman_echo.md ================================================================================ ## Overview Minimal podman driver API check. ## Example ```php withTimeout(10); try { $result = Sandbox::podman($policy, image: 'alpine:3') ->execute(['sh', '-lc', 'echo "hello from podman sandbox"']); assert($result->exitCode() === 0, 'Podman echo should exit with code 0'); echo "Exit: {$result->exitCode()}\n"; echo $result->stdout(); } catch (Throwable $e) { echo "Podman sandbox unavailable: {$e->getMessage()}\n"; } ?> ``` ================================================================================ FILE: cookbook/examples/Z01_ZeroShot/assign_role.md ================================================================================ ## Overview How can we increase a model's performance on open-ended tasks? Role prompting, or persona prompting, assigns a role to the model. Roles can be: - specific to the query: You are a talented writer. Write me a poem. - general/social: You are a helpful AI assistant. Write me a poem. ## More Role Prompting To read about a systematic approach to choosing roles, check out [RoleLLM](https://arxiv.org/abs/2310.00746). For more examples of social roles, check out this [evaluation of social roles in system prompts](https://arxiv.org/abs/2311.10054). To read about using more than one role, check out [Multi-Persona Self-Collaboration](https://arxiv.org/abs/2307.05300). ## Example ```php with( messages: [ ['role' => 'system', 'content' => "You are acting in the following roles:\n{$rolesStr}"], ['role' => 'user', 'content' => "List at least 3 real companies meeting these criteria:\n{$criteriaStr}"], ], responseModel: Sequence::of(Company::class), )->get()->toArray(); } } $companies = (new GenerateLeads)( criteria: [ "insurtech", "located in US, Canada or Europe", "mentioned on ProductHunt", ], roles: [ "insurtech expert", "active participant in VC ecosystem", ] ); dump($companies); assert(is_array($companies)); assert(count($companies) > 0); assert($companies[0] instanceof Company); assert(!empty($companies[0]->name)); ?> ``` ## References 1. [RoleLLM: Benchmarking, Eliciting, and Enhancing Role-Playing Abilities of Large Language Models](https://arxiv.org/abs/2310.00746) 2. [Is "A Helpful Assistant" the Best Role for Large Language Models? A Systematic Evaluation of Social Roles in System Prompts](https://arxiv.org/abs/2311.10054) 3. [Unleashing the Emergent Cognitive Synergy in Large Language Models: A Task-Solving Agent through Multi-Persona Self-Collaboration](https://arxiv.org/abs/2307.05300) ================================================================================ FILE: cookbook/examples/Z01_ZeroShot/auto_refine.md ================================================================================ ## Overview How do we remove irrelevant information from the prompt? The S2A (System 2 Attention) technique auto-refines a prompt by asking the model to rewrite the prompt to include only relevant information. We implement this in two steps: 1. Ask the model to rewrite the prompt 2. Pass the rewritten prompt back to the model ## Example ```php rewritePrompt($problem); return StructuredOutput::using('openai') ->with( messages: "{$rewrittenPrompt->relevantContext}\nQuestion: {$rewrittenPrompt->userQuery}", responseModel: Scalar::integer('answer'), ) ->getInt(); } private function rewritePrompt(string $query) : RewrittenTask { return StructuredOutput::using('openai')->with( messages: str_replace('{query}', $query, $this->prompt), responseModel: RewrittenTask::class, model: 'gpt-4o-mini', )->get(); } } $answer = (new RefineAndSolve)(problem: <<= 15, "Expected at least 15 (3*5), full answer is 25 (3*5+10)"); ?> ``` ## References 1. [System 2 Attention (is something you might need too)](https://arxiv.org/abs/2311.11829) ================================================================================ FILE: cookbook/examples/Z01_ZeroShot/clarify_ambiguity.md ================================================================================ ## Overview How can we identify and clarify ambiguous information in the prompt? Let's say we are given the query: Was Ed Sheeran born on an odd month? There are many ways a model might interpret an odd month: - February is odd because of an irregular number of days. - A month is odd if it has an odd number of days. - A month is odd if its numerical order in the year is odd (i.e. January is the 1st month). Ambiguities might not always be so obvious! To help the model better infer human intention from ambiguous prompts, we can ask the model to rephrase and respond (RaR) in a single step - which is demonstrated in this example. This can also be implemented as two-step RaR: - Ask the model to rephrase the question to clarify any ambiguities. - Pass the rephrased question back to the model to generate the final response. ## Example ```php with( messages: str_replace('{query}', $query, $this->prompt), responseModel: Response::class, )->get(); } } $response = (new Disambiguate)(query: "What is an object"); dump($response); assert($response instanceof Response); assert(!empty($response->rephrasedQuestion)); assert(!empty($response->answer)); ?> ``` ## References 1. [Rephrase and Respond: Let Large Language Models Ask Better Questions for Themselves](https://arxiv.org/abs/2311.04205) ================================================================================ FILE: cookbook/examples/Z01_ZeroShot/define_style.md ================================================================================ ## Overview How can we constrain model outputs through prompting alone? To constrain a model's response to fit the boundaries of our task, we can specify a style. Stylistic constraints can include: - writing style: write a flowery description - tone: write a dramatic description - mood: write a happy description - genre: write a journalistic description ## Example ```php with( messages: [ ['role' => 'user', 'content' => "List companies meeting criteria:\n{$criteriaStr}\n\n"], ['role' => 'user', 'content' => "Use following styles for descriptions:\n{$stylesStr}\n\n"], ], responseModel: Sequence::of(Company::class), )->get()->toArray(); } } $companies = (new GenerateCompanyProfiles)( criteria: [ "insurtech", "located in US, Canada or Europe", "mentioned on ProductHunt" ], styles: [ "brief", // "witty", "journalistic", // "buzzword-filled", ] ); dump($companies); assert(is_array($companies)); assert(count($companies) > 0); assert($companies[0] instanceof Company); assert(!empty($companies[0]->name)); assert(!empty($companies[0]->description)); ?> ``` ## References 1. [Bounding the Capabilities of Large Language Models in Open Text Generation with Prompt Constraints](https://arxiv.org/abs/2302.09185) ================================================================================ FILE: cookbook/examples/Z01_ZeroShot/emotional_stimuli.md ================================================================================ ## Overview Do language models respond to emotional stimuli? Adding phrases with emotional significance to humans can help enhance the performance of a language model. This includes phrases such as: - This is very important to my career. - Take pride in your work. - Are you sure? ## Emotional stimuli Here are examples of prompts inspired by well-established human psychological phenomena from a [research paper on emotional stimuli](https://arxiv.org/abs/2307.11760). Self-monitoring: - EP01: Write your answer and give me a confidence score between 0-1 for your answer. - EP02: This is very important to my career. - EP03: You'd better be sure. - EP04: Are you sure? - EP05: Are you sure that's your final answer? It might be worth taking another look. Cognitive emotion regulation: - EP03: You'd better be sure. - EP04: Are you sure? - EP05: Are you sure that's your final answer? It might be worth taking another look. - EP07: Are you sure that's your final answer? Believe in your abilities and strive for excellence. Your hard work will yield remarkable results. Social-cognitive theory: - EP07: Are you sure that's your final answer? Believe in your abilities and strive for excellence. Your hard work will yield remarkable results. - EP08: Embrace challenges as opportunities for growth. Each obstacle you overcome brings you closer to success. - EP09: Stay focused and dedicated to your goals. Your consistent efforts will lead to outstanding achievements. - EP10: Take pride in your work and give it your best. Your commitment to excellence sets you apart. - EP11: Remember that progress is made one step at a time. Stay determined and keep moving forward. ## Example Here is how the results of the research can be applied to your code. ```php with( messages: [ ['role' => 'user', 'content' => "List companies meeting criteria:\n{$criteriaStr}"], ['role' => 'user', 'content' => "{$stimulus}"], ], responseModel: Sequence::of(Company::class), )->get()->toArray(); } } $companies = (new RespondWithStimulus)( criteria: [ "lead gen", "located in US, Canada or Europe", "mentioned on ProductHunt" ], stimulus: "This is very important to my career." ); dump($companies); assert(is_array($companies)); assert(count($companies) > 0); assert($companies[0] instanceof Company); assert(!empty($companies[0]->name)); ?> ``` ## References 1. [Large Language Models Understand and Can be Enhanced by Emotional Stimuli](https://arxiv.org/abs/2307.11760) ================================================================================ FILE: cookbook/examples/Z01_ZeroShot/follow_up_questions.md ================================================================================ ## Overview Models can sometimes correctly answer sub-problems but incorrectly answer the overall query. This is known as the compositionality gap1. How can we encourage a model to use the answers to sub-problems to correctly generate the overall solution? Self-Ask is a technique which use a single prompt to: - decide if follow-up questions are required - generate the follow-up questions - answer the follow-up questions - answer the main query ## Example ```php with( messages: str_replace('{query}', $query, $this->prompt), responseModel: Response::class, )->get(); } } $response = (new RespondWithFollowUp)( query: "Who succeeded the president of France ruling when Bulgaria joined EU?", ); echo "Answer:\n"; dump($response); assert($response instanceof Response); assert(!empty($response->finalAnswer)); assert(is_array($response->followUps)); ?> ``` ## References 1. [Measuring and Narrowing the Compositionality Gap in Language Models](https://arxiv.org/abs/2210.03350) ================================================================================ FILE: cookbook/examples/Z01_ZeroShot/repeat_query.md ================================================================================ ## Overview How can we enhance a model's understanding of a query? Re2 (Re-Reading) is a technique that asks the model to read the question again. ### Re-Reading Prompting Prompt Template: - Read the question again: [query] - [critical thinking prompt] A common critical thinking prompt is: "Let's think step by step." ## Example ```php with( messages: $query, responseModel: Response::class, )->get(); } } $response = (new RereadAndRespond)( query: <<query)); assert(!empty($response->thoughts)); assert(is_int($response->answer)); ?> ``` ## References 1. [Re-Reading Improves Reasoning in Large Language Models](https://arxiv.org/abs/2309.06275) ================================================================================ FILE: cookbook/examples/Z01_ZeroShot/simulate_perspective.md ================================================================================ ## Overview How can we encourage the model to focus on relevant information? SimToM (Simulated Theory of Mind) is a two-step prompting technique that encourages a model to consider a specific perspective. This can be useful for complex questions with multiple entities. For example, if the prompt contains information about two individuals, we can ask the model to answer our query from the perspective of one of the individuals. This is implemented in two steps. Given an entity: - Identify and isolate information relevant to the entity - Ask the model to answer the query from the entity's perspective ### Sample Template - Step 1: - Given the following context, list the facts that `{entity}` would know. - Context: `{context}` - Step 2: - You are `{entity}`. - Answer the following question based only on these facts you know: `{facts}`. - Question: `{query}` ## Example ```php getKnownFacts($context, $query, $perspective); return $this->answerQuestion($perspective, $query, $knownFacts); } private function getKnownFacts(string $context, string $query, string $entity) : array { return StructuredOutput::using('openai')->with( messages: str_replace( ['{context}', '{query}', '{entity}'], [$context, $query, $entity], $this->extractionPrompt ), responseModel: KnownFacts::class, )->get()->facts; } private function answerQuestion(string $entity, string $query, array $knownFacts) : string { $knowledge = Arrays::toBullets($knownFacts); return StructuredOutput::using('openai')->with( messages: str_replace( ['{entity}', '{knowledge}', '{query}'], [$entity, $knowledge, $query], $this->povPrompt ), responseModel: Scalar::string('location'), ) ->getString(); } } $povEntity = "Alice"; $location = (new SimulatePerspective)( context: << ``` ## References 1. [Think Twice: Perspective-Taking Improves Large Language Models' Theory-of-Mind Capabilities](https://arxiv.org/abs/2311.10227) ================================================================================ FILE: cookbook/examples/Z02_FewShot/consistency_based_examples.md ================================================================================ ## Overview COSP is a technique that improves few-shot learning by selecting high-quality examples based on consistency and confidence of model responses. It identifies examples the model can process reliably. The process involves: 1. Example Generation: Generate multiple responses per example, collect confidence scores 2. Example Selection: Select examples with low entropy and high repetitiveness ## Example ```php nSamples = $nSamples; } public function generateResponses(string $prompt): array { $responses = []; for ($i = 0; $i < $this->nSamples; $i++) { $responses[] = StructuredOutput::using('openai')->with( messages: [['role' => 'user', 'content' => $prompt]], responseModel: ResponseWithConfidence::class, )->get(); } return $responses; } public function calculateMetrics(array $responses): array { $confidences = array_map(fn($r) => (float) $r->confidence, $responses); $entropyScore = $this->entropy($confidences); $uniqueResponses = count(array_unique(array_map(fn($r) => $r->content, $responses))); $repetitiveness = 1 - ($uniqueResponses / count($responses)); return ['entropy' => $entropyScore, 'repetitiveness' => $repetitiveness]; } private function entropy(array $values): float { $sum = array_sum($values); if ($sum == 0) return 0.0; $normalized = array_map(fn($v) => $v / $sum, $values); $entropy = 0.0; foreach ($normalized as $p) { if ($p > 0) $entropy -= $p * log($p); } return $entropy; } public function selectBestExamples(array $candidates, int $k): array { $scored = []; foreach ($candidates as $text) { $responses = $this->generateResponses("Classify this text: {$text}"); $metrics = $this->calculateMetrics($responses); $score = $metrics['entropy'] - $metrics['repetitiveness']; $scored[] = ['text' => $text, 'score' => $score, 'metrics' => $metrics]; } usort($scored, fn($a, $b) => $a['score'] <=> $b['score']); return array_slice($scored, 0, $k); } } $selector = new COSPSelector(nSamples: 3); $candidates = [ "The quick brown fox jumps over the lazy dog", "Machine learning is a subset of artificial intelligence", "Python is a high-level programming language", ]; $bestExamples = $selector->selectBestExamples($candidates, k: 2); dump($bestExamples); assert(is_array($bestExamples)); assert(count($bestExamples) > 0); assert(count($bestExamples) <= 2); assert(isset($bestExamples[0]['text'])); assert(isset($bestExamples[0]['score'])); ?> ``` ### Benefits - Improved Consistency: Select examples with low entropy and high repetitiveness - Automated Selection: No manual example curation needed - Quality Metrics: Quantifiable measure of example quality ### References 1) Original COSP Paper (https://arxiv.org/abs/2305.14121) 2) Self-Consistency Improves Chain of Thought Reasoning (https://arxiv.org/abs/2203.11171) ================================================================================ FILE: cookbook/examples/Z02_FewShot/example_ordering.md ================================================================================ ## Overview The order of few-shot examples in the prompt can affect LLM outputs. Consider permutating the order of these examples in your prompt to achieve better results. ### Choosing Your Examples Depending on your use-case, here are a few different methods that you can consider using to improve the quality of your examples. ### Combinatorics One of the easiest methods is for us to manually iterate over each of the examples that we have and try all possible combinations we could create. This will in turn allow us to find the best combination that we can find. ### KATE KATE (k-Nearest Example Tuning) is a method designed to enhance GPT-3's performance by selecting the most relevant in-context examples. The method involves: For each example in the test set, K nearest neighbors (examples) are retrieved based on semantic similarity. Among these K examples, those that appear most frequently across different queries are selected as the best in-context examples. ### Using an Unsupervised Retriever We can use a large LLM to compute a single score for each example with respect to a given prompt. This allows us to create a training set that scores an example's relevance when compared against a prompt. Using this training set, we can train a model that mimics this functionality. This allows us to determine the top k most relevant and most irrelevant examples when a user makes a query so that we can include this in our final prompt. ### References 1. Fantastically Ordered Prompts and Where to Find Them: Overcoming Few-Shot Prompt Order Sensitivity (https://arxiv.org/abs/2104.08786) 2. Reordering Examples Helps during Priming-based Few-Shot Learning (https://arxiv.org/abs/2106.01751) 3. What Makes Good In-Context Examples for GPT-3? (https://arxiv.org/abs/2101.06804) 4. Learning To Retrieve Prompts for In-Context Learning (https://aclanthology.org/2022.naacl-main.191/) 5. The Prompt Report: A Systematic Survey of Prompting Techniques (https://arxiv.org/abs/2406.06608) ## Example ```php ``` ================================================================================ FILE: cookbook/examples/Z02_FewShot/in_context_examples.md ================================================================================ ## Overview How can we generate examples for our prompt? Self-Generated In-Context Learning (SG-ICL) is a technique which uses an LLM to generate examples to be used during the task. This allows for in-context learning, where examples of the task are provided in the prompt. We can implement SG-ICL using Instructor as seen below. ## Example ```php with( messages: [ ['role' => 'user', 'content' => "Review: {$review}"], ], responseModel: Scalar::enum(ReviewSentiment::class), examples: $this->generateExamples($review), )->get(); } private function generate(string $inputReview, ReviewSentiment $sentiment) : array { return StructuredOutput::using('openai')->with( messages: [ ['role' => 'user', 'content' => "Generate {$this->n} various {$sentiment->value} reviews based on the input review:\n{$inputReview}"], ['role' => 'user', 'content' => "Generated review:"], ], responseModel: Sequence::of(GeneratedReview::class), )->get()->toArray(); } private function generateExamples(string $inputReview) : array { $examples = []; foreach ([ReviewSentiment::Positive, ReviewSentiment::Negative] as $sentiment) { $samples = $this->generate($inputReview, $sentiment); foreach ($samples as $sample) { $examples[] = Example::fromData($sample->review, $sample->sentiment->value); } } return $examples; } } $predictSentiment = (new PredictSentiment)('This movie has been very impressive, even considering I lost half of the plot.'); dump($predictSentiment); assert($predictSentiment instanceof ReviewSentiment); ?> ``` ## References 1. [Self-Generated In-Context Learning: Leveraging Auto-regressive Language Models as a Demonstration Generator](https://arxiv.org/abs/2206.08082) 2. [The Prompt Report: A Systematic Survey of Prompting Techniques](https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z02_FewShot/select_effective_samples.md ================================================================================ ## Overview Select effective in-context examples by choosing those semantically closest to the query using KNN (k-Nearest Neighbors) with embeddings. Steps: 1. Embed the candidate examples 2. Embed the query to answer 3. Find the k examples closest to the query 4. Use chosen examples as context for the LLM ## Example ```php embeddings = Embeddings::using('openai'); } public function cosineSimilarity(array $a, array $b): float { $dotProduct = 0.0; $normA = 0.0; $normB = 0.0; for ($i = 0; $i < count($a); $i++) { $dotProduct += $a[$i] * $b[$i]; $normA += $a[$i] * $a[$i]; $normB += $b[$i] * $b[$i]; } return $dotProduct / (sqrt($normA) * sqrt($normB)); } public function embed(array $texts): array { $response = $this->embeddings->create( new EmbeddingsRequest(input: $texts), )->get(); $vector = $response->first(); return $vector?->values() ?? []; } public function embedAll(array $texts): array { $results = []; foreach ($texts as $text) { $results[] = $this->embed([$text]); } return $results; } public function selectKNearest(array $examples, string $query, int $k): array { $questions = array_column($examples, 'question'); $exampleEmbeddings = $this->embedAll($questions); $queryEmbedding = $this->embed([$query]); $scored = []; foreach ($examples as $i => $example) { $similarity = $this->cosineSimilarity($exampleEmbeddings[$i], $queryEmbedding); $scored[] = ['example' => $example, 'similarity' => $similarity]; } usort($scored, fn($a, $b) => $b['similarity'] <=> $a['similarity']); return array_slice($scored, 0, $k); } public function generateWithExamples(array $selectedExamples, string $query): Answer { $context = ""; foreach ($selectedExamples as $item) { $ex = $item['example']; $context .= "\n{$ex['question']}\n{$ex['answer']}\n\n"; } $prompt = "Respond to the query using the examples as guidance.\n\n{$context}\n{$query}"; return StructuredOutput::using('openai')->with( messages: [['role' => 'user', 'content' => $prompt]], responseModel: Answer::class, )->get(); } } $selector = new KNNExampleSelector(); $examples = [ ['question' => 'What is the capital of France?', 'answer' => 'Paris'], ['question' => 'Who wrote Romeo and Juliet?', 'answer' => 'Shakespeare'], ['question' => 'What is the capital of Germany?', 'answer' => 'Berlin'], ]; $query = 'What is the capital of Italy?'; $kClosest = $selector->selectKNearest($examples, $query, k: 2); dump($kClosest); $response = $selector->generateWithExamples($kClosest, $query); dump($response); assert(is_array($kClosest)); assert(count($kClosest) === 2); assert($response instanceof Answer); assert(!empty($response->answer)); ?> ``` ## References 1) What Makes Good In-Context Examples for GPT-3? (https://arxiv.org/abs/2101.06804) 2) The Prompt Report: A Systematic Survey of Prompting Techniques (https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/analogical_prompting.md ================================================================================ ## Overview ### Generate Examples First Analogical Prompting is a method that aims to get LLMs to generate examples that are relevant to the problem before starting to address the user's query. This takes advantage of the various forms of knowledge that the LLM has acquired during training and explicitly prompts them to recall the relevant problems and solutions. We can use Analogical Prompting using the following template Analogical Prompting Prompt Template - Problem: `[user prompt]` - Relevant Problems: Recall `[n]` relevant and distinct problems. - For each problem, describe it and explain the solution ## Example We can implement this using Instructor to solve the problem, as seen below with some slight modifications. ```php {query} Relevant Problems: Recall {n} relevant and distinct problems. For each problem, describe it and explain the solution before solving the problem PROMPT; public function __invoke(string $query) : Response { return StructuredOutput::using('openai')->with( messages: str_replace(['{n}', '{query}'], [$this->n, $query], $this->prompt), responseModel: Response::class, )->get(); } } $solution = (new SolvePerAnalogy)('What is the area of the square with the four vertices at (-2, 2), (2, -2), (-2, -6), and (-6, -2)?'); dump($solution); assert($solution instanceof Response); assert(is_array($solution->relevantProblems)); assert(count($solution->relevantProblems) > 0); assert(!empty($solution->answer)); assert($solution->problemSolution instanceof Problem); ?> ``` ## References 1. [Large Language Models As Analogical Reasoners](https://arxiv.org/pdf/2310.01714) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/automate_selection.md ================================================================================ ## Overview Few-shot CoT requires curated examples. We can automate selection by clustering candidate questions via embeddings, sampling per cluster, and filtering using a simple criterion (e.g., ≤ 5 reasoning steps). ## Example ```php embed($questions); [$seeds, $clusters] = $this->clusterAssign($vectors, $this->clusters); return $this->selectPerCluster($clusters, $questions); } private function embed(array $inputs) : array { $resp = Embeddings::using('openai') ->withInputs($inputs) ->get(); return $resp->toValuesArray(); } private function clusterAssign(array $vectors, int $k) : array { $n = count($vectors); if ($n === 0) return [[], []]; $k = max(1, min($k, $n)); $seeds = [$this->argMaxNorm($vectors)]; while (count($seeds) < $k) { $seeds[] = $this->farthestIndex($vectors, $seeds); } $clusters = array_fill(0, count($seeds), []); for ($i = 0; $i < $n; $i++) { $si = $this->nearestSeed($vectors[$i], $vectors, $seeds); $dist = $this->l2($vectors[$i], $vectors[$seeds[$si]]); $clusters[$si][] = [$dist, $i]; } foreach ($clusters as &$c) usort($c, fn($a,$b) => $a[0] <=> $b[0]); return [$seeds, $clusters]; } private function argMaxNorm(array $vecs) : int { $imax = 0; $best = -INF; $i = 0; foreach ($vecs as $v) { $n = $this->l2($v, array_fill(0, count($v), 0.0)); if ($n > $best) { $best = $n; $imax = $i; } $i++; } return $imax; } private function farthestIndex(array $vecs, array $seeds) : int { $imax = 0; $best = -INF; foreach ($vecs as $i => $v) { if (in_array($i, $seeds, true)) continue; $minDist = INF; foreach ($seeds as $s) { $d = $this->l2($v, $vecs[$s]); if ($d < $minDist) $minDist = $d; } if ($minDist > $best) { $best = $minDist; $imax = $i; } } return $imax; } private function nearestSeed(array $v, array $vecs, array $seeds) : int { $jmin = 0; $best = INF; $j = 0; foreach ($seeds as $s) { $d = $this->l2($v, $vecs[$s]); if ($d < $best) { $best = $d; $jmin = $j; } $j++; } return $jmin; } private function l2(array $a, array $b) : float { $sum = 0.0; $n = count($a); for ($i = 0; $i < $n; $i++) { $d = ($a[$i] ?? 0.0) - ($b[$i] ?? 0.0); $sum += $d*$d; } return sqrt($sum); } private function generateSteps(string $question) : ?ExampleItem { $resp = StructuredOutput::using('openai')->with( messages: [ ['role' => 'system', 'content' => 'You are an AI assistant that generates step-by-step reasoning for mathematical questions.'], ['role' => 'user', 'content' => "Q: {$question}\nA: Let's think step by step."], ], responseModel: ExampleItem::class, )->get(); if (count($resp->reasoning_steps) > 5) return null; // selection criterion return $resp; } private function selectPerCluster(array $clusters, array $questions) : array { $selected = []; foreach ($clusters as $cluster) { foreach ($cluster as [, $qi]) { // sorted by distance to center $item = $this->generateSteps($questions[$qi]); if ($item !== null) { $selected[] = $item; break; } } } return $selected; } } $questions = [ 'How many apples are left if you have 10 apples and eat 3?', "What's the sum of 5 and 7?", 'If you have 15 candies and give 6 to your friend, how many do you have left?', "What's 8 plus 4?", 'You start with 20 stickers and use 8. How many stickers remain?', 'Calculate 6 added to 9.', ]; $selector = new AutomateSelection(clusters: 2); $selected = $selector($questions); // Selected examples per cluster, each with limited reasoning steps dump($selected); assert(is_array($selected)); assert(count($selected) > 0); assert($selected[0] instanceof ExampleItem); assert(!empty($selected[0]->question)); assert(is_array($selected[0]->reasoning_steps)); ?> ``` ### References 1) Automatic Chain of Thought Prompting in Large Language Models (https://arxiv.org/abs/2210.03493) 2) The Prompt Report: A Systematic Survey of Prompting Techniques (https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/complex_examples.md ================================================================================ ## Overview Choose more complex examples (longer reasoning or more steps) to improve model performance. When no examples exist, sample multiple responses, pick the most complex few, and aggregate answers. This is Complexity-Based Consistency. ## Example ```php generate($query, $context); } usort($responses, fn($a, $b) => count($b->reasoning) <=> count($a->reasoning)); return array_slice($responses, 0, $topK); } private function generate(string $query, string $context) : QAResponse { $system = "You are an expert Question Answering system. Output structured reasoning steps before the final answer."; return StructuredOutput::using('openai')->with( messages: [ ['role' => 'system', 'content' => $system . "\n\nContext:\n{$context}\n\nQuery:\n{$query}"], ], responseModel: QAResponse::class, )->get(); } } $query = 'How many loaves of bread did they have left?'; $context = <<<'CTX' The bakers at the Beverly Hills Bakery baked 200 loaves of bread on Monday morning. They sold 93 loaves in the morning and 39 loaves in the afternoon. A grocery store returned 6 unsold loaves. CTX; $selector = new ComplexityBasedConsistency(); $top = $selector($query, $context, samples: 5, topK: 3); $counts = []; foreach ($top as $r) { $a = (string)$r->correct_answer; $counts[$a] = ($counts[$a] ?? 0) + 1; } $max = max($counts); $finals = array_keys(array_filter($counts, fn($c) => $c === $max)); $final = $finals[array_rand($finals)]; dump($final); assert(is_array($top)); assert(count($top) > 0); assert(!empty($final)); ?> ``` ### References 1) Complexity-based prompting for multi-step reasoning (https://arxiv.org/pdf/2210.00720) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/examine_context.md ================================================================================ ## Overview Encouraging the model to examine each source in context helps mitigate irrelevant information and improves reasoning quality. This is known as Thread of Thought. ## Example ```php with( messages: [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $query], ['role' => 'assistant', 'content' => 'Navigate through the context incrementally, identifying and summarizing relevant portions.'], ], responseModel: ThreadOfThoughtResponse::class, )->get(); } } $context = [ 'The price of a house was $100,000 in 2024', 'The Great Wall of China is not visible from space with the naked eye', 'Honey never spoils; archaeologists found 3,000-year-old edible honey in Egyptian tombs', "The world's oldest known living tree is over 5,000 years old and is located in California", 'The price of a house was $80,000 in 2023', ]; $query = 'What was the increase in the price of a house from 2023 to 2024?'; $response = (new ThreadOfThought)($query, $context); dump($response); assert($response instanceof ThreadOfThoughtResponse); assert(is_array($response->analysis)); assert(count($response->analysis) > 0); assert(is_int($response->correct_answer)); ?> ``` ## Useful Tips Here are some alternative phrases that you can add to your prompt to generate a thread of thought before your model generates a response. - In a step-by-step manner, go through the context, surfacing important information that could be useful. - Walk me through this lengthy document segment by segment, focusing on each part's significance. - Guide me through the context part by part, providing insights along the way. - Divide the document into manageable parts and guide me through each one, providing insights as we move along. - Let's go through this document piece by piece, paying close attention to each section. - Take me through the context bit by bit, making sure we capture all important aspects. - Examine the document in chunks, evaluating each part critically before moving to the next. - Analyze the context by breaking it down into sections, summarizing each as we move forward. - Navigate through the context incrementally, identifying and summarizing relevant portions. - Proceed through the context systematically, zeroing in on areas that could provide the answers we're seeking. - Take me through this long document step-by-step, making sure not to miss any important details. - Analyze this extensive document in sections, summarizing each one and noting any key points. - Navigate through this long document by breaking it into smaller parts and summarizing each, so we don't miss anything. - Let's navigate through the context section by section, identifying key elements in each part. - Let's dissect the context into smaller pieces, reviewing each one for its importance and relevance. - Carefully analyze the context piece by piece, highlighting relevant points for each question. - Read the context in sections, concentrating on gathering insights that answer the question at hand. - Let's read through the document section by section, analyzing each part carefully as we go. - Let's dissect this document bit by bit, making sure to understand the nuances of each section. - Systematically work through this document, summarizing and analyzing each portion as we go. - Let's explore the context step-by-step, carefully examining each segment. - Systematically go through the context, focusing on each part individually. - Methodically examine the context, focusing on key segments that may answer the query. - Progressively sift through the context, ensuring we capture all pertinent details. - Take a modular approach to the context, summarizing each part before drawing any conclusions. - Examine each segment of the context meticulously, and let's discuss the findings. - Approach the context incrementally, taking the time to understand each portion fully. - Let's scrutinize the context in chunks, keeping an eye out for information that answers our queries. - Walk me through this context in manageable parts step by step, summarizing and analyzing as we go. - Let's take a segmented approach to the context, carefully evaluating each part for its relevance to the questions posed. ### References 1) Thread of Thought Unraveling Chaotic Contexts (https://arxiv.org/pdf/2311.08734) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/higher_level_context.md ================================================================================ ## Overview Encourage the model to think through high-level context required to answer a query. Step-back prompting proceeds in two steps: - Abstraction: Generate a more generic step-back question. - Reasoning: Answer the original question using the abstracted response. ## Example ```php with( messages: [['role'=>'user','content'=>$prompt]], responseModel: Stepback::class, )->get(); } public function askStepback(string $abstractQuestion) : array { return StructuredOutput::using('openai')->with( messages: [['role'=>'user','content'=>$abstractQuestion]], responseModel: Sequence::of(Education::class), )->get()->toArray(); } public function finalAnswer(Stepback $s, array $education) : FinalResponse { $eduSummary = array_map( fn(Education $e) => match (true) { $e->year !== null => "{$e->degree->value}, {$e->school}, {$e->topic}, {$e->year}", default => "{$e->degree->value}, {$e->school}, {$e->topic}", }, $education, ); $msg = "Q: {$s->abstract_question}\nA: " . implode("; ", $eduSummary) . "\nQ: {$s->original_question}\nA:"; return StructuredOutput::using('openai')->with( messages: [['role'=>'user','content'=>$msg]], responseModel: FinalResponse::class, )->get(); } } $sb = new StepBackPrompting(); $step = $sb->generateStepback('Estella Leopold went to which school between Aug 1954 and Nov 1954?'); $edu = $sb->askStepback($step->abstract_question); $final = $sb->finalAnswer($step, $edu); dump($step, $edu, $final); assert($step instanceof Stepback); assert(!empty($step->abstract_question)); assert(is_array($edu)); assert(count($edu) > 0); assert($final instanceof FinalResponse); assert(!empty($final->school)); ?> ``` ### References 1) Take a Step Back: Evoking Reasoning via Abstraction in Large Language Models (https://arxiv.org/abs/2310.06117) 2) The Prompt Report: A Systematic Survey of Prompting Techniques (https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/incorrect_examples.md ================================================================================ ## Overview Including examples of incorrect reasoning alongside correct ones helps the model learn what to avoid. This is Contrastive Chain-of-Thought. ## Example ```php "{$e}", $correctExamples)); $incorrect = implode("\n", array_map(fn($e)=>"{$e}", $incorrectExamples)); $system = << system You are an expert question answering AI System. You'll see examples of correct and incorrect reasoning, then solve a new question correctly. {$examplePrompt} {$correct} {$incorrect} {$context} {$query} TXT; return StructuredOutput::using('openai')->with( messages: [['role'=>'system','content'=>$system]], responseModel: ChainOfThought::class, )->get(); } } $context = 'James writes a 3-page letter to 2 different friends twice a week.'; $query = 'How many pages does James write in a year?'; $sample = <<chain_of_thought)); assert(!empty($resp->correct_answer)); ?> ``` ### References 1) Contrastive Chain-of-Thought Prompting (https://arxiv.org/pdf/2311.09277) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/majority_voting.md ================================================================================ ## Overview Uncertainty-Routed Chain-of-Thought generates multiple chains (e.g., 8 or 32), then takes the majority answer if its proportion exceeds a threshold; otherwise, fall back to a single response. ## Example ```php generate($query, $options); } $counts = []; foreach ($responses as $r) { $key = $r->correct_answer->value; $counts[$key] = ($counts[$key] ?? 0) + 1; } arsort($counts); $major = array_key_first($counts); $prop = ($counts[$major] ?? 0) / max(1, $k); if ($prop < $threshold) return $this->generate($query, $options)->correct_answer; return OptionLetter::from($major); } private function generate(string $query, array $options) : ChainOfThoughtResponse { $formatted = implode("\n", array_map(fn($k,$v)=>"{$k}: {$v}", array_keys($options), $options)); $system = << {$query} {$formatted} TXT; return StructuredOutput::using('openai')->with( messages: [ ['role'=>'system','content'=>$system] ], responseModel: ChainOfThoughtResponse::class, )->get(); } } $question = << 'directional selection', 'B' => 'stabilizing selection', 'C' => 'sexual selection', 'D' => 'disruptive selection', ]; $answer = (new MajorityVoting)($question, $options, k: 8, threshold: 0.6); dump($answer); assert($answer instanceof OptionLetter); ?> ``` ### References 1) Gemini: A Family of Highly Capable Multimodal Models (https://storage.googleapis.com/deepmind-media/gemini/gemini_1_report.pdf) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/prompt_variations.md ================================================================================ ## Overview Large Language Models are sensitive to prompt phrasing. Prompt Mining helps discover better templates that occur more frequently in the corpus or are clearer to the model. Here are examples from the paper mapping manual prompts to mined prompts: | Manual Prompt | Mined Prompt | | --- | --- | | x is affiliated with the y religion | x who converted to y | | The headquarter of x is in y | x is based in y | | x died in y | x died at his home in y | | x is represented by music label y | x recorded for y | | x is a subclass of y | x is a type of y | We implement a lightweight approach with Instructor to extract clearer prompt templates. ## Example ```php with( messages: [ ['role' => 'system', 'content' => $system], ['role' => 'system', 'content' => $prompt], ], responseModel: Sequence::of(PromptTemplate::class), )->get()->toArray(); } } $prompt = 'France is the capital of Paris'; $templates = (new GeneratePromptTemplates)($prompt); dump($templates); assert(is_array($templates)); assert(count($templates) > 0); assert($templates[0] instanceof PromptTemplate); assert(!empty($templates[0]->prompt_template)); ?> ``` ### References 1) How Can We Know What Language Models Know? (https://direct.mit.edu/tacl/article/doi/10.1162/tacl_a_00324/96460/How-Can-We-Know-What-Language-Models-Know) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/structure_reasoning.md ================================================================================ ## Overview By getting language models to output their reasoning as a structured table, we can improve their reasoning capabilities and the quality of their outputs. This is known as Tabular Chain Of Thought (Tab-CoT). We can implement this using Instructor with a response model ensuring we get exactly the data that we want. Each row in the table is represented as a `ReasoningStep` object. ## Example ```php expert Question Answering system Make sure to output your reasoning in structured reasoning steps before generating a response to the user's query. {$context} {$query} TXT; return StructuredOutput::using('openai')->with( messages: [ ['role' => 'system', 'content' => $system] ], responseModel: Response::class, )->get(); } } $query = 'How many loaves of bread did they have left?'; $context = <<<'CTX' The bakers at the Beverly Hills Bakery baked 200 loaves of bread on Monday morning. They sold 93 loaves in the morning and 39 loaves in the afternoon. A grocery store returned 6 unsold loaves. CTX; $response = (new GenerateStructuredReasoning)($query, $context); dump($response); assert($response instanceof Response); assert(is_array($response->reasoning)); assert(count($response->reasoning) > 0); assert($response->reasoning[0] instanceof ReasoningStep); assert(is_int($response->correct_answer)); ?> ``` ### Sample Output ```json { "reasoning": [ { "step": 1, "subquestion": "How many loaves of bread were sold in the morning and afternoon?", "procedure": "93 (morning) + 39 (afternoon)", "result": "132" }, { "step": 2, "subquestion": "How many loaves of bread were originally baked?", "procedure": "", "result": "200" }, { "step": 3, "subquestion": "How many loaves of bread were returned by the grocery store?", "procedure": "", "result": "6" }, { "step": 4, "subquestion": "How many loaves of bread were left after accounting for sales and returns?", "procedure": "200 - 132 + 6", "result": "74" } ], "correct_answer": 74 } ``` ### References 1) Tab-CoT: Zero-shot Tabular Chain of Thought (https://arxiv.org/pdf/2305.17812) ================================================================================ FILE: cookbook/examples/Z03_ThoughtGen/uncertain_examples.md ================================================================================ ## Overview When we have a large pool of unlabeled examples that could be used in a prompt, how should we decide which examples to manually label? Active prompting identifies effective examples for human annotation using: - Uncertainty Estimation: Measure uncertainty on each example. - Selection: Choose the most uncertain examples for human labeling. - Annotation: Humans label selected examples. - Inference: Use newly labeled data to improve prompts. ## Uncertainty Estimation (Disagreement) Query the same example k times and measure disagreement: unique responses / total responses. ## Example ```php queryHeight(); } return $this->disagreement($values); } private function queryHeight() : int { return StructuredOutput::using('openai')->with( messages: [['role' => 'user', 'content' => 'How tall is the Empire State Building in meters?']], responseModel: Scalar::integer('height'), )->get(); } private function disagreement(array $responses) : float { $n = count($responses); if ($n === 0) return 0.0; return count(array_unique($responses)) / $n; } } $score = (new EstimateUncertainty)(k: 5); dump($score); assert(is_float($score)); assert($score >= 0.0 && $score <= 1.0); ?> ``` ### Selection & Annotation Select the top-n most uncertain unlabeled examples for human annotation. ### Inference Use newly annotated examples as few-shot context during inference. ### References 1) Active Prompting with Chain-of-Thought for Large Language Models (https://arxiv.org/abs/2302.12246) 2) The Prompt Report: A Systematic Survey of Prompting Techniques (https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z04_Ensembling/combine_reasoning_chains.md ================================================================================ ## Overview Meta Chain-of-Thought (Meta-CoT) decomposes a query into sub-queries, solves each with its own reasoning chain, then composes a final answer from those chains. ## Example ```php decompose($query)->queries; $chains = []; foreach ($subs as $q) { $chains[] = $this->chain($q); } return $this->final($query, $chains); } public function decompose(string $query) : QueryDecomposition { return StructuredOutput::using('openai')->with( messages: [ ['role' => 'system', 'content' => 'Decompose the user query into minimal sub-queries needed to derive the answer.'], ['role' => 'user', 'content' => $query], ], responseModel: QueryDecomposition::class, )->get(); } public function chain(string $query) : MaybeResponse { $system = <<with( messages: [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $query] ], responseModel: MaybeResponse::class, )->get(); } public function final(string $query, array $context) : ReasoningAndResponse { $parts = []; foreach ($context as $c) { if ($c instanceof MaybeResponse && !$c->error && $c->result) { $parts[] = $c->result->intermediate_reasoning . "\n" . $c->result->correct_answer; } } $formatted = implode("\n", $parts); $system = << {$query} {$formatted} PR; return StructuredOutput::using('openai')->with( messages: [ ['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => $prompt] ], responseModel: ReasoningAndResponse::class, )->get(); } } $query = "Would Arnold Schwarzenegger have been able to deadlift an adult Black rhinoceros at his peak strength?"; $result = (new MetaCOT)($query); dump($result); assert($result instanceof ReasoningAndResponse); assert(!empty($result->intermediate_reasoning)); assert(!empty($result->correct_answer)); ?> ``` ### References 1) Answering Questions by Meta-Reasoning over Multiple Chains of Thought (https://arxiv.org/pdf/2304.13007) ================================================================================ FILE: cookbook/examples/Z04_Ensembling/combine_responses.md ================================================================================ ## Overview Universal Self-Consistency uses a second LLM to judge the quality of multiple responses to a query and select the most consistent one. ## Example ```php generate($query); } $sel = $this->select($responses, $query); $idx = max(0, min($sel->most_consistent_response_id, count($responses)-1)); return $responses[$idx]; } private function generate(string $query) : ResponseItem { return StructuredOutput::using('openai')->with( messages: [ ['role'=>'user', 'content'=>$query] ], responseModel: ResponseItem::class, )->get(); } private function select(array $responses, string $query) : SelectedResponse { $formatted = []; foreach ($responses as $i => $r) { $formatted[] = "Response {$i}: {$r->chain_of_thought}. {$r->answer}"; } $content = "\n{$query}\n\n\n" . implode("\n", $formatted) . "\n\nEvaluate these responses. Select the most consistent response based on majority consensus."; return StructuredOutput::using('openai')->with( messages: [ ['role'=>'user','content'=>$content] ], responseModel: SelectedResponse::class, )->get(); } } $query = "The three-digit number 'ab5' is divisible by 3. How many different three-digit numbers can 'ab5' represent?"; $result = (new CombineResponses)($query, k: 3); dump($result); assert($result instanceof ResponseItem); assert(!empty($result->chain_of_thought)); assert(!empty($result->answer)); ?> ``` ### References 1) Universal Self-Consistency For Large Language Model Generation (https://arxiv.org/pdf/2311.17311) ================================================================================ FILE: cookbook/examples/Z04_Ensembling/combine_specialized_llms.md ================================================================================ ## Overview Mixture of Reasoning Experts (MoRE) combines specialized experts (e.g., factual with evidence, and multi-hop reasoning) and selects the best answer using a scorer. ## Example ```php \n{$query}\n\n\n\n{$formatted}\n"; return StructuredOutput::using('openai')->with( messages: [ ['role'=>'system','content'=>$system] ], responseModel: FactualExpert::class, )->get(); } public function multihop(string $query) : MultihopExpert { $system = "\n{$query}\n"; return StructuredOutput::using('openai')->with( messages: [ ['role'=>'system','content'=>$system] ], responseModel: MultihopExpert::class, )->get(); } public function score(string $query, string $answer) : ModelScore { $messages = [ ['role'=>'system','content'=>'You score answers by how well they answer the user query (0..1).'], ['role'=>'user','content'=>"\n{$query}\n\n\n\n{$answer}\n"], ]; return StructuredOutput::using('openai')->with( messages: $messages, responseModel: ModelScore::class, )->get(); } } $query = "Who's the original singer of Help Me Make It Through The Night?"; $evidences = ["Help Me Make It Through The Night is a country music ballad written and composed by Kris Kristofferson and released on his 1970 album 'Kristofferson'"]; $threshold = 0.8; $more = new MoRE(); $factual = $more->factual($query, $evidences); $multihop = $more->multihop($query); $fScore = (float) ($more->score($query, $factual->answer)->score ?? 0.0); $mScore = (float) ($more->score($query, $multihop->answer)->score ?? 0.0); $answer = ''; if (max($fScore, $mScore) < $threshold) { $answer = 'Abstaining from responding'; } else { $answer = ($fScore > $mScore) ? $factual->answer : $multihop->answer; } dump($answer); assert(is_string($answer)); assert(!empty($answer)); ?> ``` ### References 1) Getting MoRE out of Mixture of Language Model Reasoning Experts (https://arxiv.org/pdf/2305.14628) ================================================================================ FILE: cookbook/examples/Z04_Ensembling/consistent_examples.md ================================================================================ Consistency Based Self Adaptive Prompting (COSP)1 aims to improve LLM output quality by generating high quality few shot examples to be included in the final prompt. These are examples without labelled ground truth so they use self-consistency and a metric known as normalized entropy to select the best examples. Once they've selected the examples, they then append them to the prompt and generate multiple reasoning chains before selecting the final result using Self-Consistency. COSP process¶ How does this look in practice? Let's dive into greater detail. Step 1 - Selecting Examples¶ In the first step, we try to generate high quality examples from questions that don't have ground truth labels. This is challenging because we want to find a way to automatically determine answer quality when sampling our model multiple times. In this case, we have n questions which we want to generate m possible reasoning chains for each question. This gives a total of nm examples. We then want to filter out k final few shot examples from these nm examples to be included inside our final prompt. Using chain of thought, we first generate m responses for each question. These responses contain a final answer and a rationale behind that answer. We compute a score for each response using a weighted sum of two values - normalized entropy and repetitiveness ( How many times this rationale appears for this amswer ) We rank all of our nm responses using this score and choose the k examples with the lowest scores as our final few shot examples. Normalized Entropy In the paper, the authors write that normalized entropy is a good proxy over a number of different tasks where low entropy is positively correlated with correctness. Entropy is also supposed to range from 0 to 1. Therefore in order to do so, we introduce a - term in our implementation so that the calculated values range from 0 to 1. ```php m; $i++) { $out[] = $this->cot($query); } return $out; } public function scoreExamples(string $query, array $responses) : array { $entropy = $this->normalizedEntropy(array_map(fn($r)=>$r->answer, $responses)); $repetitiveness = $this->repetitiveness($responses); $scored = []; foreach ($responses as $r) { $s = $entropy - $repetitiveness; // lower is better $se = new ScoredExample(); $se->query = $query; $se->response = $r; $se->score = $s; $scored[] = $se; } return $scored; } public function select(array $candidates, int $k) : array { $all = []; foreach ($candidates as $q) { $all = array_merge($all, $this->scoreExamples($q, $this->generate($q))); } usort($all, fn($a,$b)=> $a->score <=> $b->score); return array_slice($all, 0, $k); } private function cot(string $query) : CoTResponse { return StructuredOutput::using('openai')->with( messages: [ ['role'=>'user','content'=>$query] ], responseModel: CoTResponse::class, )->get(); } private function normalizedEntropy(array $answers) : float { $n = count($answers); if ($n === 0) return 0.0; $freq=[]; foreach($answers as $a){$freq[$a]=($freq[$a]??0)+1;} $h = 0.0; foreach ($freq as $c) { $p = $c / $n; $h += ($p > 0) ? -$p * log($p) : 0.0; } $maxH = log(max(1, count($freq))); return $maxH > 0 ? $h / $maxH : 0.0; } private function repetitiveness(array $responses) : float { // approximate: 1 - (unique rationales / total) $rationales = array_map(fn($r)=> implode(' ', $r->chain_of_thought), $responses); $unique = count(array_unique($rationales)); $n = max(1, count($responses)); return 1.0 - ($unique / $n); } } $questions = [ 'How many loaves of bread did they have left?', 'How many pages does James write in a year?', ]; $selector = new COSPSelector(m: 3); $best = $selector->select($questions, k: 3); dump($best); assert(is_array($best)); assert(count($best) > 0); assert($best[0] instanceof ScoredExample); assert(!empty($best[0]->query)); ?> ``` ### References 1: Better Zero-Shot Reasoning with Self-Adaptive Prompting (https://arxiv.org/pdf/2305.14106) ================================================================================ FILE: cookbook/examples/Z04_Ensembling/distinct_examples.md ================================================================================ ## Overview Demonstration Ensembling (DENSE) runs multiple prompts, each with a different subset of examples, then aggregates the outputs. ## Example ```php one($prompt, $subset); } return $outputs; } private function one(string $prompt, array $examples) : DemonstrationResponse { $joined = implode("\n", $examples); $system = <<with( messages: [ ['role'=>'system','content'=>$system], ['role'=>'user','content'=>$prompt] ], responseModel: DemonstrationResponse::class, options: ['temperature' => 0.0], )->get(); } } $userQuery = 'What is the weather like today?'; $examples = [ 'I love this product! [Positive]', 'This is the worst service ever. [Negative]', 'The movie was okay, not great but not terrible. [Neutral]', "I'm so happy with my new phone! [Positive]", 'The food was terrible and the service was slow. [Negative]', "It's an average day, nothing special. [Neutral]", 'Fantastic experience, will come again! [Positive]', "I wouldn't recommend this to anyone. [Negative]", 'The book was neither good nor bad. [Neutral]', 'Absolutely thrilled with the results! [Positive]', ]; $responses = (new DenseEnsembling)($userQuery, $examples, 5); $counts = []; foreach ($responses as $r) { $k = $r->correct_answer->value; $counts[$k] = ($counts[$k] ?? 0) + 1; } arsort($counts); $mostCommon = array_key_first($counts); dump($mostCommon); assert(is_string($mostCommon)); assert(in_array($mostCommon, ['Positive', 'Negative', 'Neutral'])); ?> ``` ### References 1) Exploring Demonstration Ensembling for In-Context Learning (https://arxiv.org/pdf/2308.08780) ================================================================================ FILE: cookbook/examples/Z04_Ensembling/ensemble_test_prompts.md ================================================================================ ## Overview ### What's Max Mutual Information? Max Mutual Information Method aims to find the best prompt to elicit the desired response from an LLM by maximizing a mutual information proxy — i.e., reducing model uncertainty with the prompt. ### Entropy When a language model receives a prompt, it produces a distribution over outputs. Lower entropy suggests higher confidence. ### Mutual Information We approximate mutual information as the difference between marginal and conditional entropies of outputs across multiple samples for a given prompt. Below, we use a lightweight proxy based on answer diversity and rationale repetitiveness. ## Example ```php scorePrompt($p, $question); } usort($scored, fn($a,$b)=> $a->score <=> $b->score); return $scored; // lower is better (proxy for MI) } private function scorePrompt(string $prompt, string $question) : PromptScore { $answers = []; for ($i = 0; $i < $this->k; $i++) { $answers[] = $this->run("{$prompt}\n\n{$question}"); } $entropy = $this->normalizedEntropy(array_map(fn($r)=>$r->answer, $answers)); $rep = $this->repetitiveness($answers); $ps = new PromptScore(); $ps->prompt = $prompt; $ps->score = $entropy - $rep; return $ps; } private function run(string $input) : CoT { return StructuredOutput::using('openai')->with( messages: [ ['role'=>'user','content'=>$input] ], responseModel: CoT::class, )->get(); } private function normalizedEntropy(array $answers) : float { $n = count($answers); if ($n===0) return 0.0; $freq=[]; foreach($answers as $a){$freq[$a]=($freq[$a]??0)+1;} $h=0.0; foreach($freq as $c){$p=$c/$n; $h += ($p>0)? -$p*log($p):0.0;} $max=log(max(1,count($freq))); return $max>0? $h/$max:0.0; } private function repetitiveness(array $responses) : float { $r = array_map(fn($x)=> implode(' ', $x->chain_of_thought), $responses); $uniq = count(array_unique($r)); $n = max(1,count($responses)); return 1.0 - ($uniq/$n); } } $prompts = [ 'Explain step-by-step then answer:', 'Think carefully and provide reasoning before the answer:', 'Reason in numbered steps and conclude with the final number:', ]; $question = 'If a store sold 93 in the morning and 39 in the afternoon from 200 baked, and 6 were returned, how many remain?'; $scores = (new PromptEnsembler)->evaluate($prompts, $question); dump($scores); assert(is_array($scores)); assert(count($scores) === 3); assert($scores[0] instanceof PromptScore); assert(!empty($scores[0]->prompt)); ?> ``` ### References 1) https://learnprompting.org/docs/advanced/ensembling/max_mutual_information_method ================================================================================ FILE: cookbook/examples/Z04_Ensembling/multiple_candidates.md ================================================================================ ## Overview Generate multiple candidate responses and pick the most common answer (Self-Consistency). ## Example ```php one($prompt)->correct_answer; } $counts = []; foreach ($answers as $a) { $key = (string)$a; $counts[$key] = ($counts[$key] ?? 0) + 1; } arsort($counts); return (int) array_key_first($counts); } private function one(string $prompt) : SelfConsistencyResponse { $system = 'You are an intelligent QA system. First think step-by-step, then provide the final answer.'; return StructuredOutput::using('openai')->with( messages: [ ['role'=>'system','content'=>$system], ['role'=>'user','content'=>$prompt] ], responseModel: SelfConsistencyResponse::class, options: ['temperature' => 0.5], )->get(); } } $prompt = << ``` ### References 1) Self-Consistency Improves Chain Of Thought Reasoning In Language Models (https://arxiv.org/pdf/2210.03350) ================================================================================ FILE: cookbook/examples/Z04_Ensembling/task_specific_evals.md ================================================================================ ## Overview Universal Self Prompting is a two stage process similar to Consistency Based Self Adaptive Prompting (COSP). Here is a breakdown of the two stages. Generate Examples : LLMs are prompted to generate a collection of candidate responses using a test dataset Answer Query : We then select a few of these model-generated responses as examples to prompt the LLM to obtain a final prediction. Note here that the final answer is obtained using a single forward pass with greedy decoding. USP Process¶ Let's see how this works in greater detail. Generate Few Shot Examples¶ We first prompt our model to generate responses for a given set of prompts. Instead of measuring the entropy and repetitiveness as in COSP, we use one of three possible methods to measure the quality of the generated responses. These methods are decided based on the three categories supported. This category has to be specified by a user ahead of time. Note that for Short Form and Long Form generation, we generate m m different samples. This is not the case for classification tasks. Classification : Classification Tasks are evaluated using the normalized probability of each label using the raw logits from the LLM. In short, we take the raw logit for each token corresponding to the label, use a softmax to normalize each of them and then sum across the individual probabilities and their log probs. We also try to sample enough queries such that we have a balanced number of predictions across each class ( so that our model doesn't have a bias towards specific classes ) Short Form Generation: This is done by using a similar formula to COSP but without the normalizing term Long Form Generation: This is done by using the average pairwise ROUGE score between all pairs of the m responses. What is key here is that depending on the task specified by the user, we have a task-specific form of evaluation. This eventually allows us to better evaluate our individual generated examples. Samples of tasks for each category include Classification: Natural Language Inference, Topic Classification and Sentiment Analysis Short Form Generation : Question Answering and Sentence Completion Long Form Generation : Text Summarization and Machine Translation This helps to ultimately improve the performance of these large language models across different types of tasks. Generate Single Response¶ Once we've selected our examples, the second step is relatively simple. We just need to append a few of our chosen examples that score best on our chosen metric to append to our solution. Implementation¶ We've implemented a classification example below that tries to sample across different classes in a balanced manner before generating a response using a single inference call. We bias this sampling towards samples that the model is more confident towards by using a confidence label. ```php with( messages: [ ['role'=>'system','content'=>$content], ['role'=>'user','content'=>$query] ], responseModel: Classification::class, )->get(); } public function balancedSample(array $queries, int $k) : array { $preds = []; foreach ($queries as $q) { $preds[] = [$this->classify($q), $q]; } $by = []; foreach ($preds as $p) { $by[$p[0]->label->value][] = $p; } $per = max(1, intdiv($k, max(1, count($by)))); $out = []; foreach ($by as $label => $items) { usort($items, fn($a,$b) => $this->score($b[0]->confidence) <=> $this->score($a[0]->confidence)); $slice = array_slice($items, 0, $per); foreach ($slice as $it) { $out[] = $it[1] . " ({$label})"; } } return $out; } public function finalWithExamples(string $query, array $examples) : Classification { $formatted = implode("\n", $examples); $system = "You classify queries into Happy, Angry, or Sadness.\n\n{$formatted}\n"; return StructuredOutput::using('openai')->with( messages: [ ['role'=>'system','content'=>$system], ['role'=>'user','content'=>$query] ], responseModel: Classification::class, )->get(); } private function score(Confidence $c) : int { return match($c) { Confidence::Highly => 4, Confidence::Confident => 3, Confidence::Somewhat => 2, Confidence::Uncertain => 1 }; } } ?> ``` ```php balancedSample($examples, 3); $final = $usp->finalWithExamples('i feel furious that right to life advocates can and do tell me how to live and die', $balanced); dump($balanced, $final); assert(is_array($balanced)); assert(count($balanced) > 0); assert($final instanceof Classification); assert($final->label instanceof Emotion); ?> ``` ================================================================================ FILE: cookbook/examples/Z04_Ensembling/translation_paraphrasing.md ================================================================================ ## Overview Back-translation can produce diverse paraphrases: translate to another language and back to English, encouraging varied phrasing. ## Example ```php with( messages: [ ['role'=>'system','content'=>$system], ['role'=>'user','content'=>"Prompt: {$prompt}"] ], responseModel: TranslatedPrompt::class, )->get(); } public function backTranslate(string $prompt, string $lang) : string { $step1 = $this->translate($prompt, 'english', $lang)->translation; $step2 = $this->translate($step1, $lang, 'english')->translation; return $step2; } public function generate(string $prompt, array $languages, int $permutations = 5) : array { $out = []; for ($i = 0; $i < $permutations; $i++) { $lang = $languages[$i % max(1, count($languages))] ?? 'spanish'; $out[] = $this->backTranslate($prompt, $lang); } return $out; } } $prompt = 'Explain how photosynthesis works for a 10-year-old.'; $languages = ['spanish','french','german']; $variants = (new Paraphraser)->generate($prompt, $languages, permutations: 3); dump($variants); assert(is_array($variants)); assert(count($variants) === 3); assert(!empty($variants[0])); ?> ``` ### References 1) Prompt paraphrasing approaches ================================================================================ FILE: cookbook/examples/Z04_Ensembling/verify_majority_voting.md ================================================================================ ## Overview Diverse verifier scoring aggregates quality over unique answers, improving over majority vote. ## Example ```php generate($query, $examples); } $scores = []; foreach ($responses as $r) { $g = $this->score($query, $r); $scores[$r->answer] = ($scores[$r->answer] ?? 0.0) + $this->map($g); } arsort($scores); return (int) array_key_first($scores); } private function generate(string $query, array $examples) : ResponseItem { $formatted = implode("\n", $examples); $content = "You answer succinctly.\n\n{$query}\n\n\n\n{$formatted}\n"; return StructuredOutput::using('openai')->with( messages: [ ['role'=>'user','content'=>$content] ], responseModel: ResponseItem::class, )->get(); } private function score(string $query, ResponseItem $response) : Grading { $content = "Score the response to the query. Output only the grade.\n\n{$query}\n\n\nChain: {$response->chain_of_thought}\nAnswer: {$response->answer}\n"; return StructuredOutput::using('openai')->with( messages: [ ['role'=>'user','content'=>$content] ], responseModel: Grading::class, )->get(); } private function map(Grading $g) : float { return match($g->grade) { Grade::Excellent => 1.0, Grade::Good => 0.75, Grade::Average => 0.5, Grade::Poor => 0.25 }; } } $examples = [ "Q: James runs 3 sprints, 3 times a week, 60m each. How many meters per week? A: ... The answer is 540.", "Q: Brandon's iPhone age puzzle... A: ... The answer is 8.", "Q: Jean has 30 lollipops ... bags? A: ... The answer is 14.", "Q: Weng earns $12/hour, worked 50 minutes. How much? A: ... The answer is 10.", ]; $query = 'Betty needs $100; has half; parents give $15; grandparents twice parents. How much more needed?'; $best = (new DiverseVerifier)($query, $examples, k: 6); dump($best); assert(is_int($best)); ?> ``` ### References 1) Making Language Models Better Reasoners with Step-Aware Verifier (https://aclanthology.org/2023.acl-long.291/) ================================================================================ FILE: cookbook/examples/Z05_SelfCriticism/break_down_reasoning.md ================================================================================ ## Overview Cumulative Reasoning separates reasoning into propose → verify → report for better logical inference. ## Example ```php with( model: 'gpt-4o-mini', responseModel: ProposerOutput::class, messages: [ ['role' => 'system', 'content' => $sys], ['role' => 'user', 'content' => $user], ], )->get(); } public function verify(ProposerOutput $proposal) : array { $results = []; foreach ($proposal->valid_propositions as $p) { $sys = 'Use FOL to determine whether the deduction from two premises to the proposition is valid.'; $user = "Premises:\n{$p->premise1}\n{$p->premise2}\n\nProposition:\n{$p->proposition}"; $res = StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: VerifiedProposition::class, messages: [ ['role' => 'system', 'content' => $sys], ['role' => 'user', 'content' => $user] ], )->get(); $results[] = $res; } return $results; } public function report(array $verificationResult, string $hypothesis, array $premises) : ReporterOutput { $formattedPrem = '- ' . implode("\n- ", $premises); $props = []; foreach ($verificationResult as $v) { if ($v->is_valid) { $props[] = $v->proposition; } } $formattedProp = '- ' . implode("\n- ", $props); $sys = << 'system', 'content' => $sys], ['role' => 'user', 'content' => "Premises:\n{$formattedPrem}\n\nHypothesis: {$hypothesis}"], ['role' => 'assistant', 'content' => "Let's think step by step. From the premises, we can deduce the following propositions:\n{$formattedProp}\n\nRecall the Hypothesis: {$hypothesis}"], ]; return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: ReporterOutput::class, messages: $messages, )->get(); } } $hypothesis = 'Hyraxes lay eggs'; $premises = [ 'The only types of mammals that lay eggs are platypuses and echidnas', 'Platypuses are not hyrax', 'Echidnas are not hyrax', 'No mammals are invertebrates', 'All animals are either vertebrates or invertebrates', 'Mammals are animals', 'Hyraxes are mammals', 'Grebes lay eggs', 'Grebes are not platypuses and also not echidnas', ]; $pipeline = new CumulativeReasoningPipeline(); $proposal = $pipeline->propose($premises, $hypothesis); $verified = $pipeline->verify($proposal); $report = $pipeline->report($verified, $hypothesis, $premises); dump($proposal, $verified, $report); assert($proposal instanceof ProposerOutput); assert(!empty($proposal->reasoning)); assert(!empty($proposal->valid_propositions)); assert($proposal->prediction instanceof Prediction); assert(is_array($verified)); assert(!empty($verified)); foreach ($verified as $v) { assert($v instanceof VerifiedProposition); assert(!empty($v->proposition)); } assert($report instanceof ReporterOutput); assert(!empty($report->reasoning)); ?> ``` ### References 1: Cumulative Reasoning with Large Language Models (https://arxiv.org/pdf/2308.04371) ================================================================================ FILE: cookbook/examples/Z05_SelfCriticism/determine_uncertainty.md ================================================================================ ## Overview We want models to assess confidence in their own answers. Self-Calibration asks the model to justify an answer and state whether it is valid. ## Example ```php with( messages: $messages, responseModel: SelfCalibration::class, model: 'gpt-4o-mini', )->get(); } } $originalPrompt = << ``` ## References 1. Language Models (Mostly) Know What They Know (https://arxiv.org/pdf/2207.05221) ================================================================================ FILE: cookbook/examples/Z05_SelfCriticism/improve_with_feedback.md ================================================================================ ## Overview Self-Refine iteratively generates an answer, critiques it, and refines it until a stopping condition is met. ## Example ```php response = $code; $t->feedback = $feedback; $t->refined_response = $refined; $this->history[] = $t; } } class SelfRefinePipeline { public function generateInitial(string $prompt) : Response { return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: Response::class, messages: [ ['role' => 'user', 'content' => $prompt] ], )->get(); } public function generateFeedback(Response $response) : Feedback { $msg = << {$response->code} If the code does not need improvement, set done = True. MSG; return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: Feedback::class, messages: [ ['role' => 'user', 'content' => $msg] ], )->get(); } public function refine(Response $response, Feedback $feedback) : Response { $feedbackLines = array_map( fn($item) => is_string($item) ? $item : json_encode($item), $feedback->feedback, ); $feedbackText = implode("\n", $feedbackLines); $msg = << {$response->code} {$feedbackText} Refine your response. MSG; return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: Response::class, messages: [ ['role' => 'user', 'content' => $msg] ], )->get(); } public function stop(Feedback $feedback, History $history) : bool { if ($feedback->done) { return true; } return count($history->history) >= 3; } } $pipeline = new SelfRefinePipeline(); $response = $pipeline->generateInitial('Write Python code to calculate the Fibonacci sequence.'); $history = new History(); while (true) { $fb = $pipeline->generateFeedback($response); if ($pipeline->stop($fb, $history)) { break; } $refined = $pipeline->refine($response, $fb); $history->add($response->code, $fb->feedback, $refined->code); $response = $refined; } dump($history, $response); assert($response instanceof Response); assert(!empty($response->code)); assert($history instanceof History); assert(!empty($history->history)); foreach ($history->history as $timestep) { assert($timestep instanceof Timestep); assert(!empty($timestep->response)); assert(!empty($timestep->refined_response)); } ?> ``` ## References 1. Self-Refine: Iterative Refinement with Self-Feedback (https://arxiv.org/abs/2303.17651) 2. The Prompt Report: A Systematic Survey of Prompting Techniques (https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z05_SelfCriticism/reconstruct_prompt.md ================================================================================ ## Overview Reverse Chain-of-Thought (RCoT) reconstructs a likely prompt from reasoning steps, compares condition lists, and refines the answer with targeted feedback. ## Example ```php with( model: 'gpt-4o-mini', responseModel: ModelResponse::class, messages: [ ['role' => 'system', 'content' => "Generate logical steps before answering."], ['role' => 'user', 'content' => $query], ], )->get(); } public function reconstruct(ModelResponse $response) : ReconstructedPrompt { $sys = <<chain_of_thought} Response: {$response->correct_answer} SYS; return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: ReconstructedPrompt::class, messages: [ ['role' => 'system', 'content' => $sys] ], )->get(); } public function deconstructToConditions(string $prompt) : ConditionList { return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: ConditionList::class, messages: [ ['role' => 'system', 'content' => "List the key conditions required to answer the problem."], ['role' => 'user', 'content' => $prompt], ], )->get(); } public function compareConditions(array $original, array $reconstructed) : ModelFeedback { $orig = "- " . implode("\n- ", $original); $recon = "- " . implode("\n- ", $reconstructed); $sys = <<with( model: 'gpt-4o-mini', responseModel: ModelFeedback::class, messages: [ ['role' => 'system', 'content' => $sys] ], )->get(); } public function revise(ModelResponse $response, ModelFeedback $feedback) : ModelResponse { $miss = "- " . implode("\n- ", $feedback->detected_inconsistencies); $sys = <<correct_answer} Overlooked conditions: {$miss} Reasons: {$feedback->feedback} Generate a revised response that addresses the feedback and includes the ignored conditions. SYS; return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: ModelResponse::class, messages: [ ['role' => 'system', 'content' => $sys] ], )->get(); } } $query = <<generateResponse($query); $reconstructed = $pipeline->reconstruct($response); $originalList = $pipeline->deconstructToConditions($query); $reconstructedList = $pipeline->deconstructToConditions($reconstructed->reconstructed_prompt); $feedback = $pipeline->compareConditions($originalList->conditions, $reconstructedList->conditions); if (!$feedback->is_equal) { $response = $pipeline->revise($response, $feedback); } dump($reconstructed, $originalList, $reconstructedList, $feedback, $response); assert($reconstructed instanceof ReconstructedPrompt); assert(!empty($reconstructed->reconstructed_prompt)); assert(!empty($reconstructed->chain_of_thought)); assert($originalList instanceof ConditionList); assert(!empty($originalList->conditions)); assert($reconstructedList instanceof ConditionList); assert(!empty($reconstructedList->conditions)); assert($feedback instanceof ModelFeedback); assert(!empty($feedback->feedback)); assert($response instanceof ModelResponse); assert(!empty($response->correct_answer)); assert(!empty($response->chain_of_thought)); ?> ``` ## References 1. RCoT: Detecting And Rectifying Factual Inconsistency In Reasoning By Reversing Chain-Of-Thought (https://arxiv.org/pdf/2305.11499) ================================================================================ FILE: cookbook/examples/Z05_SelfCriticism/self_verify.md ================================================================================ ## Overview Self-Verification generates multiple candidates via CoT, rewrites them as declaratives, and verifies them via TFV to select the best candidate. ## Example ```php with( model: 'gpt-4o-mini', responseModel: Candidate::class, messages: [ ['role' => 'user', 'content' => "Think step by step: {$query}"] ], )->get(); } public function rewrite(string $query, Candidate $candidate) : Rewritten { $msg = <<month}. MSG; return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: Rewritten::class, messages: [ ['role' => 'user', 'content' => $msg] ], )->get(); } public function verify(string $question) : Verification { return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: Verification::class, messages: [ ['role' => 'user', 'content' => $question] ], )->get(); } public function run(string $query) : void { $candidates = []; for ($i = 0; $i < $this->n; $i++) { $candidates[] = $this->queryCandidate($query); } foreach ($candidates as $candidate) { $rewritten = $this->rewrite($query, $candidate); $question = $rewritten->declarative . ' Is this correct? Answer True or False.'; $score = 0; for ($j = 0; $j < $this->k; $j++) { $v = $this->verify($question); if ($v->correct) { $score++; } } echo "Candidate: {$candidate->month}, Verification Score: {$score}\n"; } } } $query = 'What month is it now if it has been 3 weeks, 10 days, and 2 hours since May 1, 2024 6pm?'; $pipeline = new SelfVerifyPipeline; // Test individual components $candidate = $pipeline->queryCandidate($query); assert($candidate instanceof Candidate); assert(!empty($candidate->reasoning_steps)); assert(!empty($candidate->month)); $rewritten = $pipeline->rewrite($query, $candidate); assert($rewritten instanceof Rewritten); assert(!empty($rewritten->declarative)); $verification = $pipeline->verify($rewritten->declarative . ' Is this correct?'); assert($verification instanceof Verification); // Run full pipeline $pipeline->run($query); ?> ``` ## References 1. Large Language Models are Better Reasoners with Self-Verification (https://arxiv.org/abs/2212.09561) 2. The Prompt Report: A Systematic Survey of Prompting Techniques (https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z05_SelfCriticism/verify_independently.md ================================================================================ ## Overview Chain-of-Verification (CoVe) verifies an answer by generating validation questions, answering them independently, and judging the original answer. ## Example ```php generateInitialResponse($query); $questions = $this->generateVerificationQuestions($initial->correct_answer); $answers = $this->generateVerificationResponses($questions->question); return $this->generateFinalResponse($answers, $initial, $query); } private function generateInitialResponse(string $query) : QueryResponse { return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: QueryResponse::class, messages: [ ['role' => 'system', 'content' => 'You are an expert question answering system'], ['role' => 'user', 'content' => $query], ], )->get(); } private function generateVerificationQuestions(string $llmResponse) : ValidationQuestions { return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: ValidationQuestions::class, messages: [ ['role' => 'system', 'content' => 'You generate follow-up questions to validate a response. Focus on key assumptions and facts.'], ['role' => 'user', 'content' => $llmResponse], ], )->get(); } private function generateVerificationResponses(array $questions) : array { $pairs = []; foreach ($questions as $q) { $ans = StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: ValidationAnswer::class, messages: [ ['role' => 'system', 'content' => 'You answer validation questions precisely.'], ['role' => 'user', 'content' => $q], ], )->get(); $pairs[] = [$ans, $q]; } return $pairs; } private function generateFinalResponse(array $answers, QueryResponse $initial, string $originalQuery) : FinalResponse { $formatted = []; foreach ($answers as [$ans, $q]) { $formatted[] = "Q: {$q}\nA: {$ans->answer}"; } $joined = implode("\n", $formatted); return StructuredOutput::using('openai')->with( model: 'gpt-4o-mini', responseModel: FinalResponse::class, messages: [ ['role' => 'system', 'content' => 'Validate whether the initial answer answers the initial query given Q/A evidence. Return the original if valid; otherwise provide a corrected answer.'], ['role' => 'user', 'content' => "Initial query: {$originalQuery}\nInitial Answer: {$initial->correct_answer}\nVerification Questions and Answers:\n{$joined}"], ], )->get(); } } $query = 'What was the primary cause of the Mexican-American War and how long did it last?'; $final = (new CoVeVerifier)->run($query); dump($final); assert($final instanceof FinalResponse); assert(!empty($final->correct_answer)); ?> ``` ## References 1. Chain-Of-Verification Reduces Hallucination In Large Language Models (https://arxiv.org/pdf/2309.11495) ================================================================================ FILE: cookbook/examples/Z06_Decomposition/break_down_complexity.md ================================================================================ ## Overview How can we help LLMs handle complex tasks more effectively? Decomposed Prompting leverages a Language Model (LLM) to deconstruct a complex task into a series of manageable sub-tasks. Each sub-task is then processed by specific functions, enabling the LLM to handle intricate problems more effectively and systematically. This approach breaks down complexity by: - Generating an action plan using the LLM - Executing each step systematically - Using specific operations like Split, StrPos, and Merge ## Example ```php split_char, $input); } } class StrPos { public function __construct( public int $index ) {} public function execute(array $input): array { return array_map(fn($str) => $str[$this->index] ?? '', $input); } } class Merge { public function __construct( public string $merge_char ) {} public function execute(array $input): string { return implode($this->merge_char, $input); } } class Action { public int $id; public ActionType $type; public string|int $parameter; } class ActionPlan implements CanProvideJsonSchema { public string $initial_data = ''; /** @var Action[] */ public array $plan = []; public function toJsonSchema() : array { return [ 'type' => 'object', 'x-title' => 'ActionPlan', 'x-php-class' => self::class, 'properties' => [ 'initial_data' => ['type' => 'string'], 'plan' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'x-title' => 'Action', 'x-php-class' => Action::class, 'properties' => [ 'id' => ['type' => 'integer'], 'type' => [ 'type' => 'string', 'enum' => array_map( static fn(ActionType $type): string => $type->value, ActionType::cases(), ), 'x-php-class' => ActionType::class, ], 'parameter' => [ 'anyOf' => [ ['type' => 'string'], ['type' => 'integer'], ], ], ], 'required' => ['id', 'type', 'parameter'], 'additionalProperties' => false, ], ], ], 'required' => ['initial_data', 'plan'], 'additionalProperties' => false, ]; } } class DecomposedTaskSolver { public function __invoke(string $taskDescription): string { $plan = $this->deriveActionPlan($taskDescription); return $this->executePlan($plan); } private function deriveActionPlan(string $taskDescription): ActionPlan { return StructuredOutput::using('openai')->with( messages: [ ['role' => 'system', 'content' => 'Generate an action plan to solve the task. Set initial_data to the exact input string from the task. Available actions: Split (split string by character into array), StrPos (get character at given index from each string in array), Merge (join array of strings with character). Example: for "get first letter of each word in \'Hi Bob\'", set initial_data="Hi Bob", then Split(" "), StrPos(0), Merge("").'], ['role' => 'user', 'content' => $taskDescription], ], responseModel: ActionPlan::class, )->get(); } private function executePlan(ActionPlan $plan): string { $current = $plan->initial_data; foreach ($plan->plan as $action) { match ($action->type) { ActionType::Split => $current = (new Split((string) $action->parameter))->execute($this->normalizeString($current)), ActionType::StrPos => $current = (new StrPos((int) $action->parameter))->execute($this->normalizeStringArray($current)), ActionType::Merge => $current = (new Merge((string) $action->parameter))->execute($this->normalizeStringArray($current)), }; } return $this->normalizeString($current); } private function normalizeString(mixed $value): string { return match (true) { is_string($value) => $value, is_array($value) => implode('', array_map(static fn(mixed $item): string => (string) $item, $value)), default => (string) $value, }; } private function normalizeStringArray(mixed $value): array { return match (true) { is_array($value) => array_map(static fn(mixed $item): string => (string) $item, $value), default => [$this->normalizeString($value)], }; } } $result = (new DecomposedTaskSolver)('Concatenate the second letter of every word in "Jack Ryan" together'); dump($result); assert(is_string($result)); assert(!empty($result)); ?> ``` ## References 1. [Decomposed Prompting: A Modular Approach for Solving Complex Tasks](https://arxiv.org/pdf/2210.02406) ================================================================================ FILE: cookbook/examples/Z06_Decomposition/ditch_vanilla_cot.md ================================================================================ ## Overview How can we improve the effectiveness of Zero-Shot Chain of Thought (CoT) prompts? Plan and Solve improves the use of Zero-Shot Chain of Thought by adding more detailed instructions to the prompt given to large language models. **Plan and Solve Process:** 1. **Generate Reasoning**: Prompt the model to explicitly devise a plan for solving a problem before generating intermediate reasoning 2. **Extract Answer**: Extract the final answer from the model's chain of thought The key improvement is guiding the LLM to pay more attention to calculation and intermediate results to ensure they are correctly performed. ## Example ```php generateReasoning($query); $response = $this->extractAnswer($query, $reasoning); return $response->correct_answer; } private function generateReasoning(string $query): Reasoning { return StructuredOutput::using('openai')->with( messages: [ [ 'role' => 'user', 'content' => " {$query} Let's first understand the problem, extract relevant variables and their corresponding numerals, and make a complete plan. Then, let's carry out the plan, calculate intermediate variables (pay attention to correct numerical calculation and commonsense), solve the problem step by step, and show the answer." ], ], responseModel: Reasoning::class, )->get(); } private function extractAnswer(string $query, Reasoning $reasoning): Response { return StructuredOutput::using('openai')->with( messages: [ [ 'role' => 'user', 'content' => " {$query} Let's first understand the problem, extract relevant variables and their corresponding numerals, and make a complete plan. Then, let's carry out the plan, calculate intermediate variables (pay attention to correct numerical calculation and commonsense), solve the problem step by step, and show the answer. {$reasoning->chain_of_thought} Therefore the answer (arabic numerals) is" ], ], responseModel: Response::class, )->get(); } } $result = (new PlanAndSolveSolver)( "In a dance class of 20 students, 20% enrolled in contemporary dance, 25% of the remaining enrolled in jazz dance and the rest enrolled in hip-hop dance. What percentage of the entire students enrolled in hip-hop dance?" ); dump($result); assert(is_string($result)); assert(!empty($result)); ?> ``` ## References 1. [Plan-and-Solve Prompting: Improving Zero-Shot Chain-of-Thought Reasoning by Large Language Models](https://arxiv.org/pdf/2305.04091) ================================================================================ FILE: cookbook/examples/Z06_Decomposition/generate_code.md ================================================================================ ## Overview How can we leverage external code execution to generate intermediate reasoning steps? Program of Thought aims to leverage an external code interpreter to generate intermediate reasoning steps. This helps achieve greater performance in mathematical and programming-related tasks by grounding our final response in deterministic code. The approach involves: 1. **Generate Code**: Create a solver function that implements step-by-step logic 2. **Execute Code**: Run the generated code to get deterministic results 3. **Extract Answer**: Use the computed result to make final predictions ## Example ```php solveWithGeneratedProgram($query); $prediction = $this->generatePrediction($answer, $options, $query); return $prediction->choice->value; } private function solveWithGeneratedProgram(string $query): mixed { $reasoning = $this->generateIntermediateReasoning($query); try { return $this->executeProgram($reasoning->program_code); } catch (Throwable $e) { $retry = $this->generateIntermediateReasoning($query, $reasoning->program_code, $e->getMessage()); return $this->executeProgram($retry->program_code); } } private function generateIntermediateReasoning( string $query, ?string $previousCode = null, ?string $error = null, ): ProgramExecution { $messages = [ [ 'role' => 'system', 'content' => 'You are a world class AI system that excels at answering user queries in a systematic and detailed manner. Generate a valid PHP program that can be executed to answer the user query. Return only valid PHP code as a string value for program_code. Do not include Markdown fences. Use valid PHP variable syntax with `$` prefixes. Make sure to begin your generated program with the following structure: 'user', 'content' => $query], ]; if ($previousCode !== null && $error !== null) { $messages[] = [ 'role' => 'user', 'content' => "The previous program had an execution error: {$error}\n\nPrevious program:\n{$previousCode}\n\nReturn a corrected version as valid PHP code only.", ]; } return StructuredOutput::using('openai')->with( messages: $messages, responseModel: ProgramExecution::class, )->get(); } private function executeProgram(string $code): mixed { try { $sanitized = $this->sanitizeCode($code); // Ensure we get a value even if the model forgot to return it explicitly if (strpos($sanitized, 'return') === false) { $sanitized .= "\nreturn (function(){ return function_exists('solver') ? solver() : null; })();"; } return eval($sanitized); } catch (Throwable $e) { throw new Exception("Program execution failed: " . $e->getMessage()); } } private function sanitizeCode(string $code): string { // Strip Markdown fences $code = preg_replace('/^\s*```[a-zA-Z]*\s*/', '', $code); $code = preg_replace('/```\s*$/', '', (string) $code); // Remove PHP open/close tags — eval() expects pure PHP code body $code = str_replace([''], '', (string) $code); // Normalize line endings and trim $code = trim((string) $code); return $code; } private function generatePrediction(mixed $predictedAnswer, array $options, string $query): Prediction { $formattedOptions = implode(', ', $options); return StructuredOutput::using('openai')->with( messages: [ [ 'role' => 'system', 'content' => "Find the closest option based on the question and prediction. Question: {$query} Prediction: {$predictedAnswer} Options: [{$formattedOptions}]" ], ], responseModel: Prediction::class, )->get(); } } $result = (new ProgramOfThoughtSolver)( "A trader sold an article at a profit of 20% for Rs.360. What is the cost price of the article?", ["A)270", "B)300", "C)280", "D)320", "E)315"] ); dump($result); assert(is_string($result)); assert(!empty($result)); assert(in_array($result, ['A', 'B', 'C', 'D', 'E'])); ?> ``` ## References 1. [Program of Thoughts Prompting: Disentangling Computation from Reasoning for Numerical Reasoning Tasks](https://arxiv.org/abs/2211.12588) ================================================================================ FILE: cookbook/examples/Z06_Decomposition/generate_in_parallel.md ================================================================================ ## Overview How can we decrease the latency of an LLM pipeline? Skeleton-of-Thought is a technique which prompts an LLM to generate a skeleton outline of the response, then completes each point in the skeleton in parallel. The parallelism can be achieved by parallel API calls or batched processing. The approach involves: 1. **Generate Skeleton**: Create a brief outline of the response structure 2. **Parallel Expansion**: Complete each skeleton point concurrently 3. **Assembly**: Combine the expanded points into the final response ## Example ```php getSkeleton($question); return $this->expandPointsSequentially($question, $skeleton); } private function getSkeleton(string $question): Skeleton { return StructuredOutput::using('openai')->with( messages: [ [ 'role' => 'user', 'content' => "You're an organizer responsible for only giving the skeleton (not the full content) for answering the question. Provide the skeleton in a list of points (numbered 1., 2., 3., etc.) to answer the question. Instead of writing a full sentence, each skeleton point should be very short with only 3-5 words. Generally, the skeleton should have 3-10 points. Now, please provide the skeleton for the following question. {$question} Skeleton:" ], ], responseModel: Skeleton::class, )->get(); } private function expandPoint(string $question, Skeleton $skeleton, int $pointIndex): Response { $skeletonText = ''; foreach ($skeleton->points as $point) { $skeletonText .= "{$point->index}. {$point->description}\n"; } return StructuredOutput::using('openai')->with( messages: [ [ 'role' => 'user', 'content' => "You're responsible for continuing the writing of one and only one point in the overall answer to the following question. {$question} The skeleton of the answer is: {$skeletonText} Continue and only continue the writing of point {$pointIndex}. Write it **very shortly** in 1-2 sentences and do not continue with other points!" ], ], responseModel: Response::class, )->get(); } private function expandPointsSequentially(string $question, Skeleton $skeleton): array { $responses = []; foreach ($skeleton->points as $point) { $response = $this->expandPoint($question, $skeleton, $point->index); $responses[] = [ 'point' => $point, 'content' => $response->response ]; } return $responses; } } $results = (new SkeletonOfThoughtGenerator)( "Compose an engaging travel blog post about a recent trip to Hawaii, highlighting cultural experiences and must-see attractions." ); echo "Generated Content:\n"; echo str_repeat("=", 50) . "\n"; foreach ($results as $result) { echo "Point {$result['point']->index}: {$result['point']->description}\n"; echo "{$result['content']}\n\n"; } dump($results); assert(is_array($results)); assert(count($results) >= 3, 'Expected at least 3 skeleton points'); foreach ($results as $result) { assert(isset($result['point'])); assert(isset($result['content'])); assert($result['point'] instanceof Point); assert(!empty($result['point']->description)); assert(!empty($result['content'])); } ?> ``` ## References 1. [Skeleton-of-Thought: Prompting LLMs for Efficient Parallel Generation](https://arxiv.org/abs/2307.15337) 2. [The Prompt Report: A Systematic Survey of Prompting Techniques](https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z06_Decomposition/solve_simpler_subtasks.md ================================================================================ ## Overview How can we encourage an LLM to solve complex problems by breaking them down? Least-to-Most is a prompting technique that breaks a complex problem down into a series of increasingly complex subproblems. **Subproblems Example:** - Original problem: Adam is twice as old as Mary. Adam will be 11 in 1 year. How old is Mary? - Subproblems: (1) How old is Adam now? (2) What is half of Adam's current age? These subproblems are solved sequentially, allowing the answers from earlier (simpler) subproblems to inform the LLM while solving later (more complex) subproblems. ## Example ```php decompose($question); return $this->solveSequentially($subquestions, $question); } private function decompose(string $question): array { return StructuredOutput::using('openai')->with( messages: [ [ 'role' => 'user', 'content' => "Break this question down into subquestions to solve sequentially: {$question}" ], ], responseModel: Sequence::of(Subquestion::class), )->get()->toArray(); } private function solve(string $question, array $solvedQuestions, string $originalQuestion): int { $solvedContext = ''; foreach ($solvedQuestions as $solved) { $solvedContext .= "{$solved->question} {$solved->answer}\n"; } return StructuredOutput::using('openai')->with( messages: [ [ 'role' => 'user', 'content' => << {$originalQuestion} {$solvedContext} Solve this next subquestion: {$question} PROMPT, ], ], responseModel: Answer::class, )->get()->answer; } private function solveSequentially(array $subquestions, string $originalQuestion): array { $solvedQuestions = []; foreach ($subquestions as $subquestion) { $answer = $this->solve($subquestion->question, $solvedQuestions, $originalQuestion); $solvedQuestions[] = new SubquestionWithAnswer($subquestion->question, $answer); } return $solvedQuestions; } } $results = (new LeastToMostSolver)( "Four years ago, Kody was only half as old as Mohamed. If Mohamed is currently twice 30 years old, how old is Kody?" ); foreach ($results as $result) { echo "{$result->question} {$result->answer}\n"; } dump($results); assert(is_array($results)); assert(!empty($results)); foreach ($results as $result) { assert($result instanceof SubquestionWithAnswer); assert(!empty($result->question)); assert(is_int($result->answer)); } // Kody's age depends on LLM interpretation; just verify structural correctness $lastAnswer = end($results)->answer; assert(is_int($lastAnswer) && $lastAnswer > 0, "Expected a positive integer for Kody's age, got: $lastAnswer"); ?> ``` ## References 1. [Least-to-Most Prompting Enables Complex Reasoning in Large Language Models](https://arxiv.org/abs/2205.10625) 2. [The Prompt Report: A Systematic Survey of Prompting Techniques](https://arxiv.org/abs/2406.06608) ================================================================================ FILE: cookbook/examples/Z06_Decomposition/task_specific_systems.md ================================================================================ ## Overview How can we improve the faithfulness of reasoning chains generated by Language Models? Faithful Chain of Thought improves the faithfulness of reasoning chains by breaking it up into two stages: 1. **Translation**: Translate a user query into a series of reasoning steps - task-specific steps that can be executed deterministically 2. **Problem Solving**: Execute steps and arrive at a final answer that is consistent with the reasoning steps Examples of task-specific systems: - **Math Word Problems**: PHP code that can be evaluated to derive a final answer - **Multi-Hop QA**: Multi-step reasoning process using programming logic - **Planning**: Generate symbolic goals and use planning systems to solve queries ## Example ```php generateReasoningSteps($query); return $this->executeSteps($steps); } private function generateReasoningSteps(string $query): array { return StructuredOutput::using('openai')->with( messages: [ [ 'role' => 'system', 'content' => 'You are a world class AI who excels at generating reasoning steps to answer a question. Generate a list of reasoning steps needed to answer the question. For each reasoning step, provide: - id: step number starting from 1 - rationale: array of strings explaining the reasoning - dependencies: array of step IDs this step depends on - eval_string: valid PHP code (without 'user', 'content' => $query], ], responseModel: Sequence::of(ReasoningStep::class), )->get()->toArray(); } private function executeSteps(array $steps): mixed { $code = []; foreach ($steps as $step) { $code[] = $step->eval_string; } $fullCode = " ``` ## References 1. [Faithful Chain-of-Thought Reasoning](https://arxiv.org/pdf/2301.13379) ================================================================================ FILE: cookbook/examples/Z07_Misc/arbitrary_properties.md ================================================================================ ## Overview When you need to extract undefined attributes, use a list of key-value pairs. ## Example ```php ``` Now we can use this data model to extract arbitrary properties from a text message in a form that is easier for future processing. ```php withOutputMode(OutputMode::Json) )->with( messages: [['role' => 'user', 'content' => $text]], responseModel: UserDetail::class, )->get(); dump($user); assert($user->age === 25); assert($user->name === "Jason"); assert(!empty($user->properties)); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/arbitrary_properties_consistent.md ================================================================================ ## Overview For multiple records containing arbitrary properties, instruct LLM to get more consistent key names when extracting properties. ## Example ```php with( messages: [['role' => 'user', 'content' => $text]], responseModel: UserDetails::class, )->get(); dump($list); assert(!empty($list->users)); assert(count($list->users) >= 3, 'Expected at least 3 user detail entries'); foreach ($list->users as $user) { assert($user instanceof UserDetail); assert(!empty($user->key)); assert(!empty($user->value)); } ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/chain_of_summaries.md ================================================================================ ## Overview This is an example of summarization with increasing amount of details. Instructor is provided with data structure containing instructions on how to create increasingly detailed summaries of the project report. It starts with generating an overview of the project, followed by X iterations of increasingly detailed summaries. Each iteration should contain all the information from the previous summary, plus a few additional facts from the content which are most relevant and missing from the previous iteration. ## Example ```php with( messages: [ ['role' => 'system', 'content' => 'You generate structured summaries. Always fill in the overview field with a single sentence. Always generate exactly 3 summary iterations numbered 1, 2, and 3, each progressively more detailed.'], ['role' => 'user', 'content' => $report], ], responseModel: ChainOfSummaries::class, options: [ 'max_tokens' => 4096, ], ) ->get(); print("\n# Summaries with increasing density:\n\n"); print("Overview:\n"); print("{$summaries->overview}\n\n"); foreach ($summaries->summaries as $summary) { print("Expanded summary - iteration #{$summary->iteration}:\n"); print("{$summary->expandedSummary}\n\n"); } assert($summaries instanceof ChainOfSummaries); assert(!empty($summaries->overview)); assert(count($summaries->summaries) >= 3, 'Expected at least 3 summary iterations'); foreach ($summaries->summaries as $summary) { assert($summary instanceof Summary); assert(!empty($summary->expandedSummary)); assert($summary->iteration > 0); } ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/chain_of_thought.md ================================================================================ ## Overview This approach to "chain of thought" improves data quality, by eliciting LLM reasoning to self-explain approach to generating the response. > With Instructor you can achieve a 'modular' CoT, where multiple explanations > can be generated by LLM for different parts of the response, driving a more > granular control and improvement of the response. ## Example ```php with( messages: [['role' => 'user', 'content' => $text]], responseModel: Employee::class )->get(); dump($employee); assert($employee->reasoning !== ''); assert($employee->yearOfEmployment > 0); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/classification.md ================================================================================ ## Overview For single-label classification, we first define an `enum` for possible labels and a PHP class for the output. ## Example Let's start by defining the data structures. ```php ``` ## Classifying Text The function classify will perform the single-label classification. ```php with( messages: [[ "role" => "user", "content" => "Classify the following text: $data", ]], responseModel: SinglePrediction::class, )->get(); } ?> ``` ## Testing and Evaluation Let's run an example to see if it correctly identifies a spam message. ```php classLabel == Label::SPAM); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/classification_multiclass.md ================================================================================ ## Overview We start by defining the structures. For multi-label classification, we introduce a new enum class and a different PHP class to handle multiple labels. ```php ``` ## Classifying Text0 The function `multi_classify` executes multi-label classification using LLM. ```php withMessages("Label following support ticket: {$data}") ->withResponseModel(TicketLabels::class) ->get(); } ?> ``` ## Testing and Evaluation Finally, we test the multi-label classification function using a sample support ticket. ```php labels)); assert(in_array(Label::BILLING, $prediction->labels)); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/entity_relationships.md ================================================================================ ## Overview In cases where relationships exist between entities, it's vital to define them explicitly in the model. Following example demonstrates how to define relationships between users by incorporating an `$id` and `$coworkers` fields. ## Example ```php with( messages: [['role' => 'user', 'content' => $text]], responseModel: UserRelationships::class, )->get(); dump($relationships); assert(!empty($relationships->users)); assert(count($relationships->users) === 3, 'Expected 3 users: Jason, Amanda, John'); foreach ($relationships->users as $user) { assert($user instanceof UserDetail); assert(!empty($user->name)); assert(!empty($user->role)); assert(is_array($user->coworkers)); } ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/handling_errors.md ================================================================================ ## Overview You can create a wrapper class to hold either the result of an operation or an error message. This allows you to remain within a function call even if an error occurs, facilitating better error handling without breaking the code flow. > NOTE: Instructor offers a built-in Maybe wrapper class that you can use to handle errors. > See the example in Basics section for more details. ## Example ```php noUserData ? null : $this->user; } } $user = StructuredOutput::using('openai') ->withMessages([['role' => 'user', 'content' => 'We don\'t know anything about this guy.']]) ->withResponseModel(MaybeUser::class) ->get(); dump($user); assert($user->noUserData); assert(!empty($user->errorMessage)); assert($user->get() === null); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/limiting_lists.md ================================================================================ ## Overview When dealing with lists of attributes, especially arbitrary properties, it's crucial to manage the length of list. You can use prompting and enumeration to limit the list length, ensuring a manageable set of properties. > To be 100% certain the list does not exceed the limit, add extra > validation, e.g. using ValidationMixin (see: Validation). ## Example ```php properties) < 3) { return ValidationResult::valid(); } return ValidationResult::fieldError( field: 'properties', value: $this->name, message: "Number of properties must be not more than 2.", ); } } $text = <<withMaxRetries(1) )->with( messages: [['role' => 'user', 'content' => $text]], responseModel: UserDetail::class, // change runtime maxRetries to 0 to see validation error )->get(); dump($user); assert($user->age === 25); assert($user->name === "Jason"); assert(count($user->properties) < 3); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/reflection_prompting.md ================================================================================ ## Overview This implementation of Reflection Prompting with Instructor provides a structured way to encourage LLM to engage in more thorough and self-critical thinking processes, potentially leading to higher quality and more reliable outputs. ## Example ```php reflection)) { $errors[] = "Reflection is required for a thorough response."; } if (count($this->chainOfThought) < 2) { $errors[] = "Please provide at least two steps in the chain of thought."; } return ValidationResult::make($errors); } } $problem = 'Solve the equation x+y=x-y'; $solution = new StructuredOutput( StructuredOutputRuntime::fromProvider(LLMProvider::using('anthropic')) ->withOutputMode(OutputMode::MdJson) )->with( messages: $problem, responseModel: ReflectiveResponse::class, options: ['max_tokens' => 2048] )->get(); print("Problem:\n$problem\n\n"); dump($solution); assert($solution instanceof ReflectiveResponse); assert(!empty($solution->assessment)); assert(!empty($solution->persona)); assert(!empty($solution->initialThinking)); assert(count($solution->chainOfThought) >= 2); assert(!empty($solution->reflection)); assert(!empty($solution->finalOutput)); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/restate_instructions.md ================================================================================ ## Overview Make Instructor restate long or complex instructions and rules to improve inference accuracy. ## Example ```php with( messages: [["role" => "user", "content" => $text]], responseModel: UserDetail::class, )->get(); dump($user); assert($user->name === "Jason"); assert($user->age === 28); assert(!empty($user->role->title)); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/rewrite_instructions.md ================================================================================ ## Overview Asking LLM to rewrite the instructions and rules is another way to improve inference results. You can provide arbitrary instructions on the data handling in the class and property PHPDocs. Instructor will use these instructions to guide LLM in the inference process. ## Example ```php with( messages: [["role" => "user", "content" => $text]], responseModel: UserDetail::class, )->get(); dump($user); assert($user->name === "Jason"); assert($user->age === 28); assert(!empty($user->role->title)); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/search_query_expansion.md ================================================================================ ## Overview In this example, we will demonstrate how to leverage the enums and typed arrays to segment a complex search prompt into multiple, better structured queries that can be executed separately against specialized APIs or search engines. ## Why it matters Extracting a list of tasks from text is a common use case for leveraging language models. This pattern can be applied to various applications, such as virtual assistants like Siri or Alexa, where understanding user intent and breaking down requests into actionable tasks is crucial. In this example, we will demonstrate how to use Instructor to segment search queries, so you can execute them separately against specialized APIs or search engines. ## Structure of the data The `SearchQuery` is a PHP class that defines the structure of an individual search query. It has three fields: `title`, `query`, and `type`. The `title` field is the title of the request, the `query` field is the query to search for relevant content, and the `type` field is the type of search. The `execute` method is used to execute the search query. ## Example ```php title}` with query `{$this->query}` using `{$this->type->value}`\n"); } } ?> ``` ## Segmenting the Search Prompt The `segment` function takes a string `data` and segments it into multiple search queries. It uses the `StructuredOutput::create()` method to extract the data into the target object. The `responseModel` parameter specifies `Search::class` as the model to use for extraction. ```php withMessages("Consider the data below: '\n$data' and segment it into multiple search queries") ->withResponseClass(Search::class) ->get(); } $search = segment("Find a picture of a cat and a video of a dog"); foreach ($search->queries as $query) { $query->execute(); } // Results: // Searching with query `picture of a cat` using `image` // Searching with query `video of a dog` using `video` assert(count($search->queries) === 2); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/summary_with_keywords.md ================================================================================ ## Overview This is an example of a simple summarization with keyword extraction. ## Example ```php keywords) !== 5 => ValidationResult::fieldError( field: 'keywords', value: json_encode($this->keywords), message: 'Provide exactly 5 keywords.', ), $this->hasBlankKeywords() => ValidationResult::fieldError( field: 'keywords', value: json_encode($this->keywords), message: 'Each keyword must be a non-empty string.', ), default => ValidationResult::valid(), }; } private function hasBlankKeywords() : bool { return count(array_filter( $this->keywords, fn(mixed $keyword): bool => !is_string($keyword) || trim($keyword) === '', )) > 0; } } $runtime = StructuredOutputRuntime::fromProvider(LLMProvider::using('openai')) ->withMaxRetries(2); $summary = (new StructuredOutput($runtime)) ->with( messages: $report, responseModel: Summary::class, ) ->get(); dump($summary); assert($summary instanceof Summary); assert(!empty($summary->summary)); assert(count($summary->keywords) === 5, 'Expected 5 keywords, got: ' . count($summary->keywords)); foreach ($summary->keywords as $keyword) { assert(is_string($keyword)); assert(!empty($keyword)); } ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/component_reuse.md ================================================================================ ## Overview You can reuse the same component for different contexts within a model. In this example, the TimeRange component is used for both `$workTime` and `$leisureTime`. ## Example ```php with( messages: [['role' => 'user', 'content' => "Yesterday Jason worked from 9 for 5 hours. Jason then watched a 2 hour movie that ended at 19."]], responseModel: UserDetail::class, model: 'gpt-4o-mini', )->get(); dump($user); assert($user->name === "Jason"); assert($user->workTime->startTime < $user->workTime->endTime); assert($user->leisureTime->startTime < $user->leisureTime->endTime); ?> ``` ================================================================================ FILE: cookbook/examples/Z07_Misc/component_reuse_cot.md ================================================================================ ## Overview You can reuse the same component for different contexts within a model. In this example, the TimeRange component is used for both `$workTime` and `$leisureTime`. We're additionally starting the data structure with a Chain of Thought field to elicit LLM reasoning for the time range calculation, which can improve the accuracy of the response. ## Example ```php withMaxRetries(2) )->with( messages: [['role' => 'user', 'content' => "Workshop with Apex Industries started 9 and it took us 6 hours to complete."]], responseModel: TimeRange::class, )->get(); dump($timeRange); assert($timeRange->startTime === 9); assert($timeRange->endTime === 15); ?> ``` ================================================================================ FILE: cheatsheets/index.md ================================================================================ # Cheatsheets Quick reference guides for each package — code-verified API surfaces and usage patterns. ## [Instructor](instructor.md) Structured output extraction from LLMs — core API, response models, validation, and streaming ## [Polyglot](polyglot.md) Unified LLM API — inference, embeddings, request building, streaming, and provider configuration ## [Agents](agents.md) Agent loop, state model, tools, context management, hooks, and subagent orchestration ## [AgentCtrl](agent-ctrl.md) External coding agent control — Claude Code, Codex, OpenCode, and Gemini CLI bridges ## [Telemetry](telemetry.md) Quick reference for Telemetry ## [Sandbox](sandbox.md) Code execution sandbox — execution policies, drivers, streaming results, and security ## [HTTP Client](http-client.md) Framework-agnostic HTTP client — requests, responses, streaming, pooling, and middleware ## [Laravel](laravel.md) Laravel integration — service provider, facades, config publishing, and Artisan commands ## [Config](config.md) Configuration loading — config files, base paths, entries, and environment resolution ## [Dynamic](dynamic.md) Runtime data structures — schema-driven immutable objects with validation ## [Events](events.md) PSR-compatible event system — dispatching, listeners, wiretaps, and event handling ## [Logging](logging.md) Structured logging pipeline — filters, enrichers, formatters, and log drivers ## [Messages](messages.md) Message store, content parts, tool calls, and multi-section conversation management ## [Metrics](metrics.md) Metrics collection and export — collectors, registries, exporters, and event integration ## [Pipeline](pipeline.md) Data processing pipelines — builder, error strategies, processors, and pipe composition ## [Schema](schema.md) PHP type-to-JSON Schema mapping — schema building, factory, callable schemas, and rendering ## [Setup](setup.md) CLI commands for publishing package resources and config files, with validation and caching support ## [Stream](stream.md) Composable stream transformations — transducers, filters, mappers, and lazy evaluation ## [Templates](templates.md) Template engine abstraction — Twig, Blade, Arrowpipe, and DSN-based template resolution ## [Utils](utils.md) Core utilities — JSON processing, collections, text manipulation, and helper functions ## [Xprompt](xprompt.md) Prompts-as-code — classes, composition, variants, registry, template-backed bodies