diff --git a/.cursor/commands/fix-stan.md b/.cursor/commands/fix-stan.md new file mode 100644 index 0000000..78b3d84 --- /dev/null +++ b/.cursor/commands/fix-stan.md @@ -0,0 +1 @@ +Use `composer stan --no-progress` to see the PHPStan failures, and then fix them. diff --git a/.gitattributes b/.gitattributes index 205eff7..2e91244 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,5 +9,5 @@ /testbench.yaml export-ignore /ecs.php export-ignore /tests export-ignore -/cortex export-ignore /phpstan.neon export-ignore +/workbench export-ignore diff --git a/.github/actions/setup-php-composer/action.yml b/.github/actions/setup-php-composer/action.yml new file mode 100644 index 0000000..85935fe --- /dev/null +++ b/.github/actions/setup-php-composer/action.yml @@ -0,0 +1,48 @@ +name: 'Setup PHP and Composer' +description: 'Setup PHP and install Composer dependencies with caching' + +inputs: + php-version: + description: 'PHP version to use' + required: false + default: '8.4' + coverage: + description: 'Coverage driver to use (none, xdebug, pcov)' + required: false + default: 'none' + stability: + description: 'Composer stability preference (prefer-stable, prefer-lowest)' + required: false + default: 'prefer-stable' + +runs: + using: 'composite' + steps: + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + coverage: ${{ inputs.coverage }} + + - name: Get Composer Cache Directory + id: composer-cache + shell: bash + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ inputs.stability }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Install dependencies + shell: bash + run: | + if [ "${{ inputs.stability }}" = "prefer-lowest" ]; then + composer update --prefer-lowest --prefer-dist --no-interaction --no-progress + else + composer install --prefer-dist --no-interaction --no-progress + fi + diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f5681f4..4a603f9 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [8.3, 8.4] + php: [8.4, 8.5] stability: [prefer-lowest, prefer-stable] name: PHP${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} @@ -22,20 +22,17 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - name: Setup PHP and Composer + uses: ./.github/actions/setup-php-composer with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo coverage: none + stability: ${{ matrix.stability }} - name: Setup problem matchers run: | echo "::add-matcher::${{ runner.tool_cache }}/php.json" echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - name: Install dependencies - run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - name: Execute tests run: vendor/bin/pest --colors=always diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index bb18609..0894379 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -14,26 +14,8 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.3 - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo - - - name: Get Composer Cache Directory - id: composer-cache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache Composer dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Install dependencies - run: composer install --prefer-dist --no-interaction --no-progress + - name: Setup PHP and Composer + uses: ./.github/actions/setup-php-composer - name: Cache phpstan results uses: actions/cache@v5 @@ -52,26 +34,36 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: 8.3 - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + - name: Setup PHP and Composer + uses: ./.github/actions/setup-php-composer + + - name: Check type coverage + run: vendor/bin/pest --type-coverage --min=100 + + format: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 - - name: Get Composer Cache Directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Setup PHP and Composer + uses: ./.github/actions/setup-php-composer - - name: Cache Composer dependencies + - name: Cache rector results uses: actions/cache@v5 with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + path: /tmp/rector + key: "rector-cache-${{ github.run_id }}" # always write a new cache restore-keys: | - ${{ runner.os }}-composer- + rector-cache- - - name: Install dependencies - run: composer install --prefer-dist --no-interaction --no-progress + - name: Cache ecs results + uses: actions/cache@v5 + with: + path: /tmp/ecs + key: "ecs-cache-${{ github.run_id }}" # always write a new cache + restore-keys: | + ecs-cache- - - name: Check type coverage - run: vendor/bin/pest --type-coverage --min=100 + - name: Run format checks + run: composer format --no-progress-bar diff --git a/composer.json b/composer.json index 4c0b7e3..f92a4ef 100644 --- a/composer.json +++ b/composer.json @@ -11,18 +11,19 @@ "authors": [ { "name": "Sean Tymon", - "email": "tymon148@gmail.com", + "email": "sean@tymon.dev", "role": "Developer" } ], "require": { - "php": "^8.3", + "php": "^8.4", "adhocore/json-fixer": "^1.0", - "cortexphp/json-schema": "^0.6", + "cortexphp/json-schema": "dev-main", "cortexphp/model-info": "^0.3", - "illuminate/collections": "^11.23", + "illuminate/collections": "^12.0", + "laravel/prompts": "^0.3.8", "mozex/anthropic-php": "^1.1", - "openai-php/client": "^0.15", + "openai-php/client": "^0.18", "php-mcp/client": "^1.0", "psr-discovery/cache-implementations": "^1.2", "psr-discovery/event-dispatcher-implementations": "^1.1", @@ -34,24 +35,28 @@ "hkulekci/qdrant": "^0.5.8", "league/event": "^3.0", "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.8", - "pestphp/pest": "^3.0", - "pestphp/pest-plugin-type-coverage": "^3.4", + "orchestra/testbench": "^10.6", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0", "phpstan/phpstan": "^2.0", "rector/rector": "^2.0", - "symplify/easy-coding-standard": "^12.5" + "symplify/easy-coding-standard": "^13.0" }, "autoload": { "psr-4": { "Cortex\\": "src/" }, "files": [ - "src/Support/helpers.php" + "src/Support/helpers.php", + "src/Prompts/helpers.php" ] }, "autoload-dev": { "psr-4": { - "Cortex\\Tests\\": "tests/" + "Cortex\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Factories\\": "workbench/database/factories/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "scripts": { @@ -69,6 +74,18 @@ "@test", "@stan", "@type-coverage" + ], + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi", + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@build", + "@php vendor/bin/testbench serve --ansi" ] }, "config": { @@ -79,9 +96,9 @@ "php-http/discovery": true } }, - "extra" : { - "laravel" : { - "providers" : [ + "extra": { + "laravel": { + "providers": [ "Cortex\\CortexServiceProvider" ] } diff --git a/config/cortex.php b/config/cortex.php index db39909..6f38310 100644 --- a/config/cortex.php +++ b/config/cortex.php @@ -2,6 +2,9 @@ declare(strict_types=1); +use Cortex\LLM\Enums\LLMDriver; +use Cortex\Agents\Prebuilt\WeatherAgent; +use Cortex\ModelInfo\Enums\ModelProvider; use Cortex\ModelInfo\Providers\OllamaModelInfoProvider; use Cortex\ModelInfo\Providers\LiteLLMModelInfoProvider; use Cortex\ModelInfo\Providers\LMStudioModelInfoProvider; @@ -15,19 +18,35 @@ | Here you may define all of the LLM "providers" for your application. | Feel free to add/remove providers as needed. | - | Supported drivers: "openai", "anthropic" + | Supported drivers: "openai_chat", "openai_responses", "anthropic" */ 'llm' => [ 'default' => env('CORTEX_DEFAULT_LLM', 'openai'), 'openai' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, + 'options' => [ + 'api_key' => env('OPENAI_API_KEY', ''), + 'base_uri' => env('OPENAI_BASE_URI'), + 'organization' => env('OPENAI_ORGANIZATION'), + ], + 'default_model' => 'gpt-4.1-mini', + 'default_parameters' => [ + 'temperature' => null, + 'max_tokens' => 1024, + 'top_p' => null, + ], + ], + + 'openai_responses' => [ + 'driver' => LLMDriver::OpenAIResponses, + 'model_provider' => ModelProvider::OpenAI, 'options' => [ 'api_key' => env('OPENAI_API_KEY', ''), 'base_uri' => env('OPENAI_BASE_URI'), 'organization' => env('OPENAI_ORGANIZATION'), ], - 'default_model' => 'gpt-4o', + 'default_model' => 'gpt-5-mini', 'default_parameters' => [ 'temperature' => null, 'max_tokens' => null, @@ -36,7 +55,7 @@ ], 'anthropic' => [ - 'driver' => 'anthropic', + 'driver' => LLMDriver::Anthropic, 'options' => [ 'api_key' => env('ANTHROPIC_API_KEY', ''), 'headers' => [], @@ -50,7 +69,7 @@ ], 'groq' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => env('GROQ_API_KEY', ''), 'base_uri' => env('GROQ_BASE_URI', 'https://api.groq.com/openai/v1'), @@ -64,7 +83,7 @@ ], 'ollama' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => 'ollama', 'base_uri' => env('OLLAMA_BASE_URI', 'http://localhost:11434/v1'), @@ -78,7 +97,7 @@ ], 'lmstudio' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => 'lmstudio', 'base_uri' => env('LMSTUDIO_BASE_URI', 'http://localhost:1234/v1'), @@ -92,7 +111,7 @@ ], 'xai' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => env('XAI_API_KEY', ''), 'base_uri' => env('XAI_BASE_URI', 'https://api.x.ai/v1'), @@ -106,7 +125,7 @@ ], 'gemini' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => env('GEMINI_API_KEY', ''), 'base_uri' => env('GEMINI_BASE_URI', 'https://generativelanguage.googleapis.com/v1beta/openai'), @@ -120,7 +139,7 @@ ], 'mistral' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => env('MISTRAL_API_KEY', ''), 'base_uri' => env('MISTRAL_BASE_URI', 'https://api.mistral.ai/v1'), @@ -134,7 +153,7 @@ ], 'together' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => env('TOGETHER_API_KEY', ''), 'base_uri' => env('TOGETHER_BASE_URI', 'https://api.together.xyz/v1'), @@ -148,7 +167,7 @@ ], 'openrouter' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => env('OPENROUTER_API_KEY', ''), 'base_uri' => env('OPENROUTER_BASE_URI', 'https://openrouter.ai/api/v1'), @@ -162,7 +181,7 @@ ], 'deepseek' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ 'api_key' => env('DEEPSEEK_API_KEY', ''), 'base_uri' => env('DEEPSEEK_BASE_URI', 'https://api.deepseek.com/v1'), @@ -176,7 +195,7 @@ ], 'github' => [ - 'driver' => 'openai', + 'driver' => LLMDriver::OpenAIChat, 'options' => [ // 'api_key' => env('GITHUB_API_KEY', ''), 'base_uri' => env('GITHUB_BASE_URI', 'https://models.github.ai/inference'), @@ -213,20 +232,10 @@ 'url' => 'http://localhost:3001/sse', ], - // 'tavily' => [ - // 'transport' => 'http', - // 'url' => 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'), - // ], - - // 'tavily' => [ - // 'transport' => 'stdio', - // 'command' => 'npx', - // 'args' => [ - // '-y', - // 'mcp-remote', - // 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'), - // ], - // ], + 'tavily' => [ + 'transport' => 'http', + 'url' => 'https://mcp.tavily.com/mcp/?tavilyApiKey=' . env('TAVILY_API_KEY'), + ], ], /* @@ -236,12 +245,22 @@ | | Here you may define the prompt factories. | - | Supported drivers: "langfuse", "mcp" + | Supported drivers: "langfuse", "mcp", "blade" | */ 'prompt_factory' => [ 'default' => env('CORTEX_DEFAULT_PROMPT_FACTORY', 'langfuse'), + 'mcp' => [ + /** References an MCP server defined above. */ + 'server' => env('CORTEX_MCP_PROMPT_SERVER', 'local_http'), + ], + + 'blade' => [ + /** The path to the Blade views for prompts, relative to the base_path(). */ + 'path' => 'resources/views/prompts', + ], + 'langfuse' => [ 'username' => env('LANGFUSE_USERNAME', ''), 'password' => env('LANGFUSE_PASSWORD', ''), @@ -249,15 +268,10 @@ 'cache' => [ 'enabled' => env('CORTEX_PROMPT_CACHE_ENABLED', false), - 'store' => env('CORTEX_PROMPT_CACHE_STORE', null), + 'store' => env('CORTEX_PROMPT_CACHE_STORE'), 'ttl' => env('CORTEX_PROMPT_CACHE_TTL', 3600), ], ], - - 'mcp' => [ - /** References an MCP server defined above. */ - 'server' => env('CORTEX_MCP_PROMPT_SERVER', 'local_http'), - ], ], /* @@ -296,33 +310,59 @@ /* |-------------------------------------------------------------------------- - | Model Info Providers + | Model Info |-------------------------------------------------------------------------- | - | Here you may define the model info providers. + | Configuration for how model info/capabilities are retrieved. | | @see https://github.com/cortexphp/model-info | */ - 'model_info_providers' => [ - OllamaModelInfoProvider::class => [ - 'host' => env('OLLAMA_BASE_URI', 'http://localhost:11434'), - ], - LMStudioModelInfoProvider::class => [ - 'host' => env('LMSTUDIO_BASE_URI', 'http://localhost:1234'), - ], - LiteLLMModelInfoProvider::class => [ - 'host' => env('LITELLM_BASE_URI'), - 'apiKey' => env('LITELLM_API_KEY'), + 'model_info' => [ + + /** + * Whether to check a models features before attempting to use it. + * Set to true to ensure a given model feature is available. + */ + 'ignore_features' => env('CORTEX_MODEL_INFO_IGNORE_FEATURES', true), + + 'providers' => [ + OllamaModelInfoProvider::class => [ + 'host' => env('OLLAMA_BASE_URI', 'http://localhost:11434'), + ], + LMStudioModelInfoProvider::class => [ + 'host' => env('LMSTUDIO_BASE_URI', 'http://localhost:1234'), + ], + LiteLLMModelInfoProvider::class => [ + 'host' => env('LITELLM_BASE_URI'), + 'apiKey' => env('LITELLM_API_KEY'), + ], ], ], /* - * Configure the cache settings. - */ - 'cache' => [ - 'enabled' => env('CORTEX_CACHE_ENABLED', false), - 'store' => env('CORTEX_CACHE_STORE', null), - 'ttl' => env('CORTEX_CACHE_TTL', 3600), + |-------------------------------------------------------------------------- + | Agents + |-------------------------------------------------------------------------- + | + | Configure registered agents. + | + | Example manual registration in service provider: + | use Cortex\Cortex; + | + | public function boot(): void + | { + | Cortex::registerAgent(\App\Agents\WeatherAgent::class); + | // Or with an instance: + | Cortex::registerAgent(new Agent(...)); + | } + | + | Usage: + | $agent = Cortex::agent('weather_agent'); + | $result = $agent->invoke(input: ['location' => 'New York']); + | + */ + 'agents' => [ + WeatherAgent::class, ], ]; diff --git a/ecs.php b/ecs.php index 8a40c3e..90a25da 100644 --- a/ecs.php +++ b/ecs.php @@ -25,6 +25,9 @@ __DIR__ . '/config', __DIR__ . '/tests', ]) + ->withCache( + directory: '/tmp/ecs', + ) ->withRootFiles() ->withSpacing(indentation: Option::INDENTATION_SPACES) ->withPreparedSets( @@ -34,7 +37,7 @@ strict: true, ) ->withPhpCsFixerSets( - php83Migration: true, + php84Migration: true, ) ->withRules([ NotOperatorWithSuccessorSpaceFixer::class, diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 9d1b66b..3a4ef42 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -3,5 +3,6 @@ parameters: paths: - src excludePaths: - - src/LLM/Drivers/AnthropicChat.php + - src/LLM/Drivers/Anthropic/AnthropicChat.php + - src/LLM/Streaming tmpDir: .phpstan-cache diff --git a/rector.php b/rector.php index e9dd157..b899d11 100644 --- a/rector.php +++ b/rector.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Rector\Config\RectorConfig; +use Rector\Caching\ValueObject\Storage\FileCacheStorage; use Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector; use Rector\CodingStyle\Rector\Catch_\CatchExceptionNameMatchingTypeRector; use Rector\CodeQuality\Rector\Identical\FlipTypeControlToUseExclusiveTypeRector; @@ -13,6 +14,10 @@ __DIR__ . '/config', __DIR__ . '/tests', ]) + ->withCache( + cacheClass: FileCacheStorage::class, + cacheDirectory: '/tmp/rector', + ) ->withParallel() ->withImportNames( importDocBlockNames: false, @@ -26,7 +31,6 @@ typeDeclarations: true, instanceOf: true, earlyReturn: true, - strictBooleans: true, ) ->withFluentCallNewLine() ->withSkip([ diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..d1a10e0 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,11 @@ +name('cortex.')->group(function () { + Route::prefix('agents')->name('agents.')->group(function () { + Route::get('/{agent}/invoke', [AgentsController::class, 'invoke'])->name('invoke'); + Route::get('/{agent}/stream', [AgentsController::class, 'stream'])->name('stream'); + }); +}); diff --git a/scratchpad.php b/scratchpad.php index b61d040..1910990 100644 --- a/scratchpad.php +++ b/scratchpad.php @@ -3,10 +3,14 @@ declare(strict_types=1); use Cortex\Cortex; +use Cortex\Agents\Agent; use Cortex\Prompts\Prompt; -use Cortex\JsonSchema\SchemaFactory; +use Cortex\JsonSchema\Schema; +use Cortex\Pipeline\RuntimeConfig; +use Cortex\Agents\Prebuilt\WeatherAgent; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\LLM\Data\Messages\SystemMessage; +use Cortex\Tools\Prebuilt\OpenMeteoWeatherTool; $prompt = Cortex::prompt([ new SystemMessage('You are an expert at geography.'), @@ -22,8 +26,8 @@ ->metadata( provider: 'anthropic', model: 'claude-3-5-sonnet-20240620', - structuredOutput: SchemaFactory::object()->properties( - SchemaFactory::string('capital'), + structuredOutput: Schema::object()->properties( + Schema::string('capital'), ), ) ->llm() @@ -40,6 +44,13 @@ new UserMessage('What is the capital of {country}?'), ]); +// Get a generic agent builder from the prompt +$agentBuilder = $prompt->agentBuilder(); + +$result = $agentBuilder->invoke(input: [ + 'country' => 'France', +]); + // Get a prompt from the given factory $prompt = Cortex::prompt()->factory('langfuse')->make('test-prompt'); @@ -64,8 +75,8 @@ new UserMessage('What is the capital of France?'), ]); -$schema = SchemaFactory::object()->properties( - SchemaFactory::string('capital'), +$schema = Schema::object()->properties( + Schema::string('capital'), ); $result = Cortex::llm()->withStructuredOutput($schema)->invoke([ @@ -81,18 +92,47 @@ new UserMessage('What is the capital of France?'), ]); -// Run a task -$jokeGenerator = Cortex::task('joke_generator') - ->llm('ollama', 'llama3.2') - ->user('Tell me a joke about {topic}') - ->output(SchemaFactory::object()->properties( - SchemaFactory::string('joke'), - SchemaFactory::string('punchline'), - )); +$jokeAgent = new Agent( + name: 'joke_generator', + prompt: 'You are a joke generator. You generate jokes about {topic}.', + llm: 'ollama:gpt-oss:20b', +); -$result = $jokeGenerator([ +$result = $jokeAgent->invoke(input: [ 'topic' => 'programming', ]); +$weatherAgent = WeatherAgent::make()->invoke(input: [ + 'location' => 'Paris', +]); + +Cortex::registerAgent('weather_agent', WeatherAgent::class); + +Cortex::agent('weather_agent')->invoke(input: [ + 'location' => 'Paris', +]); + +// Cortex::agent('weather_agent')->invoke(config: new RuntimeConfig(input: [ +// 'location' => 'Paris', +// ])); + +$agent = Cortex::agent() + ->withName('weather_agent') + ->withPrompt('You are a weather agent. You tell the weather in {location}.') + ->withLLM('lmstudio/openai/gpt-oss-20b') + ->withTools([ + OpenMeteoWeatherTool::class, + ]) + ->withOutput([ + Schema::string('location')->required(), + Schema::string('summary')->required(), + ]) + ->withMaxSteps(3) + ->withStrict(true); + +$result = $agent->invoke(input: [ + 'location' => 'London', +]); + $result = Cortex::embeddings('openai') ->invoke('Lorem ipsum dolor sit amet'); diff --git a/src/AGUI/Contracts/Event.php b/src/AGUI/Contracts/Event.php new file mode 100644 index 0000000..5c0a25d --- /dev/null +++ b/src/AGUI/Contracts/Event.php @@ -0,0 +1,17 @@ + $patch + */ + public function __construct( + ?DateTimeImmutable $timestamp = null, + mixed $rawEvent = null, + public string $messageId = '', + public string $activityType = '', + public array $patch = [], + ) { + parent::__construct($timestamp, $rawEvent); + $this->type = EventType::ActivityDelta; + } +} diff --git a/src/AGUI/Events/ActivitySnapshot.php b/src/AGUI/Events/ActivitySnapshot.php new file mode 100644 index 0000000..5ab8e1c --- /dev/null +++ b/src/AGUI/Events/ActivitySnapshot.php @@ -0,0 +1,26 @@ + $content + */ + public function __construct( + ?DateTimeImmutable $timestamp = null, + mixed $rawEvent = null, + public string $messageId = '', + public string $activityType = '', + public array $content = [], + public bool $replace = true, + ) { + parent::__construct($timestamp, $rawEvent); + $this->type = EventType::ActivitySnapshot; + } +} diff --git a/src/AGUI/Events/Custom.php b/src/AGUI/Events/Custom.php new file mode 100644 index 0000000..a8c74aa --- /dev/null +++ b/src/AGUI/Events/Custom.php @@ -0,0 +1,21 @@ +type = EventType::Custom; + } +} diff --git a/src/AGUI/Events/MessagesSnapshot.php b/src/AGUI/Events/MessagesSnapshot.php new file mode 100644 index 0000000..788de82 --- /dev/null +++ b/src/AGUI/Events/MessagesSnapshot.php @@ -0,0 +1,23 @@ + $messages + */ + public function __construct( + ?DateTimeImmutable $timestamp = null, + mixed $rawEvent = null, + public array $messages = [], + ) { + parent::__construct($timestamp, $rawEvent); + $this->type = EventType::MessagesSnapshot; + } +} diff --git a/src/AGUI/Events/Raw.php b/src/AGUI/Events/Raw.php new file mode 100644 index 0000000..f79c473 --- /dev/null +++ b/src/AGUI/Events/Raw.php @@ -0,0 +1,21 @@ +type = EventType::Raw; + } +} diff --git a/src/AGUI/Events/RunError.php b/src/AGUI/Events/RunError.php new file mode 100644 index 0000000..adb58c2 --- /dev/null +++ b/src/AGUI/Events/RunError.php @@ -0,0 +1,21 @@ +type = EventType::RunError; + } +} diff --git a/src/AGUI/Events/RunFinished.php b/src/AGUI/Events/RunFinished.php new file mode 100644 index 0000000..4c2f6ed --- /dev/null +++ b/src/AGUI/Events/RunFinished.php @@ -0,0 +1,22 @@ +type = EventType::RunFinished; + } +} diff --git a/src/AGUI/Events/RunStarted.php b/src/AGUI/Events/RunStarted.php new file mode 100644 index 0000000..006cf05 --- /dev/null +++ b/src/AGUI/Events/RunStarted.php @@ -0,0 +1,23 @@ +type = EventType::RunStarted; + } +} diff --git a/src/AGUI/Events/StateDelta.php b/src/AGUI/Events/StateDelta.php new file mode 100644 index 0000000..8f9f50b --- /dev/null +++ b/src/AGUI/Events/StateDelta.php @@ -0,0 +1,23 @@ + $delta + */ + public function __construct( + ?DateTimeImmutable $timestamp = null, + mixed $rawEvent = null, + public array $delta = [], + ) { + parent::__construct($timestamp, $rawEvent); + $this->type = EventType::StateDelta; + } +} diff --git a/src/AGUI/Events/StateSnapshot.php b/src/AGUI/Events/StateSnapshot.php new file mode 100644 index 0000000..ad17898 --- /dev/null +++ b/src/AGUI/Events/StateSnapshot.php @@ -0,0 +1,20 @@ +type = EventType::StateSnapshot; + } +} diff --git a/src/AGUI/Events/StepFinished.php b/src/AGUI/Events/StepFinished.php new file mode 100644 index 0000000..dbfea42 --- /dev/null +++ b/src/AGUI/Events/StepFinished.php @@ -0,0 +1,20 @@ +type = EventType::StepFinished; + } +} diff --git a/src/AGUI/Events/StepStarted.php b/src/AGUI/Events/StepStarted.php new file mode 100644 index 0000000..c13d7cd --- /dev/null +++ b/src/AGUI/Events/StepStarted.php @@ -0,0 +1,20 @@ +type = EventType::StepStarted; + } +} diff --git a/src/AGUI/Events/TextMessageChunk.php b/src/AGUI/Events/TextMessageChunk.php new file mode 100644 index 0000000..ca0848a --- /dev/null +++ b/src/AGUI/Events/TextMessageChunk.php @@ -0,0 +1,22 @@ +type = EventType::TextMessageChunk; + } +} diff --git a/src/AGUI/Events/TextMessageContent.php b/src/AGUI/Events/TextMessageContent.php new file mode 100644 index 0000000..9fae383 --- /dev/null +++ b/src/AGUI/Events/TextMessageContent.php @@ -0,0 +1,21 @@ +type = EventType::TextMessageContent; + } +} diff --git a/src/AGUI/Events/TextMessageEnd.php b/src/AGUI/Events/TextMessageEnd.php new file mode 100644 index 0000000..4dc018c --- /dev/null +++ b/src/AGUI/Events/TextMessageEnd.php @@ -0,0 +1,20 @@ +type = EventType::TextMessageEnd; + } +} diff --git a/src/AGUI/Events/TextMessageStart.php b/src/AGUI/Events/TextMessageStart.php new file mode 100644 index 0000000..65075b1 --- /dev/null +++ b/src/AGUI/Events/TextMessageStart.php @@ -0,0 +1,21 @@ +type = EventType::TextMessageStart; + } +} diff --git a/src/AGUI/Events/ThinkingEnd.php b/src/AGUI/Events/ThinkingEnd.php new file mode 100644 index 0000000..ec1ae51 --- /dev/null +++ b/src/AGUI/Events/ThinkingEnd.php @@ -0,0 +1,19 @@ +type = EventType::ThinkingEnd; + } +} diff --git a/src/AGUI/Events/ThinkingStart.php b/src/AGUI/Events/ThinkingStart.php new file mode 100644 index 0000000..07178a4 --- /dev/null +++ b/src/AGUI/Events/ThinkingStart.php @@ -0,0 +1,20 @@ +type = EventType::ThinkingStart; + } +} diff --git a/src/AGUI/Events/ThinkingTextMessageContent.php b/src/AGUI/Events/ThinkingTextMessageContent.php new file mode 100644 index 0000000..404dc90 --- /dev/null +++ b/src/AGUI/Events/ThinkingTextMessageContent.php @@ -0,0 +1,20 @@ +type = EventType::ThinkingTextMessageContent; + } +} diff --git a/src/AGUI/Events/ThinkingTextMessageEnd.php b/src/AGUI/Events/ThinkingTextMessageEnd.php new file mode 100644 index 0000000..0f6de9f --- /dev/null +++ b/src/AGUI/Events/ThinkingTextMessageEnd.php @@ -0,0 +1,19 @@ +type = EventType::ThinkingTextMessageEnd; + } +} diff --git a/src/AGUI/Events/ThinkingTextMessageStart.php b/src/AGUI/Events/ThinkingTextMessageStart.php new file mode 100644 index 0000000..b12b970 --- /dev/null +++ b/src/AGUI/Events/ThinkingTextMessageStart.php @@ -0,0 +1,19 @@ +type = EventType::ThinkingTextMessageStart; + } +} diff --git a/src/AGUI/Events/ToolCallArgs.php b/src/AGUI/Events/ToolCallArgs.php new file mode 100644 index 0000000..241bfa5 --- /dev/null +++ b/src/AGUI/Events/ToolCallArgs.php @@ -0,0 +1,21 @@ +type = EventType::ToolCallArgs; + } +} diff --git a/src/AGUI/Events/ToolCallChunk.php b/src/AGUI/Events/ToolCallChunk.php new file mode 100644 index 0000000..41844fa --- /dev/null +++ b/src/AGUI/Events/ToolCallChunk.php @@ -0,0 +1,23 @@ +type = EventType::ToolCallChunk; + } +} diff --git a/src/AGUI/Events/ToolCallEnd.php b/src/AGUI/Events/ToolCallEnd.php new file mode 100644 index 0000000..cd8f7c3 --- /dev/null +++ b/src/AGUI/Events/ToolCallEnd.php @@ -0,0 +1,20 @@ +type = EventType::ToolCallEnd; + } +} diff --git a/src/AGUI/Events/ToolCallResult.php b/src/AGUI/Events/ToolCallResult.php new file mode 100644 index 0000000..bf66d2d --- /dev/null +++ b/src/AGUI/Events/ToolCallResult.php @@ -0,0 +1,23 @@ +type = EventType::ToolCallResult; + } +} diff --git a/src/AGUI/Events/ToolCallStart.php b/src/AGUI/Events/ToolCallStart.php new file mode 100644 index 0000000..491ed38 --- /dev/null +++ b/src/AGUI/Events/ToolCallStart.php @@ -0,0 +1,22 @@ +type = EventType::ToolCallStart; + } +} diff --git a/src/Agents/AbstractAgentBuilder.php b/src/Agents/AbstractAgentBuilder.php new file mode 100644 index 0000000..52f508f --- /dev/null +++ b/src/Agents/AbstractAgentBuilder.php @@ -0,0 +1,141 @@ +> + */ + public function tools(): array|ToolKit + { + return []; + } + + public function toolChoice(): ToolChoice|string + { + return ToolChoice::Auto; + } + + public function output(): ObjectSchema|array|string|null + { + return null; + } + + public function outputMode(): StructuredOutputMode + { + return StructuredOutputMode::Auto; + } + + public function memoryStore(): ?Store + { + return null; + } + + public function maxSteps(): int + { + return 5; + } + + public function strict(): bool + { + return true; + } + + /** + * @return array + */ + public function initialPromptVariables(): array + { + return []; + } + + /** + * @return array + */ + public function middleware(): array + { + return []; + } + + public function build(): Agent + { + return new Agent( + name: $this->name(), + prompt: $this->prompt(), + llm: $this->llm(), + tools: $this->tools(), + toolChoice: $this->toolChoice(), + output: $this->output(), + outputMode: $this->outputMode(), + memoryStore: $this->memoryStore(), + initialPromptVariables: $this->initialPromptVariables(), + maxSteps: $this->maxSteps(), + strict: $this->strict(), + middleware: $this->middleware(), + ); + } + + /** + * Convenience method to make an agent instance using the methods defined in this class. + * + * @param array $parameters + */ + public static function make(array $parameters = []): Agent + { + $builder = app(static::class, $parameters); + + return $builder->build(); + } + + /** + * Convenience method to invoke the built agent instance.. + * + * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Data\Messages\UserMessage|array|string $messages + * @param array $input + */ + public function invoke( + MessageCollection|UserMessage|array|string $messages = [], + array $input = [], + ?RuntimeConfig $config = null, + ): ChatResult { + return $this->build()->invoke($messages, $input, $config); + } + + /** + * Convenience method to stream from the built agent instance. + * + * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Data\Messages\UserMessage|array|string $messages + * @param array $input + */ + public function stream( + MessageCollection|UserMessage|array|string $messages = [], + array $input = [], + ?RuntimeConfig $config = null, + ): ChatStreamResult { + return $this->build()->stream($messages, $input, $config); + } +} diff --git a/src/Agents/Agent.php b/src/Agents/Agent.php new file mode 100644 index 0000000..d19b6c8 --- /dev/null +++ b/src/Agents/Agent.php @@ -0,0 +1,587 @@ +|null $output + * @param array|\Cortex\Contracts\ToolKit $tools + * @param array $initialPromptVariables + * @param array $middleware + */ + public function __construct( + protected string $name, + ChatPromptTemplate|ChatPromptBuilder|string|null $prompt = null, + LLMContract|string|null $llm = null, + protected ?string $description = null, + protected array|ToolKit $tools = [], + protected ToolChoice|string $toolChoice = ToolChoice::Auto, + ObjectSchema|array|string|null $output = null, + protected StructuredOutputMode $outputMode = StructuredOutputMode::Auto, + protected ?Store $memoryStore = null, + protected array $initialPromptVariables = [], + protected int $maxSteps = 5, + protected bool $strict = true, + protected ?RuntimeConfig $runtimeConfig = null, + protected array $middleware = [], + ) { + $this->prompt = self::buildPromptTemplate($prompt, $strict, $initialPromptVariables); + $this->memory = self::buildMemory($this->prompt, $this->memoryStore); + $this->middleware = [...$this->defaultMiddleware(), ...$this->middleware]; + + // Reset the prompt to only the message placeholders, since the initial + // messages have already been added to the memory. + $this->prompt->keepOnlyPlaceholders(); + + $this->output = self::buildOutput($output); + $this->llm = self::buildLLM( + $this->prompt, + $this->name, + $llm, + $this->tools, + $this->toolChoice, + $this->output, + $this->outputMode, + $this->strict, + ); + $this->pipeline = $this->buildPipeline(); + } + + /** + * @param array $messages + * @param array $input + */ + public function invoke( + MessageCollection|UserMessage|array|string $messages = [], + array $input = [], + ?RuntimeConfig $config = null, + ): ChatResult { + return $this->invokePipeline( + messages: $messages, + input: $input, + config: $config, + streaming: false, + ); + } + + /** + * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Data\Messages\UserMessage|array|string $messages + * @param array $input + * + * @return \Cortex\LLM\Data\ChatStreamResult<\Cortex\LLM\Data\ChatGenerationChunk> + */ + public function stream( + MessageCollection|UserMessage|array|string $messages = [], + array $input = [], + ?RuntimeConfig $config = null, + ): ChatStreamResult { + return $this->invokePipeline( + messages: $messages, + input: $input, + config: $config, + streaming: true, + ); + } + + /** + * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Data\Messages\UserMessage|array|string $messages + * @param array $input + * + * @return ($streaming is true ? \Cortex\LLM\Data\ChatStreamResult : \Cortex\LLM\Data\ChatResult) + */ + public function __invoke( + MessageCollection|UserMessage|array|string $messages = [], + array $input = [], + ?RuntimeConfig $config = null, + bool $streaming = false, + ): ChatResult|ChatStreamResult { + return $this->invokePipeline( + messages: $messages, + input: $input, + config: $config, + streaming: $streaming, + ); + } + + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + $messages = match (true) { + $payload instanceof MessageCollection => $payload->all(), + $payload instanceof Message => [$payload], + default => [], + }; + + $input = match (true) { + $payload === null => [], + $payload instanceof ChatResult => is_array($payload->content()) ? $payload->content() : [], + is_array($payload) => $payload, + $payload instanceof Arrayable => $payload->toArray(), + is_object($payload) => get_object_vars($payload), + default => [], + }; + + return $next($this->invoke($messages, $input, $config), $config); + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getPrompt(): ChatPromptTemplate + { + return $this->prompt; + } + + /** + * @return array + */ + public function getTools(): array + { + return $this->tools instanceof ToolKit + ? $this->tools->getTools() + : $this->tools; + } + + public function getLLM(): LLMContract + { + return $this->llm; + } + + public function getMemory(): ChatMemoryContract + { + return $this->memory; + } + + /** + * Get the total usage for the agent after all steps have been executed. + */ + public function getTotalUsage(): Usage + { + return $this->runtimeConfig?->context?->getUsageSoFar() ?? Usage::empty(); + } + + /** + * @return \Illuminate\Support\Collection + */ + public function getSteps(): Collection + { + return $this->runtimeConfig?->context?->getSteps() ?? collect(); + } + + /** + * Get the parsed output for the current (or final) step. + */ + public function getParsedOutput(): mixed + { + return $this->runtimeConfig?->context?->getCurrentStep()?->parsedOutput; + } + + /** + * Get the runtime config for the agent. + */ + public function getRuntimeConfig(): ?RuntimeConfig + { + return $this->runtimeConfig; + } + + /** + * Register a listener for the start of the agent. + */ + public function onStart(Closure $listener): self + { + return $this->on(AgentStart::class, $listener); + } + + /** + * Register a listener for the end of the agent. + */ + public function onEnd(Closure $listener): self + { + return $this->on(AgentEnd::class, $listener); + } + + /** + * Register a listener for the start of an agent step. + */ + public function onStepStart(Closure $listener): self + { + return $this->on(AgentStepStart::class, $listener); + } + + /** + * Register a listener for the end of an agent step. + */ + public function onStepEnd(Closure $listener): self + { + return $this->on(AgentStepEnd::class, $listener); + } + + /** + * Register a listener for the error of an agent step. + */ + public function onStepError(Closure $listener): self + { + return $this->on(AgentStepError::class, $listener); + } + + /** + * Register a listener for the stream chunks of the agent. + */ + public function onChunk(Closure $listener): self + { + return $this->on(AgentStreamChunk::class, $listener); + } + + /** + * Set the LLM for the agent. + */ + public function withLLM(LLMContract|string|null $llm): self + { + $this->llm = self::buildLLM( + $this->prompt, + $this->name, + $llm, + $this->tools, + $this->toolChoice, + $this->output, + $this->outputMode, + $this->strict, + ); + + return $this; + } + + /** + * @param class-string|\Cortex\JsonSchema\Types\ObjectSchema|array|null $output + */ + public function withOutput(ObjectSchema|array|string|null $output): self + { + $this->output = self::buildOutput($output); + + return $this->withLLM($this->llm); + } + + /** + * Set the runtime config for the agent. + */ + public function withRuntimeConfig(RuntimeConfig $runtimeConfig): self + { + $this->runtimeConfig = $runtimeConfig; + + return $this; + } + + protected function buildPipeline(): Pipeline + { + $tools = Utils::toToolCollection($this->getTools()); + $executionStages = $this->executionStages(); + + $pipeline = new Pipeline( + new TrackAgentStart($this), + ...$executionStages, + ); + + return $pipeline->when( + $tools->isNotEmpty(), + fn(Pipeline $pipeline): Pipeline => $pipeline->pipe( + new HandleToolCalls($tools, $this->memory, $executionStages, $this->maxSteps), + ), + ); + } + + /** + * Get the execution stages that will be used to generate the output. + * These stages are executed once initially, and then re-executed by HandleToolCalls + * when tool calls are made. + * + * @return array<\Cortex\Contracts\Pipeable|\Closure> + */ + protected function executionStages(): array + { + return [ + new TrackAgentStepStart($this), + ...$this->getMiddleware(BeforePromptMiddleware::class), + $this->prompt, + ...$this->getMiddleware(BeforeModelMiddleware::class), + $this->llm, + ...$this->getMiddleware(AfterModelMiddleware::class), + new TrackAgentStepEnd($this), + ]; + } + + /** + * @param \Cortex\LLM\Data\Messages\MessageCollection|\Cortex\LLM\Data\Messages\UserMessage|array|string $messages + * @param array $input + * + * @return ($streaming is true ? \Cortex\LLM\Data\ChatStreamResult : \Cortex\LLM\Data\ChatResult) + */ + protected function invokePipeline( + MessageCollection|UserMessage|array|string $messages = [], + array $input = [], + ?RuntimeConfig $config = null, + bool $streaming = false, + ): ChatResult|ChatStreamResult { + $config ??= $this->runtimeConfig ?? new RuntimeConfig(); + $this->withRuntimeConfig($config); + + if ($streaming) { + $config->onStreamChunk(function (RuntimeConfigStreamChunk $event): void { + $this->withRuntimeConfig($event->config); + $this->dispatchEvent(new AgentStreamChunk($this, $event->chunk, $event->config)); + + $event = match ($event->chunk->type) { + ChunkType::StepStart => new AgentStepStart($this, $event->config), + ChunkType::StepEnd => new AgentStepEnd($this, $event->config), + ChunkType::RunStart => new AgentStart($this, $event->config), + ChunkType::RunEnd => new AgentEnd($this, $event->config), + ChunkType::Error => new AgentStepError($this, $event->config->exception), + default => null, + }; + + if ($event !== null) { + $this->dispatchEvent($event); + } + }); + } + + $messages = Utils::toMessageCollection($messages); + + $this->memory + ->setMessages($this->memory->getMessages()->merge($messages)) + ->setVariables([ + ...$this->initialPromptVariables, + ...$input, + ]); + + $payload = [ + ...$input, + 'messages' => $this->memory->getMessages(), + ]; + + $result = $this->pipeline + ->enableStreaming($streaming) + ->onStart(function (PipelineStart $event): void { + $this->withRuntimeConfig($event->config); + }) + ->onEnd(function (PipelineEnd $event): void { + $this->withRuntimeConfig($event->config); + $event->config->pushChunkWhenStreaming( + new ChatGenerationChunk(ChunkType::RunEnd), + fn() => $this->dispatchEvent(new AgentEnd($this, $event->config)), + ); + }) + ->onError(function (PipelineError $event): void { + $this->withRuntimeConfig($event->config); + $event->config->pushChunkWhenStreaming( + new ChatGenerationChunk(ChunkType::Error), + fn() => $this->dispatchEvent(new AgentStepError($this, $event->exception, $event->config)), + ); + }) + ->invoke($payload, $config); + + // Append any final chunks from the stream buffer to the result. + // This ensures RunEnd chunk pushed in onEnd callback is included + if ($result instanceof ChatStreamResult) { + return $result->appendStreamBuffer($config); + } + + return $result; + } + + /** + * Build the prompt template for the agent. + * + * @param array $initialPromptVariables + */ + protected static function buildPromptTemplate( + ChatPromptTemplate|ChatPromptBuilder|string|null $prompt = null, + bool $strict = true, + array $initialPromptVariables = [], + ): ChatPromptTemplate { + $promptTemplate = match (true) { + $prompt === null => new ChatPromptTemplate([], $initialPromptVariables), + is_string($prompt) => Prompt::builder('chat') + ->messages([new SystemMessage($prompt)]) + ->strict($strict) + ->initialVariables($initialPromptVariables) + ->build(), + $prompt instanceof ChatPromptBuilder => $prompt->build(), + default => $prompt, + }; + + $promptTemplate->addMessage(new MessagePlaceholder('messages')); + + return $promptTemplate; + } + + /** + * Build the memory instance for the agent. + */ + protected static function buildMemory(ChatPromptTemplate $prompt, ?Store $memoryStore = null): ChatMemoryContract + { + $memoryStore ??= new InMemoryStore(Str::uuid7()->toString()); + $memoryStore->setMessages($prompt->messages->withoutPlaceholders()); + + return new ChatMemory($memoryStore); + } + + /** + * Build the LLM instance for the agent. + * + * @param array|\Cortex\Contracts\ToolKit $tools + */ + protected static function buildLLM( + ChatPromptTemplate $prompt, + string $name, + LLMContract|string|null $llm = null, + array|ToolKit $tools = [], + ToolChoice|string $toolChoice = ToolChoice::Auto, + ObjectSchema|string|null $output = null, + StructuredOutputMode $outputMode = StructuredOutputMode::Auto, + bool $strict = true, + ): LLMContract { + $llm = $llm !== null + ? Utils::llm($llm) + : $prompt->metadata?->llm() ?? Utils::llm($llm); + + // The LLM instance will already contain the configuration from + // the prompt metadata if it was provided. + // Below those can be overridden. + + if ($tools !== []) { + $llm->withTools($tools, $toolChoice); + } + + if ($output !== null) { + $llm->withStructuredOutput( + output: $output, + name: $name, + strict: $strict, + outputMode: $outputMode, + ); + } + + return $llm; + } + + /** + * Build the output schema for the agent. + * + * @param class-string|class-string<\BackedEnum>|\Cortex\JsonSchema\Types\ObjectSchema|array|null $output + * + * @throws \Cortex\Exceptions\GenericException + */ + protected static function buildOutput(ObjectSchema|array|string|null $output): ObjectSchema|string|null + { + if (is_array($output)) { + try { + collect($output)->ensure(JsonSchema::class); + } catch (UnexpectedValueException $e) { + throw new GenericException('Invalid output schema: ' . $e->getMessage(), previous: $e); + } + + return Schema::object()->properties(...$output); + } + + return $output; + } + + /** + * Get the default middleware for the agent. + * + * @return array + */ + protected function defaultMiddleware(): array + { + return [ + new AppendUsageMiddleware(), + new AddMessageToMemoryMiddleware($this->memory), + ]; + } + + protected function eventBelongsToThisInstance(object $event): bool + { + return $event instanceof AgentEvent && $event->agent === $this; + } +} diff --git a/src/Agents/Concerns/HandlesMiddleware.php b/src/Agents/Concerns/HandlesMiddleware.php new file mode 100644 index 0000000..8c5eed6 --- /dev/null +++ b/src/Agents/Concerns/HandlesMiddleware.php @@ -0,0 +1,69 @@ + $type + * + * @return array + */ + protected function getMiddleware(string $type): array + { + return array_map( + function (Middleware $middleware) use ($type): Middleware { + // Wrap all hook-based middleware to ensure hook methods are called + if (! $this->isHookMiddlewareType($type)) { + return $middleware; + } + + // If middleware implements multiple interfaces, wrap to delegate to correct hook + // If it only implements one interface, still wrap to ensure hook method is called + return $this->wrapMiddleware($middleware, $type); + }, + array_filter($this->middleware, fn(Middleware $middleware): bool => $middleware instanceof $type), + ); + } + + /** + * Check if the given type is a hook-based middleware interface. + * + * @param class-string<\Cortex\Agents\Contracts\Middleware> $type + */ + protected function isHookMiddlewareType(string $type): bool + { + return in_array($type, [ + BeforePromptMiddleware::class, + BeforeModelMiddleware::class, + AfterModelMiddleware::class, + ], true); + } + + /** + * Wrap middleware to delegate to the appropriate hook method. + */ + protected function wrapMiddleware(Middleware $middleware, string $type): Middleware + { + return match ($type) { + BeforePromptMiddleware::class => new BeforePromptWrapper($middleware), // @phpstan-ignore argument.type + BeforeModelMiddleware::class => new BeforeModelWrapper($middleware), // @phpstan-ignore argument.type + AfterModelMiddleware::class => new AfterModelWrapper($middleware), // @phpstan-ignore argument.type + default => $middleware, + }; + } +} diff --git a/src/Agents/Concerns/SetsAgentProperties.php b/src/Agents/Concerns/SetsAgentProperties.php new file mode 100644 index 0000000..64f5172 --- /dev/null +++ b/src/Agents/Concerns/SetsAgentProperties.php @@ -0,0 +1,151 @@ +|\Cortex\Contracts\ToolKit + */ + protected array|ToolKit $tools = []; + + protected ToolChoice|string $toolChoice = ToolChoice::Auto; + + /** + * @var class-string|\Cortex\JsonSchema\Types\ObjectSchema|array|null + */ + protected ObjectSchema|array|string|null $output = null; + + protected StructuredOutputMode $outputMode = StructuredOutputMode::Auto; + + protected ?Store $memoryStore = null; + + protected int $maxSteps = 5; + + protected bool $strict = true; + + /** + * @var array + */ + protected array $initialPromptVariables = []; + + /** + * @var array + */ + protected array $middleware = []; + + public function withPrompt(ChatPromptTemplate|ChatPromptBuilder|string $prompt): self + { + $this->prompt = $prompt; + + return $this; + } + + public function withLLM(LLM|string|null $llm): self + { + $this->llm = $llm; + + return $this; + } + + /** + * @param array|\Cortex\Contracts\ToolKit $tools + */ + public function withTools(array|ToolKit $tools, ToolChoice|string|null $toolChoice = null): self + { + $this->tools = $tools; + + if ($toolChoice !== null) { + $this->withToolChoice($toolChoice); + } + + return $this; + } + + public function withToolChoice(ToolChoice|string $toolChoice): self + { + $this->toolChoice = $toolChoice; + + return $this; + } + + /** + * @param class-string|\Cortex\JsonSchema\Types\ObjectSchema|array|null $output + */ + public function withOutput(ObjectSchema|array|string|null $output, ?StructuredOutputMode $outputMode = null): self + { + $this->output = $output; + + if ($outputMode !== null) { + $this->withOutputMode($outputMode); + } + + return $this; + } + + public function withOutputMode(StructuredOutputMode $outputMode): self + { + $this->outputMode = $outputMode; + + return $this; + } + + public function withMemoryStore(Store|string|null $memoryStore): self + { + $this->memoryStore = $memoryStore; + + return $this; + } + + public function withMaxSteps(int $maxSteps): self + { + $this->maxSteps = $maxSteps; + + return $this; + } + + public function withStrict(bool $strict): self + { + $this->strict = $strict; + + return $this; + } + + /** + * @param array $initialPromptVariables + */ + public function withInitialPromptVariables(array $initialPromptVariables): self + { + $this->initialPromptVariables = $initialPromptVariables; + + return $this; + } + + /** + * @param array $middleware + */ + public function withMiddleware(array $middleware): self + { + $this->middleware = $middleware; + + return $this; + } +} diff --git a/src/Agents/Contracts/AfterModelMiddleware.php b/src/Agents/Contracts/AfterModelMiddleware.php new file mode 100644 index 0000000..a238128 --- /dev/null +++ b/src/Agents/Contracts/AfterModelMiddleware.php @@ -0,0 +1,25 @@ +>|\Cortex\Contracts\ToolKit + */ + public function tools(): array|ToolKit; + + /** + * Specify the tool choice for the agent. + */ + public function toolChoice(): ToolChoice|string; + + /** + * Specify the output schema or class string that the LLM should output. + * + * @return class-string<\BackedEnum>|class-string|\Cortex\JsonSchema\Types\ObjectSchema|array|null + */ + public function output(): ObjectSchema|array|string|null; + + /** + * Specify the structured output mode for the agent. + */ + public function outputMode(): StructuredOutputMode; + + /** + * Specify the maximum number of steps the agent should take. + */ + public function maxSteps(): int; + + /** + * Specify whether the agent should be strict about the input and output. + */ + public function strict(): bool; + + /** + * Specify the initial prompt variables. + * + * @return array + */ + public function initialPromptVariables(): array; + + /** + * Specify the middleware for the agent. + * + * @return array + */ + public function middleware(): array; + + /** + * Build the agent instance using the methods defined in this class. + */ + public function build(): Agent; +} diff --git a/src/Agents/Contracts/BeforeModelMiddleware.php b/src/Agents/Contracts/BeforeModelMiddleware.php new file mode 100644 index 0000000..9c92935 --- /dev/null +++ b/src/Agents/Contracts/BeforeModelMiddleware.php @@ -0,0 +1,25 @@ + + */ +class Step implements Arrayable +{ + public function __construct( + public int $number, + public ?AssistantMessage $message = null, + public ToolCallCollection $toolCalls = new ToolCallCollection(), + public ?Usage $usage = null, + public mixed $parsedOutput = null, + ) {} + + public function hasToolCalls(): bool + { + return $this->toolCalls->isNotEmpty(); + } + + public function setUsage(Usage $usage): self + { + $this->usage = $usage; + + return $this; + } + + public function setAssistantMessage(AssistantMessage $message): self + { + $this->message = $message; + + if ($message->hasToolCalls()) { + $this->toolCalls = $message->toolCalls; + } + + return $this; + } + + public function setParsedOutput(mixed $parsedOutput): self + { + $this->parsedOutput = $parsedOutput; + + return $this; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'number' => $this->number, + 'message' => $this->message?->toArray(), + 'has_tool_calls' => $this->hasToolCalls(), + 'parsed_output' => $this->parsedOutput, + 'usage' => $this->usage?->toArray(), + ]; + } +} diff --git a/src/Agents/Middleware/AbstractMiddleware.php b/src/Agents/Middleware/AbstractMiddleware.php new file mode 100644 index 0000000..3f40acd --- /dev/null +++ b/src/Agents/Middleware/AbstractMiddleware.php @@ -0,0 +1,117 @@ +handlePipeable($payload, $config, $next); + } + + /** + * Hook that runs before the model call. + * Default implementation delegates to handlePipeable(). + * Override this method to provide before-model logic. + * + * @param mixed $payload The input to process + * @param RuntimeConfig $config The runtime context + * @param Closure $next The next stage in the pipeline + * + * @return mixed The processed result + */ + public function beforeModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $this->handlePipeable($payload, $config, $next); + } + + /** + * Hook that runs after the model call. + * Default implementation delegates to handlePipeable(). + * Override this method to provide after-model logic. + * + * @param mixed $payload The input to process + * @param RuntimeConfig $config The runtime context + * @param Closure $next The next stage in the pipeline + * + * @return mixed The processed result + */ + public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $this->handlePipeable($payload, $config, $next); + } + + /** + * Handle the pipeline processing. + * Default implementation passes through unchanged. + * Override this method to provide default logic used by all hooks, + * or override individual hook methods for hook-specific logic. + * + * @param mixed $payload The input to process + * @param RuntimeConfig $config The runtime context + * @param Closure $next The next stage in the pipeline + * + * @return mixed The processed result + */ + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $next($payload, $config); + } +} diff --git a/src/Agents/Middleware/AfterModelClosureMiddleware.php b/src/Agents/Middleware/AfterModelClosureMiddleware.php new file mode 100644 index 0000000..1b47f1c --- /dev/null +++ b/src/Agents/Middleware/AfterModelClosureMiddleware.php @@ -0,0 +1,33 @@ +closure)($payload, $config, $next); + } + + public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $this->handlePipeable($payload, $config, $next); + } +} diff --git a/src/Agents/Middleware/AfterModelWrapper.php b/src/Agents/Middleware/AfterModelWrapper.php new file mode 100644 index 0000000..37c7cc4 --- /dev/null +++ b/src/Agents/Middleware/AfterModelWrapper.php @@ -0,0 +1,33 @@ +middleware->afterModel($payload, $config, $next); + } + + public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $this->handlePipeable($payload, $config, $next); + } +} diff --git a/src/Agents/Middleware/BeforeModelClosureMiddleware.php b/src/Agents/Middleware/BeforeModelClosureMiddleware.php new file mode 100644 index 0000000..365a00f --- /dev/null +++ b/src/Agents/Middleware/BeforeModelClosureMiddleware.php @@ -0,0 +1,33 @@ +closure)($payload, $config, $next); + } + + public function beforeModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $this->handlePipeable($payload, $config, $next); + } +} diff --git a/src/Agents/Middleware/BeforeModelWrapper.php b/src/Agents/Middleware/BeforeModelWrapper.php new file mode 100644 index 0000000..71e9fae --- /dev/null +++ b/src/Agents/Middleware/BeforeModelWrapper.php @@ -0,0 +1,33 @@ +middleware->beforeModel($payload, $config, $next); + } + + public function beforeModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $this->handlePipeable($payload, $config, $next); + } +} diff --git a/src/Agents/Middleware/BeforePromptClosureMiddleware.php b/src/Agents/Middleware/BeforePromptClosureMiddleware.php new file mode 100644 index 0000000..a110d5f --- /dev/null +++ b/src/Agents/Middleware/BeforePromptClosureMiddleware.php @@ -0,0 +1,33 @@ +closure)($payload, $config, $next); + } + + public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $this->handlePipeable($payload, $config, $next); + } +} diff --git a/src/Agents/Middleware/BeforePromptWrapper.php b/src/Agents/Middleware/BeforePromptWrapper.php new file mode 100644 index 0000000..bf5c9a9 --- /dev/null +++ b/src/Agents/Middleware/BeforePromptWrapper.php @@ -0,0 +1,33 @@ +middleware->beforePrompt($payload, $config, $next); + } + + public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $this->handlePipeable($payload, $config, $next); + } +} diff --git a/src/Agents/Middleware/Default/AddMessageToMemoryMiddleware.php b/src/Agents/Middleware/Default/AddMessageToMemoryMiddleware.php new file mode 100644 index 0000000..e84a64f --- /dev/null +++ b/src/Agents/Middleware/Default/AddMessageToMemoryMiddleware.php @@ -0,0 +1,52 @@ + $payload, + $payload instanceof ChatGenerationChunk && $payload->isFinal => $payload, + $payload instanceof ChatResult => $payload->generation, + default => null, + }; + + if ($generation !== null) { + $message = $generation instanceof ChatGenerationChunk + ? $generation->message->cloneWithContent($generation->contentSoFar) + : $generation->message; + + // Add the message to the memory + $this->memory->addMessage($message); + + // Set the message and parsed output for the current step + $config->context->getCurrentStep() + ->setAssistantMessage($message) + ->setParsedOutput($generation->parsedOutput); + + // Set the message history in the context + $config->context->setMessageHistory($this->memory->getMessages()); + } + + return $next($payload, $config); + } +} diff --git a/src/Agents/Middleware/Default/AppendUsageMiddleware.php b/src/Agents/Middleware/Default/AppendUsageMiddleware.php new file mode 100644 index 0000000..f3903f9 --- /dev/null +++ b/src/Agents/Middleware/Default/AppendUsageMiddleware.php @@ -0,0 +1,36 @@ + $payload->usage, + $payload instanceof ChatGenerationChunk && $payload->usage !== null => $payload->usage, + default => null, + }; + + if ($usage !== null) { + // Set the usage for the current step + $config->context->getCurrentStep()->setUsage($usage); + + // Append the usage to the context so we can track usage as we move through the steps. + $config->context->appendUsage($usage); + } + + return $next($payload, $config); + } +} diff --git a/src/Agents/Prebuilt/GenericAgentBuilder.php b/src/Agents/Prebuilt/GenericAgentBuilder.php new file mode 100644 index 0000000..a23b8db --- /dev/null +++ b/src/Agents/Prebuilt/GenericAgentBuilder.php @@ -0,0 +1,91 @@ +prompt ?? 'You are a helpful assistant.'; + } + + public function llm(): LLM|string|null + { + return $this->llm; + } + + #[Override] + public function tools(): array + { + return $this->tools; + } + + #[Override] + public function toolChoice(): ToolChoice + { + return $this->toolChoice; + } + + public function output(): ObjectSchema|array|string|null + { + return $this->output; + } + + #[Override] + public function outputMode(): StructuredOutputMode + { + return $this->outputMode; + } + + #[Override] + public function maxSteps(): int + { + return $this->maxSteps; + } + + #[Override] + public function strict(): bool + { + return $this->strict; + } + + #[Override] + public function initialPromptVariables(): array + { + return $this->initialPromptVariables; + } + + #[Override] + public function middleware(): array + { + return $this->middleware; + } + + public function withName(string $name): self + { + static::$name = $name; + + return $this; + } +} diff --git a/src/Agents/Prebuilt/WeatherAgent.php b/src/Agents/Prebuilt/WeatherAgent.php new file mode 100644 index 0000000..64858e7 --- /dev/null +++ b/src/Agents/Prebuilt/WeatherAgent.php @@ -0,0 +1,59 @@ +ignoreFeatures(); + // return Cortex::llm('openai', 'gpt-4.1-mini')->ignoreFeatures(); + return Cortex::llm('lmstudio', 'openai/gpt-oss-20b')->ignoreFeatures(); + } + + #[Override] + public function tools(): array|ToolKit + { + return [ + OpenMeteoWeatherTool::class, + ]; + } +} diff --git a/src/Agents/Registry.php b/src/Agents/Registry.php new file mode 100644 index 0000000..3a4fec3 --- /dev/null +++ b/src/Agents/Registry.php @@ -0,0 +1,93 @@ +> + */ + private array $agents = []; + + /** + * Register an agent instance or class. + * + * @param \Cortex\Agents\Agent|class-string<\Cortex\Agents\AbstractAgentBuilder> $agent + */ + public function register(Agent|string $agent, ?string $nameOverride = null): void + { + if (is_string($agent)) { + if (! class_exists($agent)) { + throw new InvalidArgumentException( + sprintf('Agent class [%s] does not exist.', $agent), + ); + } + + // @phpstan-ignore function.alreadyNarrowedType + if (! is_subclass_of($agent, AbstractAgentBuilder::class)) { + throw new InvalidArgumentException( + sprintf( + 'Agent class [%s] must extend %s.', + $agent, + AbstractAgentBuilder::class, + ), + ); + } + + $name = $agent::name(); + } else { + $name = $agent->getName(); + } + + $this->agents[$nameOverride ?? $name] = $agent; + } + + /** + * Get an agent instance by name. + * + * @param array $parameters + * + * @throws \InvalidArgumentException + */ + public function get(string $name, array $parameters = []): Agent + { + if (! isset($this->agents[$name])) { + throw new InvalidArgumentException( + sprintf('Agent [%s] not found.', $name), + ); + } + + $agent = $this->agents[$name]; + + if ($agent instanceof Agent) { + return $agent; + } + + /** @var AbstractAgentBuilder $agent */ + return $agent::make($parameters); + } + + /** + * Check if an agent is registered. + */ + public function has(string $name): bool + { + return isset($this->agents[$name]); + } + + /** + * Get all registered agent names. + * + * @return array + */ + public function names(): array + { + return array_keys($this->agents); + } +} diff --git a/src/Agents/Stages/HandleToolCalls.php b/src/Agents/Stages/HandleToolCalls.php new file mode 100644 index 0000000..528cd3e --- /dev/null +++ b/src/Agents/Stages/HandleToolCalls.php @@ -0,0 +1,166 @@ + $tools + * @param array<\Cortex\Contracts\Pipeable|\Closure> $executionStages + */ + public function __construct( + protected Collection $tools, + protected ChatMemory $memory, + protected array $executionStages, + protected int $maxSteps, + ) {} + + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return match (true) { + $payload instanceof ChatGenerationChunk && $payload->type === ChunkType::ToolInputEnd => $this->handleStreamingChunk($payload, $config, $next), + $payload instanceof ChatStreamResult => $this->handleStreamingResult($payload), + default => $this->handleNonStreaming($payload, $config, $next), + }; + } + + /** + * Handle streaming chunks (individual ChatGenerationChunk objects). + */ + protected function handleStreamingChunk(ChatGenerationChunk $chunk, RuntimeConfig $config, Closure $next): mixed + { + $processedChunk = $next($chunk, $config); + + // Process tool calls if needed + if ($chunk->message->hasToolCalls() && $this->currentStep++ < $this->maxSteps) { + $nestedPayload = $this->processToolCalls($chunk, $config); + + if ($nestedPayload !== null) { + // Return stream with ToolInputEnd chunk + nested stream + // AbstractLLM will yield from this stream + return new ChatStreamResult(function () use ($processedChunk, $nestedPayload): Generator { + if ($processedChunk instanceof ChatGenerationChunk) { + yield $processedChunk; + } + + if ($nestedPayload instanceof ChatStreamResult) { + foreach ($nestedPayload as $nestedChunk) { + yield $nestedChunk; + } + } + }); + } + } + + return $processedChunk; + } + + /** + * Handle streaming results (ChatStreamResult from nested pipeline). + */ + protected function handleStreamingResult(ChatStreamResult $result): ChatStreamResult + { + // This happens when we return a nested stream - AbstractLLM will handle it + return $result; + } + + /** + * Handle non-streaming payloads (ChatResult, ChatGeneration, etc.). + */ + protected function handleNonStreaming(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + $generation = $this->getGeneration($payload); + + while ($generation?->message?->hasToolCalls() && $this->currentStep++ < $this->maxSteps) { + $nestedPayload = $this->processToolCalls($generation, $config); + + if ($nestedPayload !== null) { + // Update the generation so that the loop can check the new generation for tool calls + $generation = $this->getGeneration($nestedPayload); + $payload = $nestedPayload; + } + } + + return $next($payload, $config); + } + + /** + * Process tool calls and return the nested pipeline result. + * + * @return \Cortex\LLM\Data\ChatResult|\Cortex\LLM\Data\ChatStreamResult|null Returns null if no tool calls to process + */ + protected function processToolCalls(ChatGeneration|ChatGenerationChunk $generation, RuntimeConfig $config): ChatResult|ChatStreamResult|null + { + $toolMessages = $generation->message->toolCalls->invokeAsToolMessages($this->tools, $config); + + if ($toolMessages->isEmpty()) { + return null; + } + + // @phpstan-ignore argument.type + $toolMessages->each(function (ToolMessage $message) use ($config): void { + $this->memory->addMessage($message); + + if ($config->streaming) { + // Here we prepend the ToolOutputEnd chunk to the stream buffer so that + // it lands before the StepEnd chunk. + $config->stream->prepend(new ChatGenerationChunk( + type: ChunkType::ToolOutputEnd, + message: $message, + )); + } + }); + + $config->context->addNextStep(); + + $nestedPipeline = new Pipeline(...$this->executionStages) + ->enableStreaming($config->streaming); + + $nestedPayload = $nestedPipeline->invoke([ + 'messages' => $this->memory->getMessages(), + ...$this->memory->getVariables(), + ], $config); + + // If the payload is a stream result, append any stream buffer chunks + // (like StepStart/StepEnd) that were pushed during the nested pipeline execution + if ($nestedPayload instanceof ChatStreamResult) { + return $nestedPayload->appendStreamBuffer($config); + } + + return $nestedPayload; + } + + /** + * Get the generation from the payload. + */ + protected function getGeneration(mixed $payload): ChatGeneration|ChatGenerationChunk|null + { + return match (true) { + $payload instanceof ChatGeneration => $payload, + $payload instanceof ChatGenerationChunk && $payload->type === ChunkType::ToolInputEnd => $payload, + $payload instanceof ChatResult => $payload->generation, + default => null, + }; + } +} diff --git a/src/Agents/Stages/TrackAgentStart.php b/src/Agents/Stages/TrackAgentStart.php new file mode 100644 index 0000000..8b34e3c --- /dev/null +++ b/src/Agents/Stages/TrackAgentStart.php @@ -0,0 +1,33 @@ +pushChunkWhenStreaming( + new ChatGenerationChunk(ChunkType::RunStart), + fn() => $this->agent->dispatchEvent(new AgentStart($this->agent, $config)), + ); + + return $next($payload, $config); + } +} diff --git a/src/Agents/Stages/TrackAgentStepEnd.php b/src/Agents/Stages/TrackAgentStepEnd.php new file mode 100644 index 0000000..3159f3a --- /dev/null +++ b/src/Agents/Stages/TrackAgentStepEnd.php @@ -0,0 +1,46 @@ + $payload, + $payload instanceof ChatGenerationChunk && $payload->isFinal => $payload, + $payload instanceof ChatResult => $payload->generation, + default => null, + }; + + // Only push StepEnd chunk and dispatch event when it's the final chunk or a non-streaming result + if ($generation !== null) { + $config->pushChunkWhenStreaming( + new ChatGenerationChunk(ChunkType::StepEnd), + fn() => $this->agent->dispatchEvent(new AgentStepEnd($this->agent, $config)), + ); + } + + return $next($payload, $config); + } +} diff --git a/src/Agents/Stages/TrackAgentStepStart.php b/src/Agents/Stages/TrackAgentStepStart.php new file mode 100644 index 0000000..bbee95f --- /dev/null +++ b/src/Agents/Stages/TrackAgentStepStart.php @@ -0,0 +1,39 @@ +context->hasSteps()) { + $config->context->addInitialStep(); + } + + $config->pushChunkWhenStreaming( + new ChatGenerationChunk(ChunkType::StepStart), + fn() => $this->agent->dispatchEvent(new AgentStepStart($this->agent, $config)), + ); + + return $next($payload, $config); + } +} diff --git a/src/Console/AgentChat.php b/src/Console/AgentChat.php new file mode 100644 index 0000000..478b499 --- /dev/null +++ b/src/Console/AgentChat.php @@ -0,0 +1,76 @@ +getAgent(); + + if ($agent === null) { + return self::FAILURE; + } + + // Create and start the TUI + $chatPrompt = new ChatPrompt($agent, $this->option('debug')); + $chatPrompt->prompt(); + + outro('Goodbye!'); + + return self::SUCCESS; + } + + protected function getAgent(): ?Agent + { + $agentName = $this->argument('agent'); + + try { + return Cortex::agent($agentName); + } catch (InvalidArgumentException) { + promptsError(sprintf("Agent '%s' not found in registry.", $agentName)); + + $availableAgents = AgentRegistry::names(); + + if (! empty($availableAgents)) { + info('Available agents:'); + table( + headers: ['Name'], + rows: array_map(fn(string $name): array => [$name], $availableAgents), + ); + } + + return null; + } + } +} diff --git a/src/Console/ChatPrompt.php b/src/Console/ChatPrompt.php new file mode 100644 index 0000000..4ed4be8 --- /dev/null +++ b/src/Console/ChatPrompt.php @@ -0,0 +1,487 @@ + + */ + public array $messages = []; + + /** + * Current agent being chatted with. + */ + public ?Agent $agent = null; + + /** + * Whether we're waiting for agent response. + */ + public bool $waitingForResponse = false; + + /** + * Current streaming response content. + */ + public string $streamingContent = ''; + + /** + * Terminal height for layout calculations. + */ + public int $terminalHeight = 24; + + /** + * Scroll offset (number of lines scrolled up from bottom). + */ + public int $scrollOffset = 0; + + /** + * Whether to auto-scroll to bottom when new messages arrive. + */ + public bool $autoScroll = true; + + /** + * Current tool calls being executed (for debug display). + * + * @var array + */ + public array $toolCalls = []; + + /** + * Last render timestamp for throttling. + */ + private float $lastRenderTime = 0.0; + + /** + * Last typed value for detecting actual content changes. + */ + private string $lastRenderedValue = ''; + + /** + * Minimum time between renders (in seconds) to prevent flickering. + */ + private float $renderThrottle = 0.05; // 50ms = ~20 FPS max for typing + + /** + * Minimum time between renders for cursor-only updates (more aggressive). + */ + private float $cursorRenderThrottle = 0.15; // 150ms = ~6.7 FPS for cursor movement + + public function __construct( + Agent $agent, + public bool $debug = false, + ) { + $this->agent = $agent; + $this->terminalHeight = $this->getTerminalHeight(); + + // Register the renderer + static::$themes['default'][self::class] = ChatRenderer::class; + + // Track typed value with custom handling for scrolling and submission + $this->trackTypedValue('', submit: false, ignore: function (string $key): bool { + // Ignore scrolling keys - we'll handle them separately + if ($key[0] === "\e") { + // Check if it's a scrolling key (not left/right arrows which are for cursor) + if (in_array($key, [Key::UP, Key::UP_ARROW, Key::DOWN, Key::DOWN_ARROW, Key::PAGE_UP, Key::PAGE_DOWN], true) || + Key::oneOf([Key::HOME, Key::END], $key)) { + return true; // Ignore scrolling keys - handle in listenForKeys + } + + // Left/right arrows should be handled by TypedValue for cursor movement + } + + // Ignore Enter - we'll handle submission ourselves + return $key === Key::ENTER; // Don't ignore regular keys + }); + + $this->listenForKeys(); + } + + public function value(): mixed + { + return $this->typedValue; + } + + /** + * Get the entered value with a virtual cursor. + */ + public function valueWithCursor(int $maxWidth): string + { + if ($this->value() === '') { + return ''; + } + + return $this->addCursor($this->value(), $this->cursorPosition, $maxWidth); + } + + protected function listenForKeys(): void + { + $this->on('key', function (string $key): void { + // Don't process keys while waiting for response (except scrolling) + if ($this->waitingForResponse) { + // Allow scrolling even while waiting + $this->handleScrollKeys($key); + + return; + } + + // Handle scrolling keys (up/down/page up/page down/home/end) + if ($key[0] === "\e") { + if (in_array($key, [Key::UP, Key::UP_ARROW, Key::DOWN, Key::DOWN_ARROW, Key::PAGE_UP, Key::PAGE_DOWN], true) || + Key::oneOf([Key::HOME, Key::END], $key)) { + $this->handleScrollKeys($key); + + return; + } + + // Left/right arrows are handled by TypedValue for cursor movement + return; + } + + // Handle Enter to submit + if ($key === Key::ENTER) { + $this->handleSubmit(); + + return; + } + + // Handle 'q' to quit (only if input is empty) + if ($key === 'q' && $this->value() === '') { + $this->quit(); + + return; + } + + // TypedValue trait handles all other input (typing, backspace, cursor movement, etc.) + // Track value changes to use different throttle for content vs cursor movement + $currentValue = $this->value(); + $valueChanged = $currentValue !== $this->lastRenderedValue; + + if ($valueChanged) { + // Value changed (typing/backspace) - update tracked value and render with normal throttle + $this->lastRenderedValue = $currentValue; + $this->throttledRender(); + } else { + // Value didn't change (cursor movement only) - use more aggressive throttling + $this->throttledRenderForCursor(); + } + }); + } + + protected function handleScrollKeys(string $key): void + { + // Handle arrow keys and page up/down for scrolling + // String constants can be compared directly, array constants need Key::oneOf() + if ($key === Key::UP || $key === Key::UP_ARROW) { + $this->scrollUp(); + } elseif ($key === Key::DOWN || $key === Key::DOWN_ARROW) { + $this->scrollDown(); + } elseif ($key === Key::PAGE_UP) { + $this->scrollPageUp(); + } elseif ($key === Key::PAGE_DOWN) { + $this->scrollPageDown(); + } elseif (Key::oneOf(Key::HOME, $key)) { + $this->scrollToTop(); + } elseif (Key::oneOf(Key::END, $key)) { + $this->scrollToBottom(); + } + } + + protected function scrollUp(int $lines = 3): void + { + // Set a very large scroll offset - the renderer will clamp it to the correct max + $this->scrollOffset += $lines; + $this->autoScroll = false; + } + + protected function scrollDown(int $lines = 3): void + { + $this->scrollOffset = max(0, $this->scrollOffset - $lines); + + if ($this->scrollOffset === 0) { + $this->autoScroll = true; + } + } + + protected function scrollPageUp(): void + { + // Page size should match chat area height (terminal height minus header and input) + $headerHeight = 4; + $inputAreaHeight = 4; + $pageSize = $this->terminalHeight - $headerHeight - $inputAreaHeight; + $this->scrollUp($pageSize); + } + + protected function scrollPageDown(): void + { + $pageSize = $this->terminalHeight - 8; + $this->scrollDown($pageSize); + } + + protected function scrollToTop(): void + { + // Set a very large scroll offset - the renderer will clamp it to show from index 0 + $this->scrollOffset = PHP_INT_MAX; + $this->autoScroll = false; + } + + protected function scrollToBottom(): void + { + $this->scrollOffset = 0; + $this->autoScroll = true; + } + + protected function getTotalLines(): int + { + $lines = []; + + // Calculate wrap width to match renderer (terminal width - 4 for box - 10 for prefix) + // Use terminal width if available, otherwise default to 80 + $terminalWidth = $this->getTerminalWidth(); + $wrapWidth = max(50, $terminalWidth - 14); // Match renderer calculation + + // Count lines from messages (matching renderer logic) + foreach ($this->messages as $message) { + $wrapped = wordwrap($message['content'], $wrapWidth, "\n", true); + $contentLines = explode("\n", $wrapped); + // Each message adds lines (prefix is on first line, continuation lines are indented) + $lines = array_merge($lines, $contentLines); + $lines[] = ''; // Empty line between messages + } + + // Add streaming content if available (matching renderer logic) + if ($this->waitingForResponse && $this->streamingContent !== '') { + $wrapped = wordwrap($this->streamingContent, $wrapWidth, "\n", true); + $streamingLines = explode("\n", $wrapped); + $lines = array_merge($lines, $streamingLines); + $lines[] = ''; + } elseif ($this->waitingForResponse) { + $lines[] = ''; // "Thinking..." line + $lines[] = ''; + } + + return count($lines); + } + + protected function getTerminalWidth(): int + { + return static::terminal()->cols(); + } + + protected function handleToolInputStart(ChatGenerationChunk $chunk): void + { + $toolCalls = $chunk->message->toolCalls; + + if ($toolCalls === null || $toolCalls->isEmpty()) { + return; + } + + foreach ($toolCalls as $toolCall) { + $this->toolCalls[] = [ + 'name' => $toolCall->function->name, + 'status' => 'calling', + ]; + } + + $this->render(); + } + + protected function handleToolInputEnd(ChatGenerationChunk $chunk): void + { + // Tool input complete - update status + foreach ($this->toolCalls as &$toolCall) { + if ($toolCall['status'] === 'calling') { + $toolCall['status'] = 'executing'; + } + } + + unset($toolCall); + + $this->render(); + } + + protected function handleToolOutputEnd(ChatGenerationChunk $chunk): void + { + // Tool execution complete - update status + foreach ($this->toolCalls as &$toolCall) { + if ($toolCall['status'] === 'executing') { + $toolCall['status'] = 'complete'; + } + } + + unset($toolCall); + + // Always render tool call updates immediately (not throttled) + $this->render(); + } + + /** + * Render with throttling to prevent flickering during rapid updates. + */ + protected function throttledRender(): void + { + $now = microtime(true); + $timeSinceLastRender = $now - $this->lastRenderTime; + + // Only render if enough time has passed + if ($timeSinceLastRender >= $this->renderThrottle) { + $this->render(); + $this->lastRenderTime = $now; + } + } + + /** + * Render with more aggressive throttling for cursor-only updates. + */ + protected function throttledRenderForCursor(): void + { + $now = microtime(true); + $timeSinceLastRender = $now - $this->lastRenderTime; + + // Use longer throttle for cursor movement to reduce flicker + if ($timeSinceLastRender >= $this->cursorRenderThrottle) { + $this->render(); + $this->lastRenderTime = $now; + } + } + + protected function handleSubmit(): void + { + $userInput = trim((string) $this->value()); + + if (in_array(strtolower($userInput), ['exit', 'quit', 'q'], true)) { + $this->quit(); + + return; + } + + if ($userInput === '') { + return; + } + + // Clear the input (TypedValue handles this) + $this->typedValue = ''; + $this->cursorPosition = 0; + + // Add user message to history + $this->messages[] = [ + 'role' => 'user', + 'content' => $userInput, + ]; + + // Auto-scroll to bottom when sending a message + $this->scrollToBottom(); + + // Immediately render to show cleared input and user message + $this->render(); + + // Start agent response + $this->waitingForResponse = true; + $this->streamingContent = ''; + + // Render again to show "waiting for response" state + $this->render(); + + // Process agent response + $this->processAgentResponse($userInput); + } + + protected function processAgentResponse(string $userInput): void + { + try { + $result = $this->agent->stream($userInput); + $fullResponse = ''; + + // Stream response in real-time + foreach ($result as $chunk) { + // Handle tool calls in debug mode + if ($this->debug) { + match ($chunk->type) { + ChunkType::ToolInputStart => $this->handleToolInputStart($chunk), + ChunkType::ToolInputEnd => $this->handleToolInputEnd($chunk), + ChunkType::ToolOutputEnd => $this->handleToolOutputEnd($chunk), + default => null, + }; + } + + if ($chunk->type === ChunkType::TextDelta && $chunk->contentSoFar !== '') { + $fullResponse = $chunk->contentSoFar; + $this->streamingContent = $chunk->contentSoFar; + + // Auto-scroll to bottom during streaming if enabled + if ($this->autoScroll) { + $this->scrollOffset = 0; + } + + // Throttle rendering to prevent flickering + $this->throttledRender(); + } + + if ($chunk->isFinal && $chunk->contentSoFar !== '') { + $fullResponse = $chunk->contentSoFar; + $this->streamingContent = $chunk->contentSoFar; + // Force render for final chunk (not throttled) + $this->render(); + } + } + + // Add complete response to history + $this->messages[] = [ + 'role' => 'agent', + 'content' => $fullResponse ?: '(No response)', + ]; + + $this->waitingForResponse = false; + $this->streamingContent = ''; + + // Auto-scroll to bottom if enabled + if ($this->autoScroll) { + $this->scrollToBottom(); + } + + // Clear tool calls after response completes + $this->toolCalls = []; + + // Reset render throttle timestamp + $this->lastRenderTime = 0.0; + + // Re-render after response completes + $this->prompt(); + } catch (Throwable $e) { + // Clear tool calls on error + $this->toolCalls = []; + $this->messages[] = [ + 'role' => 'error', + 'content' => 'Error: ' . $e->getMessage(), + ]; + $this->waitingForResponse = false; + $this->streamingContent = ''; + + // Re-render after error + $this->prompt(); + } + } + + protected function quit(): void + { + static::terminal()->exit(); + } + + protected function getTerminalHeight(): int + { + return static::terminal()->lines(); + } +} diff --git a/src/Console/ChatRenderer.php b/src/Console/ChatRenderer.php new file mode 100644 index 0000000..7488b31 --- /dev/null +++ b/src/Console/ChatRenderer.php @@ -0,0 +1,413 @@ +output = ''; + + // Get terminal height to ensure exact output height + $terminalHeight = Prompt::terminal()->lines(); + $headerHeight = 3; // ═ line, text line, ═ line + $inputAreaHeight = 3; // ═ line, input line, ═ line + $chatAreaHeight = $terminalHeight - $headerHeight - $inputAreaHeight; + + // Always draw header first - it stays fixed at the top + $this->drawHeader($prompt); + + // Draw scrollable chat area (only this area scrolls) - ensure it's exactly chatAreaHeight lines + $this->drawChatArea($prompt, $chatAreaHeight); + + // Always draw input area last - it stays fixed at the bottom + $this->drawInputArea($prompt); + + // Ensure total output is exactly terminalHeight lines + // Count lines in output (excluding final trailing newline) + $outputLines = explode(PHP_EOL, rtrim($this->output, PHP_EOL)); + $currentHeight = count($outputLines); + + if ($currentHeight < $terminalHeight) { + // Add blank lines to fill to exact terminal height + $linesToAdd = $terminalHeight - $currentHeight; + for ($i = 0; $i < $linesToAdd; $i++) { + $this->line(''); + } + } elseif ($currentHeight > $terminalHeight) { + // Trim excess lines from the end (keep header and input, trim chat area) + // This shouldn't happen if calculations are correct, but safety check + $outputLines = array_slice($outputLines, 0, $terminalHeight); + $this->output = implode(PHP_EOL, $outputLines) . PHP_EOL; + } + + return $this->output; + } + + protected function drawHeader(ChatPrompt $prompt): void + { + $agentName = $prompt->agent?->getName() ?? 'Unknown'; + $width = Prompt::terminal()->cols(); + + $this->line(str_repeat('═', $width)); + $this->line(' Chatting with: ' . $this->cyan($agentName)); + $this->line(str_repeat('═', $width)); + // Don't add extra newline - it's accounted for in headerHeight + } + + protected function drawChatArea(ChatPrompt $prompt, int $chatHeight): void + { + + // Get terminal width and calculate content width + $terminalWidth = Prompt::terminal()->cols(); + $contentWidth = $terminalWidth - 4; // Box adds 4 chars total (│ + space on each side + │) + + // Calculate prefix width for alignment (accounting for ANSI codes) + $prefixWidth = max( + mb_strwidth($this->stripEscapeSequences($this->green('You:'))), + mb_strwidth($this->stripEscapeSequences($this->cyan('Agent:'))), + mb_strwidth($this->stripEscapeSequences($this->red('Error:'))), + ); + $indentWidth = $prefixWidth + 1; // Prefix width + space + $wrapWidth = $contentWidth - $indentWidth; // Account for aligned prefix + + $lines = []; + + // Initialize markdown converter for formatting agent messages + $markdownConverter = new MarkdownConverter($this); + + // Set maximum table width to fit within chat area + // Account for box borders (4 chars) and prefix indentation + $maxTableWidth = $contentWidth - $indentWidth; + $markdownConverter->setMaxTableWidth($maxTableWidth); + + // Set maximum code block width to fit within chat area + // Account for chat box borders (4 chars), prefix indentation, and code block's own borders (4 chars) + // The code block content width will be maxCodeBlockWidth - 4 (for code block borders) + $maxCodeBlockWidth = $contentWidth - $indentWidth; + $markdownConverter->setMaxCodeBlockWidth($maxCodeBlockWidth); + + // Add messages, wrapping long lines + foreach ($prompt->messages as $message) { + $role = $message['role']; + $content = $message['content']; + + $prefix = match ($role) { + 'user' => $this->green('You:'), + 'agent' => $this->cyan('Agent:'), + 'error' => $this->red('Error:'), + default => '', + }; + + // Pad prefix to align content + $alignedPrefix = $this->pad($prefix, $prefixWidth); + + // Convert markdown to ANSI for agent messages (this converts tables too) + if ($role === 'agent') { + $content = $markdownConverter->convert($content); + } + + // Process content line by line, detecting and preserving rendered tables + $allLines = explode("\n", $content); + $contentLines = []; + $inTable = false; + $tableLines = []; + + foreach ($allLines as $line) { + $trimmedLine = trim($line); + + // Check if this line is part of a table (starts with box drawing char) + $isTableLine = $trimmedLine !== '' && $trimmedLine !== '0' && preg_match('/^[┌├└│┼┬┴┤┘┐─]/', $trimmedLine); + + if ($isTableLine) { + // Start or continue table + if (! $inTable) { + $inTable = true; + $tableLines = []; + } + + $tableLines[] = $trimmedLine; + } else { + // Not a table line + if ($inTable) { + // End of table - add all table lines with indentation + foreach ($tableLines as $tableLine) { + $contentLines[] = $this->pad('', $indentWidth) . $tableLine; + } + + $inTable = false; + $tableLines = []; + } + + // Process regular content line + if ($trimmedLine !== '' && $trimmedLine !== '0') { + // Word wrap this line + $plainContent = $this->stripEscapeSequences($trimmedLine); + $contentWrapWidth = $wrapWidth - $indentWidth; + $wrapped = $this->mbWordwrap($plainContent, $contentWrapWidth, "\n", true); + $wrappedLines = explode("\n", $wrapped); + + // Reapply markdown formatting if needed + if ($role === 'agent') { + $wrappedLines = array_map($markdownConverter->convert(...), $wrappedLines); + } + + $contentLines = array_merge($contentLines, $wrappedLines); + } else { + // Empty line + $contentLines[] = ''; + } + } + } + + // Handle table at end of content + if ($inTable && $tableLines !== []) { + foreach ($tableLines as $tableLine) { + $contentLines[] = $this->pad('', $indentWidth) . $tableLine; + } + } + + foreach ($contentLines as $index => $line) { + if ($index === 0) { + $lines[] = $alignedPrefix . ' ' . $line; + } else { + // Indent continuation lines to align with content + $lines[] = $this->pad('', $indentWidth) . $line; + } + } + + $lines[] = ''; // Empty line between messages + } + + // Show streaming content if available + if ($prompt->waitingForResponse && $prompt->streamingContent !== '') { + // Pad Agent prefix to align with other messages + $agentPrefix = $this->cyan('Agent:'); + $alignedPrefix = $this->pad($agentPrefix, $prefixWidth); + + // Convert markdown to ANSI for streaming content + $formattedContent = $markdownConverter->convert($prompt->streamingContent); + + // Word wrap streaming content (accounting for aligned prefix) + // Strip ANSI for accurate width calculation + $plainContent = $this->stripEscapeSequences($formattedContent); + $contentWrapWidth = $wrapWidth - $indentWidth; + $wrapped = $this->mbWordwrap($plainContent, $contentWrapWidth, "\n", true); + $streamingLines = explode("\n", $wrapped); + + // Reapply markdown formatting to each wrapped line + $streamingLines = array_map($markdownConverter->convert(...), $streamingLines); + + foreach ($streamingLines as $index => $line) { + // Only add cursor to the last line + $cursor = ($index === count($streamingLines) - 1) ? $this->dim('█') : ''; + + if ($index === 0) { + $lines[] = $alignedPrefix . ' ' . $line . $cursor; + } else { + // Indent continuation lines to align with content + $lines[] = $this->pad('', $indentWidth) . $line . $cursor; + } + } + + $lines[] = ''; + } elseif ($prompt->waitingForResponse) { + // Pad Agent prefix to align with other messages + $agentPrefix = $this->cyan('Agent:'); + $alignedPrefix = $this->pad($agentPrefix, $prefixWidth); + + $lines[] = $alignedPrefix . ' ' . $this->dim('Thinking...'); + $lines[] = ''; + } + + // Show tool calls in debug mode + if ($prompt->debug && $prompt->toolCalls !== []) { + $lines[] = ''; + $lines[] = $this->yellow('🔧 Tool Calls:'); + foreach ($prompt->toolCalls as $toolCall) { + $status = match ($toolCall['status']) { + 'calling' => $this->dim('(calling)'), + 'executing' => $this->yellow('(executing)'), + 'complete' => $this->green('(complete)'), + default => '', + }; + $lines[] = ' - ' . $this->cyan($toolCall['name']) . ' ' . $status; + } + + $lines[] = ''; + } + + // Apply scroll offset + $totalLines = count($lines); + + // First, determine if scrolling is needed at all + $needsScrolling = $totalLines > $chatHeight; + + if (! $needsScrolling) { + // All content fits, no scrolling needed + $startIndex = 0; + $displayLines = array_slice($lines, 0, $totalLines); + $hasTopIndicator = false; + $hasBottomIndicator = false; + } else { + // We need scrolling - determine indicators iteratively + // Start with assumption that we might show both indicators + $availableHeight = $chatHeight - 2; + $maxScroll = max(0, $totalLines - $availableHeight); + + // Use the raw scroll offset (don't clamp yet) to determine indicators + $rawScrollOffset = max(0, $prompt->scrollOffset); + $hasTopIndicator = $rawScrollOffset > 0; + // In scrolling scenario, maxScroll is always > 0, so we can simplify the check + $hasBottomIndicator = $rawScrollOffset < $maxScroll; + + // Recalculate with actual indicators + $availableHeight = $chatHeight - ($hasTopIndicator ? 1 : 0) - ($hasBottomIndicator ? 1 : 0); + $maxScroll = max(0, $totalLines - $availableHeight); + + // Now clamp scroll offset to final maxScroll + $scrollOffset = min($rawScrollOffset, $maxScroll); + + // Determine indicators: we need to check if we're at the edges + // Start with assumption that we might show indicators + $hasTopIndicator = $rawScrollOffset > 0; + // In scrolling scenario, maxScroll is always > 0, so we can simplify the check + $hasBottomIndicator = $rawScrollOffset < $maxScroll; + + // Recalculate with indicators + $finalAvailableHeight = $chatHeight - ($hasTopIndicator ? 1 : 0) - ($hasBottomIndicator ? 1 : 0); + $finalMaxScroll = max(0, $totalLines - $finalAvailableHeight); + $scrollOffset = min($rawScrollOffset, $finalMaxScroll); + + // Now check if we're actually at the edges after clamping + $isAtTop = $scrollOffset === $finalMaxScroll && $finalMaxScroll > 0; + $isAtBottom = $scrollOffset === 0; + + // Hide indicators if at edges + if ($isAtTop) { + $hasTopIndicator = false; + } + + if ($isAtBottom) { + $hasBottomIndicator = false; + } + + // Recalculate one more time with final indicator state + $finalAvailableHeight = $chatHeight - ($hasTopIndicator ? 1 : 0) - ($hasBottomIndicator ? 1 : 0); + $finalMaxScroll = max(0, $totalLines - $finalAvailableHeight); + $scrollOffset = min($scrollOffset, $finalMaxScroll); + + // Calculate start index - when at top (scrollOffset = maxScroll), startIndex should be 0 + $startIndex = max(0, $totalLines - $finalAvailableHeight - $scrollOffset); + $displayLines = array_slice($lines, $startIndex, $finalAvailableHeight); + + // Use final values + $availableHeight = $finalAvailableHeight; + $maxScroll = $finalMaxScroll; + } + + // Prepare box content + // The box must be exactly chatHeight lines total (including 2 border lines) + // So content inside box = chatHeight - 2 + $maxContentLines = $chatHeight - 2; // Subtract 2 for top and bottom borders + + // Build box content with scroll indicators + $boxContentLines = []; + + // Add scroll indicator at top if scrolled up + if ($hasTopIndicator) { + $boxContentLines[] = $this->dim('▲ More messages above (↑/↓ to scroll, Home/End for top/bottom)'); + } + + // Calculate available space for messages (accounting for indicators) + $availableForMessages = $maxContentLines - ($hasTopIndicator ? 1 : 0) - ($hasBottomIndicator ? 1 : 0); + + // When auto-scrolling (at bottom), ensure we show the latest lines including streaming content + // displayLines already contains the correct slice based on scrollOffset, so we just need to ensure + // we don't cut off the last line when there's a bottom indicator + if (count($displayLines) > $availableForMessages) { + // If we have more lines than available space, take the last availableForMessages lines + // This ensures the latest streaming content (with cursor) is always visible + $messageLines = array_slice($displayLines, -$availableForMessages); + } else { + $messageLines = $displayLines; + } + + foreach ($messageLines as $line) { + $boxContentLines[] = $line; + } + + // Add scroll indicator at bottom if more content below + // Note: When auto-scrolling (at bottom), hasBottomIndicator should be false, so this won't show + if ($hasBottomIndicator) { + $boxContentLines[] = $this->dim('▼ More messages below (↑/↓ to scroll, Home/End for top/bottom)'); + } + + // Ensure we have exactly maxContentLines (pad with empty lines if needed) + while (count($boxContentLines) < $maxContentLines) { + $boxContentLines[] = ''; + } + + // Trim to exact size if somehow we exceeded + // This should only happen if displayLines calculation was wrong + if (count($boxContentLines) > $maxContentLines) { + // Always prioritize showing the last lines when streaming (to show cursor) + // or when at bottom (to show latest content) + if ($prompt->waitingForResponse || $prompt->autoScroll) { + // Keep the last maxContentLines lines to ensure streaming cursor/latest content is visible + $boxContentLines = array_slice($boxContentLines, -$maxContentLines); + } else { + // Normal case: trim from the beginning + $boxContentLines = array_slice($boxContentLines, 0, $maxContentLines); + } + } + + $boxBody = implode(PHP_EOL, $boxContentLines); + + // Set minWidth to force box to use full terminal width + // Box method uses min(minWidth, terminal->cols() - 6) and adds 4 chars for borders + // So to fill terminal: minWidth should be terminal width - 6 + $this->minWidth = $terminalWidth - 6; + + // Draw box around chat area (box method will pad lines internally to fill width) + // Box height will be exactly chatHeight (2 borders + maxContentLines content) + $this->box( + title: '', + body: $boxBody, + color: 'gray', + ); + } + + protected function drawInputArea(ChatPrompt $prompt): void + { + $width = Prompt::terminal()->cols(); + // Don't add newline here - it's accounted for in inputAreaHeight calculation + $this->line(str_repeat('═', $width)); + + if ($prompt->waitingForResponse) { + $this->line(' ' . $this->dim('Waiting for agent response...')); + } else { + // Use TypedValue's valueWithCursor for proper cursor display + $terminalWidth = Prompt::terminal()->cols(); + $maxInputWidth = $terminalWidth - 10; // Account for " You: " prefix + $inputDisplay = $prompt->valueWithCursor($maxInputWidth); + + if ($prompt->value() === '') { + // Show placeholder when empty + $inputDisplay = $this->dim('Type your message...'); + } + + $this->line(' ' . $this->green('You:') . ' ' . $inputDisplay); + } + + $this->line(str_repeat('═', $width)); + } +} diff --git a/src/Console/MarkdownConverter.php b/src/Console/MarkdownConverter.php new file mode 100644 index 0000000..585b971 --- /dev/null +++ b/src/Console/MarkdownConverter.php @@ -0,0 +1,480 @@ +maxTableWidth = $width; + } + + /** + * Set the maximum width for code blocks. + */ + public function setMaxCodeBlockWidth(int $width): void + { + $this->maxCodeBlockWidth = $width; + } + + /** + * Convert markdown to ANSI-formatted terminal text. + */ + public function convert(string $markdown): string + { + // Convert tables first (before other formatting that might interfere) + $markdown = $this->convertTables($markdown); + + // Convert code blocks (before inline formatting) + $markdown = $this->convertCodeBlocks($markdown); + + // Convert inline code + $markdown = $this->convertInlineCode($markdown); + + // Convert links + $markdown = $this->convertLinks($markdown); + + // Convert bold (**text**) + $markdown = $this->convertBold($markdown); + + // Convert italic (*text* or _text_) + $markdown = $this->convertItalic($markdown); + + return $markdown; + } + + /** + * Convert markdown tables to Laravel Prompts table format. + */ + protected function convertTables(string $text): string + { + // Match markdown tables: header row, separator row (|----|----|), data rows + // Pattern matches: + // - Header row: | col1 | col2 | + // - Separator: |------|------| (with dashes, spaces, or colons) + // - Data rows: | val1 | val2 | (one or more) + return preg_replace_callback( + '/^\|(.+)\|\s*\n\|[-\s|:]+\|\s*\n((?:\|.+\|\s*\n?)+)/m', + function (array $matches): string { + $tableOutput = $this->renderTable($matches[1], $matches[2]); + + // Wrap table in a special marker so renderer knows not to word-wrap it + return "\n" . $tableOutput . "\n"; + }, + $text, + ); + } + + /** + * Render a markdown table using Symfony Table helper. + */ + protected function renderTable(string $headerRow, string $dataRows): string + { + // Parse headers + $headers = $this->parseTableRow($headerRow); + + // Parse data rows + $rows = []; + foreach (explode("\n", trim($dataRows)) as $row) { + $row = trim($row); + + if ($row === '') { + continue; + } + + if ($row === '0') { + continue; + } + + if (! str_starts_with($row, '|')) { + continue; + } + + $rows[] = $this->parseTableRow($row); + } + + if ($rows === []) { + return $headerRow . "\n|" . str_repeat('-', 10) . "|\n" . $dataRows; + } + + // Create table style matching Laravel Prompts + $tableStyle = new TableStyle() + ->setHorizontalBorderChars('─') + ->setVerticalBorderChars('│', '│') + ->setCellHeaderFormat($this->renderer->dim('%s')) + ->setCellRowFormat('%s'); + + if ($headers === []) { + $tableStyle->setCrossingChars('┼', '', '', '', '┤', '┘', '┴', '└', '├', '┌', '┬', '┐'); + } else { + $tableStyle->setCrossingChars('┼', '┌', '┬', '┐', '┤', '┘', '┴', '└', '├'); + } + + // Render table to buffered output + $buffered = new BufferedConsoleOutput(); + + $table = new SymfonyTable($buffered); + $table->setHeaders($headers) + ->setRows($rows) + ->setStyle($tableStyle); + + // Set maximum width for each column if specified (to fit within chat area) + // Note: $rows is guaranteed to be non-empty due to early return above + if ($this->maxTableWidth !== null) { + $numColumns = max(count($headers), count($rows[0])); + + if ($numColumns > 0) { + // Calculate available width: maxTableWidth minus borders + // Each column has 2 borders (left and right), plus start border + // Formula: borders = (numColumns * 2) + 1 (start) + 1 (end) = numColumns * 2 + 2 + $borderWidth = ($numColumns * 2) + 2; + $availableWidth = max(20, $this->maxTableWidth - $borderWidth); + + // Distribute width across columns (with minimum width per column) + $minColumnWidth = 10; + $columnWidth = max($minColumnWidth, (int) floor($availableWidth / $numColumns)); + + // Set max width for each column + for ($i = 0; $i < $numColumns; $i++) { + $table->setColumnMaxWidth($i, $columnWidth); + } + } + } + + $table->render(); + + // Convert buffered output to string with proper formatting + $tableOutput = trim($buffered->content(), PHP_EOL); + $tableLines = explode(PHP_EOL, $tableOutput); + + // Return formatted table lines + return implode("\n", array_map(fn(string $line): string => ' ' . $line, $tableLines)); + } + + /** + * Parse a markdown table row into an array of cells. + * + * @return array + */ + protected function parseTableRow(string $row): array + { + // Remove leading/trailing pipe, split by pipe, trim each cell + $row = trim($row, '|'); + $cells = explode('|', $row); + + return array_map(trim(...), $cells); + } + + /** + * Convert code blocks (```code```) to formatted text. + */ + protected function convertCodeBlocks(string $text): string + { + return preg_replace_callback( + '/```(\w+)?\n(.*?)```/s', + function (array $matches): string { + // $matches[1] always exists (regex uses ? for optional), just check if non-empty + $language = $matches[1]; + $code = trim($matches[2]); + + // Calculate available width for code content + // Box borders: ┌─ (2) + header text + ─┐ (2) = variable + // Code lines: │ (1) + space (1) + code + space (1) + │ (1) = code + 4 + // Bottom border: └─ (2) + dashes + ┘ (1) = variable + + // Calculate header text + $headerText = 'Code' . ($language !== '' && $language !== '0' ? ' (' . $language . ')' : ''); + $headerTextWidth = mb_strwidth($headerText); + + // Get code lines + $codeLines = explode("\n", $code); + + // Determine available widths + // maxCodeBlockWidth is the total available width (including code block borders) + // Code block format: │ (2) + space (1) + content + space (1) + │ (1) = content + 5 + // So content width = total width - 5 + $maxTotalWidth = $this->maxCodeBlockWidth; + $maxContentWidth = $maxTotalWidth !== null + ? max(10, $maxTotalWidth - 5) + : null; + + // Wrap code lines if needed and find longest line + $wrappedCodeLines = []; + $maxCodeLineWidth = 0; + + foreach ($codeLines as $line) { + $lineWidth = mb_strwidth($line); + + // Wrap if exceeds available content width + if ($maxContentWidth !== null && $lineWidth > $maxContentWidth) { + $wrapped = $this->mbWordwrap($line, $maxContentWidth, "\n", true); + $wrappedParts = explode("\n", $wrapped); + foreach ($wrappedParts as $part) { + $wrappedCodeLines[] = $part; + $maxCodeLineWidth = max($maxCodeLineWidth, mb_strwidth($part)); + } + } else { + $wrappedCodeLines[] = $line; + $maxCodeLineWidth = max($maxCodeLineWidth, $lineWidth); + } + } + + $codeLines = $wrappedCodeLines; + + // Calculate content width: use longest line (matching DrawsBoxes::box() longest() method) + // The box width is determined by the longest content line + $contentWidth = max($maxCodeLineWidth, $headerTextWidth); + + // If we have a max width constraint, ensure content doesn't exceed it + // This ensures the total box width (content + 4) doesn't exceed maxTotalWidth + if ($maxContentWidth !== null) { + $contentWidth = min($contentWidth, $maxContentWidth); + } + + // Ensure minimum width + $contentWidth = max($contentWidth, 10); + + // Format header border matching DrawsBoxes::box() pattern exactly + // The box() method: width = longest(content lines), then + // titleLabel = " {$title} " and dashes = width - titleLength + (titleLength > 0 ? 0 : 2) + $titleLabel = $headerTextWidth > 0 ? sprintf(' %s ', $headerText) : ''; + // Calculate dashes to match box() method: width - titleLength + (titleLength > 0 ? 0 : 2) + $topBorderDashes = $contentWidth - $headerTextWidth + ($headerTextWidth > 0 ? 0 : 2); + $topBorder = str_repeat('─', $topBorderDashes); + $headerBorder = $this->renderer->dim(' ┌') . $titleLabel . $this->renderer->dim($topBorder . '┐'); + + // Format bottom border matching DrawsBoxes::box() pattern + // Format: └ + dashes + ┘ where dashes = width + 2 + $bottomBorder = $this->renderer->dim(' └' . str_repeat('─', $contentWidth + 2) . '┘'); + + return $headerBorder . "\n" . + $this->formatCodeLines($codeLines, $contentWidth) . + $bottomBorder; + }, + $text, + ); + } + + /** + * Format code lines with indentation and color. + * Matches DrawsBoxes::box() format: │ + space + padded_content + space + │ + * + * @param array|string $codeLines + * @param int $contentWidth The content width (inside borders, matching box() method) + */ + protected function formatCodeLines(array|string $codeLines, int $contentWidth): string + { + if (is_string($codeLines)) { + $codeLines = explode("\n", $codeLines); + } + + $formatted = []; + + foreach ($codeLines as $line) { + // Pad line to content width (matching DrawsBoxes::box() pad() call) + $paddedLine = $this->padCodeLine($line, $contentWidth); + + // Use dim/gray color for code, matching DrawsBoxes box format exactly + // Format: │ + space + padded_content + space + │ + $formatted[] = $this->renderer->dim(' │') . ' ' . $this->renderer->gray($paddedLine) . ' ' . $this->renderer->dim('│'); + } + + return implode("\n", $formatted) . "\n"; + } + + /** + * Pad a code line to the specified width (ignoring ANSI codes). + * Similar to InteractsWithStrings::pad() but for code lines. + */ + protected function padCodeLine(string $text, int $length): string + { + $plainText = $this->stripEscapeSequences($text); + $textWidth = mb_strwidth($plainText); + $rightPadding = str_repeat(' ', max(0, $length - $textWidth)); + + return $text . $rightPadding; + } + + /** + * Strip ANSI escape sequences from text (matching InteractsWithStrings). + */ + protected function stripEscapeSequences(string $text): string + { + // Strip ANSI escape sequences + $text = preg_replace("/\e[^m]*m/", '', $text); + + // Strip Symfony named style tags + $text = preg_replace("/<(info|comment|question|error)>(.*?)<\/\\1>/", '$2', (string) $text); + + // Strip Symfony inline style tags + return preg_replace("/<(?:(?:[fb]g|options)=[a-z,;]+)+>(.*?)<\/>/i", '$1', (string) $text); + } + + /** + * Word wrap helper that handles multibyte characters. + */ + protected function mbWordwrap(string $string, int $width = 75, string $break = "\n", bool $cut = false): string + { + if ($width <= 0) { + return $string; + } + + $result = ''; + $currentLine = ''; + $currentWidth = 0; + + $chars = mb_str_split($string); + + foreach ($chars as $char) { + $charWidth = mb_strwidth($char); + + if ($char === "\n") { + $result .= $currentLine . $break; + $currentLine = ''; + $currentWidth = 0; + continue; + } + + if ($currentWidth + $charWidth > $width) { + if ($cut) { + // Force cut at width + $result .= $currentLine . $break; + $currentLine = $char; + $currentWidth = $charWidth; + } else { + // Try to break at word boundary + $lastSpace = mb_strrpos($currentLine, ' '); + + if ($lastSpace !== false) { + $result .= mb_substr($currentLine, 0, $lastSpace) . $break; + $currentLine = mb_substr($currentLine, $lastSpace + 1) . $char; + $currentWidth = mb_strwidth($currentLine); + } else { + // No space found, force break + $result .= $currentLine . $break; + $currentLine = $char; + $currentWidth = $charWidth; + } + } + } else { + $currentLine .= $char; + $currentWidth += $charWidth; + } + } + + if ($currentLine !== '') { + $result .= $currentLine; + } + + return $result; + } + + /** + * Convert inline code (`code`) to formatted text with visible markers. + */ + protected function convertInlineCode(string $text): string + { + return preg_replace_callback( + '/`([^`]+)`/', + function (array $matches): string { + return $this->renderer->dim('`') . + $this->renderer->gray($matches[1]) . + $this->renderer->dim('`'); + }, + $text, + ); + } + + /** + * Convert markdown links [text](url) to formatted text with visible markers. + */ + protected function convertLinks(string $text): string + { + return preg_replace_callback( + '/\[([^\]]+)\]\(([^)]+)\)/', + function (array $matches): string { + $linkText = $matches[1]; + $url = $matches[2]; + + // Show markdown syntax with styling: [text](url) + return $this->renderer->dim('[') . + $this->renderer->underline($this->renderer->cyan($linkText)) . + $this->renderer->dim('](') . + $this->renderer->dim($url) . + $this->renderer->dim(')'); + }, + $text, + ); + } + + /** + * Convert bold markdown (**text**) to styled text with visible markers. + */ + protected function convertBold(string $text): string + { + // Handle **bold** syntax - style the markers and make text bold + // Match any characters except ** (two asterisks together) + // This allows quotes, parentheses, and other special characters + return preg_replace_callback( + '/\*\*((?:(?!\*\*).)+)\*\*/', + function (array $matches): string { + return $this->renderer->dim('**') . + $this->renderer->bold($matches[1]) . + $this->renderer->dim('**'); + }, + $text, + ); + } + + /** + * Convert italic markdown (*text* or _text_) to styled text with visible markers. + */ + protected function convertItalic(string $text): string + { + // Handle *italic* syntax (but not **bold**) + $text = preg_replace_callback( + '/(?renderer->dim('*') . + $this->renderer->italic($matches[1]) . + $this->renderer->dim('*'); + }, + $text, + ); + + // Handle _italic_ syntax + return preg_replace_callback( + '/_([^_]+)_/', + function (array $matches): string { + return $this->renderer->dim('_') . + $this->renderer->italic($matches[1]) . + $this->renderer->dim('_'); + }, + (string) $text, + ); + } +} diff --git a/src/Contracts/Memory.php b/src/Contracts/ChatMemory.php similarity index 73% rename from src/Contracts/Memory.php rename to src/Contracts/ChatMemory.php index ae37166..db96b22 100644 --- a/src/Contracts/Memory.php +++ b/src/Contracts/ChatMemory.php @@ -7,7 +7,7 @@ use Cortex\LLM\Contracts\Message; use Cortex\LLM\Data\Messages\MessageCollection; -interface Memory +interface ChatMemory { /** * Get the messages from the memory instance. @@ -26,6 +26,11 @@ public function addMessage(Message $message): void; */ public function addMessages(MessageCollection|array $messages): void; + /** + * Set the messages in the memory instance. + */ + public function setMessages(MessageCollection $messages): static; + /** * Set the variables for the memory instance. * @@ -39,4 +44,10 @@ public function setVariables(array $variables): static; * @return array */ public function getVariables(): array; + + /** + * Get the thread ID for this memory instance. + * Delegates to the underlying store - the store is the source of truth for threadId. + */ + public function getThreadId(): string; } diff --git a/src/Contracts/Pipeable.php b/src/Contracts/Pipeable.php index 6480f66..0185cf3 100644 --- a/src/Contracts/Pipeable.php +++ b/src/Contracts/Pipeable.php @@ -6,6 +6,7 @@ use Closure; use Cortex\Pipeline; +use Cortex\Pipeline\RuntimeConfig; interface Pipeable { @@ -13,14 +14,15 @@ interface Pipeable * Handle the pipeline processing. * * @param mixed $payload The input to process + * @param RuntimeConfig $config The runtime context containing settings * @param Closure $next The next stage in the pipeline * * @return mixed The processed result */ - public function handlePipeable(mixed $payload, Closure $next): mixed; + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed; /** * Pipe the pipeable into another pipeable. */ - public function pipe(self|callable $pipeable): Pipeline; + public function pipe(self|Closure $pipeable): Pipeline; } diff --git a/src/Contracts/ToolKit.php b/src/Contracts/ToolKit.php new file mode 100644 index 0000000..8238eb6 --- /dev/null +++ b/src/Contracts/ToolKit.php @@ -0,0 +1,13 @@ + + */ + public function getTools(): array; +} diff --git a/src/Cortex.php b/src/Cortex.php index e212c66..416864b 100644 --- a/src/Cortex.php +++ b/src/Cortex.php @@ -4,15 +4,18 @@ namespace Cortex; +use Closure; use Cortex\Facades\LLM; +use Cortex\Agents\Agent; +use Cortex\Support\Utils; use Cortex\Prompts\Prompt; use Cortex\Facades\Embeddings; -use Cortex\Tasks\Enums\TaskType; -use Cortex\Tasks\Builders\TextTaskBuilder; +use Cortex\Facades\AgentRegistry; use Cortex\Prompts\Contracts\PromptBuilder; use Cortex\LLM\Contracts\LLM as LLMContract; +use Cortex\Prompts\Contracts\PromptTemplate; +use Cortex\Agents\Prebuilt\GenericAgentBuilder; use Cortex\LLM\Data\Messages\MessageCollection; -use Cortex\Tasks\Builders\StructuredTaskBuilder; use Cortex\Embeddings\Contracts\Embeddings as EmbeddingsContract; class Cortex @@ -22,58 +25,84 @@ class Cortex * * @param \Cortex\LLM\Data\Messages\MessageCollection|array|string|null $messages * - * @return ($messages is null ? \Cortex\Prompts\Prompt : ($messages is string ? \Cortex\Prompts\Builders\TextPromptBuilder : \Cortex\Prompts\Builders\ChatPromptBuilder)) + * @return ($messages is null ? \Cortex\Prompts\Prompt : ($messages is string ? \Cortex\Prompts\Builders\TextPromptBuilder|\Cortex\Prompts\Contracts\PromptTemplate : \Cortex\Prompts\Builders\ChatPromptBuilder)) */ public static function prompt( MessageCollection|array|string|null $messages = null, - ): Prompt|PromptBuilder { + ): Prompt|PromptBuilder|PromptTemplate { if (func_num_args() === 0) { return new Prompt(); } - return is_string($messages) - ? Prompt::builder('text')->text($messages) - : Prompt::builder('chat')->messages($messages); + if (is_string($messages)) { + if (Utils::isPromptShortcut($messages)) { + ['factory' => $factory, 'driver' => $driver] = Utils::splitPromptShortcut($messages); + + return Prompt::factory($driver)->make($factory); + } + + return Prompt::builder('text')->text($messages); + } + + return Prompt::builder('chat')->messages($messages); } /** * Create an LLM instance. */ - public static function llm(?string $provider = null, ?string $model = null): LLMContract + public static function llm(?string $provider = null, Closure|string|null $model = null): LLMContract { - $llm = LLM::provider($provider); + // Check if shortcut string is provided + if ($provider !== null && Utils::isLLMShortcut($provider)) { + $llm = Utils::llm($provider); + } else { + $llm = LLM::provider($provider); + } - if ($model !== null) { + if ($model instanceof Closure) { + $llm = $model($llm); + } elseif (is_string($model)) { $llm->withModel($model); } return $llm; } - public static function embeddings(?string $driver = null, ?string $model = null): EmbeddingsContract + /** + * Get an agent instance from the registry by name. + * + * @return ($name is null ? \Cortex\Agents\Prebuilt\GenericAgentBuilder : \Cortex\Agents\Agent) + */ + public static function agent(?string $name = null): Agent|GenericAgentBuilder { - $embeddings = Embeddings::driver($driver); - - if ($model !== null) { - $embeddings->withModel($model); - } + return $name === null + ? new GenericAgentBuilder() + : AgentRegistry::get($name); + } - return $embeddings; + /** + * Register an agent instance or class. + * + * @param \Cortex\Agents\Agent|class-string<\Cortex\Agents\AbstractAgentBuilder> $agent + */ + public static function registerAgent(Agent|string $agent, ?string $nameOverride = null): void + { + AgentRegistry::register($agent, $nameOverride); } /** - * Create a new task builder with the given name and type. + * Create an embeddings instance. */ - public static function task( - ?string $name = null, - TaskType $type = TaskType::Text, - ): TextTaskBuilder|StructuredTaskBuilder { - $builder = $type->builder(); - - if ($name !== null) { - $builder->name($name); + public static function embeddings(?string $driver = null, Closure|string|null $model = null): EmbeddingsContract + { + $embeddings = Embeddings::driver($driver); + + if ($model instanceof Closure) { + $embeddings = $model($embeddings); + } elseif (is_string($model)) { + $embeddings->withModel($model); } - return $builder; + return $embeddings; } } diff --git a/src/CortexServiceProvider.php b/src/CortexServiceProvider.php index 40cd4bd..7f9284e 100644 --- a/src/CortexServiceProvider.php +++ b/src/CortexServiceProvider.php @@ -4,9 +4,13 @@ namespace Cortex; +use Throwable; use Cortex\LLM\LLMManager; +use Cortex\Agents\Registry; +use Cortex\Console\AgentChat; use Cortex\LLM\Contracts\LLM; use Cortex\Mcp\McpServerManager; +use Illuminate\Support\Facades\Blade; use Cortex\ModelInfo\ModelInfoFactory; use Spatie\LaravelPackageTools\Package; use Cortex\Embeddings\EmbeddingsManager; @@ -20,34 +24,113 @@ class CortexServiceProvider extends PackageServiceProvider { public function configurePackage(Package $package): void { - $package->name('cortex')->hasConfigFile(); + $package->name('cortex') + ->hasConfigFile() + ->hasRoutes('api') + ->hasCommand(AgentChat::class); } public function packageRegistered(): void + { + $this->registerLLMManager(); + $this->registerEmbeddingsManager(); + $this->registerMcpServerManager(); + $this->registerPromptFactoryManager(); + $this->registerModelInfoFactory(); + $this->registerAgentRegistry(); + } + + public function packageBooted(): void + { + foreach (config('cortex.agents', []) as $key => $agent) { + Cortex::registerAgent($agent, is_string($key) ? $key : null); + } + + $this->registerBladeDirectives(); + } + + protected function registerBladeDirectives(): void + { + // System message directives + Blade::directive('system', function (): string { + return '"; ?>'; + }); + + Blade::directive('endsystem', function (): string { + return '"; ?>'; + }); + + // User message directives + Blade::directive('user', function (): string { + return '"; ?>'; + }); + + Blade::directive('enduser', function (): string { + return '"; ?>'; + }); + + // Assistant message directives + Blade::directive('assistant', function (): string { + return '"; ?>'; + }); + + Blade::directive('endassistant', function (): string { + return '"; ?>'; + }); + + // Tool message directives + Blade::directive('tool', function (string $expression): string { + return sprintf('"; ?>', $expression); + }); + + Blade::directive('endtool', function (): string { + return '"; ?>'; + }); + } + + protected function registerLLMManager(): void { $this->app->singleton('cortex.llm', fn(Container $app): LLMManager => new LLMManager($app)); $this->app->alias('cortex.llm', LLMManager::class); $this->app->bind(LLM::class, fn(Container $app) => $app->make('cortex.llm')->driver()); + } + protected function registerEmbeddingsManager(): void + { $this->app->singleton('cortex.embeddings', fn(Container $app): EmbeddingsManager => new EmbeddingsManager($app)); $this->app->alias('cortex.embeddings', EmbeddingsManager::class); $this->app->bind(Embeddings::class, fn(Container $app) => $app->make('cortex.embeddings')->driver()); + } + protected function registerMcpServerManager(): void + { $this->app->singleton('cortex.mcp_server', fn(Container $app): McpServerManager => new McpServerManager($app)); $this->app->alias('cortex.mcp_server', McpServerManager::class); + } + protected function registerPromptFactoryManager(): void + { $this->app->singleton('cortex.prompt_factory', fn(Container $app): PromptFactoryManager => new PromptFactoryManager($app)); $this->app->alias('cortex.prompt_factory', PromptFactoryManager::class); $this->app->bind(PromptFactory::class, fn(Container $app) => $app->make('cortex.prompt_factory')->driver()); + } + protected function registerModelInfoFactory(): void + { $this->app->singleton('cortex.model_info_factory', function (Container $app): ModelInfoFactory { $providers = []; - foreach ($app->make('config')->get('cortex.model_info_providers', []) as $provider => $config) { + foreach ($app->make('config')->get('cortex.model_info.providers', []) as $provider => $config) { // $app->when($provider) // ->needs(CacheInterface::class) // ->give($app->make('cache')->store()); - $providers[] = $app->make($provider, is_array($config) ? $config : []); + try { + $providers[] = $app->make($provider, is_array($config) ? $config : []); + } catch (Throwable) { + // Silently skip providers that fail to instantiate (e.g., during testbench setup when services aren't available) + // This prevents errors during composer autoload/testbench commands + continue; + } } return new ModelInfoFactory( @@ -58,4 +141,10 @@ public function packageRegistered(): void $this->app->alias('cortex.model_info_factory', ModelInfoFactory::class); } + + protected function registerAgentRegistry(): void + { + $this->app->singleton('cortex.agent_registry', fn(Container $app): Registry => new Registry()); + $this->app->alias('cortex.agent_registry', Registry::class); + } } diff --git a/src/Events/AgentEnd.php b/src/Events/AgentEnd.php new file mode 100644 index 0000000..7358a25 --- /dev/null +++ b/src/Events/AgentEnd.php @@ -0,0 +1,17 @@ + $params + * @param array $parameters */ public function __construct( - public array $params, + public LLM $llm, + public array $parameters, public Throwable $exception, ) {} } diff --git a/src/Events/ChatModelStart.php b/src/Events/ChatModelStart.php index 2f5671d..2d12658 100644 --- a/src/Events/ChatModelStart.php +++ b/src/Events/ChatModelStart.php @@ -4,14 +4,17 @@ namespace Cortex\Events; +use Cortex\LLM\Contracts\LLM; +use Cortex\Events\Contracts\ChatModelEvent; use Cortex\LLM\Data\Messages\MessageCollection; -readonly class ChatModelStart +readonly class ChatModelStart implements ChatModelEvent { /** * @param array $parameters */ public function __construct( + public LLM $llm, public MessageCollection $messages, public array $parameters = [], ) {} diff --git a/src/Events/ChatModelStream.php b/src/Events/ChatModelStream.php index eecd938..37c15b4 100644 --- a/src/Events/ChatModelStream.php +++ b/src/Events/ChatModelStream.php @@ -4,11 +4,14 @@ namespace Cortex\Events; +use Cortex\LLM\Contracts\LLM; use Cortex\LLM\Data\ChatGenerationChunk; +use Cortex\Events\Contracts\ChatModelEvent; -readonly class ChatModelStream +readonly class ChatModelStream implements ChatModelEvent { public function __construct( + public LLM $llm, public ChatGenerationChunk $chunk, ) {} } diff --git a/src/Events/ChatModelStreamEnd.php b/src/Events/ChatModelStreamEnd.php new file mode 100644 index 0000000..e53ae15 --- /dev/null +++ b/src/Events/ChatModelStreamEnd.php @@ -0,0 +1,17 @@ + $agent, ?string $nameOverride = null) + * @method static bool has(string $name) + * @method static array names() + * + * @see \Cortex\Agents\Registry + */ +class AgentRegistry extends Facade +{ + protected static function getFacadeAccessor(): string + { + return 'cortex.agent_registry'; + } +} diff --git a/src/Facades/LLM.php b/src/Facades/LLM.php index 491b147..cbe6b81 100644 --- a/src/Facades/LLM.php +++ b/src/Facades/LLM.php @@ -15,7 +15,7 @@ * @method static \Cortex\LLM\Contracts\LLM withTemperature(float $temperature) * @method static \Cortex\LLM\Contracts\LLM withMaxTokens(int $maxTokens) * @method static \Cortex\LLM\Contracts\LLM withTools(array $tools, string $toolChoice = 'auto') - * @method static \Cortex\LLM\Contracts\LLM withStructuredOutput(\Cortex\JsonSchema\Types\ObjectSchema|string $output, ?string $name = null, ?string $description = null, bool $strict = true, \Cortex\Tasks\Enums\StructuredOutputMode $outputMode = \Cortex\Tasks\Enums\StructuredOutputMode::Auto) + * @method static \Cortex\LLM\Contracts\LLM withStructuredOutput(\Cortex\JsonSchema\Types\ObjectSchema|string $output, ?string $name = null, ?string $description = null, bool $strict = true, \Cortex\LLM\Enums\StructuredOutputMode $outputMode = \Cortex\LLM\Enums\StructuredOutputMode::Auto) * @method static \Cortex\LLM\Contracts\LLM supportsFeature(\Cortex\ModelInfo\Enums\ModelFeature $feature) * @method static \Cortex\LLM\Contracts\LLM withStreaming(bool $streaming = true) * @method static \Cortex\LLM\Contracts\LLM withCaching(bool $useCache = true) @@ -24,6 +24,8 @@ * @method static \Cortex\LLM\Contracts\LLM withFeatures(\Cortex\ModelInfo\Enums\ModelFeature ...$features) * @method static \Cortex\LLM\Contracts\LLM addFeature(\Cortex\ModelInfo\Enums\ModelFeature $feature) * @method static \Cortex\LLM\Contracts\LLM getModelInfo(): ?\Cortex\ModelInfo\Data\ModelInfo + * @method static \Cortex\LLM\Contracts\LLM getModelProvider(): \Cortex\ModelInfo\Enums\ModelProvider + * @method static \Cortex\LLM\Contracts\LLM getModel(): string * @method static \Cortex\LLM\Contracts\LLM getFeatures(): array * @method static string getDefaultDriver() * diff --git a/src/Http/Controllers/AgentsController.php b/src/Http/Controllers/AgentsController.php new file mode 100644 index 0000000..99ff7ee --- /dev/null +++ b/src/Http/Controllers/AgentsController.php @@ -0,0 +1,128 @@ +onStart(function (AgentStart $event): void { + // dump('-- agent start'); + }); + $agent->onEnd(function (AgentEnd $event): void { + // dump('-- agent end'); + }); + + $agent->onStepStart(function (AgentStepStart $event): void { + // dump( + // sprintf('---- step %d start', $event->config?->context?->getCurrentStepNumber()), + // // $event->config?->context->toArray(), + // ); + }); + $agent->onStepEnd(function (AgentStepEnd $event): void { + // dump( + // sprintf('---- step %d end', $event->config?->context?->getCurrentStepNumber()), + // // $event->config?->toArray(), + // ); + }); + $agent->onStepError(function (AgentStepError $event): void { + // dump(sprintf('step error: %d', $event->config?->context?->getCurrentStepNumber())); + // dump($event->exception->getMessage()); + // dump($event->exception->getTraceAsString()); + }); + $result = $agent->invoke(input: $request->all()); + } catch (Throwable $e) { + return response()->json([ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ], 500); + } + + // dd([ + // 'result' => $result->toArray(), + // // 'config' => $agent->getRuntimeConfig()?->toArray(), + // 'memory' => $agent->getMemory()->getMessages()->toArray(), + // 'steps' => $agent->getSteps()->toArray(), + // 'total_usage' => $agent->getTotalUsage()->toArray(), + // ]); + + return response()->json([ + 'result' => $result, + 'config' => $agent->getRuntimeConfig()?->toArray(), + 'steps' => $agent->getSteps()->toArray(), + 'total_usage' => $agent->getTotalUsage()->toArray(), + // 'memory' => $agent->getMemory()->getMessages()->toArray(), + ]); + } + + public function stream(string $agent, Request $request): void// : StreamedResponse + { + $agent = Cortex::agent($agent); + + // $agent->onStart(function (AgentStart $event): void { + // dump('---- AGENT START ----'); + // }); + // $agent->onEnd(function (AgentEnd $event): void { + // dump('---- AGENT END ----'); + // }); + // $agent->onStepStart(function (AgentStepStart $event): void { + // dump('-- STEP START --'); + // }); + // $agent->onStepEnd(function (AgentStepEnd $event): void { + // dump('-- STEP END --'); + // }); + // $agent->onStepError(function (AgentStepError $event): void { + // dump('-- STEP ERROR --'); + // }); + // $agent->onChunk(function (AgentStreamChunk $event): void { + // dump($event->chunk->type->value); + // $toolCalls = $event->chunk->message->toolCalls; + + // if ($toolCalls !== null) { + // dump(sprintf('chunk: %s', $event->chunk->message->toolCalls?->toJson())); + // } else { + // dump(sprintf('chunk: %s', $event->chunk->message->content)); + // } + // }); + + $result = $agent->stream( + messages: $request->has('message') ? [ + new UserMessage($request->input('message')), + ] : [], + input: $request->all(), + ); + + try { + foreach ($result as $chunk) { + dump(sprintf('%s: %s', $chunk->type->value, $chunk->message->content())); + } + + // return $result->streamResponse(); + } catch (Throwable $e) { + dd($e); + } + + dd([ + 'total_usage' => $agent->getTotalUsage()->toArray(), + 'steps' => $agent->getSteps()->toArray(), + 'parsed_output' => $agent->getParsedOutput(), + 'memory' => $agent->getMemory()->getMessages()->toArray(), + ]); + } +} diff --git a/src/LLM/AbstractLLM.php b/src/LLM/AbstractLLM.php index f110499..c9c4a36 100644 --- a/src/LLM/AbstractLLM.php +++ b/src/LLM/AbstractLLM.php @@ -5,22 +5,34 @@ namespace Cortex\LLM; use Closure; +use Generator; use BackedEnum; use Cortex\Pipeline; use Cortex\Support\Utils; use Cortex\Tools\SchemaTool; +use Cortex\Contracts\ToolKit; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Contracts\LLM; use Cortex\LLM\Contracts\Tool; +use Cortex\Events\ChatModelEnd; use Cortex\LLM\Data\ToolConfig; use Cortex\LLM\Enums\ToolChoice; +use Cortex\Events\ChatModelError; +use Cortex\Events\ChatModelStart; use Cortex\LLM\Contracts\Message; use Cortex\LLM\Enums\MessageRole; +use Cortex\Pipeline\StreamBuffer; use Cortex\Contracts\OutputParser; +use Cortex\Events\ChatModelStream; +use Cortex\Events\OutputParserEnd; +use Cortex\Pipeline\RuntimeConfig; use Cortex\Support\Traits\CanPipe; use Cortex\Exceptions\LLMException; use Cortex\LLM\Data\ChatGeneration; -use Cortex\JsonSchema\SchemaFactory; +use Cortex\Events\OutputParserError; +use Cortex\Events\OutputParserStart; use Cortex\ModelInfo\Data\ModelInfo; +use Cortex\Events\ChatModelStreamEnd; use Cortex\LLM\Data\ChatStreamResult; use Cortex\Exceptions\PipelineException; use Cortex\LLM\Data\ChatGenerationChunk; @@ -28,13 +40,15 @@ use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\ModelInfo\Enums\ModelProvider; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\OutputParsers\EnumOutputParser; +use Cortex\Events\Contracts\ChatModelEvent; +use Cortex\Events\RuntimeConfigStreamChunk; use Cortex\LLM\Data\Messages\SystemMessage; use Cortex\LLM\Data\StructuredOutputConfig; use Cortex\OutputParsers\ClassOutputParser; use Cortex\Support\Traits\DispatchesEvents; use Cortex\Exceptions\OutputParserException; -use Cortex\Tasks\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\MessageCollection; use Cortex\OutputParsers\StructuredOutputParser; @@ -75,26 +89,45 @@ abstract class AbstractLLM implements LLM */ protected array $features = []; - protected bool $ignoreModelFeatures = false; + protected bool $includeRaw = false; + + protected ?StreamBuffer $streamBuffer = null; public function __construct( protected string $model, protected ModelProvider $modelProvider, + protected bool $ignoreModelFeatures = false, ) { - $this->modelInfo = $modelProvider->info($model); - $this->features = $this->modelInfo->features ?? []; + if (! $ignoreModelFeatures) { + [$this->modelInfo, $this->features] = static::loadModelInfo($modelProvider, $model); + } } - // IDEA: - // Could have a third param which is StageMetadata - // where it includes the class path of the next/previous stage - - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $this->shouldParseOutput($config->context->shouldParseOutput()); + + // This allows any pipeables downstream to add items to the + // streaming output during a pipeline operation. + $this->setStreamBuffer($config->stream); + + // Apply LLM configurator if present (allows middleware to modify LLM parameters) + $llm = $this; + + if (($configurator = $config->getLLMConfigurator()) !== null) { + $llm = clone $this; + $llm = $configurator($llm); + + // Clear configurator if it's marked as "once" + if ($config->shouldClearLLMConfigurator()) { + $config->clearLLMConfigurator(); + } + } + // Invoke the LLM with the given input $result = match (true) { - $payload instanceof MessageCollection, $payload instanceof Message, is_array($payload) => $this->invoke($payload), - is_string($payload) => $this->invoke(new UserMessage($payload)), + $payload instanceof MessageCollection, $payload instanceof Message, is_array($payload) => $llm->invoke($payload), + is_string($payload) => $llm->invoke(new UserMessage($payload)), default => throw new PipelineException('Invalid input'), }; @@ -102,16 +135,52 @@ public function handlePipeable(mixed $payload, Closure $next): mixed // And if that happens to be an output parser, we ignore any parsing errors and continue. // Otherwise, we return the message as is. return $result instanceof ChatStreamResult - ? new ChatStreamResult(function () use ($result, $next) { + ? new ChatStreamResult(function () use ($result, $config, $next) { foreach ($result as $chunk) { try { - yield $next($chunk); + $chunk = $next($chunk, $config); + + yield from $this->flattenAndYield($chunk, $config, dispatchEvents: true); } catch (OutputParserException) { // Ignore any parsing errors and continue } } }) - : $next($result); + : $next($result, $config); + } + + protected function flattenAndYield(mixed $content, RuntimeConfig $config, bool $dispatchEvents = false): Generator + { + if ($content instanceof ChatStreamResult) { + // When flattening a nested stream, don't dispatch events here + // The inner stream's AbstractLLM already dispatched them + foreach ($content as $chunk) { + yield from $this->flattenAndYield($chunk, $config, dispatchEvents: false); + } + } else { + $shouldDispatchEvent = $dispatchEvents && $content instanceof ChatGenerationChunk; + // Dispatch events at the right time based on chunk type: + // - "Start" events should fire BEFORE the chunk is yielded (so listeners can prepare/initialize) + // - "End" events should fire AFTER the chunk is yielded (so consumers see the chunk first) + // - Other events fire before yielding (default behavior) + $shouldDispatchAfterYield = $shouldDispatchEvent && $content->type->isEnd(); + + if ($shouldDispatchEvent && ! $shouldDispatchAfterYield) { + $config->dispatchEvent( + event: new RuntimeConfigStreamChunk($config, $content), + dispatchToGlobalDispatcher: false, + ); + } + + yield $content; + + if ($shouldDispatchEvent && $shouldDispatchAfterYield) { + $config->dispatchEvent( + event: new RuntimeConfigStreamChunk($config, $content), + dispatchToGlobalDispatcher: false, + ); + } + } } public function output(OutputParser $parser): Pipeline @@ -120,15 +189,19 @@ public function output(OutputParser $parser): Pipeline } /** - * @param array $tools + * @param array $tools */ public function withTools( - array $tools, + array|ToolKit $tools, ToolChoice|string $toolChoice = ToolChoice::Auto, bool $allowParallelToolCalls = true, ): static { $this->supportsFeatureOrFail(ModelFeature::ToolCalling); + $tools = $tools instanceof ToolKit + ? $tools->getTools() + : $tools; + $this->toolConfig = $tools === [] ? null : new ToolConfig( @@ -154,10 +227,7 @@ public function addTool( ], $toolChoice, $allowParallelToolCalls); } - /** - * TODO: should change to protected once Tasks are refactored to use `withStructuredOutput()` - */ - public function withStructuredOutputConfig( + protected function withStructuredOutputConfig( ObjectSchema|StructuredOutputConfig $schema, ?string $name = null, ?string $description = null, @@ -190,7 +260,7 @@ public function withStructuredOutput( if ($outputMode === StructuredOutputMode::Tool) { $this->supportsFeatureOrFail(ModelFeature::ToolCalling); - return $this->withTools([new SchemaTool($schema, $name, $description)], ToolChoice::Required); + return $this->withTools([new SchemaTool($schema, $description)], ToolChoice::Required); } if ($outputMode === StructuredOutputMode::Auto) { @@ -204,7 +274,7 @@ public function withStructuredOutput( } if ($this->supportsFeature(ModelFeature::ToolCalling)) { - return $this->withTools([new SchemaTool($schema, $name, $description)], ToolChoice::Required); + return $this->withTools([new SchemaTool($schema, $description)], ToolChoice::Required); } } @@ -224,6 +294,10 @@ public function withModel(string $model): static { $this->model = $model; + if (! $this->ignoreModelFeatures) { + [$this->modelInfo, $this->features] = static::loadModelInfo($this->modelProvider, $model); + } + return $this; } @@ -304,7 +378,7 @@ public function supportsFeature(ModelFeature $feature): bool return true; } - return in_array($feature, $this->features, true); + return in_array($feature, $this->getFeatures(), true); } public function supportsFeatureOrFail(ModelFeature $feature): void @@ -339,11 +413,12 @@ public function getFeatures(): array /** * Explicitly set the model info for the LLM. - * This will override the values from the ModelProvider instance. + * This will override the values from the ModelProvider instance and the features. */ public function withModelInfo(ModelInfo $modelInfo): static { $this->modelInfo = $modelInfo; + $this->features = $modelInfo->features ?? []; return $this; } @@ -379,6 +454,31 @@ public function shouldApplyFormatInstructions(bool $applyFormatInstructions = tr return $this; } + public function onStart(Closure $listener): static + { + return $this->on(ChatModelStart::class, $listener); + } + + public function onEnd(Closure $listener): static + { + return $this->on(ChatModelEnd::class, $listener); + } + + public function onError(Closure $listener): static + { + return $this->on(ChatModelError::class, $listener); + } + + public function onStream(Closure $listener): static + { + return $this->on(ChatModelStream::class, $listener); + } + + public function onStreamEnd(Closure $listener): static + { + return $this->on(ChatModelStreamEnd::class, $listener); + } + /** * Apply the given format instructions to the messages. * @@ -410,14 +510,27 @@ public function shouldParseOutput(bool $shouldParseOutput = true): static return $this; } + public function includeRaw(bool $includeRaw = true): static + { + $this->includeRaw = $includeRaw; + + return $this; + } + protected function applyOutputParserIfApplicable( ChatGeneration|ChatGenerationChunk $generationOrChunk, ): ChatGeneration|ChatGenerationChunk { if ($this->shouldParseOutput && $this->outputParser !== null) { try { + // $this->streamBuffer?->push(new ChatGenerationChunk(type: ChunkType::OutputParserStart)); + $this->dispatchEvent(new OutputParserStart($this->outputParser, $generationOrChunk)); $parsedOutput = $this->outputParser->parse($generationOrChunk); + // $this->streamBuffer?->push(new ChatGenerationChunk(type: ChunkType::OutputParserEnd)); + $this->dispatchEvent(new OutputParserEnd($this->outputParser, $parsedOutput)); + $generationOrChunk = $generationOrChunk->cloneWithParsedOutput($parsedOutput); } catch (OutputParserException $e) { + $this->dispatchEvent(new OutputParserError($this->outputParser, $generationOrChunk, $e)); $this->outputParserError = $e->getMessage(); } } @@ -475,7 +588,7 @@ protected function resolveSchemaAndOutputParser(ObjectSchema|string $outputType, } if (enum_exists($outputType) && is_subclass_of($outputType, BackedEnum::class)) { - $enumSchema = SchemaFactory::fromEnum($outputType); + $enumSchema = Schema::fromEnum($outputType); $title = $enumSchema->getTitle() ?? class_basename($outputType); $schema = new ObjectSchema($title); @@ -485,7 +598,7 @@ protected function resolveSchemaAndOutputParser(ObjectSchema|string $outputType, } if (class_exists($outputType)) { - $schema = SchemaFactory::fromClass($outputType); + $schema = Schema::fromClass($outputType); $schema->title($schema->getTitle() ?? class_basename($outputType)); return [$schema, new ClassOutputParser($outputType, $strict)]; @@ -493,4 +606,32 @@ protected function resolveSchemaAndOutputParser(ObjectSchema|string $outputType, throw new LLMException('Unsupported output type: ' . $outputType); } + + /** + * Load the model info for the LLM. + * + * @return array{\Cortex\ModelInfo\Data\ModelInfo|null, array<\Cortex\ModelInfo\Enums\ModelFeature>} + */ + protected static function loadModelInfo(ModelProvider $modelProvider, string $model): array + { + $modelInfo = $modelProvider->info($model); + $features = $modelInfo->features ?? []; + + return [$modelInfo, $features]; + } + + protected function setStreamBuffer(StreamBuffer $streamBuffer): static + { + $this->streamBuffer = $streamBuffer; + + return $this; + } + + /** + * Check if an event belongs to this LLM instance. + */ + protected function eventBelongsToThisInstance(object $event): bool + { + return $event instanceof ChatModelEvent && $event->llm === $this; + } } diff --git a/src/LLM/CacheDecorator.php b/src/LLM/CacheDecorator.php deleted file mode 100644 index 5c80b28..0000000 --- a/src/LLM/CacheDecorator.php +++ /dev/null @@ -1,253 +0,0 @@ -cache = $cache ?? $this->discoverCache(); - } - - public function invoke( - MessageCollection|Message|array|string $messages, - array $additionalParameters = [], - ): ChatResult|ChatStreamResult { - // Caching not supported for streaming responses - if ($this->llm->isStreaming() || ! $this->llm->shouldCache()) { - return $this->llm->invoke($messages, $additionalParameters); - } - - $cacheKey = $this->cacheKey($messages, $additionalParameters); - - if ($this->cache->has($cacheKey)) { - return $this->cache->get($cacheKey); - } - - $result = $this->llm->invoke($messages, $additionalParameters); - - $this->cache->set($cacheKey, $result, $this->ttl); - - return $result; - } - - /** - * @param MessageCollection|Message|array $messages - * @param array $additionalParameters - */ - protected function cacheKey(MessageCollection|Message|array $messages, array $additionalParameters = []): string - { - return vsprintf('%s:%s:%s', [ - 'cortex', - $this->llm::class, - hash('sha256', json_encode([$messages, $additionalParameters], JSON_THROW_ON_ERROR), true), - ]); - } - - public function withTools(array $tools, ToolChoice|string $toolChoice = ToolChoice::Auto): static - { - $this->llm = $this->llm->withTools($tools, $toolChoice); - - return $this; - } - - public function addTool(Tool|Closure|string $tool, ToolChoice|string $toolChoice = ToolChoice::Auto): static - { - $this->llm = $this->llm->addTool($tool, $toolChoice); - - return $this; - } - - public function withStructuredOutputConfig( - ObjectSchema|StructuredOutputConfig $schema, - ?string $name = null, - ?string $description = null, - bool $strict = true, - ): static { - $this->llm = $this->llm->withStructuredOutputConfig($schema, $name, $description, $strict); - - return $this; - } - - public function withStructuredOutput( - ObjectSchema|string $output, - ?string $name = null, - ?string $description = null, - bool $strict = true, - StructuredOutputMode $outputMode = StructuredOutputMode::Auto, - ): static { - $this->llm = $this->llm->withStructuredOutput($output, $name, $description, $strict, $outputMode); - - return $this; - } - - public function forceJsonOutput(): static - { - $this->llm = $this->llm->forceJsonOutput(); - - return $this; - } - - public function supportsFeature(ModelFeature $feature): bool - { - return $this->llm->supportsFeature($feature); - } - - public function ignoreFeatures(bool $ignoreModelFeatures = true): static - { - $this->llm = $this->llm->ignoreFeatures($ignoreModelFeatures); - - return $this; - } - - public function shouldParseOutput(bool $shouldParseOutput = true): static - { - $this->llm = $this->llm->shouldParseOutput($shouldParseOutput); - - return $this; - } - - public function withModel(string $model): static - { - $this->llm = $this->llm->withModel($model); - - return $this; - } - - public function withTemperature(float $temperature): static - { - $this->llm = $this->llm->withTemperature($temperature); - - return $this; - } - - public function withMaxTokens(int $maxTokens): static - { - $this->llm = $this->llm->withMaxTokens($maxTokens); - - return $this; - } - - public function withStreaming(bool $streaming = true): static - { - $this->llm = $this->llm->withStreaming($streaming); - - return $this; - } - - public function withCaching(bool $useCache = true): static - { - $this->llm = $this->llm->withCaching($useCache); - - return $this; - } - - public function withParameters(array $parameters): static - { - $this->llm = $this->llm->withParameters($parameters); - - return $this; - } - - public function isStreaming(): bool - { - return $this->llm->isStreaming(); - } - - public function shouldCache(): bool - { - return $this->llm->shouldCache(); - } - - public function handlePipeable(mixed $payload, Closure $next): mixed - { - return $this->llm->handlePipeable($payload, $next); - } - - public function pipe(Pipeable|callable $next): Pipeline - { - return $this->llm->pipe($next); - } - - public function withFeatures(ModelFeature ...$features): static - { - $this->llm = $this->llm->withFeatures(...$features); - - return $this; - } - - public function addFeature(ModelFeature $feature): static - { - $this->llm = $this->llm->addFeature($feature); - - return $this; - } - - public function withModelInfo(ModelInfo $modelInfo): static - { - $this->llm = $this->llm->withModelInfo($modelInfo); - - return $this; - } - - public function getFeatures(): array - { - return $this->llm->getFeatures(); - } - - public function getModelInfo(): ?ModelInfo - { - return $this->llm->getModelInfo(); - } - - /** - * @param array $arguments - */ - public function __call(string $name, array $arguments): mixed - { - return $this->llm->{$name}(...$arguments); - } - - public function __get(string $name): mixed - { - return $this->llm->{$name}; - } - - public function __set(string $name, mixed $value): void - { - $this->llm->{$name} = $value; - } - - public function __isset(string $name): bool - { - return isset($this->llm->{$name}); - } -} diff --git a/src/LLM/Contracts/Content.php b/src/LLM/Contracts/Content.php index 1521499..a0b3d11 100644 --- a/src/LLM/Contracts/Content.php +++ b/src/LLM/Contracts/Content.php @@ -4,6 +4,8 @@ namespace Cortex\LLM\Contracts; +use Cortex\Prompts\Contracts\PromptCompiler; + interface Content { /** @@ -18,5 +20,10 @@ public function variables(): array; * * @param array $variables */ - public function replaceVariables(array $variables): self; + public function replaceVariables(array $variables): static; + + /** + * Set the compiler for the content. + */ + public function withCompiler(PromptCompiler $compiler): static; } diff --git a/src/LLM/Contracts/LLM.php b/src/LLM/Contracts/LLM.php index 805a20e..20c02f8 100644 --- a/src/LLM/Contracts/LLM.php +++ b/src/LLM/Contracts/LLM.php @@ -12,8 +12,8 @@ use Cortex\LLM\Data\ChatStreamResult; use Cortex\ModelInfo\Enums\ModelFeature; use Cortex\JsonSchema\Types\ObjectSchema; -use Cortex\LLM\Data\StructuredOutputConfig; -use Cortex\Tasks\Enums\StructuredOutputMode; +use Cortex\ModelInfo\Enums\ModelProvider; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\MessageCollection; interface LLM extends Pipeable @@ -56,16 +56,6 @@ public function withStructuredOutput( StructuredOutputMode $outputMode = StructuredOutputMode::Auto, ): static; - /** - * Specify the structured output configuration for the LLM. - */ - public function withStructuredOutputConfig( - ObjectSchema|StructuredOutputConfig $schema, - ?string $name = null, - ?string $description = null, - bool $strict = true, - ): static; - /** * Specify that the LLM should output in JSON format. */ @@ -146,15 +136,55 @@ public function shouldCache(): bool; */ public function getFeatures(): array; + /** + * Get the model for the LLM. + */ + public function getModel(): string; + + /** + * Get the model provider for the LLM. + */ + public function getModelProvider(): ModelProvider; + /** * Get the model info for the LLM. */ public function getModelInfo(): ?ModelInfo; + /** + * Set whether the raw provider response should be included in the result, if available. + */ + public function includeRaw(bool $includeRaw = true): static; + /** * Set whether the output should be parsed. * This may be set to false when called in a pipeline context and output parsing * is done as part of the next pipeable. */ public function shouldParseOutput(bool $shouldParseOutput = true): static; + + /** + * Register a listener for when this LLM starts. + */ + public function onStart(Closure $listener): static; + + /** + * Register a listener for when this LLM ends. + */ + public function onEnd(Closure $listener): static; + + /** + * Register a listener for when this LLM errors. + */ + public function onError(Closure $listener): static; + + /** + * Register a listener for when this LLM streams. + */ + public function onStream(Closure $listener): static; + + /** + * Register a listener for when the last LLM's stream ends. + */ + public function onStreamEnd(Closure $listener): static; } diff --git a/src/LLM/Contracts/StreamingProtocol.php b/src/LLM/Contracts/StreamingProtocol.php new file mode 100644 index 0000000..1d20116 --- /dev/null +++ b/src/LLM/Contracts/StreamingProtocol.php @@ -0,0 +1,19 @@ + + */ + public function mapChunkToPayload(ChatGenerationChunk $chunk): array; +} diff --git a/src/LLM/Contracts/Tool.php b/src/LLM/Contracts/Tool.php index 1504f35..caef692 100644 --- a/src/LLM/Contracts/Tool.php +++ b/src/LLM/Contracts/Tool.php @@ -5,6 +5,7 @@ namespace Cortex\LLM\Contracts; use Cortex\LLM\Data\ToolCall; +use Cortex\Pipeline\RuntimeConfig; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\ToolMessage; @@ -37,10 +38,10 @@ public function format(): array; * * @param ToolCall|array $arguments */ - public function invoke(ToolCall|array $arguments = []): mixed; + public function invoke(ToolCall|array $arguments = [], ?RuntimeConfig $config = null): mixed; /** * Invoke the tool as a tool message. */ - public function invokeAsToolMessage(ToolCall $toolCall): ToolMessage; + public function invokeAsToolMessage(ToolCall $toolCall, ?RuntimeConfig $config = null): ToolMessage; } diff --git a/src/LLM/Data/ChatGeneration.php b/src/LLM/Data/ChatGeneration.php index 83034e4..c84bc4b 100644 --- a/src/LLM/Data/ChatGeneration.php +++ b/src/LLM/Data/ChatGeneration.php @@ -7,13 +7,16 @@ use DateTimeImmutable; use DateTimeInterface; use Cortex\LLM\Enums\FinishReason; +use Illuminate\Contracts\Support\Arrayable; use Cortex\LLM\Data\Messages\AssistantMessage; -readonly class ChatGeneration +/** + * @implements Arrayable + */ +readonly class ChatGeneration implements Arrayable { public function __construct( public AssistantMessage $message, - public int $index, public DateTimeInterface $createdAt, public FinishReason $finishReason, public mixed $parsedOutput = null, @@ -24,7 +27,6 @@ public function cloneWithMessage(AssistantMessage $message): self { return new self( $message, - $this->index, $this->createdAt, $this->finishReason, $this->parsedOutput, @@ -36,7 +38,6 @@ public function cloneWithParsedOutput(mixed $parsedOutput): self { return new self( $this->message, - $this->index, $this->createdAt, $this->finishReason, $parsedOutput, @@ -49,9 +50,19 @@ public static function fromMessage(AssistantMessage $message, ?FinishReason $fin // TODO: move to FakeChatGeneration return new self( message: $message, - index: 0, createdAt: new DateTimeImmutable(), finishReason: $finishReason ?? FinishReason::Stop, ); } + + public function toArray(): array + { + return [ + 'message' => $this->message->toArray(), + 'finish_reason' => $this->finishReason->value, + 'parsed_output' => $this->parsedOutput, + 'output_parser_error' => $this->outputParserError, + 'created_at' => $this->createdAt, + ]; + } } diff --git a/src/LLM/Data/ChatGenerationChunk.php b/src/LLM/Data/ChatGenerationChunk.php index bed512e..936bdd0 100644 --- a/src/LLM/Data/ChatGenerationChunk.php +++ b/src/LLM/Data/ChatGenerationChunk.php @@ -4,31 +4,56 @@ namespace Cortex\LLM\Data; +use Throwable; +use DateTimeImmutable; use DateTimeInterface; +use Cortex\LLM\Enums\ChunkType; use Cortex\LLM\Enums\FinishReason; +use Cortex\LLM\Data\Messages\ToolMessage; +use Illuminate\Contracts\Support\Arrayable; use Cortex\LLM\Data\Messages\AssistantMessage; -readonly class ChatGenerationChunk +/** + * @implements Arrayable + */ +readonly class ChatGenerationChunk implements Arrayable { + /** + * @param array|null $rawChunk + * @param array $metadata + */ public function __construct( - public string $id, - public AssistantMessage $message, - public int $index, - public DateTimeInterface $createdAt, + public ChunkType $type, + public ?string $id = null, + public AssistantMessage|ToolMessage $message = new AssistantMessage(), + public DateTimeInterface $createdAt = new DateTimeImmutable(), public ?FinishReason $finishReason = null, public ?Usage $usage = null, public string $contentSoFar = '', public bool $isFinal = false, public mixed $parsedOutput = null, public ?string $outputParserError = null, + public ?array $rawChunk = null, + public ?Throwable $exception = null, + public array $metadata = [], ) {} + public function content(): mixed + { + return $this->parsedOutput ?? $this->message->content(); + } + + public function text(): ?string + { + return $this->message->text(); + } + public function cloneWithParsedOutput(mixed $parsedOutput): self { return new self( + $this->type, $this->id, $this->message, - $this->index, $this->createdAt, $this->finishReason, $this->usage, @@ -38,4 +63,23 @@ public function cloneWithParsedOutput(mixed $parsedOutput): self $this->outputParserError, ); } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'type' => $this->type->value, + 'message' => $this->message->toArray(), + 'finish_reason' => $this->finishReason?->value, + 'usage' => $this->usage?->toArray(), + 'content_so_far' => $this->contentSoFar, + 'is_final' => $this->isFinal, + 'parsed_output' => $this->parsedOutput, + 'output_parser_error' => $this->outputParserError, + 'created_at' => $this->createdAt, + 'raw_chunk' => $this->rawChunk, + 'exception' => $this->exception?->getMessage(), + 'metadata' => $this->metadata, + ]; + } } diff --git a/src/LLM/Data/ChatResult.php b/src/LLM/Data/ChatResult.php index a69253b..9e01ade 100644 --- a/src/LLM/Data/ChatResult.php +++ b/src/LLM/Data/ChatResult.php @@ -4,22 +4,52 @@ namespace Cortex\LLM\Data; -readonly class ChatResult -{ - public ChatGeneration $generation; +use Illuminate\Contracts\Support\Arrayable; +/** + * @implements Arrayable + */ +readonly class ChatResult implements Arrayable +{ public mixed $parsedOutput; /** - * @param array $generations - * @param array $rawResponse + * @param array|null $rawResponse */ public function __construct( - public array $generations, + public ChatGeneration $generation, public Usage $usage, - public array $rawResponse = [], + public ?array $rawResponse = null, ) { - $this->generation = $generations[0]; - $this->parsedOutput = $generations[0]->parsedOutput; + $this->parsedOutput = $this->generation->parsedOutput; + } + + public function cloneWithGeneration(ChatGeneration $generation): self + { + return new self( + $generation, + $this->usage, + $this->rawResponse, + ); + } + + public function content(): mixed + { + return $this->generation->parsedOutput + ?? $this->generation->message->content(); + } + + public function text(): ?string + { + return $this->generation->message->text(); + } + + public function toArray(): array + { + return [ + 'generation' => $this->generation, + 'usage' => $this->usage, + 'raw_response' => $this->rawResponse, + ]; } } diff --git a/src/LLM/Data/ChatStreamResult.php b/src/LLM/Data/ChatStreamResult.php index f784787..313fa1b 100644 --- a/src/LLM/Data/ChatStreamResult.php +++ b/src/LLM/Data/ChatStreamResult.php @@ -4,30 +4,79 @@ namespace Cortex\LLM\Data; -use DateTimeImmutable; +use Generator; +use Cortex\LLM\Enums\ChunkType; use Cortex\LLM\Enums\FinishReason; +use Cortex\Pipeline\RuntimeConfig; use Illuminate\Support\LazyCollection; +use Cortex\Events\RuntimeConfigStreamChunk; use Cortex\LLM\Data\Messages\AssistantMessage; +use Cortex\LLM\Data\Concerns\HasStreamResponses; /** * @extends LazyCollection */ class ChatStreamResult extends LazyCollection { - // public function streamResponse(): StreamedResponse - // { - // return response()->eventStream(function () { - // foreach ($this as $chunk) { - // yield $chunk; - // } - // }); - // } + use HasStreamResponses; + + /** + * Stream only text chunks. + */ + public function text(): self + { + return $this->filter(fn(ChatGenerationChunk $chunk): bool => $chunk->type->isText()); + } + + /** + * Stream only chunks where message content is not empty. + */ + public function withoutEmpty(): self + { + return $this->reject(fn(ChatGenerationChunk $chunk): bool => $chunk->type->isText() && empty($chunk->content())); + } + + public function appendStreamBuffer(RuntimeConfig $config): self + { + return new self(function () use ($config): Generator { + foreach ($this as $chunk) { + yield $chunk; + } + + // Drain items from the buffer and dispatch events for them + if ($config->stream->isNotEmpty()) { + foreach ($config->stream->drain() as $chunk) { + if (! $chunk instanceof ChatGenerationChunk) { + continue; + } + + $shouldYieldBeforeEvent = ! $chunk->type->isEnd(); + + if ($shouldYieldBeforeEvent) { + $config->dispatchEvent( + event: new RuntimeConfigStreamChunk($config, $chunk), + dispatchToGlobalDispatcher: false, + ); + } + + yield $chunk; + + if (! $shouldYieldBeforeEvent) { + $config->dispatchEvent( + event: new RuntimeConfigStreamChunk($config, $chunk), + dispatchToGlobalDispatcher: false, + ); + } + } + } + }); + } public static function fake(?string $string = null, ?ToolCallCollection $toolCalls = null): self { return new self(function () use ($string, $toolCalls) { $contentSoFar = ''; - $chunks = $string !== null && $string !== '' && $string !== '0' ? preg_split('/(\s+)/', $string, -1, PREG_SPLIT_DELIM_CAPTURE) : [null]; + $chunks = in_array($string, [null, '', '0'], true) ? [null] : preg_split('/(\s+)/', $string, -1, PREG_SPLIT_DELIM_CAPTURE); foreach ($chunks as $index => $chunk) { $contentSoFar .= $chunk; @@ -35,10 +84,9 @@ public static function fake(?string $string = null, ?ToolCallCollection $toolCal $isFinal = count($chunks) === $index + 1; $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, id: 'fake-' . $index, message: new AssistantMessage($chunk, $toolCalls), - index: $index, - createdAt: new DateTimeImmutable(), finishReason: $isFinal ? FinishReason::Stop : null, usage: new Usage( promptTokens: 0, diff --git a/src/LLM/Data/Concerns/HasStreamResponses.php b/src/LLM/Data/Concerns/HasStreamResponses.php new file mode 100644 index 0000000..317e2b4 --- /dev/null +++ b/src/LLM/Data/Concerns/HasStreamResponses.php @@ -0,0 +1,65 @@ +toStreamedResponse(new DefaultDataStream()); + } + + /** + * Create a plain text streaming response (Vercel AI SDK text format). + * Streams only the text content without any JSON encoding or metadata. + * + * @see https://sdk.vercel.ai/docs/ai-sdk-core/generating-text + */ + public function vercelTextStreamResponse(): StreamedResponse + { + return $this->toStreamedResponse(new VercelTextStream()); + } + + public function vercelDataStreamResponse(): StreamedResponse + { + return $this->toStreamedResponse(new VercelDataStream()); + } + + /** + * Create a streaming response using the AG-UI protocol. + * + * @see https://docs.ag-ui.com/concepts/events.md + */ + public function agUiStreamResponse(): StreamedResponse + { + return $this->toStreamedResponse(new AgUiDataStream()); + } + + /** + * Create a streaming response using a custom streaming protocol. + */ + public function toStreamedResponse(StreamingProtocol $protocol): StreamedResponse + { + /** @var \Illuminate\Routing\ResponseFactory $responseFactory */ + $responseFactory = response(); + + return $responseFactory->stream($protocol->streamResponse($this), headers: [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + ]); + } +} diff --git a/src/LLM/Data/Messages/AssistantMessage.php b/src/LLM/Data/Messages/AssistantMessage.php index 079eb79..f8e2b51 100644 --- a/src/LLM/Data/Messages/AssistantMessage.php +++ b/src/LLM/Data/Messages/AssistantMessage.php @@ -55,6 +55,13 @@ public function text(): ?string : null; } + public function isTextEmpty(): bool + { + $text = $this->text(); + + return $text === null || $text === ''; + } + /** * Get the reasoning content of the message. */ diff --git a/src/LLM/Data/Messages/Content/AbstractContent.php b/src/LLM/Data/Messages/Content/AbstractContent.php index dcd5448..1dad0c5 100644 --- a/src/LLM/Data/Messages/Content/AbstractContent.php +++ b/src/LLM/Data/Messages/Content/AbstractContent.php @@ -5,9 +5,13 @@ namespace Cortex\LLM\Data\Messages\Content; use Cortex\LLM\Contracts\Content; +use Cortex\Prompts\Compilers\TextCompiler; +use Cortex\Prompts\Contracts\PromptCompiler; -abstract readonly class AbstractContent implements Content +abstract class AbstractContent implements Content { + protected ?PromptCompiler $compiler = null; + /** * Get the variables that the content expects. * @@ -23,8 +27,25 @@ public function variables(): array * * @param array $variables */ - public function replaceVariables(array $variables): self + public function replaceVariables(array $variables): static + { + return $this; + } + + public function withCompiler(PromptCompiler $compiler): static { + $this->compiler = $compiler; + return $this; } + + public function getCompiler(): PromptCompiler + { + return $this->compiler ?? self::defaultCompiler(); + } + + public static function defaultCompiler(): PromptCompiler + { + return new TextCompiler(); + } } diff --git a/src/LLM/Data/Messages/Content/AudioContent.php b/src/LLM/Data/Messages/Content/AudioContent.php index 845ec03..eebcb19 100644 --- a/src/LLM/Data/Messages/Content/AudioContent.php +++ b/src/LLM/Data/Messages/Content/AudioContent.php @@ -5,9 +5,8 @@ namespace Cortex\LLM\Data\Messages\Content; use Override; -use Cortex\Support\Utils; -readonly class AudioContent extends AbstractContent +final class AudioContent extends AbstractContent { /** * @var array @@ -18,7 +17,7 @@ public function __construct( public string $base64Data, public string $format, ) { - $this->variables = Utils::findVariables($this->base64Data); + $this->variables = $this->getCompiler()->variables($this->base64Data); } #[Override] @@ -28,10 +27,10 @@ public function variables(): array } #[Override] - public function replaceVariables(array $variables): self + public function replaceVariables(array $variables): static { return new self( - Utils::replaceVariables($this->base64Data, $variables), + $this->getCompiler()->compile($this->base64Data, $variables), $this->format, ); } diff --git a/src/LLM/Data/Messages/Content/FileContent.php b/src/LLM/Data/Messages/Content/FileContent.php index a508a99..d44f55c 100644 --- a/src/LLM/Data/Messages/Content/FileContent.php +++ b/src/LLM/Data/Messages/Content/FileContent.php @@ -5,11 +5,10 @@ namespace Cortex\LLM\Data\Messages\Content; use Override; -use Cortex\Support\Utils; use Cortex\Support\DataUrl; use Cortex\LLM\Data\Messages\Concerns\InteractsWithFiles; -readonly class FileContent extends AbstractContent +final class FileContent extends AbstractContent { use InteractsWithFiles; @@ -26,7 +25,7 @@ public function __construct( public ?string $fileName = null, ) { $this->url = $url instanceof DataUrl ? $url->toString() : $url; - $this->variables = Utils::findVariables($this->url); + $this->variables = $this->getCompiler()->variables($this->url); } #[Override] @@ -36,10 +35,10 @@ public function variables(): array } #[Override] - public function replaceVariables(array $variables): self + public function replaceVariables(array $variables): static { return new self( - Utils::replaceVariables($this->url, $variables), + $this->getCompiler()->compile($this->url, $variables), $this->mimeType, $this->fileName, ); diff --git a/src/LLM/Data/Messages/Content/ImageContent.php b/src/LLM/Data/Messages/Content/ImageContent.php index ef1c103..0afbec0 100644 --- a/src/LLM/Data/Messages/Content/ImageContent.php +++ b/src/LLM/Data/Messages/Content/ImageContent.php @@ -6,11 +6,10 @@ use Override; use Stringable; -use Cortex\Support\Utils; use Cortex\Support\DataUrl; use Cortex\LLM\Data\Messages\Concerns\InteractsWithFiles; -readonly class ImageContent extends AbstractContent implements Stringable +final class ImageContent extends AbstractContent implements Stringable { use InteractsWithFiles; @@ -26,7 +25,7 @@ public function __construct( public ?string $mimeType = null, ) { $this->url = $url instanceof DataUrl ? $url->toString() : $url; - $this->variables = Utils::findVariables($this->url); + $this->variables = $this->getCompiler()->variables($this->url); } #[Override] @@ -36,8 +35,8 @@ public function variables(): array } #[Override] - public function replaceVariables(array $variables): self + public function replaceVariables(array $variables): static { - return new self(Utils::replaceVariables($this->url, $variables), $this->mimeType); + return new self($this->getCompiler()->compile($this->url, $variables), $this->mimeType); } } diff --git a/src/LLM/Data/Messages/Content/ReasoningContent.php b/src/LLM/Data/Messages/Content/ReasoningContent.php index e53a6ad..b5b49e3 100644 --- a/src/LLM/Data/Messages/Content/ReasoningContent.php +++ b/src/LLM/Data/Messages/Content/ReasoningContent.php @@ -4,7 +4,7 @@ namespace Cortex\LLM\Data\Messages\Content; -readonly class ReasoningContent extends AbstractContent +final class ReasoningContent extends AbstractContent { public function __construct( public string $id, diff --git a/src/LLM/Data/Messages/Content/TextContent.php b/src/LLM/Data/Messages/Content/TextContent.php index e84f1f4..4611bf9 100644 --- a/src/LLM/Data/Messages/Content/TextContent.php +++ b/src/LLM/Data/Messages/Content/TextContent.php @@ -5,9 +5,8 @@ namespace Cortex\LLM\Data\Messages\Content; use Override; -use Cortex\Support\Utils; -readonly class TextContent extends AbstractContent +final class TextContent extends AbstractContent { public function __construct( public ?string $text = null, @@ -16,12 +15,16 @@ public function __construct( #[Override] public function variables(): array { - return Utils::findVariables($this->text ?? ''); + return $this->getCompiler()->variables($this->text ?? ''); } #[Override] - public function replaceVariables(array $variables): self + public function replaceVariables(array $variables): static { - return new self(Utils::replaceVariables($this->text ?? '', $variables)); + if ($this->text === null) { + return $this; + } + + return new self($this->getCompiler()->compile($this->text, $variables)); } } diff --git a/src/LLM/Data/Messages/Content/ToolContent.php b/src/LLM/Data/Messages/Content/ToolContent.php index 671bbb2..eba6846 100644 --- a/src/LLM/Data/Messages/Content/ToolContent.php +++ b/src/LLM/Data/Messages/Content/ToolContent.php @@ -7,7 +7,7 @@ /** * Mainly used for Anthropic chat. */ -readonly class ToolContent extends AbstractContent +final class ToolContent extends AbstractContent { public function __construct( public string $id, diff --git a/src/LLM/Data/Messages/MessageCollection.php b/src/LLM/Data/Messages/MessageCollection.php index 3e15a63..f6f77c7 100644 --- a/src/LLM/Data/Messages/MessageCollection.php +++ b/src/LLM/Data/Messages/MessageCollection.php @@ -4,12 +4,13 @@ namespace Cortex\LLM\Data\Messages; -use Cortex\Support\Utils; use InvalidArgumentException; use Cortex\LLM\Contracts\Content; use Cortex\LLM\Contracts\Message; use Cortex\LLM\Enums\MessageRole; use Illuminate\Support\Collection; +use Cortex\Prompts\Compilers\TextCompiler; +use Cortex\Prompts\Contracts\PromptCompiler; /** * @extends \Illuminate\Support\Collection @@ -19,10 +20,12 @@ class MessageCollection extends Collection /** * @param array $variables */ - public function replaceVariables(array $variables): self + public function replaceVariables(array $variables, ?PromptCompiler $compiler = null): self { + $compiler ??= new TextCompiler(); + /** @var self */ - $messages = $this->map(function (Message|MessagePlaceholder $message) use ($variables): MessagePlaceholder|Message { + $messages = $this->map(function (Message|MessagePlaceholder $message) use ($variables, $compiler): MessagePlaceholder|Message { if ($message instanceof MessagePlaceholder) { return $message; } @@ -31,13 +34,13 @@ public function replaceVariables(array $variables): self // If the content is an array, map over it and replace the variables in each item is_array($message->content()) => $message->cloneWithContent( array_map( - fn(mixed $item): Content => $item->replaceVariables($variables), + fn(Content $item): Content => $item->withCompiler($compiler)->replaceVariables($variables), $message->content(), ), ), // If the content is a string, replace the variables in it $message->text() !== null => $message->cloneWithContent( - Utils::replaceVariables($message->text(), $variables), + $compiler->compile($message->text(), $variables), ), // If the content is null, return the message as is default => $message, @@ -88,15 +91,17 @@ public function placeholderVariables(): Collection * * @return \Illuminate\Support\Collection */ - public function variables(): Collection + public function variables(?PromptCompiler $compiler = null): Collection { + $compiler ??= new TextCompiler(); + return $this->flatMap(fn(Message|MessagePlaceholder $message) => match (true) { $message instanceof MessagePlaceholder => [$message->name], is_array($message->content()) => collect($message->content()) - ->flatMap(fn(Content $item): array => $item->variables()) + ->flatMap(fn(Content $item): array => $item->withCompiler($compiler)->variables()) ->all(), default => $message->text() !== null - ? Utils::findVariables($message->text()) + ? $compiler->variables($message->text()) : [], }) ->unique() @@ -145,6 +150,18 @@ public function withoutPlaceholders(): self ); } + /** + * Get only the message placeholders in the collection. + * + * @return self + */ + public function onlyPlaceholders(): self + { + return $this->filter( + fn(Message|MessagePlaceholder $message): bool => $message instanceof MessagePlaceholder, + ); + } + /** * Determine if the collection has a message placeholder with the given name. */ diff --git a/src/LLM/Data/ResponseMetadata.php b/src/LLM/Data/ResponseMetadata.php index 9eba50c..f2b860b 100644 --- a/src/LLM/Data/ResponseMetadata.php +++ b/src/LLM/Data/ResponseMetadata.php @@ -13,12 +13,17 @@ */ readonly class ResponseMetadata implements Arrayable { + /** + * @param array $providerMetadata + */ public function __construct( public string $id, public string $model, public ModelProvider $provider, public ?FinishReason $finishReason = null, public ?Usage $usage = null, + public ?int $processingTime = null, + public array $providerMetadata = [], ) {} /** @@ -32,6 +37,8 @@ public function toArray(): array 'provider' => $this->provider->value, 'finish_reason' => $this->finishReason?->value, 'usage' => $this->usage?->toArray(), + 'processing_time_ms' => $this->processingTime, + 'provider_metadata' => $this->providerMetadata, ]; } } diff --git a/src/LLM/Data/ToolCallCollection.php b/src/LLM/Data/ToolCallCollection.php index bb2d114..8958c59 100644 --- a/src/LLM/Data/ToolCallCollection.php +++ b/src/LLM/Data/ToolCallCollection.php @@ -5,6 +5,7 @@ namespace Cortex\LLM\Data; use Cortex\LLM\Contracts\Tool; +use Cortex\Pipeline\RuntimeConfig; use Illuminate\Support\Collection; use Cortex\LLM\Data\Messages\MessageCollection; @@ -25,10 +26,10 @@ public function findByName(string $name): ?ToolCall * * @return \Cortex\LLM\Data\Messages\MessageCollection */ - public function invokeAsToolMessages(Collection $availableTools): MessageCollection + public function invokeAsToolMessages(Collection $availableTools, ?RuntimeConfig $config = null): MessageCollection { /** @var \Cortex\LLM\Data\Messages\MessageCollection $messages */ - $messages = $this->map(function (ToolCall $toolCall) use ($availableTools) { + $messages = $this->map(function (ToolCall $toolCall) use ($availableTools, $config) { $matchingTool = $availableTools->first( fn(Tool $tool): bool => $tool->name() === $toolCall->function->name, ); @@ -45,7 +46,7 @@ public function invokeAsToolMessages(Collection $availableTools): MessageCollect } } - return $matchingTool->invokeAsToolMessage($toolCall); + return $matchingTool->invokeAsToolMessage($toolCall, $config); }) ->filter() ->values() diff --git a/src/LLM/Drivers/AnthropicChat.php b/src/LLM/Drivers/Anthropic/AnthropicChat.php similarity index 94% rename from src/LLM/Drivers/AnthropicChat.php rename to src/LLM/Drivers/Anthropic/AnthropicChat.php index 0d17fb7..541df14 100644 --- a/src/LLM/Drivers/AnthropicChat.php +++ b/src/LLM/Drivers/Anthropic/AnthropicChat.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Cortex\LLM\Drivers; +namespace Cortex\LLM\Drivers\Anthropic; use Generator; use Throwable; @@ -15,6 +15,7 @@ use Cortex\LLM\Contracts\Tool; use Cortex\Events\ChatModelEnd; use Cortex\LLM\Data\ChatResult; +use Cortex\LLM\Enums\ChunkType; use Cortex\LLM\Enums\ToolChoice; use Anthropic\Testing\ClientFake; use Cortex\Events\ChatModelError; @@ -33,8 +34,8 @@ use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\LLM\Data\Messages\ToolMessage; use Cortex\ModelInfo\Enums\ModelProvider; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\SystemMessage; -use Cortex\Tasks\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\LLM\Data\Messages\MessageCollection; use Anthropic\Responses\Messages\CreateResponse; @@ -51,9 +52,10 @@ class AnthropicChat extends AbstractLLM public function __construct( protected readonly ClientContract $client, protected string $model, - protected ModelProvider $modelProvider, + protected ModelProvider $modelProvider = ModelProvider::Anthropic, + protected bool $ignoreModelFeatures = false, ) { - parent::__construct($model, $modelProvider); + parent::__construct($model, $modelProvider, $ignoreModelFeatures); } public function invoke( @@ -82,14 +84,14 @@ public function invoke( $params['system'] = $systemMessage->text(); } - $this->dispatchEvent(new ChatModelStart($messages, $params)); + $this->dispatchEvent(new ChatModelStart($this, $messages, $params)); try { return $this->streaming ? $this->mapStreamResponse($this->client->messages()->createStreamed($params)) : $this->mapResponse($this->client->messages()->create($params)); } catch (Throwable $e) { - $this->dispatchEvent(new ChatModelError($params, $e)); + $this->dispatchEvent(new ChatModelError($this, $params, $e)); throw $e; } @@ -148,7 +150,6 @@ protected function mapResponse(CreateResponse $response): ChatResult usage: $usage, ), ), - index: 0, createdAt: new DateTimeImmutable(), finishReason: $finishReason, ); @@ -156,12 +157,12 @@ protected function mapResponse(CreateResponse $response): ChatResult $generation = $this->applyOutputParserIfApplicable($generation); $result = new ChatResult( - [$generation], + $generation, $usage, $response->toArray(), // @phpstan-ignore argument.type ); - $this->dispatchEvent(new ChatModelEnd($result)); + $this->dispatchEvent(new ChatModelEnd($this, $result)); return $result; } @@ -255,7 +256,7 @@ protected function mapStreamResponse(StreamResponse $response): ChatStreamResult collect($toolCallsSoFar) ->map(function (array $toolCall): ToolCall { try { - $arguments = json_decode($toolCall['function']['arguments'], true, flags: JSON_THROW_ON_ERROR); + $arguments = json_decode((string) $toolCall['function']['arguments'], true, flags: JSON_THROW_ON_ERROR); } catch (JsonException) { $arguments = []; } @@ -274,7 +275,8 @@ protected function mapStreamResponse(StreamResponse $response): ChatStreamResult } $chunk = new ChatGenerationChunk( - id: $messageId ?? 'unknown', + type: ChunkType::TextDelta, + id: $messageId, message: new AssistantMessage( content: $chunkDelta, toolCalls: $accumulatedToolCallsSoFar, @@ -286,8 +288,7 @@ protected function mapStreamResponse(StreamResponse $response): ChatStreamResult usage: $usage, ), ), - index: 0, - createdAt: new DateTimeImmutable(), + createdAt: new DateTimeImmutable(), // TODO finishReason: $finishReason, usage: $usage, contentSoFar: $contentSoFar, @@ -296,11 +297,7 @@ protected function mapStreamResponse(StreamResponse $response): ChatStreamResult $chunk = $this->applyOutputParserIfApplicable($chunk); - $this->dispatchEvent( - $chunk->isFinal - ? new ChatModelEnd($chunk) - : new ChatModelStream($chunk), - ); + $this->dispatchEvent(new ChatModelStream($this, $chunk)); yield $chunk; } diff --git a/src/LLM/Drivers/FakeChat.php b/src/LLM/Drivers/FakeChat.php index 6d4b0fb..5b877f2 100644 --- a/src/LLM/Drivers/FakeChat.php +++ b/src/LLM/Drivers/FakeChat.php @@ -32,7 +32,11 @@ class FakeChat extends AbstractLLM public function __construct( protected array $generations, protected bool $streaming = false, - ) {} + string $model = 'fake-model', + ModelProvider $modelProvider = ModelProvider::OpenAI, + ) { + parent::__construct($model, $modelProvider); + } /** * @return ChatResult|ChatStreamResult @@ -43,7 +47,7 @@ public function invoke( ): ChatResult|ChatStreamResult { $messages = Utils::toMessageCollection($messages)->withoutPlaceholders(); - $this->dispatchEvent(new ChatModelStart($messages, $additionalParameters)); + $this->dispatchEvent(new ChatModelStart($this, $messages, $additionalParameters)); $currentGeneration = $this->getNextGeneration(); @@ -67,9 +71,9 @@ public function invoke( $currentGeneration = $currentGeneration->cloneWithMessage($message); - $result = new ChatResult([$currentGeneration], $usage); + $result = new ChatResult($currentGeneration, $usage); - $this->dispatchEvent(new ChatModelEnd($result)); + $this->dispatchEvent(new ChatModelEnd($this, $result)); return $result; } diff --git a/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsFinishReason.php b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsFinishReason.php new file mode 100644 index 0000000..3a3a810 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsFinishReason.php @@ -0,0 +1,29 @@ + FinishReason::Stop, + 'length' => FinishReason::Length, + 'content_filter' => FinishReason::ContentFilter, + 'tool_calls' => FinishReason::ToolCalls, + default => FinishReason::Unknown, + }; + } +} diff --git a/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsMessages.php b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsMessages.php new file mode 100644 index 0000000..bbb9e32 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsMessages.php @@ -0,0 +1,102 @@ +> + */ + protected function mapMessagesForInput(MessageCollection $messages): array + { + return $messages + ->map(function (Message $message) { + if ($message instanceof ToolMessage) { + return [ + 'tool_call_id' => $message->id, + 'role' => $message->role->value, + 'content' => $message->content, + ]; + } + + if ($message instanceof AssistantMessage && $message->toolCalls?->isNotEmpty()) { + $formattedMessage = $message->toArray(); + + // Ensure the function arguments are encoded as a string + foreach ($message->toolCalls as $index => $toolCall) { + Arr::set( + $formattedMessage, + 'tool_calls.' . $index . '.function.arguments', + json_encode($toolCall->function->arguments), + ); + } + + return $formattedMessage; + } + + $formattedMessage = $message->toArray(); + + if (isset($formattedMessage['content']) && is_array($formattedMessage['content'])) { + $formattedMessage['content'] = array_map(function (mixed $content) { + if ($content instanceof ImageContent) { + $this->supportsFeatureOrFail(ModelFeature::Vision); + + return [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $content->url, + ], + ]; + } + + if ($content instanceof AudioContent) { + $this->supportsFeatureOrFail(ModelFeature::AudioInput); + + return [ + 'type' => 'input_audio', + 'input_audio' => [ + 'data' => $content->base64Data, + 'format' => $content->format, + ], + ]; + } + + return match (true) { + $content instanceof TextContent => [ + 'type' => 'text', + 'text' => $content->text, + ], + $content instanceof FileContent => [ + 'type' => 'file', + 'file' => [ + 'filename' => $content->fileName, + 'file_data' => $content->toDataUrl()->toString(), + ], + ], + default => $content, + }; + }, $formattedMessage['content']); + } + + return $formattedMessage; + }) + ->values() + ->toArray(); + } +} diff --git a/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsResponse.php b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsResponse.php new file mode 100644 index 0000000..8639443 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsResponse.php @@ -0,0 +1,63 @@ +choices[0]; + + $usage = $this->mapUsage($response->usage); + $finishReason = $this->mapFinishReason($choice->finishReason); + $meta = $response->meta(); + + $generation = new ChatGeneration( + message: new AssistantMessage( + content: $choice->message->content, + // content: [ + // new TextContent($choice->message->content), + // ], + toolCalls: $this->mapToolCalls($choice->message->toolCalls), + metadata: new ResponseMetadata( + id: $response->id ?? 'unknown', + model: $response->model, + provider: $this->modelProvider, + finishReason: $finishReason, + usage: $usage, + processingTime: $meta->openai->processingMs, + providerMetadata: $meta->toArray(), + ), + id: $response->id, + ), + createdAt: DateTimeImmutable::createFromFormat('U', (string) $response->created), + finishReason: $finishReason, + ); + + $generation = $this->applyOutputParserIfApplicable($generation); + + /** @var array|null $rawResponse */ + $rawResponse = $this->includeRaw + ? $response->toArray() + : null; + + return new ChatResult( + $generation, + $usage, + $rawResponse, // @phpstan-ignore argument.type + ); + } +} diff --git a/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsStreamResponse.php b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsStreamResponse.php new file mode 100644 index 0000000..42d2267 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsStreamResponse.php @@ -0,0 +1,276 @@ + $response + * + * @return \Cortex\LLM\Data\ChatStreamResult<\Cortex\LLM\Data\ChatGenerationChunk> + */ + protected function mapStreamResponse(StreamResponse $response): ChatStreamResult + { + return new ChatStreamResult(function () use ($response): Generator { + $contentSoFar = ''; + $toolCallsSoFar = []; + $isActiveText = false; + $finishReason = null; + $chunkType = null; + $isLastContentChunk = false; + $chatGenerationChunk = null; + + yield from $this->streamBuffer?->drain() ?? []; + + /** @var \OpenAI\Responses\Chat\CreateStreamedResponse $chunk */ + foreach ($response as $chunk) { + yield from $this->streamBuffer?->drain() ?? []; + + $usage = $this->mapUsage($chunk->usage); + + // There may not be a choice, when for example the usage is returned at the end of the stream. + if ($chunk->choices !== []) { + /** @var \OpenAI\Responses\Chat\CreateStreamedResponseChoice $choice */ + $choice = $chunk->choices[0]; + + $finishReason = $this->mapFinishReason($choice->finishReason ?? null); + + // Determine chunk type BEFORE updating tracking state + $chunkType = $this->resolveOpenAIChunkType( + $choice, + $finishReason, + $toolCallsSoFar, + $isActiveText, + $usage, + ); + + // Now update content and tool call tracking + $contentSoFar .= $choice->delta->content; + + // Track tool calls across chunks + foreach ($choice->delta->toolCalls as $toolCall) { + $index = $toolCall->index ?? 0; + + // Tool call start: OpenAI returns all information except the arguments in the first chunk + if (! isset($toolCallsSoFar[$index])) { + if ($toolCall->id !== null && $toolCall->function->name !== null) { + $toolCallsSoFar[$index] = [ + 'id' => $toolCall->id, + 'function' => [ + 'name' => $toolCall->function->name, + 'arguments' => $toolCall->function->arguments ?? '', + ], + 'hasFinished' => false, + ]; + + // Check if tool call is complete (some providers send the full tool call in one chunk) + if ($this->isParsableJson($toolCallsSoFar[$index]['function']['arguments'])) { + $toolCallsSoFar[$index]['hasFinished'] = true; + } + } + + continue; + } + + // Existing tool call, merge if not finished + if ($toolCallsSoFar[$index]['hasFinished']) { + continue; + } + + if ($toolCall->function->arguments !== '') { + $toolCallsSoFar[$index]['function']['arguments'] .= $toolCall->function->arguments; + + // Check if tool call is complete + if ($this->isParsableJson($toolCallsSoFar[$index]['function']['arguments'])) { + $toolCallsSoFar[$index]['hasFinished'] = true; + } + } + } + + $accumulatedToolCallsSoFar = $toolCallsSoFar === [] ? null : new ToolCallCollection( + collect($toolCallsSoFar) + ->map(function (array $toolCall): ToolCall { + try { + $arguments = new JsonOutputParser()->parse($toolCall['function']['arguments']); + } catch (OutputParserException) { + $arguments = []; + } + + return new ToolCall( + $toolCall['id'], + new FunctionCall( + $toolCall['function']['name'], + $arguments, + ), + ); + }) + ->values() + ->all(), + ); + + // Update isActiveText flag after determining chunk type + if ($chunkType === ChunkType::TextStart) { + $isActiveText = true; + } + + // This is the last content chunk if we have a finish reason + $isLastContentChunk = $usage !== null; + + $chunkType = $isActiveText && $isLastContentChunk + ? ChunkType::TextEnd + : $chunkType; + } elseif ($usage !== null) { + // This else case will always represent the end of the stream, + // since choices is empty and usage is present. + + // We will also correct the end of the tool call if delta is currently set. + if ($chunkType === ChunkType::ToolInputDelta) { + $chunkType = ChunkType::ToolInputEnd; + } + + // And the end of the text if delta is currently set. + if ($chunkType === ChunkType::TextDelta) { + $chunkType = ChunkType::TextEnd; + } + } + + $chatGenerationChunk = new ChatGenerationChunk( + type: $chunkType, + id: $chunk->id, + message: new AssistantMessage( + content: $choice->delta->content ?? null, + toolCalls: $accumulatedToolCallsSoFar ?? null, + metadata: new ResponseMetadata( + id: $chunk->id, + model: $this->model, + provider: $this->modelProvider, + finishReason: $finishReason, + usage: $usage, + ), + ), + createdAt: DateTimeImmutable::createFromFormat('U', (string) $chunk->created), + finishReason: $usage !== null ? $finishReason : null, + usage: $usage, + contentSoFar: $contentSoFar, + isFinal: $usage !== null, + rawChunk: $this->includeRaw ? $chunk->toArray() : null, + ); + + $chatGenerationChunk = $this->applyOutputParserIfApplicable($chatGenerationChunk); + + $this->dispatchEvent(new ChatModelStream($this, $chatGenerationChunk)); + + yield $chatGenerationChunk; + } + + yield from $this->streamBuffer?->drain() ?? []; + + if ($chatGenerationChunk !== null) { + $this->dispatchEvent(new ChatModelStreamEnd($this, $chatGenerationChunk)); + } + }); + } + + /** + * Resolve the chunk type based on OpenAI streaming patterns. + * + * @param array $toolCallsSoFar + */ + protected function resolveOpenAIChunkType( + CreateStreamedResponseChoice $choice, + ?FinishReason $finishReason, + array $toolCallsSoFar, + bool $isActiveText, + ?Usage $usage, + ): ChunkType { + // Process tool calls + foreach ($choice->delta->toolCalls as $toolCall) { + $index = $toolCall->index ?? 0; + + // Tool call start: OpenAI returns all information except the arguments in the first chunk + if (! isset($toolCallsSoFar[$index]) && ($toolCall->id !== null && $toolCall->function->name !== null)) { + // Some providers return the full tool call in one chunk, so we need to check if it's parsable JSON. + if ($this->isParsableJson($toolCall->function->arguments ?? '')) { + return ChunkType::ToolInputEnd; + } + + return ChunkType::ToolInputStart; + } + + // Existing tool call, check if it's finished + if (isset($toolCallsSoFar[$index])) { + $existingToolCall = $toolCallsSoFar[$index]; + + // Skip if already finished + if ($existingToolCall['hasFinished']) { + continue; + } + + // If we have arguments in this delta + if ($toolCall->function->arguments !== '') { + return ChunkType::ToolInputDelta; + } + } + } + + // if ($choice->delta->reasoning !== null) { + // return ChunkType::ReasoningDelta; + // } + + // Check if we have text content + if ($choice->delta->content !== null) { + // If text streaming hasn't started yet, this is text start + if (! $isActiveText) { + return ChunkType::TextStart; + } + + // Otherwise it's a text delta + return ChunkType::TextDelta; + } + + if ($finishReason === FinishReason::ToolCalls && $usage !== null) { + return ChunkType::ToolInputEnd; + } + + // Default fallback - this handles empty deltas and other cases + // If we have tool calls accumulated, empty delta is ToolInputDelta + // Otherwise, it's TextDelta (for text responses) + return $toolCallsSoFar !== [] ? ChunkType::ToolInputDelta : ChunkType::TextDelta; + } + + /** + * Check if a string is parseable JSON. + */ + protected function isParsableJson(string $value): bool + { + if ($value === '') { + return false; + } + + return json_validate($value); + } +} diff --git a/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsToolCalls.php b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsToolCalls.php new file mode 100644 index 0000000..e6e91ca --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsToolCalls.php @@ -0,0 +1,39 @@ + $toolCalls + */ + protected function mapToolCalls(array $toolCalls): ?ToolCallCollection + { + if ($toolCalls === []) { + return null; + } + + return new ToolCallCollection( + collect($toolCalls) + ->map(fn(CreateResponseToolCall $toolCall): ToolCall => new ToolCall( + $toolCall->id, + new FunctionCall( + $toolCall->function->name, + json_decode($toolCall->function->arguments, true, flags: JSON_THROW_ON_ERROR), + ), + )) + ->values() + ->all(), + ); + } +} diff --git a/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsUsage.php b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsUsage.php new file mode 100644 index 0000000..88088f6 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Chat/Concerns/MapsUsage.php @@ -0,0 +1,31 @@ +promptTokens, + completionTokens: $usage->completionTokens, + cachedTokens: $usage->promptTokensDetails?->cachedTokens, + totalTokens: $usage->totalTokens, + inputCost: $this->modelProvider->inputCostForTokens($this->model, $usage->promptTokens), + outputCost: $this->modelProvider->outputCostForTokens($this->model, $usage->completionTokens), + ); + } +} diff --git a/src/LLM/Drivers/OpenAI/Chat/OpenAIChat.php b/src/LLM/Drivers/OpenAI/Chat/OpenAIChat.php new file mode 100644 index 0000000..7c1e072 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Chat/OpenAIChat.php @@ -0,0 +1,184 @@ +resolveMessages($messages); + + $params = $this->buildParams([ + ...$additionalParameters, + 'messages' => $this->mapMessagesForInput($messages), + ]); + + $this->dispatchEvent(new ChatModelStart($this, $messages, $params)); + + try { + $result = $this->streaming + ? $this->mapStreamResponse($this->client->chat()->createStreamed($params)) + : $this->mapResponse($this->client->chat()->create($params)); + } catch (Throwable $e) { + $this->dispatchEvent(new ChatModelError($this, $params, $e)); + + throw $e; + } + + if (! $this->streaming) { + $this->dispatchEvent(new ChatModelEnd($this, $result)); + } + + return $result; + } + + /** + * @param array $additionalParameters + * + * @return array + */ + protected function buildParams(array $additionalParameters): array + { + $params = [ + 'model' => $this->model, + ]; + + if ($this->structuredOutputConfig !== null) { + $this->supportsFeatureOrFail(ModelFeature::StructuredOutput); + + $schema = $this->structuredOutputConfig->schema; + $params['response_format'] = [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => $this->structuredOutputConfig->name, + 'description' => $this->structuredOutputConfig->description ?? $schema->getDescription(), + 'schema' => $schema->additionalProperties(false)->toArray(), + 'strict' => $this->structuredOutputConfig->strict, + ], + ]; + } elseif ($this->forceJsonOutput) { + $params['response_format'] = [ + 'type' => 'json_object', + ]; + } + + if ($this->toolConfig !== null) { + $this->supportsFeatureOrFail(ModelFeature::ToolCalling); + + if (is_string($this->toolConfig->toolChoice)) { + $toolChoice = [ + 'type' => 'function', + 'function' => [ + 'name' => $this->toolConfig->toolChoice, + ], + ]; + } else { + $toolChoice = $this->toolConfig->toolChoice->value; + } + + $params['tool_choice'] = $toolChoice; + $params['parallel_tool_calls'] = $this->toolConfig->allowParallelToolCalls; + $params['tools'] = collect($this->toolConfig->tools) + ->map(fn(Tool $tool): array => [ + 'type' => 'function', + 'function' => $tool->format(), + ]) + ->toArray(); + } + + // Ensure the usage information is returned when streaming + if ($this->streaming) { + $params['stream_options'] = [ + 'include_usage' => true, + ]; + } + + $allParams = [ + ...$params, + ...$this->parameters, + ...$additionalParameters, + ]; + + if ($this->modelProvider === ModelProvider::OpenAI) { + if (array_key_exists('max_tokens', $allParams)) { + // `max_tokens` is deprecated in favour of `max_completion_tokens` + // https://platform.openai.com/docs/api-reference/chat/create#chat-create-max_tokens + $allParams['max_completion_tokens'] = $allParams['max_tokens']; + unset($allParams['max_tokens']); + } + + if (array_key_exists('temperature', $allParams) && $allParams['temperature'] === null) { + unset($allParams['temperature']); + } + + if (array_key_exists('top_p', $allParams) && $allParams['top_p'] === null) { + unset($allParams['top_p']); + } + } + + return $allParams; + } + + public function getClient(): ClientContract + { + return $this->client; + } + + /** + * @param array|\OpenAI\Responses\StreamResponse<\OpenAI\Responses\Chat\CreateStreamedResponse>|string> $responses + * + * @phpstan-ignore-next-line + */ + public static function fake(array $responses, ?string $model = null, ?ModelProvider $modelProvider = null): self + { + $client = new ClientFake($responses); + + return new self( + $client, + $model ?? CreateResponseFixture::ATTRIBUTES['model'], + $modelProvider ?? ModelProvider::OpenAI, + ); + } +} diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsFinishReason.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsFinishReason.php new file mode 100644 index 0000000..d3ea723 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsFinishReason.php @@ -0,0 +1,27 @@ + FinishReason::Stop, + 'failed' => FinishReason::Error, + default => FinishReason::Unknown, + }; + } +} diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsMessages.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsMessages.php new file mode 100644 index 0000000..d885db7 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsMessages.php @@ -0,0 +1,147 @@ +> + */ + protected function mapMessagesForInput(MessageCollection $messages): array + { + return $messages + ->map(function (Message $message): array { + // Handle ToolMessage specifically for Responses API + if ($message instanceof ToolMessage) { + return [ + 'role' => $message->role->value, + 'content' => $message->content(), + 'tool_call_id' => $message->id, + ]; + } + + // Handle AssistantMessage with tool calls + if ($message instanceof AssistantMessage && $message->toolCalls?->isNotEmpty()) { + $baseMessage = [ + 'role' => $message->role->value, + ]; + + // Add content if present + if ($message->content() !== null) { + $baseMessage['content'] = $message->content(); + } + + // Add tool calls + $baseMessage['tool_calls'] = $message->toolCalls->map(function (ToolCall $toolCall): array { + return [ + 'id' => $toolCall->id, + 'type' => 'function', + 'function' => [ + 'name' => $toolCall->function->name, + 'arguments' => json_encode($toolCall->function->arguments), + ], + ]; + })->toArray(); + + return $baseMessage; + } + + // Handle all other message types + $formattedMessage = [ + 'role' => $message->role()->value, + 'content' => $this->mapContentForInput($message->content()), + ]; + + return $formattedMessage; + }) + ->values() + ->toArray(); + } + + /** + * Map content to the OpenAI Responses API format. + * + * @param string|array|null $content + * + * @return array> + */ + protected function mapContentForInput(string|array|null $content): array + { + if ($content === null) { + return []; + } + + if (is_string($content)) { + return [ + [ + 'type' => 'input_text', + 'text' => $content, + ], + ]; + } + + return array_map(function (mixed $item): array { + if ($item instanceof ImageContent) { + $this->supportsFeatureOrFail(ModelFeature::Vision); + + return [ + 'type' => 'input_image', + 'image_url' => $item->url, + 'detail' => 'auto', // Default detail level + ]; + } + + if ($item instanceof AudioContent) { + $this->supportsFeatureOrFail(ModelFeature::AudioInput); + + return [ + 'type' => 'input_audio', + 'data' => $item->base64Data, + 'format' => $item->format, + ]; + } + + if ($item instanceof FileContent) { + return [ + 'type' => 'input_file', + 'file_id' => $item->fileName, // Assuming file_id should be the fileName + ]; + } + + if ($item instanceof TextContent) { + return [ + 'type' => 'input_text', + 'text' => $item->text ?? '', + ]; + } + + // Handle ReasoningContent and ToolContent + if ($item instanceof ReasoningContent) { + return [ + 'type' => 'input_text', + 'text' => $item->reasoning, + ]; + } + + // Fallback for unknown content types + return []; + }, $content); + } +} diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsResponse.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsResponse.php new file mode 100644 index 0000000..a381a91 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsResponse.php @@ -0,0 +1,108 @@ +output); + $usage = $this->mapUsage($response->usage); + $finishReason = $this->mapFinishReason($response->status); + + /** @var \OpenAI\Responses\Responses\Output\OutputMessage $outputMessage */ + $outputMessage = $output->filter(fn(ResponseContract $item): bool => $item instanceof OutputMessage)->first(); + + $outputMessageContent = collect($outputMessage->content); + + if ($outputMessageContent->contains(fn(ResponseContract $item): bool => $item instanceof OutputMessageContentRefusal)) { + throw new LLMException('LLM refusal: ' . $outputMessageContent->first()->refusal); + } + + /** @var \OpenAI\Responses\Responses\Output\OutputMessageContentOutputText $textContent */ + $textContent = $outputMessageContent + ->filter(fn(ResponseContract $item): bool => $item instanceof OutputMessageContentOutputText) + ->first(); + + // TODO: Ignore other provider specific tool calls + // and only support function tool calls for now + $toolCalls = $output + ->filter(fn(ResponseContract $item): bool => $item instanceof OutputFunctionToolCall) + ->map(fn(OutputFunctionToolCall $toolCall): ToolCall => new ToolCall( + $toolCall->id, + new FunctionCall( + $toolCall->name, + json_decode($toolCall->arguments, true, flags: JSON_THROW_ON_ERROR), + ), + )) + ->values() + ->all(); + + $reasoningContent = $output + ->filter(fn(ResponseContract $item): bool => $item instanceof OutputReasoning) + ->map(fn(OutputReasoning $reasoning): ReasoningContent => new ReasoningContent( + $reasoning->id, + Arr::first($reasoning->summary)->text ?? '', + )) + ->all(); + + $generation = new ChatGeneration( + message: new AssistantMessage( + content: [ + ...$reasoningContent, + new TextContent($textContent->text), + ], + toolCalls: $toolCalls !== [] ? new ToolCallCollection($toolCalls) : null, + metadata: new ResponseMetadata( + id: $response->id, + model: $response->model, + provider: $this->modelProvider, + finishReason: $finishReason, + usage: $usage, + ), + id: $outputMessage->id, + ), + createdAt: DateTimeImmutable::createFromFormat('U', (string) $response->createdAt), + finishReason: $finishReason, + ); + + $generation = $this->applyOutputParserIfApplicable($generation); + + /** @var array|null $rawResponse */ + $rawResponse = $this->includeRaw + ? $response->toArray() + : null; + + return new ChatResult( + $generation, + $usage, + $rawResponse, // @phpstan-ignore argument.type + ); + } +} diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsStreamResponse.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsStreamResponse.php new file mode 100644 index 0000000..4cfd444 --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsStreamResponse.php @@ -0,0 +1,368 @@ + $response + * + * @return \Cortex\LLM\Data\ChatStreamResult<\Cortex\LLM\Data\ChatGenerationChunk> + */ + protected function mapStreamResponse(StreamResponse $response): ChatStreamResult + { + return new ChatStreamResult(function () use ($response): Generator { + $contentSoFar = ''; + $toolCallsSoFar = []; + $reasoningSoFar = []; + $reasoningTextSoFar = []; + $responseId = null; + $responseModel = null; + $responseCreatedAt = null; + $responseUsage = null; + $responseStatus = null; + $messageId = null; + $chatGenerationChunk = null; + $isNewToolCall = false; + + yield from $this->streamBuffer?->drain() ?? []; + + /** @var \OpenAI\Responses\Responses\CreateStreamedResponse $streamChunk */ + foreach ($response as $streamChunk) { + yield from $this->streamBuffer?->drain() ?? []; + $event = $streamChunk->event; + $data = $streamChunk->response; + + // Track response-level metadata + if ($data instanceof CreateResponse) { + $responseId = $data->id; + $responseModel = $data->model; + $responseCreatedAt = $data->createdAt; + $responseStatus = $data->status; + + if ($data->usage !== null) { + $responseUsage = $this->mapUsage($data->usage); + } + + // Extract tool calls from the completed response if present + // This ensures tool calls are included in the final chunk + foreach ($data->output as $outputItem) { + if ($outputItem instanceof OutputFunctionToolCall) { + $toolCallsSoFar[$outputItem->id] = [ + 'id' => $outputItem->id, + 'function' => [ + 'name' => $outputItem->name, + 'arguments' => $outputItem->arguments ?? '', + ], + ]; + + // If we have tool calls but no message ID yet, use the response ID as fallback + if ($messageId === null) { + $messageId = $responseId; + } + } + } + } + + // Handle output items (message, tool calls, reasoning) + if ($data instanceof OutputItem) { + $item = $data->item; + + // Track message ID when we encounter a message item + if ($item instanceof OutputMessage) { + $messageId = $item->id; + } + + // Track function tool calls - this indicates tool call start + if ($item instanceof OutputFunctionToolCall) { + $isNewToolCall = ! isset($toolCallsSoFar[$item->id]); + $toolCallsSoFar[$item->id] = [ + 'id' => $item->id, + 'function' => [ + 'name' => $item->name, + 'arguments' => $item->arguments ?? '', + ], + ]; + + // If we have tool calls but no message ID yet, use the response ID as fallback + // This handles cases where Responses API returns only tool calls without a message item + if ($messageId === null && $responseId !== null) { + $messageId = $responseId; + } + } + + // Track reasoning blocks + if ($item instanceof OutputReasoning) { + $reasoningSoFar[$item->id] = [ + 'id' => $item->id, + 'summary' => '', + ]; + } + } + + // Handle text deltas + $currentDelta = null; + + if ($data instanceof OutputTextDelta) { + $currentDelta = $data->delta; + $contentSoFar .= $currentDelta; + } + + // Handle function call arguments deltas + if ($data instanceof FunctionCallArgumentsDelta) { + $itemId = $data->itemId; + + if (isset($toolCallsSoFar[$itemId])) { + $toolCallsSoFar[$itemId]['function']['arguments'] .= $data->delta; + } + } + + // Handle reasoning summary text deltas + if ($data instanceof ReasoningSummaryTextDelta) { + $itemId = $data->itemId; + + if (isset($reasoningSoFar[$itemId])) { + $reasoningSoFar[$itemId]['summary'] .= $data->delta; + } + } + + // Handle reasoning text deltas (full reasoning content, not just summary) + if ($data instanceof ReasoningTextDelta) { + $itemId = $data->itemId; + + if (! isset($reasoningTextSoFar[$itemId])) { + $reasoningTextSoFar[$itemId] = ''; + } + + $reasoningTextSoFar[$itemId] .= $data->delta; + } + + // Build accumulated tool calls + $accumulatedToolCalls = null; + + if ($toolCallsSoFar !== []) { + $accumulatedToolCalls = new ToolCallCollection( + collect($toolCallsSoFar) + ->map(function (array $toolCall): ToolCall { + try { + $arguments = json_decode($toolCall['function']['arguments'], true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $arguments = []; + } + + return new ToolCall( + $toolCall['id'], + new FunctionCall( + $toolCall['function']['name'], + $arguments, + ), + ); + }) + ->values() + ->all(), + ); + } + + // Determine finish reason + $finishReason = $this->mapFinishReason($responseStatus); + $isFinal = in_array($event, [ + 'response.completed', + 'response.failed', + 'response.incomplete', + ], true); + + // Determine chunk type + // For output_item.added events, check if it's a new tool call to determine chunk type + $chunkType = $this->resolveResponsesChunkType( + $event, + $currentDelta, + $contentSoFar, + $finishReason, + $event === 'response.output_item.added' && $isNewToolCall && $data instanceof OutputItem && $data->item instanceof OutputFunctionToolCall, + ); + + /** @var array|null $rawChunk */ + $rawChunk = $this->includeRaw + ? $streamChunk->toArray() + : null; + + // Determine content for message - use delta for text deltas, null otherwise (matching Chat API pattern) + // The accumulated content is tracked in contentSoFar, not in message->content + $messageContent = $data instanceof OutputTextDelta ? $currentDelta : null; + + $chatGenerationChunk = new ChatGenerationChunk( + type: $chunkType, + id: $responseId, + message: new AssistantMessage( + content: $messageContent, + toolCalls: $accumulatedToolCalls, + metadata: new ResponseMetadata( + id: $responseId, + model: $responseModel ?? $this->model, + provider: $this->modelProvider, + finishReason: $finishReason, + usage: $responseUsage, + ), + id: $messageId, + ), + createdAt: $responseCreatedAt !== null + ? DateTimeImmutable::createFromFormat('U', (string) $responseCreatedAt) + : new DateTimeImmutable(), + finishReason: $finishReason, + usage: $responseUsage, + contentSoFar: $contentSoFar, + isFinal: $isFinal, + rawChunk: $rawChunk, + ); + + $chatGenerationChunk = $this->applyOutputParserIfApplicable($chatGenerationChunk); + + $this->dispatchEvent(new ChatModelStream($this, $chatGenerationChunk)); + + yield $chatGenerationChunk; + + // Reset tool call flag after processing + $isNewToolCall = false; + } + + yield from $this->streamBuffer?->drain() ?? []; + + $this->dispatchEvent(new ChatModelStreamEnd($this, $chatGenerationChunk)); + }); + } + + /** + * Resolve the chunk type based on OpenAI Responses API streaming events. + * + * The Responses API uses structured event types that make chunk type detection + * more straightforward than raw delta analysis. This method maps event types + * to the appropriate ChunkType enum values. + */ + protected function resolveResponsesChunkType( + string $event, + ?string $currentDelta, + string $contentSoFar, + ?FinishReason $finishReason, + bool $isNewToolCall = false, + ): ChunkType { + // Handle error events + if ($event === 'error') { + return ChunkType::Error; + } + + // Final chunks based on response status + if ($finishReason !== null) { + return match ($finishReason) { + FinishReason::ToolCalls => ChunkType::ToolInputEnd, + default => ChunkType::Done, + }; + } + + // Map event types to chunk types + return match ($event) { + // Response lifecycle events + 'response.created' => ChunkType::MessageStart, + 'response.in_progress' => ChunkType::MessageStart, + 'response.completed', 'response.failed', 'response.incomplete' => ChunkType::Done, + + // Output item events + // When a function call is added, it's the start of tool input + 'response.output_item.added' => $isNewToolCall ? ChunkType::ToolInputStart : ChunkType::MessageStart, + 'response.output_item.done' => ChunkType::MessageEnd, + + // Content part events + 'response.content_part.added' => ChunkType::TextStart, + 'response.content_part.done' => ChunkType::TextEnd, + + // Text delta events + 'response.output_text.delta' => $contentSoFar === $currentDelta ? ChunkType::TextStart : ChunkType::TextDelta, + 'response.output_text.done' => ChunkType::TextEnd, + 'response.output_text.annotation.added' => ChunkType::TextDelta, // Annotation is part of text + + // Tool call events + 'response.function_call_arguments.delta' => ChunkType::ToolInputDelta, + 'response.function_call_arguments.done' => ChunkType::ToolInputEnd, + + // Reasoning events - summary + 'response.reasoning_summary_part.added' => ChunkType::ReasoningStart, + 'response.reasoning_summary_part.done' => ChunkType::ReasoningEnd, + 'response.reasoning_summary_text.delta' => ChunkType::ReasoningDelta, + 'response.reasoning_summary_text.done' => ChunkType::ReasoningEnd, + + // Reasoning events - full text + 'response.reasoning_text.delta' => ChunkType::ReasoningDelta, + 'response.reasoning_text.done' => ChunkType::ReasoningEnd, + + // Refusal events + 'response.refusal.delta' => ChunkType::TextDelta, + 'response.refusal.done' => ChunkType::Done, + + // Tool-specific events (file search, web search, code interpreter, etc.) + // These are treated as tool calls, map to appropriate chunk types + 'response.file_search_call.in_progress', + 'response.file_search_call.searching' => ChunkType::ToolInputDelta, + 'response.file_search_call.completed' => ChunkType::ToolInputEnd, + + 'response.web_search_call.in_progress', + 'response.web_search_call.searching' => ChunkType::ToolInputDelta, + 'response.web_search_call.completed' => ChunkType::ToolInputEnd, + + 'response.code_interpreter_call.in_progress', + 'response.code_interpreter_call.running', + 'response.code_interpreter_call.interpreting' => ChunkType::ToolInputDelta, + 'response.code_interpreter_call.completed' => ChunkType::ToolInputEnd, + 'response.code_interpreter_call_code.delta' => ChunkType::ToolInputDelta, + 'response.code_interpreter_call_code.done' => ChunkType::ToolInputEnd, + + // MCP (Model Context Protocol) events - treat as tool calls + 'response.mcp_list_tools.in_progress', + 'response.mcp_list_tools.failed', + 'response.mcp_list_tools.completed' => ChunkType::ToolInputDelta, + 'response.mcp_call.in_progress', + 'response.mcp_call.failed', + 'response.mcp_call.completed' => ChunkType::ToolInputDelta, + 'response.mcp_call.arguments.delta', + 'response.mcp_call_arguments.delta' => ChunkType::ToolInputDelta, + 'response.mcp_call.arguments.done', + 'response.mcp_call_arguments.done' => ChunkType::ToolInputEnd, + + // Image generation events - treat as tool output + 'response.image_generation_call.in_progress', + 'response.image_generation_call.generating', + 'response.image_generation_call.completed', + 'response.image_generation_call.partial_image' => ChunkType::ToolOutputEnd, + + default => ChunkType::Custom, + }; + } +} diff --git a/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsUsage.php b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsUsage.php new file mode 100644 index 0000000..9cf59af --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Responses/Concerns/MapsUsage.php @@ -0,0 +1,32 @@ +inputTokens, + completionTokens: $usage->outputTokens, + cachedTokens: $usage->inputTokensDetails->cachedTokens, + reasoningTokens: $usage->outputTokensDetails->reasoningTokens, + totalTokens: $usage->totalTokens, + inputCost: $this->modelProvider->inputCostForTokens($this->model, $usage->inputTokens), + outputCost: $this->modelProvider->outputCostForTokens($this->model, $usage->outputTokens), + ); + } +} diff --git a/src/LLM/Drivers/OpenAI/Responses/OpenAIResponses.php b/src/LLM/Drivers/OpenAI/Responses/OpenAIResponses.php new file mode 100644 index 0000000..4f6a7cb --- /dev/null +++ b/src/LLM/Drivers/OpenAI/Responses/OpenAIResponses.php @@ -0,0 +1,172 @@ +resolveMessages($messages); + + $params = $this->buildParams([ + ...$additionalParameters, + 'input' => $this->mapMessagesForInput($messages), + ]); + + $this->dispatchEvent(new ChatModelStart($this, $messages, $params)); + + try { + $result = $this->streaming + ? $this->mapStreamResponse($this->client->responses()->createStreamed($params)) + : $this->mapResponse($this->client->responses()->create($params)); + } catch (Throwable $e) { + $this->dispatchEvent(new ChatModelError($this, $params, $e)); + + throw $e; + } + + if (! $this->streaming) { + $this->dispatchEvent(new ChatModelEnd($this, $result)); + } + + return $result; + } + + /** + * @param array $additionalParameters + * + * @return array + */ + protected function buildParams(array $additionalParameters): array + { + $params = [ + 'model' => $this->model, + ]; + + if ($this->structuredOutputConfig !== null) { + $this->supportsFeatureOrFail(ModelFeature::StructuredOutput); + + $schema = $this->structuredOutputConfig->schema; + $params['text']['format'] = [ + 'type' => 'json_schema', + 'name' => $this->structuredOutputConfig->name, + 'description' => $this->structuredOutputConfig->description ?? $schema->getDescription(), + 'schema' => $schema->additionalProperties(false)->toArray(), + 'strict' => $this->structuredOutputConfig->strict, + ]; + } elseif ($this->forceJsonOutput) { + $params['text']['format'] = [ + 'type' => 'json_object', + ]; + } + + if ($this->toolConfig !== null) { + $this->supportsFeatureOrFail(ModelFeature::ToolCalling); + + if (is_string($this->toolConfig->toolChoice)) { + $toolChoice = [ + 'type' => 'function', + 'function' => [ + 'name' => $this->toolConfig->toolChoice, + ], + ]; + } else { + $toolChoice = $this->toolConfig->toolChoice->value; + } + + $params['tool_choice'] = $toolChoice; + $params['parallel_tool_calls'] = $this->toolConfig->allowParallelToolCalls; + $params['tools'] = collect($this->toolConfig->tools) + ->map(fn(Tool $tool): array => [ + 'type' => 'function', + 'function' => $tool->format(), + ]) + ->toArray(); + } + + $allParams = [ + ...$params, + ...$this->parameters, + ...$additionalParameters, + ]; + + if ($this->modelProvider === ModelProvider::OpenAI) { + if (array_key_exists('max_tokens', $allParams)) { + // Backwards compatibility for `max_tokens` + $allParams['max_output_tokens'] = $allParams['max_tokens']; + unset($allParams['max_tokens']); + } + + if (array_key_exists('temperature', $allParams) && $allParams['temperature'] === null) { + unset($allParams['temperature']); + } + + if (array_key_exists('top_p', $allParams) && $allParams['top_p'] === null) { + unset($allParams['top_p']); + } + } + + return $allParams; + } + + public function getClient(): ClientContract + { + return $this->client; + } + + /** + * @param array|\OpenAI\Responses\StreamResponse<\OpenAI\Responses\Chat\CreateStreamedResponse>|string> $responses + * + * @phpstan-ignore-next-line + */ + public static function fake(array $responses, ?string $model = null, ?ModelProvider $modelProvider = null): self + { + $client = new ClientFake($responses); + + return new self( + $client, + $model ?? CreateResponseFixture::ATTRIBUTES['model'], + $modelProvider ?? ModelProvider::OpenAI, + ); + } +} diff --git a/src/LLM/Drivers/OpenAIChat.php b/src/LLM/Drivers/OpenAIChat.php deleted file mode 100644 index 8bae2d7..0000000 --- a/src/LLM/Drivers/OpenAIChat.php +++ /dev/null @@ -1,462 +0,0 @@ -resolveMessages($messages); - - $params = $this->buildParams([ - ...$additionalParameters, - 'messages' => $this->mapMessagesForInput($messages), - ]); - - $this->dispatchEvent(new ChatModelStart($messages, $params)); - - try { - return $this->streaming - ? $this->mapStreamResponse($this->client->chat()->createStreamed($params)) - : $this->mapResponse($this->client->chat()->create($params)); - } catch (Throwable $e) { - $this->dispatchEvent(new ChatModelError($params, $e)); - - throw $e; - } - } - - /** - * Map a standard (non-streaming) response to a ChatResult. - */ - protected function mapResponse(CreateResponse $response): ChatResult - { - $choice = $response->choices[0]; - $toolCalls = $choice->message->toolCalls === [] ? null : new ToolCallCollection( - collect($choice->message->toolCalls) - ->map(fn(CreateResponseToolCall $toolCall): ToolCall => new ToolCall( - $toolCall->id, - new FunctionCall( - $toolCall->function->name, - json_decode($toolCall->function->arguments, true, flags: JSON_THROW_ON_ERROR), - ), - )) - ->values() - ->all(), - ); - - $usage = $this->mapUsage($response->usage); - $finishReason = static::mapFinishReason($choice->finishReason ?? null); - - $generations = collect($response->choices) - ->map(function (CreateResponseChoice $choice) use ($toolCalls, $finishReason, $usage, $response): ChatGeneration { - $generation = new ChatGeneration( - message: new AssistantMessage( - content: [ - new TextContent($choice->message->content), - ], - toolCalls: $toolCalls, - metadata: new ResponseMetadata( - id: $response->id, - model: $response->model, - provider: $this->modelProvider, - finishReason: $finishReason, - usage: $usage, - ), - ), - index: $choice->index, - createdAt: DateTimeImmutable::createFromFormat('U', (string) $response->created), - finishReason: $finishReason, - ); - - return $this->applyOutputParserIfApplicable($generation); - }) - ->all(); - - $result = new ChatResult( - $generations, - $usage, - $response->toArray(), // @phpstan-ignore argument.type - ); - - $this->dispatchEvent(new ChatModelEnd($result)); - - return $result; - } - - /** - * Map a streaming response to a ChatStreamResult. - * - * @param StreamResponse<\OpenAI\Responses\Chat\CreateStreamedResponse> $response - * - * @return ChatStreamResult - */ - protected function mapStreamResponse(StreamResponse $response): ChatStreamResult - { - return new ChatStreamResult(function () use ($response): Generator { - $contentSoFar = ''; - $toolCallsSoFar = []; - - /** @var \OpenAI\Responses\Chat\CreateStreamedResponse $chunk */ - foreach ($response as $chunk) { - // Grab the usage if available - $usage = $chunk->usage !== null - ? $this->mapUsage($chunk->usage) - : null; - - // There may not be a choice, when for example the usage is returned at the end of the stream. - if ($chunk->choices !== []) { - // we only handle a single choice for now when streaming - $choice = $chunk->choices[0]; - $contentSoFar .= $choice->delta->content; - - // Track tool calls across chunks - foreach ($choice->delta->toolCalls as $toolCall) { - // If this chunk has an ID and name, it's the start of a new tool call - if ($toolCall->id !== null && $toolCall->function->name !== null) { - $toolCallsSoFar[] = [ - 'id' => $toolCall->id, - 'function' => [ - 'name' => $toolCall->function->name, - 'arguments' => $toolCall->function->arguments, - ], - ]; - } - // If it has arguments, it belongs to the last tool call - elseif ($toolCall->function->arguments !== '' && $toolCallsSoFar !== []) { - $lastIndex = count($toolCallsSoFar) - 1; - $toolCallsSoFar[$lastIndex]['function']['arguments'] .= $toolCall->function->arguments; - } - } - - $accumulatedToolCallsSoFar = $toolCallsSoFar === [] ? null : new ToolCallCollection( - collect($toolCallsSoFar) - ->map(function (array $toolCall): ToolCall { - try { - $arguments = (new JsonOutputParser())->parse($toolCall['function']['arguments']); - } catch (OutputParserException) { - $arguments = []; - } - - return new ToolCall( - $toolCall['id'], - new FunctionCall( - $toolCall['function']['name'], - $arguments, - ), - ); - }) - ->values() - ->all(), - ); - } - - $finishReason = static::mapFinishReason($choice->finishReason ?? null); - - $chunk = new ChatGenerationChunk( - id: $chunk->id, - message: new AssistantMessage( - content: $choice->delta->content ?? null, - toolCalls: $accumulatedToolCallsSoFar ?? null, - metadata: new ResponseMetadata( - id: $chunk->id, - model: $this->model, - provider: $this->modelProvider, - finishReason: $finishReason, - usage: $usage, - ), - ), - index: $choice->index ?? 0, - createdAt: DateTimeImmutable::createFromFormat('U', (string) $chunk->created), - finishReason: $finishReason, - usage: $usage, - contentSoFar: $contentSoFar, - isFinal: $finishReason !== null, - ); - - $chunk = $this->applyOutputParserIfApplicable($chunk); - - $this->dispatchEvent( - $chunk->isFinal - ? new ChatModelEnd($chunk) - : new ChatModelStream($chunk), - ); - - yield $chunk; - } - }); - } - - /** - * Map the OpenAI usage response to a Usage object. - */ - protected function mapUsage(CreateResponseUsage $usage): Usage - { - return new Usage( - promptTokens: $usage->promptTokens, - completionTokens: $usage->completionTokens, - cachedTokens: $usage->promptTokensDetails?->cachedTokens, - totalTokens: $usage->totalTokens, - inputCost: $this->modelProvider->inputCostForTokens($this->model, $usage->promptTokens), - outputCost: $this->modelProvider->outputCostForTokens($this->model, $usage->completionTokens), - ); - } - - /** - * Take the given messages and format them for the OpenAI API. - * - * @return array> - */ - protected function mapMessagesForInput(MessageCollection $messages): array - { - return $messages - ->map(function (Message $message) { - if ($message instanceof ToolMessage) { - return [ - 'tool_call_id' => $message->id, - 'role' => $message->role->value, - 'content' => $message->content, - ]; - } - - if ($message instanceof AssistantMessage && $message->toolCalls?->isNotEmpty()) { - $formattedMessage = $message->toArray(); - - // Ensure the function arguments are encoded as a string - foreach ($message->toolCalls as $index => $toolCall) { - Arr::set( - $formattedMessage, - 'tool_calls.' . $index . '.function.arguments', - json_encode($toolCall->function->arguments), - ); - } - - return $formattedMessage; - } - - $formattedMessage = $message->toArray(); - - if (isset($formattedMessage['content']) && is_array($formattedMessage['content'])) { - $formattedMessage['content'] = array_map(function (mixed $content) { - if ($content instanceof ImageContent) { - $this->supportsFeatureOrFail(ModelFeature::Vision); - - return [ - 'type' => 'image_url', - 'image_url' => [ - 'url' => $content->url, - ], - ]; - } - - if ($content instanceof AudioContent) { - $this->supportsFeatureOrFail(ModelFeature::AudioInput); - - return [ - 'type' => 'input_audio', - 'input_audio' => [ - 'data' => $content->base64Data, - 'format' => $content->format, - ], - ]; - } - - return match (true) { - $content instanceof TextContent => [ - 'type' => 'text', - 'text' => $content->text, - ], - $content instanceof FileContent => [ - 'type' => 'file', - 'file' => [ - 'filename' => $content->fileName, - 'file_data' => $content->toDataUrl()->toString(), - ], - ], - default => $content, - }; - }, $formattedMessage['content']); - } - - return $formattedMessage; - }) - ->values() - ->toArray(); - } - - protected static function mapFinishReason(?string $finishReason): ?FinishReason - { - if ($finishReason === null) { - return null; - } - - return match ($finishReason) { - 'stop' => FinishReason::Stop, - 'length' => FinishReason::Length, - 'content_filter' => FinishReason::ContentFilter, - 'tool_calls' => FinishReason::ToolCalls, - default => FinishReason::Unknown, - }; - } - - /** - * @param array $additionalParameters - * - * @return array - */ - protected function buildParams(array $additionalParameters): array - { - $params = [ - 'model' => $this->model, - ]; - - if ($this->structuredOutputConfig !== null) { - $this->supportsFeatureOrFail(ModelFeature::StructuredOutput); - - $schema = $this->structuredOutputConfig->schema; - $params['response_format'] = [ - 'type' => 'json_schema', - 'json_schema' => [ - 'name' => $this->structuredOutputConfig->name, - 'description' => $this->structuredOutputConfig->description ?? $schema->getDescription(), - 'schema' => $schema->additionalProperties(false)->toArray(), - 'strict' => $this->structuredOutputConfig->strict, - ], - ]; - } elseif ($this->forceJsonOutput) { - $params['response_format'] = [ - 'type' => 'json_object', - ]; - } - - if ($this->toolConfig !== null) { - $this->supportsFeatureOrFail(ModelFeature::ToolCalling); - - if (is_string($this->toolConfig->toolChoice)) { - $toolChoice = [ - 'type' => 'function', - 'function' => [ - 'name' => $this->toolConfig->toolChoice, - ], - ]; - } else { - $toolChoice = $this->toolConfig->toolChoice->value; - } - - $params['tool_choice'] = $toolChoice; - $params['parallel_tool_calls'] = $this->toolConfig->allowParallelToolCalls; - $params['tools'] = collect($this->toolConfig->tools) - ->map(fn(Tool $tool): array => [ - 'type' => 'function', - 'function' => $tool->format(), - ]) - ->toArray(); - } - - // Ensure the usage information is returned when streaming - if ($this->streaming) { - $params['stream_options'] = [ - 'include_usage' => true, - ]; - } - - $allParams = [ - ...$params, - ...$this->parameters, - ...$additionalParameters, - ]; - - if ($this->modelProvider === ModelProvider::OpenAI) { - if (array_key_exists('max_tokens', $allParams)) { - // `max_tokens` is deprecated in favour of `max_completion_tokens` - // https://platform.openai.com/docs/api-reference/chat/create#chat-create-max_tokens - $allParams['max_completion_tokens'] = $allParams['max_tokens']; - unset($allParams['max_tokens']); - } - - if (array_key_exists('temperature', $allParams) && $allParams['temperature'] === null) { - unset($allParams['temperature']); - } - - if (array_key_exists('top_p', $allParams) && $allParams['top_p'] === null) { - unset($allParams['top_p']); - } - } - - return $allParams; - } - - public function getClient(): ClientContract - { - return $this->client; - } - - /** - * @param array|\OpenAI\Responses\StreamResponse<\OpenAI\Responses\Chat\CreateStreamedResponse>|string> $responses - * - * @phpstan-ignore-next-line - */ - public static function fake(array $responses, ?string $model = null, ?ModelProvider $modelProvider = null): self - { - $client = new ClientFake($responses); - - return new self( - $client, - $model ?? CreateResponseFixture::ATTRIBUTES['model'], - $modelProvider ?? ModelProvider::OpenAI, - ); - } -} diff --git a/src/LLM/Drivers/OpenAIResponses.php b/src/LLM/Drivers/OpenAIResponses.php deleted file mode 100644 index 67e6bc7..0000000 --- a/src/LLM/Drivers/OpenAIResponses.php +++ /dev/null @@ -1,532 +0,0 @@ -resolveMessages($messages); - - $params = $this->buildParams([ - ...$additionalParameters, - 'input' => $this->mapMessagesForInput($messages), - ]); - - $this->dispatchEvent(new ChatModelStart($messages, $params)); - - try { - return $this->streaming - ? $this->mapStreamResponse($this->client->responses()->createStreamed($params)) - : $this->mapResponse($this->client->responses()->create($params)); - } catch (Throwable $e) { - $this->dispatchEvent(new ChatModelError($params, $e)); - - throw $e; - } - } - - /** - * Map a standard (non-streaming) response to a ChatResult. - */ - protected function mapResponse(CreateResponse $response): ChatResult - { - $output = collect($response->output); - $usage = $this->mapUsage($response->usage); - $finishReason = static::mapFinishReason($response->status); - - /** @var \OpenAI\Responses\Responses\Output\OutputMessage $outputMessage */ - $outputMessage = $output->filter(fn(ResponseContract $item): bool => $item instanceof OutputMessage)->first(); - - $outputMessageContent = collect($outputMessage->content); - - if ($outputMessageContent->contains(fn(ResponseContract $item): bool => $item instanceof OutputMessageContentRefusal)) { - throw new LLMException('LLM refusal: ' . $outputMessageContent->first()->refusal); - } - - /** @var \OpenAI\Responses\Responses\Output\OutputMessageContentOutputText $textContent */ - $textContent = $outputMessageContent - ->filter(fn(ResponseContract $item): bool => $item instanceof OutputMessageContentOutputText) - ->first(); - - // TODO: Ignore other provider specific tool calls - // and only support function tool calls for now - $toolCalls = $output - ->filter(fn(ResponseContract $item): bool => $item instanceof OutputFunctionToolCall) - ->map(fn(OutputFunctionToolCall $toolCall): ToolCall => new ToolCall( - $toolCall->id, - new FunctionCall( - $toolCall->name, - json_decode($toolCall->arguments, true, flags: JSON_THROW_ON_ERROR), - ), - )) - ->all(); - - $reasoningContent = $output - ->filter(fn(ResponseContract $item): bool => $item instanceof OutputReasoning) - ->map(fn(OutputReasoning $reasoning): ReasoningContent => new ReasoningContent( - $reasoning->id, - Arr::first($reasoning->summary)->text ?? '', - )) - ->all(); - - $generation = new ChatGeneration( - message: new AssistantMessage( - content: [ - ...$reasoningContent, - new TextContent($textContent->text), - ], - toolCalls: new ToolCallCollection($toolCalls), - metadata: new ResponseMetadata( - id: $response->id, - model: $response->model, - provider: $this->modelProvider, - finishReason: $finishReason, - usage: $usage, - ), - id: $outputMessage->id, - ), - index: 0, - createdAt: DateTimeImmutable::createFromFormat('U', (string) $response->createdAt), - finishReason: $finishReason, - ); - - $generation = $this->applyOutputParserIfApplicable($generation); - - /** @var array $rawResponse */ - $rawResponse = $response->toArray(); - - $result = new ChatResult( - [$generation], - $usage, - $rawResponse, // @phpstan-ignore argument.type - ); - - $this->dispatchEvent(new ChatModelEnd($result)); - - return $result; - } - - /** - * Map a streaming response to a ChatStreamResult. - * - * @param StreamResponse<\OpenAI\Responses\Responses\CreateStreamedResponse> $response - * - * @return \Cortex\LLM\Data\ChatStreamResult<\Cortex\LLM\Data\ChatGenerationChunk> - */ - protected function mapStreamResponse(StreamResponse $response): ChatStreamResult - { - throw new Exception('Not implemented'); - // return new ChatStreamResult(function () use ($response): Generator { - // $contentSoFar = ''; - // $toolCallsSoFar = []; - - // /** @var \OpenAI\Responses\Responses\CreateStreamedResponse $chunk */ - // foreach ($response as $chunk) { - // // Grab the usage if available - // $usage = $chunk->usage !== null - // ? $this->mapUsage($chunk->usage) - // : null; - - // // There may not be a choice, when for example the usage is returned at the end of the stream. - // if ($chunk->choices !== []) { - // // we only handle a single choice for now when streaming - // $choice = $chunk->choices[0]; - // $contentSoFar .= $choice->delta->content; - - // // Track tool calls across chunks - // foreach ($choice->delta->toolCalls as $toolCall) { - // // If this chunk has an ID and name, it's the start of a new tool call - // if ($toolCall->id !== null && $toolCall->function->name !== null) { - // $toolCallsSoFar[] = [ - // 'id' => $toolCall->id, - // 'function' => [ - // 'name' => $toolCall->function->name, - // 'arguments' => $toolCall->function->arguments, - // ], - // ]; - // } - // // If it has arguments, it belongs to the last tool call - // elseif ($toolCall->function->arguments !== '' && $toolCallsSoFar !== []) { - // $lastIndex = count($toolCallsSoFar) - 1; - // $toolCallsSoFar[$lastIndex]['function']['arguments'] .= $toolCall->function->arguments; - // } - // } - - // $accumulatedToolCallsSoFar = $toolCallsSoFar === [] ? null : new ToolCallCollection( - // collect($toolCallsSoFar) - // ->map(function (array $toolCall): ToolCall { - // try { - // $arguments = (new JsonOutputParser())->parse($toolCall['function']['arguments']); - // } catch (OutputParserException) { - // $arguments = []; - // } - - // return new ToolCall( - // $toolCall['id'], - // new FunctionCall( - // $toolCall['function']['name'], - // $arguments, - // ), - // ); - // }) - // ->values() - // ->all(), - // ); - // } - - // $finishReason = static::mapFinishReason($choice->finishReason ?? null); - - // $chunk = new ChatGenerationChunk( - // id: $chunk->id, - // message: new AssistantMessage( - // content: $choice->delta->content ?? null, - // toolCalls: $accumulatedToolCallsSoFar ?? null, - // metadata: new ResponseMetadata( - // id: $chunk->id, - // model: $this->model, - // provider: $this->modelProvider, - // finishReason: $finishReason, - // usage: $usage, - // ), - // ), - // index: $choice->index ?? 0, - // createdAt: DateTimeImmutable::createFromFormat('U', (string) $chunk->created), - // finishReason: $finishReason, - // usage: $usage, - // contentSoFar: $contentSoFar, - // isFinal: $finishReason !== null, - // ); - - // $chunk = $this->applyOutputParserIfApplicable($chunk); - - // $this->dispatchEvent( - // $chunk->isFinal - // ? new ChatModelEnd($chunk) - // : new ChatModelStream($chunk), - // ); - - // yield $chunk; - // } - // }); - } - - /** - * Map the OpenAI usage response to a Usage object. - */ - protected function mapUsage(CreateResponseUsage $usage): Usage - { - return new Usage( - promptTokens: $usage->inputTokens, - completionTokens: $usage->outputTokens, - cachedTokens: $usage->inputTokensDetails->cachedTokens, - reasoningTokens: $usage->outputTokensDetails->reasoningTokens, - totalTokens: $usage->totalTokens, - inputCost: $this->modelProvider->inputCostForTokens($this->model, $usage->inputTokens), - outputCost: $this->modelProvider->outputCostForTokens($this->model, $usage->outputTokens), - ); - } - - /** - * Take the given messages and format them for the OpenAI Responses API. - * - * @return array> - */ - protected function mapMessagesForInput(MessageCollection $messages): array - { - return $messages - ->map(function (Message $message): array { - // Handle ToolMessage specifically for Responses API - if ($message instanceof ToolMessage) { - return [ - 'role' => $message->role->value, - 'content' => $this->mapContentForResponsesAPI($message->content()), - 'tool_call_id' => $message->id, - ]; - } - - // Handle AssistantMessage with tool calls - if ($message instanceof AssistantMessage && $message->toolCalls?->isNotEmpty()) { - $baseMessage = [ - 'role' => $message->role->value, - ]; - - // Add content if present - if ($message->content() !== null) { - $baseMessage['content'] = $this->mapContentForResponsesAPI($message->content()); - } - - // Add tool calls - $baseMessage['tool_calls'] = $message->toolCalls->map(function (ToolCall $toolCall): array { - return [ - 'id' => $toolCall->id, - 'type' => 'function', - 'function' => [ - 'name' => $toolCall->function->name, - 'arguments' => json_encode($toolCall->function->arguments), - ], - ]; - })->toArray(); - - return $baseMessage; - } - - // Handle all other message types - $formattedMessage = [ - 'role' => $message->role()->value, - 'content' => $this->mapContentForResponsesAPI($message->content()), - ]; - - return $formattedMessage; - }) - ->values() - ->toArray(); - } - - /** - * Map content to the OpenAI Responses API format. - * - * @param string|array|null $content - * - * @return array> - */ - protected function mapContentForResponsesAPI(string|array|null $content): array - { - if ($content === null) { - return []; - } - - if (is_string($content)) { - return [ - [ - 'type' => 'input_text', - 'text' => $content, - ], - ]; - } - - return array_map(function (mixed $item): array { - if ($item instanceof ImageContent) { - $this->supportsFeatureOrFail(ModelFeature::Vision); - - return [ - 'type' => 'input_image', - 'image_url' => $item->url, - 'detail' => 'auto', // Default detail level - ]; - } - - if ($item instanceof AudioContent) { - $this->supportsFeatureOrFail(ModelFeature::AudioInput); - - return [ - 'type' => 'input_audio', - 'data' => $item->base64Data, - 'format' => $item->format, - ]; - } - - if ($item instanceof FileContent) { - return [ - 'type' => 'input_file', - 'file_id' => $item->fileName, // Assuming file_id should be the fileName - ]; - } - - if ($item instanceof TextContent) { - return [ - 'type' => 'input_text', - 'text' => $item->text ?? '', - ]; - } - - // Handle ReasoningContent and ToolContent - if ($item instanceof ReasoningContent) { - return [ - 'type' => 'input_text', - 'text' => $item->reasoning, - ]; - } - - // Fallback for unknown content types - return []; - }, $content); - } - - protected static function mapFinishReason(?string $finishReason): ?FinishReason - { - if ($finishReason === null) { - return null; - } - - return match ($finishReason) { - 'stop' => FinishReason::Stop, - 'length' => FinishReason::Length, - 'content_filter' => FinishReason::ContentFilter, - 'tool_calls' => FinishReason::ToolCalls, - default => FinishReason::Unknown, - }; - } - - /** - * @param array $additionalParameters - * - * @return array - */ - protected function buildParams(array $additionalParameters): array - { - $params = [ - 'model' => $this->model, - ]; - - if ($this->structuredOutputConfig !== null) { - $this->supportsFeatureOrFail(ModelFeature::StructuredOutput); - - $schema = $this->structuredOutputConfig->schema; - $params['response_format'] = [ - 'type' => 'json_schema', - 'json_schema' => [ - 'name' => $this->structuredOutputConfig->name, - 'description' => $this->structuredOutputConfig->description ?? $schema->getDescription(), - 'schema' => $schema->additionalProperties(false)->toArray(), - 'strict' => $this->structuredOutputConfig->strict, - ], - ]; - } elseif ($this->forceJsonOutput) { - $params['response_format'] = [ - 'type' => 'json_object', - ]; - } - - if ($this->toolConfig !== null) { - $this->supportsFeatureOrFail(ModelFeature::ToolCalling); - - if (is_string($this->toolConfig->toolChoice)) { - $toolChoice = [ - 'type' => 'function', - 'function' => [ - 'name' => $this->toolConfig->toolChoice, - ], - ]; - } else { - $toolChoice = $this->toolConfig->toolChoice->value; - } - - $params['tool_choice'] = $toolChoice; - $params['parallel_tool_calls'] = $this->toolConfig->allowParallelToolCalls; - $params['tools'] = collect($this->toolConfig->tools) - ->map(fn(Tool $tool): array => [ - 'type' => 'function', - 'function' => $tool->format(), - ]) - ->toArray(); - } - - // Ensure the usage information is returned when streaming - if ($this->streaming) { - $params['stream_options'] = [ - 'include_usage' => true, - ]; - } - - $allParams = [ - ...$params, - ...$this->parameters, - ...$additionalParameters, - ]; - - if ($this->modelProvider === ModelProvider::OpenAI) { - if (array_key_exists('max_tokens', $allParams)) { - // Backwards compatibility for `max_tokens` - $allParams['max_output_tokens'] = $allParams['max_tokens']; - unset($allParams['max_tokens']); - } - - if (array_key_exists('temperature', $allParams) && $allParams['temperature'] === null) { - unset($allParams['temperature']); - } - - if (array_key_exists('top_p', $allParams) && $allParams['top_p'] === null) { - unset($allParams['top_p']); - } - } - - return $allParams; - } - - public function getClient(): ClientContract - { - return $this->client; - } - - /** - * @param array|\OpenAI\Responses\StreamResponse<\OpenAI\Responses\Chat\CreateStreamedResponse>|string> $responses - * - * @phpstan-ignore-next-line - */ - public static function fake(array $responses, ?string $model = null, ?ModelProvider $modelProvider = null): self - { - $client = new ClientFake($responses); - - return new self( - $client, - $model ?? CreateResponseFixture::ATTRIBUTES['model'], - $modelProvider ?? ModelProvider::OpenAI, - ); - } -} diff --git a/src/LLM/Enums/ChunkType.php b/src/LLM/Enums/ChunkType.php new file mode 100644 index 0000000..c3ea25c --- /dev/null +++ b/src/LLM/Enums/ChunkType.php @@ -0,0 +1,129 @@ + true, + self::TextEnd => true, + default => false, + }; + } + + public function isOperational(): bool + { + return in_array($this, [ + self::RunStart, + self::RunEnd, + self::StepStart, + self::StepEnd, + ], true); + } + + public function isStart(): bool + { + return match ($this) { + self::MessageStart, + self::TextStart, + self::ReasoningStart, + self::ToolInputStart, + self::RunStart, + self::ChatModelStart, + self::OutputParserStart, + self::StepStart => true, + default => false, + }; + } + + public function isEnd(): bool + { + return match ($this) { + self::MessageEnd, + self::TextEnd, + self::ReasoningEnd, + self::ToolInputEnd, + self::ToolOutputEnd, + self::RunEnd, + self::ChatModelEnd, + self::OutputParserEnd, + self::StepEnd => true, + default => false, + }; + } +} diff --git a/src/LLM/Enums/LLMDriver.php b/src/LLM/Enums/LLMDriver.php new file mode 100644 index 0000000..febb6ce --- /dev/null +++ b/src/LLM/Enums/LLMDriver.php @@ -0,0 +1,23 @@ +driver($name); } + /** + * Override the driver method to always return a cloned instance. + * This ensures that each consumer gets its own independent LLM instance + * and prevents mutations from affecting other consumers. + * + * @param string|null $driver + * + * @throws \InvalidArgumentException + */ + #[Override] + public function driver($driver = null): LLM // @pest-ignore-type + { + /** @var LLM $instance */ + $instance = parent::driver($driver); + + return clone $instance; + } + /** * @param string $driver This is actually the name of the LLM provider. * @@ -48,6 +67,10 @@ protected function createDriver($driver): LLM // @pest-ignore-type $config = $this->config->get('cortex.llm.' . $name, []); $driver = $config['driver']; + $driver = $driver instanceof LLMDriver + ? $driver->value + : $driver; + if (isset($this->customCreators[$driver])) { return $this->callCustomCreator($config); } @@ -66,18 +89,19 @@ protected function createDriver($driver): LLM // @pest-ignore-type * * @param array{default_model?: string, model_provider?: string, default_parameters: array, options: array{api_key?: string, organization?: string, base_uri?: string, headers?: array, query_params?: array}} $config */ - public function createOpenAIDriver(array $config, string $name): OpenAIChat|CacheDecorator + public function createOpenAIChatDriver(array $config, string $name): OpenAIChat { $driver = new OpenAIChat( $this->buildOpenAIClient($config), $config['default_model'], $this->getModelProviderFromConfig($config, $name), + $this->config->get('cortex.model_info.ignore_features', false), ); $driver->withParameters(Arr::get($config, 'default_parameters', [])); $driver->setEventDispatcher(new IlluminateEventDispatcherBridge($this->container->make('events'))); - return $this->getCacheDecorator($driver) ?? $driver; + return $driver; } /** @@ -91,12 +115,13 @@ public function createOpenAIResponsesDriver(array $config, string $name): OpenAI $this->buildOpenAIClient($config), $config['default_model'], $this->getModelProviderFromConfig($config, $name), + $this->config->get('cortex.model_info.ignore_features', false), ); $driver->withParameters(Arr::get($config, 'default_parameters', [])); $driver->setEventDispatcher(new IlluminateEventDispatcherBridge($this->container->make('events'))); - return $this->getCacheDecorator($driver) ?? $driver; + return $driver; } /** @@ -104,10 +129,10 @@ public function createOpenAIResponsesDriver(array $config, string $name): OpenAI * * @param array{default_model?: string, model_provider?: string, default_parameters: array, options: array{api_key?: string, organization?: string, base_uri?: string, headers?: array, query_params?: array}} $config */ - public function createAnthropicDriver(array $config, string $name): AnthropicChat|CacheDecorator + public function createAnthropicDriver(array $config, string $name): AnthropicChat { $client = Anthropic::factory() - ->withApiKey(Arr::get($config, 'options.api_key', '')) + ->withApiKey(Arr::get($config, 'options.api_key') ?? '') ->withHttpHeader('anthropic-version', Arr::get($config, 'options.version', '2023-06-01')); foreach (Arr::get($config, 'options.headers', []) as $key => $value) { @@ -130,6 +155,7 @@ public function createAnthropicDriver(array $config, string $name): AnthropicCha $client->make(), $config['default_model'], $this->getModelProviderFromConfig($config, $name), + $this->config->get('cortex.model_info.ignore_features', false), ); $driver->withParameters( @@ -137,7 +163,7 @@ public function createAnthropicDriver(array $config, string $name): AnthropicCha ); $driver->setEventDispatcher(new IlluminateEventDispatcherBridge($this->container->make('events'))); - return $this->getCacheDecorator($driver) ?? $driver; + return $driver; } /** @@ -145,12 +171,16 @@ public function createAnthropicDriver(array $config, string $name): AnthropicCha */ protected function buildOpenAIClient(array $config): ClientContract { - $client = OpenAI::factory()->withApiKey(Arr::get($config, 'options.api_key', '')); + $client = OpenAI::factory()->withApiKey(Arr::get($config, 'options.api_key') ?? ''); if ($organization = Arr::get($config, 'options.organization')) { $client->withOrganization($organization); } + if ($project = Arr::get($config, 'options.project')) { + $client->withProject($project); + } + if ($baseUri = Arr::get($config, 'options.base_uri')) { $client->withBaseUri($baseUri); } @@ -183,17 +213,4 @@ protected function getModelProviderFromConfig(array $config, string $name): Mode return $modelProvider; } - - protected function getCacheDecorator(LLM $llm): ?CacheDecorator - { - if ($this->config->get('cortex.cache.enabled')) { - return new CacheDecorator( - $llm, - $this->container->make('cache')->store($this->config->get('cortex.cache.store')), - $this->config->get('cortex.cache.ttl'), - ); - } - - return null; - } } diff --git a/src/LLM/Streaming/AgUiDataStream.php b/src/LLM/Streaming/AgUiDataStream.php new file mode 100644 index 0000000..875c783 --- /dev/null +++ b/src/LLM/Streaming/AgUiDataStream.php @@ -0,0 +1,301 @@ +mapChunkToEvents($chunk); + + foreach ($events as $event) { + $payload = Js::encode($event); + + echo 'event: message' . "\n"; + echo 'data: ' . $payload . "\n\n"; + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } + } + + // Send final events if needed + if ($this->messageStarted) { + $this->sendEvent([ + 'type' => 'TextMessageEnd', + 'messageId' => $this->currentMessageId, + 'timestamp' => now()->toIso8601String(), + ]); + } + + if ($this->runStarted) { + $this->sendEvent([ + 'type' => 'RunFinished', + 'runId' => $this->runId, + 'threadId' => $this->threadId, + 'timestamp' => now()->toIso8601String(), + ]); + } + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + }; + } + + public function mapChunkToPayload(ChatGenerationChunk $chunk): array + { + // For compatibility with the interface, return the first event + $events = $this->mapChunkToEvents($chunk); + + return $events[0] ?? []; + } + + /** + * Map a ChatGenerationChunk to one or more AG-UI events. + * + * @return array> + */ + protected function mapChunkToEvents(ChatGenerationChunk $chunk): array + { + $events = []; + $timestamp = $chunk->createdAt->format('c'); + + // Handle lifecycle events + if ($chunk->type === ChunkType::MessageStart) { + // Start run if not started + if (! $this->runStarted) { + // Use chunk ID as basis for run and thread IDs + $this->runId = 'run_' . $chunk->id; + $this->threadId = 'thread_' . $chunk->id; + + $events[] = [ + 'type' => 'RunStarted', + 'runId' => $this->runId, + 'threadId' => $this->threadId, + 'timestamp' => $timestamp, + ]; + $this->runStarted = true; + } + + // Start text message + $this->currentMessageId = $chunk->message->metadata?->id ?? $chunk->id; + $events[] = [ + 'type' => 'TextMessageStart', + 'messageId' => $this->currentMessageId, + 'role' => 'assistant', + 'timestamp' => $timestamp, + ]; + $this->messageStarted = true; + } + + // Handle text content + if ($chunk->type === ChunkType::TextDelta && $chunk->message->content !== null && $chunk->message->content !== '') { + $events[] = [ + 'type' => 'TextMessageContent', + 'messageId' => $this->currentMessageId ?? ($chunk->message->metadata?->id ?? $chunk->id), + 'delta' => $chunk->message->content, + 'timestamp' => $timestamp, + ]; + } + + // Handle text end + if ($chunk->type === ChunkType::TextEnd) { + $events[] = [ + 'type' => 'TextMessageEnd', + 'messageId' => $this->currentMessageId ?? ($chunk->message->metadata?->id ?? $chunk->id), + 'timestamp' => $timestamp, + ]; + $this->messageStarted = false; + } + + // Handle reasoning content (using draft reasoning events) + if ($chunk->type === ChunkType::ReasoningStart) { + $reasoningId = $chunk->message->metadata?->id ?? $chunk->id; + $events[] = [ + 'type' => 'ReasoningStart', + 'messageId' => $reasoningId, + 'timestamp' => $timestamp, + ]; + } + + if ($chunk->type === ChunkType::ReasoningDelta && $chunk->message->content !== null && $chunk->message->content !== '') { + $events[] = [ + 'type' => 'ReasoningMessageContent', + 'messageId' => $chunk->message->metadata?->id ?? $chunk->id, + 'delta' => $chunk->message->content, + 'timestamp' => $timestamp, + ]; + } + + if ($chunk->type === ChunkType::ReasoningEnd) { + $events[] = [ + 'type' => 'ReasoningEnd', + 'messageId' => $chunk->message->metadata?->id ?? $chunk->id, + 'timestamp' => $timestamp, + ]; + } + + // Handle tool calls + if ($chunk->type === ChunkType::ToolInputStart && $chunk->message->toolCalls !== null) { + foreach ($chunk->message->toolCalls as $toolCall) { + $events[] = [ + 'type' => 'ToolCallStart', + 'toolCallId' => $toolCall->id, + 'toolName' => $toolCall->function->name, + 'timestamp' => $timestamp, + ]; + } + } + + if ($chunk->type === ChunkType::ToolInputDelta && $chunk->message->toolCalls !== null) { + foreach ($chunk->message->toolCalls as $toolCall) { + $events[] = [ + 'type' => 'ToolCallContent', + 'toolCallId' => $toolCall->id, + 'delta' => json_encode($toolCall->function->arguments), + 'timestamp' => $timestamp, + ]; + } + } + + if ($chunk->type === ChunkType::ToolInputEnd && $chunk->message->toolCalls !== null) { + foreach ($chunk->message->toolCalls as $toolCall) { + $events[] = [ + 'type' => 'ToolCallEnd', + 'toolCallId' => $toolCall->id, + 'timestamp' => $timestamp, + ]; + } + } + + // Handle tool output + if ($chunk->type === ChunkType::ToolOutputEnd && $chunk->message->toolCalls !== null) { + foreach ($chunk->message->toolCalls as $toolCall) { + $events[] = [ + 'type' => 'ToolCallResult', + 'toolCallId' => $toolCall->id, + 'result' => $toolCall->result ?? null, + 'timestamp' => $timestamp, + ]; + } + } + + // Handle step events + if ($chunk->type === ChunkType::StepStart) { + $events[] = [ + 'type' => 'StepStarted', + 'stepName' => 'step_' . $chunk->id, + 'timestamp' => $timestamp, + ]; + } + + if ($chunk->type === ChunkType::StepEnd) { + $events[] = [ + 'type' => 'StepFinished', + 'stepName' => 'step_' . $chunk->id, + 'timestamp' => $timestamp, + ]; + } + + // Handle errors + if ($chunk->type === ChunkType::Error) { + $events[] = [ + 'type' => 'RunError', + 'message' => $chunk->message->content ?? 'An error occurred', + 'timestamp' => $timestamp, + ]; + $this->runStarted = false; + $this->messageStarted = false; + } + + // Handle final chunk + if ($chunk->isFinal && $chunk->type === ChunkType::MessageEnd) { + // End message if it was started + if ($this->messageStarted) { + $events[] = [ + 'type' => 'TextMessageEnd', + 'messageId' => $this->currentMessageId ?? ($chunk->message->metadata?->id ?? $chunk->id), + 'timestamp' => $timestamp, + ]; + $this->messageStarted = false; + } + + // End run + if ($this->runStarted) { + $event = [ + 'type' => 'RunFinished', + 'runId' => $this->runId, + 'threadId' => $this->threadId, + 'timestamp' => $timestamp, + ]; + + // Add usage as result if available + if ($chunk->usage !== null) { + $event['result'] = [ + 'usage' => $chunk->usage->toArray(), + ]; + } + + $events[] = $event; + $this->runStarted = false; + } + } + + return $events; + } + + /** + * Send a single event to the output stream. + * + * @param array $event + */ + protected function sendEvent(array $event): void + { + $payload = Js::encode($event); + + echo 'event: message' . "\n"; + echo 'data: ' . $payload . "\n\n"; + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } +} diff --git a/src/LLM/Streaming/DefaultDataStream.php b/src/LLM/Streaming/DefaultDataStream.php new file mode 100644 index 0000000..754da47 --- /dev/null +++ b/src/LLM/Streaming/DefaultDataStream.php @@ -0,0 +1,55 @@ +mapChunkToPayload($chunk)); + + echo 'data: ' . $payload; + echo "\n\n"; + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } + + echo '[DONE]'; + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + }; + } + + public function mapChunkToPayload(ChatGenerationChunk $chunk): array + { + return [ + 'id' => $chunk->id, + 'type' => $chunk->type->value, + 'content' => $chunk->message->content, + 'finish_reason' => $chunk->finishReason?->value, + 'created_at' => $chunk->createdAt->getTimestamp(), + ]; + } +} diff --git a/src/LLM/Streaming/VercelDataStream.php b/src/LLM/Streaming/VercelDataStream.php new file mode 100644 index 0000000..109ee1c --- /dev/null +++ b/src/LLM/Streaming/VercelDataStream.php @@ -0,0 +1,128 @@ +mapChunkToPayload($chunk)); + + echo 'data: ' . $payload; + echo "\n\n"; + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } + + echo '[DONE]'; + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + }; + } + + public function mapChunkToPayload(ChatGenerationChunk $chunk): array + { + $payload = [ + 'type' => $this->mapChunkTypeToVercelType($chunk->type), + ]; + + // Add messageId for message start events (per Vercel protocol) + if ($chunk->type === ChunkType::MessageStart) { + $payload['messageId'] = $chunk->id; + } + + // Add unique ID for text/reasoning blocks + if (in_array($chunk->type, [ + ChunkType::TextStart, + ChunkType::TextDelta, + ChunkType::TextEnd, + ChunkType::ReasoningStart, + ChunkType::ReasoningDelta, + ChunkType::ReasoningEnd, + ], true)) { + // Use message ID as the block identifier + $payload['id'] = $chunk->message->metadata?->id ?? $chunk->id; + } + + // Add delta content for incremental updates + if (in_array($chunk->type, [ChunkType::TextDelta, ChunkType::ReasoningDelta], true)) { + $payload['delta'] = $chunk->message->content; + } + + // Add full message content for other types + if (! isset($payload['delta']) && $chunk->message->content !== null) { + $payload['content'] = $chunk->message->content; + } + + // Add tool calls if present + if ($chunk->message->toolCalls !== null && $chunk->message->toolCalls->isNotEmpty()) { + $payload['toolCalls'] = $chunk->message->toolCalls->toArray(); + } + + // Add usage information if available + if ($chunk->usage !== null) { + $payload['usage'] = $chunk->usage->toArray(); + } + + // Add finish reason if final chunk + if ($chunk->isFinal && $chunk->finishReason !== null) { + $payload['finishReason'] = $chunk->finishReason->value; + } + + return $payload; + } + + protected function mapChunkTypeToVercelType(ChunkType $type): string + { + return match ($type) { + ChunkType::MessageStart => 'start', + ChunkType::MessageEnd => 'finish', + + ChunkType::TextStart => 'text-start', + ChunkType::TextDelta => 'text-delta', + ChunkType::TextEnd => 'text-end', + + ChunkType::ReasoningStart => 'reasoning-start', + ChunkType::ReasoningDelta => 'reasoning-delta', + ChunkType::ReasoningEnd => 'reasoning-end', + + ChunkType::SourceDocument => 'source-document', + ChunkType::File => 'file', + + ChunkType::ToolInputStart => 'tool-input-start', + ChunkType::ToolInputDelta => 'tool-input-delta', + ChunkType::ToolInputEnd => 'tool-input-available', + ChunkType::ToolOutputEnd => 'tool-output-available', + + ChunkType::StepStart => 'start-step', + ChunkType::StepEnd => 'finish-step', + + ChunkType::Error => 'error', + + default => $type->value, + }; + } +} diff --git a/src/LLM/Streaming/VercelTextStream.php b/src/LLM/Streaming/VercelTextStream.php new file mode 100644 index 0000000..aa5fd80 --- /dev/null +++ b/src/LLM/Streaming/VercelTextStream.php @@ -0,0 +1,66 @@ +shouldOutputChunk($chunk) && $chunk->message->content !== null && $chunk->message->content !== '') { + echo $chunk->message->content; + + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } + } + }; + } + + public function mapChunkToPayload(ChatGenerationChunk $chunk): array + { + // For text stream, we don't use JSON payloads + // Return minimal structure for interface compatibility + return [ + 'content' => $chunk->message->content ?? '', + ]; + } + + /** + * Determine if this chunk should be output to the stream. + * Only text and reasoning deltas are output. + */ + protected function shouldOutputChunk(ChatGenerationChunk $chunk): bool + { + return in_array($chunk->type, [ + ChunkType::TextDelta, + ChunkType::ReasoningDelta, + ], true); + } +} diff --git a/src/Memory/ChatMemory.php b/src/Memory/ChatMemory.php index 7e67b30..138855b 100644 --- a/src/Memory/ChatMemory.php +++ b/src/Memory/ChatMemory.php @@ -4,31 +4,38 @@ namespace Cortex\Memory; -use Cortex\Contracts\Memory; +use Illuminate\Support\Str; use Cortex\LLM\Contracts\Message; use Cortex\Memory\Contracts\Store; use Cortex\Memory\Stores\InMemoryStore; use Cortex\LLM\Data\Messages\MessageCollection; +use Cortex\Contracts\ChatMemory as ChatMemoryContract; -class ChatMemory implements Memory +class ChatMemory implements ChatMemoryContract { /** * @var array */ protected array $variables = []; + protected Store $store; + /** - * @param Store $store The store to use for message persistence + * @param Store|null $store The store to use for message persistence (must have threadId) * @param int|null $limit The maximum number of messages to return */ public function __construct( - protected Store $store = new InMemoryStore(), + ?Store $store = null, protected ?int $limit = null, - ) {} + ) { + // Create store if not provided (with auto-generated threadId) + if ($store === null) { + $store = new InMemoryStore(threadId: Str::uuid7()->toString()); + } + + $this->store = $store; + } - /** - * Add a message to the memory. - */ public function addMessage(Message $message): void { $this->store->addMessage($message); @@ -44,9 +51,6 @@ public function addMessages(MessageCollection|array $messages): void $this->store->addMessages($messages); } - /** - * Get the messages from memory. - */ public function getMessages(): MessageCollection { $messages = $this->store->getMessages()->replaceVariables($this->variables); @@ -58,6 +62,13 @@ public function getMessages(): MessageCollection return $messages; } + public function setMessages(MessageCollection $messages): static + { + $this->store->setMessages($messages); + + return $this; + } + /** * @param array $variables */ @@ -83,4 +94,9 @@ public function reset(): void $this->store->reset(); $this->setVariables([]); } + + public function getThreadId(): string + { + return $this->store->getThreadId(); + } } diff --git a/src/Memory/ChatSummaryMemory.php b/src/Memory/ChatSummaryMemory.php deleted file mode 100644 index 3141ae8..0000000 --- a/src/Memory/ChatSummaryMemory.php +++ /dev/null @@ -1,108 +0,0 @@ - - */ - protected array $variables = []; - - /** - * @param LLM $llm The LLM to use for summarization - * @param Store $store The store to use for message persistence - * @param int|null $summariseAfter The number of messages after which to summarize - */ - public function __construct( - protected LLM $llm, - protected Store $store = new InMemoryStore(), - protected ?int $summariseAfter = null, - ) {} - - /** - * Get the prompt for summarising the chat. - */ - public function prompt(): ChatPromptTemplate - { - return new ChatPromptTemplate([ - new MessagePlaceholder('history'), - new UserMessage('Distill the above chat messages into a single summary message. Include as many specific details as you can.'), - ]); - } - - /** - * Add a message to the memory. - */ - public function addMessage(Message $message): void - { - $this->store->addMessage($message); - } - - /** - * Add multiple messages to the memory. - * - * @param \Cortex\LLM\Data\Messages\MessageCollection|array $messages - */ - public function addMessages(MessageCollection|array $messages): void - { - $this->store->addMessages($messages); - } - - /** - * Get the messages from memory. - */ - public function getMessages(): MessageCollection - { - $messages = $this->store->getMessages(); - - if ($messages->count() <= $this->summariseAfter) { - return $messages; - } - - /** @var \Cortex\LLM\Data\ChatResult $result */ - $result = $this->prompt()->pipe($this->llm)->invoke([ - 'history' => $messages, - ]); - - return new MessageCollection([$result->generation->message]); - } - - /** - * @param array $variables - */ - public function setVariables(array $variables): static - { - $this->variables = $variables; - - return $this; - } - - /** - * Get the variables from the memory. - * - * @return array - */ - public function getVariables(): array - { - return $this->variables; - } - - public function reset(): void - { - $this->store->reset(); - $this->setVariables([]); - } -} diff --git a/src/Memory/Contracts/Store.php b/src/Memory/Contracts/Store.php index c4192ae..5b77698 100644 --- a/src/Memory/Contracts/Store.php +++ b/src/Memory/Contracts/Store.php @@ -26,8 +26,18 @@ public function addMessage(Message $message): void; */ public function addMessages(MessageCollection|array $messages): void; + /** + * Set the messages in the store. + */ + public function setMessages(MessageCollection $messages): void; + /** * Reset the store. */ public function reset(): void; + + /** + * Get the thread ID for this store. + */ + public function getThreadId(): string; } diff --git a/src/Memory/Stores/CacheStore.php b/src/Memory/Stores/CacheStore.php index 4016153..21e335b 100644 --- a/src/Memory/Stores/CacheStore.php +++ b/src/Memory/Stores/CacheStore.php @@ -13,19 +13,29 @@ class CacheStore implements Store { /** * @param CacheInterface $cache The PSR-16 cache implementation - * @param string $key The cache key to store messages under + * @param string $threadId The thread ID to scope messages to a specific thread (required, immutable) + * @param string $key The cache key prefix to store messages under * @param int|null $ttl Optional TTL in seconds for the cache */ public function __construct( protected CacheInterface $cache, + protected string $threadId, protected string $key = 'cortex:memory:messages', protected ?int $ttl = null, ) {} + /** + * Get the cache key with thread ID. + */ + protected function getCacheKey(): string + { + return $this->key . ':thread:' . $this->threadId; + } + public function getMessages(): MessageCollection { /** @var MessageCollection|null $messages */ - $messages = $this->cache->get($this->key); + $messages = $this->cache->get($this->getCacheKey()); return $messages ?? new MessageCollection(); } @@ -35,7 +45,7 @@ public function addMessage(Message $message): void $messages = $this->getMessages(); $messages->add($message); - $this->cache->set($this->key, $messages, $this->ttl); + $this->cache->set($this->getCacheKey(), $messages, $this->ttl); } public function addMessages(MessageCollection|array $messages): void @@ -43,11 +53,21 @@ public function addMessages(MessageCollection|array $messages): void $existingMessages = $this->getMessages(); $existingMessages->merge($messages); - $this->cache->set($this->key, $existingMessages, $this->ttl); + $this->cache->set($this->getCacheKey(), $existingMessages, $this->ttl); + } + + public function setMessages(MessageCollection $messages): void + { + $this->cache->set($this->getCacheKey(), $messages, $this->ttl); } public function reset(): void { - $this->cache->delete($this->key); + $this->cache->delete($this->getCacheKey()); + } + + public function getThreadId(): string + { + return $this->threadId; } } diff --git a/src/Memory/Stores/InMemoryStore.php b/src/Memory/Stores/InMemoryStore.php index b889d74..77237f0 100644 --- a/src/Memory/Stores/InMemoryStore.php +++ b/src/Memory/Stores/InMemoryStore.php @@ -11,6 +11,7 @@ class InMemoryStore implements Store { public function __construct( + protected string $threadId, protected MessageCollection $messages = new MessageCollection(), ) {} @@ -29,8 +30,18 @@ public function addMessages(MessageCollection|array $messages): void $this->messages->merge($messages); } + public function setMessages(MessageCollection $messages): void + { + $this->messages = $messages; + } + public function reset(): void { $this->messages = new MessageCollection(); } + + public function getThreadId(): string + { + return $this->threadId; + } } diff --git a/src/OutputParsers/AbstractOutputParser.php b/src/OutputParsers/AbstractOutputParser.php index 1764543..9e02aac 100644 --- a/src/OutputParsers/AbstractOutputParser.php +++ b/src/OutputParsers/AbstractOutputParser.php @@ -7,8 +7,10 @@ use Closure; use Throwable; use Cortex\LLM\Data\ChatResult; +use Cortex\LLM\Enums\ChunkType; use Cortex\Contracts\OutputParser; use Cortex\Events\OutputParserEnd; +use Cortex\Pipeline\RuntimeConfig; use Cortex\Support\Traits\CanPipe; use Cortex\LLM\Data\ChatGeneration; use Cortex\Events\OutputParserError; @@ -18,6 +20,7 @@ use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\Support\Traits\DispatchesEvents; use Cortex\Exceptions\OutputParserException; +use Cortex\Events\Contracts\OutputParserEvent; abstract class AbstractOutputParser implements OutputParser { @@ -47,16 +50,19 @@ public function withFormatInstructions(string $formatInstructions): self return $this; } - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { - $this->dispatchEvent(new OutputParserStart($this, $payload)); + $config->pushChunkWhenStreaming( + new ChatGenerationChunk(ChunkType::OutputParserStart), + fn() => $this->dispatchEvent(new OutputParserStart($this, $payload)), + ); try { $parsed = match (true) { is_string($payload) => $this->parse($payload), $payload instanceof ChatGeneration, $payload instanceof ChatGenerationChunk => $this->parse($payload), $payload instanceof ChatResult => $this->parse($payload->generation), - $payload instanceof ChatStreamResult => $this->handleChatStreamResult($payload, $next), + $payload instanceof ChatStreamResult => $this->handleChatStreamResult($payload, $config, $next), default => throw new PipelineException('Invalid input'), }; } catch (Throwable $e) { @@ -65,21 +71,60 @@ public function handlePipeable(mixed $payload, Closure $next): mixed throw $e; } - $this->dispatchEvent(new OutputParserEnd($this, $parsed)); + $config->pushChunkWhenStreaming( + new ChatGenerationChunk(ChunkType::OutputParserEnd, metadata: [ + 'parsed' => $parsed, + ]), + fn() => $this->dispatchEvent(new OutputParserEnd($this, $parsed)), + ); - return $next($parsed); + return $next($parsed, $config); } - protected function handleChatStreamResult(ChatStreamResult $result, Closure $next): ChatStreamResult + protected function handleChatStreamResult(ChatStreamResult $result, RuntimeConfig $config, Closure $next): ChatStreamResult { - return new ChatStreamResult(function () use ($result, $next) { + return new ChatStreamResult(function () use ($result, $config, $next) { foreach ($result as $chunk) { try { - yield $this->handlePipeable($chunk, $next); + $parsed = $this->parse($chunk); + + yield $next($parsed, $config); } catch (OutputParserException) { // Ignore any parsing errors and continue } } }); } + + /** + * Check if an event belongs to this output parser instance. + */ + protected function eventBelongsToThisInstance(object $event): bool + { + return $event instanceof OutputParserEvent && $event->outputParser === $this; + } + + /** + * Register a listener for when this output parser starts. + */ + public function onStart(callable $listener): self + { + return $this->on(OutputParserStart::class, $listener); + } + + /** + * Register a listener for when this output parser ends. + */ + public function onEnd(callable $listener): self + { + return $this->on(OutputParserEnd::class, $listener); + } + + /** + * Register a listener for when this output parser errors. + */ + public function onError(callable $listener): self + { + return $this->on(OutputParserError::class, $listener); + } } diff --git a/src/OutputParsers/ClassOutputParser.php b/src/OutputParsers/ClassOutputParser.php index 5183dfd..7a81588 100644 --- a/src/OutputParsers/ClassOutputParser.php +++ b/src/OutputParsers/ClassOutputParser.php @@ -8,15 +8,15 @@ use ReflectionClass; use ReflectionException; use ReflectionNamedType; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Data\ChatGeneration; -use Cortex\JsonSchema\SchemaFactory; -use Cortex\JsonSchema\Contracts\Schema; use Cortex\LLM\Data\ChatGenerationChunk; +use Cortex\JsonSchema\Contracts\JsonSchema; use Cortex\Exceptions\OutputParserException; class ClassOutputParser extends AbstractOutputParser { - protected Schema $schema; + protected JsonSchema $schema; protected StructuredOutputParser $outputParser; @@ -27,7 +27,7 @@ public function __construct( protected object|string $class, protected bool $strict = true, ) { - $this->schema = SchemaFactory::fromClass($class, publicOnly: true); + $this->schema = Schema::fromClass($class, publicOnly: true); $this->outputParser = new StructuredOutputParser($this->schema, $strict); } diff --git a/src/OutputParsers/EnumOutputParser.php b/src/OutputParsers/EnumOutputParser.php index 6865de0..4bd6861 100644 --- a/src/OutputParsers/EnumOutputParser.php +++ b/src/OutputParsers/EnumOutputParser.php @@ -29,7 +29,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): Backed // If the output has a tool call with the enum name, then assume we are using the schema tool. if (! is_string($output) && $output->message->hasToolCall($enumName)) { $schemaTool = $output->message->getToolCall($enumName); - $parsed = (new JsonOutputToolsParser(key: $schemaTool->function->name, singleToolCall: true)) + $parsed = new JsonOutputToolsParser(key: $schemaTool->function->name, singleToolCall: true) ->parse($output); if (array_key_exists($enumName, $parsed)) { @@ -53,7 +53,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): Backed // Then use the json output parser to get the enum from the json output if ($enum === null) { try { - $data = (new JsonOutputParser())->parse($output); + $data = new JsonOutputParser()->parse($output); } catch (OutputParserException $e) { throw OutputParserException::failed( sprintf('Enum %s not found in output', $this->enum), diff --git a/src/OutputParsers/JsonOutputParser.php b/src/OutputParsers/JsonOutputParser.php index 5499067..f83fe6c 100644 --- a/src/OutputParsers/JsonOutputParser.php +++ b/src/OutputParsers/JsonOutputParser.php @@ -80,7 +80,7 @@ protected function repairJson(string $json): string // Note: This only works for top level at the moment. $json = preg_replace('/\{(\s*"\w+"\s*:\s*)\{[^}]*?\}\}/', '{$1{}}', $json); - return (new Fixer())->silent()->fix((string) $json); + return new Fixer()->silent()->fix((string) $json); } #[Override] diff --git a/src/OutputParsers/StructuredOutputParser.php b/src/OutputParsers/StructuredOutputParser.php index 81dd025..00a6910 100644 --- a/src/OutputParsers/StructuredOutputParser.php +++ b/src/OutputParsers/StructuredOutputParser.php @@ -5,15 +5,16 @@ namespace Cortex\OutputParsers; use Override; +use Cortex\Tools\SchemaTool; use Cortex\LLM\Data\ChatGeneration; -use Cortex\JsonSchema\Contracts\Schema; use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\JsonSchema\Contracts\JsonSchema; class StructuredOutputParser extends AbstractOutputParser { public function __construct( - protected Schema $schema, + protected JsonSchema $schema, protected bool $strict = true, ) {} @@ -24,8 +25,7 @@ public function parse(ChatGeneration|ChatGenerationChunk|string $output): array { $parser = match (true) { is_string($output) => new JsonOutputParser(), - // If the message has tool calls and no text, assume we are using the schema tool - $output->message->hasToolCalls() && in_array($output->message->text(), [null, ''], true) => new JsonOutputToolsParser(singleToolCall: true), + $output->message->hasToolCall(SchemaTool::NAME) => new JsonOutputToolsParser(singleToolCall: true), default => new JsonOutputParser(), }; diff --git a/src/ParallelGroup.php b/src/ParallelGroup.php index 257b0d4..41a27c0 100644 --- a/src/ParallelGroup.php +++ b/src/ParallelGroup.php @@ -6,6 +6,7 @@ use Closure; use Cortex\Contracts\Pipeable; +use Cortex\Pipeline\RuntimeConfig; use Cortex\Support\Traits\CanPipe; use function React\Async\async; @@ -34,17 +35,17 @@ public function __construct(callable|Pipeable ...$stages) /** * Execute grouped stages in parallel and pass collected results to next stage. */ - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { $promises = []; foreach ($this->stages as $stage) { - $promises[] = async(fn(): mixed => $this->processStage($stage, $payload)); + $promises[] = async(fn(): mixed => $this->processStage($stage, $payload, $config)); } $results = await(parallel($promises)); - return $next(array_values($results)); + return $next(array_values($results), $config); } /** @@ -58,11 +59,21 @@ public function getStages(): array /** * Process individual stage with error handling. */ - protected function processStage(callable|Pipeable $stage, mixed $payload): mixed + protected function processStage(callable|Pipeable $stage, mixed $payload, RuntimeConfig $config): mixed { + $next = fn(mixed $p, RuntimeConfig $c): mixed => $p; + return match (true) { - $stage instanceof Pipeable => $stage->handlePipeable($payload, fn(mixed $p): mixed => $p), - default => $stage($payload, fn(mixed $p): mixed => $p) + $stage instanceof Pipeable => $stage->handlePipeable($payload, $config, $next), + default => $this->invokeCallable($stage, $payload, $config, $next), }; } + + /** + * Invoke a callable stage. + */ + protected function invokeCallable(callable $stage, mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $stage($payload, $config, $next); + } } diff --git a/src/Pipeline.php b/src/Pipeline.php index 496abf6..20bcfc1 100644 --- a/src/Pipeline.php +++ b/src/Pipeline.php @@ -6,13 +6,20 @@ use Closure; use Throwable; +use Cortex\Events\StageEnd; +use Cortex\Events\StageError; +use Cortex\Events\StageStart; use Cortex\LLM\Contracts\LLM; use Cortex\Contracts\Pipeable; use Cortex\Events\PipelineEnd; use Cortex\Events\PipelineError; use Cortex\Events\PipelineStart; use Cortex\Contracts\OutputParser; +use Cortex\Pipeline\RuntimeConfig; +use Cortex\Events\ChatModelStreamEnd; +use Cortex\Events\Contracts\StageEvent; use Illuminate\Support\Traits\Dumpable; +use Cortex\Events\Contracts\PipelineEvent; use Cortex\Support\Traits\DispatchesEvents; use Illuminate\Support\Traits\Conditionable; @@ -25,11 +32,32 @@ class Pipeline implements Pipeable /** * The array of stages. * - * @var array + * @var array<\Cortex\Contracts\Pipeable|\Closure> */ protected array $stages = []; - public function __construct(callable|Pipeable ...$stages) + /** + * The runtime context for this pipeline execution. + */ + // protected ?RuntimeConfig $config = null; + + protected bool $streaming = false; + + /** + * The exception handler callbacks. + * + * @var array<\Closure(\Throwable, \Cortex\Pipeline\RuntimeConfig): mixed> + */ + protected array $catchCallbacks = []; + + /** + * The finally callback. + * + * @var null|\Closure(\Cortex\Pipeline\RuntimeConfig, mixed, \Cortex\Pipeline): void + */ + protected ?Closure $finally = null; + + public function __construct(Pipeable|Closure ...$stages) { $this->stages = $stages; } @@ -40,9 +68,9 @@ public function __construct(callable|Pipeable ...$stages) * When an array of stages is provided, they will be executed in parallel using amphp. * The results of parallel stages will be merged into a single payload. * - * @param callable|Pipeable|array $stage Single stage or array of parallel stages + * @param \Cortex\Contracts\Pipeable|\Closure|array<\Cortex\Contracts\Pipeable|\Closure> $stage Single stage or array of parallel stages */ - public function pipe(callable|Pipeable|array $stage): self + public function pipe(Pipeable|Closure|array $stage): self { $this->stages[] = is_array($stage) ? new ParallelGroup(...$stage) @@ -51,23 +79,64 @@ public function pipe(callable|Pipeable|array $stage): self return $this; } + /** + * Pipe multiple stages into the pipeline. + */ + public function pipeMany(Pipeable|Closure ...$stages): self + { + foreach ($stages as $stage) { + $this->pipe($stage); + } + + return $this; + } + + /** + * Prepend a stage to the pipeline. + * + * @param \Cortex\Contracts\Pipeable|\Closure|array<\Cortex\Contracts\Pipeable|\Closure> $stage Single stage or array of parallel stages + */ + public function prepend(Pipeable|Closure|array $stage): self + { + array_unshift($this->stages, is_array($stage) + ? new ParallelGroup(...$stage) + : $stage); + + return $this; + } + /** * Process the payload through the pipeline. + * + * @param \Cortex\Pipeline\RuntimeConfig|null $config Optional runtime config. If not provided, a new one will be created. */ - public function invoke(mixed $payload = null): mixed + public function invoke(mixed $payload = null, ?RuntimeConfig $config = null): mixed { - $this->dispatchEvent(new PipelineStart($this, $payload)); + $config ??= new RuntimeConfig(); + + $config->setStreaming($this->streaming); + + $this->dispatchEvent(new PipelineStart($this, $payload, $config)); try { - $pipeline = $this->getInvokablePipeline(fn(mixed $payload): mixed => $payload); - $result = $pipeline($payload); + $pipeline = $this->getInvokablePipeline(fn(mixed $payload, RuntimeConfig $config): mixed => $payload, $config); + $result = $pipeline($payload, $config); } catch (Throwable $e) { - $this->dispatchEvent(new PipelineError($this, $payload, $e)); + $config->setException($e); + $this->dispatchEvent(new PipelineError($this, $payload, $config, $e)); + + foreach ($this->catchCallbacks as $callback) { + $callback($e, $config); + } throw $e; + } finally { + if ($this->finally !== null) { + ($this->finally)($config, $payload, $this); + } } - $this->dispatchEvent(new PipelineEnd($this, $payload, $result)); + $this->dispatchEvent(new PipelineEnd($this, $payload, $config, $result ?? null)); return $result; } @@ -75,20 +144,20 @@ public function invoke(mixed $payload = null): mixed /** * Ensures that all LLMs in the pipeline are streaming. */ - public function stream(mixed $payload = null): mixed + public function stream(mixed $payload = null, ?RuntimeConfig $config = null): mixed { - return $this->enableStreaming()->invoke($payload); + return $this->enableStreaming()->invoke($payload, $config); } - public function __invoke(mixed $payload = null): mixed + public function __invoke(mixed $payload = null, ?RuntimeConfig $config = null): mixed { - return $this->invoke($payload); + return $this->invoke($payload, $config); } /** * Get the stages. * - * @return array + * @return array<\Cortex\Contracts\Pipeable|\Closure> */ public function getStages(): array { @@ -98,20 +167,20 @@ public function getStages(): array /** * Pipeline's themselves are also pipeable. */ - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { - $pipeline = $this->getInvokablePipeline($next); + $pipeline = $this->getInvokablePipeline($next, $config); - return $pipeline($payload); + return $pipeline($payload, $config); } /** * Enable streaming for all LLMs in the pipeline. */ - public function enableStreaming(): self + public function enableStreaming(bool $streaming = true): self { foreach ($this->getStages() as $stage) { - $this->setLLMStreaming($stage, true); + $this->setLLMStreaming($stage, $streaming); } return $this; @@ -122,11 +191,7 @@ public function enableStreaming(): self */ public function disableStreaming(): self { - foreach ($this->getStages() as $stage) { - $this->setLLMStreaming($stage, false); - } - - return $this; + return $this->enableStreaming(false); } /** @@ -137,22 +202,149 @@ public function output(OutputParser $parser): self return $this->pipe($parser); } + public function isStreaming(): bool + { + return $this->streaming; + } + + /** + * Register a listener for when this pipeline starts. + */ + public function onStart(Closure $listener): self + { + return $this->on(PipelineStart::class, $listener); + } + + /** + * Register a listener for when this pipeline ends. + */ + public function onEnd(Closure $listener): self + { + if ($this->streaming) { + return $this->onLastLLMStreamEnd($listener); + } + + return $this->on(PipelineEnd::class, $listener); + } + + /** + * Register a listener for when this pipeline errors. + */ + public function onError(Closure $listener): self + { + return $this->on(PipelineError::class, $listener); + } + + /** + * Register a callback to catch exceptions during pipeline execution. + * The callback receives the exception and RuntimeConfig instance. + * Multiple callbacks can be registered and will all be called when an exception occurs. + * + * @param \Closure(\Throwable, \Cortex\Pipeline\RuntimeConfig): mixed $callback + */ + public function catch(Closure $callback): self + { + $this->catchCallbacks[] = $callback; + + return $this; + } + + /** + * Register a callback to run when the pipeline finally completes. + * + * @param \Closure(\Cortex\Pipeline\RuntimeConfig, mixed, \Cortex\Pipeline): void $callback + */ + public function finally(Closure $callback): self + { + $this->finally = $callback; + + return $this; + } + + /** + * Register a listener for when a stage starts in this pipeline. + */ + public function onStageStart(Closure $listener): self + { + return $this->on(StageStart::class, $listener); + } + + /** + * Register a listener for when a stage ends in this pipeline. + */ + public function onStageEnd(Closure $listener): self + { + return $this->on(StageEnd::class, $listener); + } + + /** + * Register a listener for when a stage errors in this pipeline. + */ + public function onStageError(Closure $listener): self + { + return $this->on(StageError::class, $listener); + } + + /** + * Check if an event belongs to this pipeline instance. + */ + protected function eventBelongsToThisInstance(object $event): bool + { + return match (true) { + $event instanceof PipelineEvent, $event instanceof StageEvent => $event->pipeline === $this, + default => false, + }; + } + /** * Create the callable for the current stage. */ - protected function carry(callable $next, callable|Pipeable $stage): Closure + protected function carry(Closure $next, Pipeable|Closure $stage, RuntimeConfig $config): Closure { - return fn(mixed $payload) => match (true) { - $stage instanceof Pipeable => $stage->handlePipeable($payload, $next), - default => $stage($payload, $next), + return function (mixed $payload) use ($next, $stage, $config): mixed { + $this->dispatchEvent(new StageStart($this, $stage, $payload, $config)); + + try { + $result = match (true) { + $stage instanceof Pipeable => $stage->handlePipeable($payload, $config, $next), + default => $this->invokeCallable($stage, $payload, $config, $next), + }; + + $this->dispatchEvent(new StageEnd($this, $stage, $payload, $config, $result)); + + return $result; + } catch (Throwable $e) { + $this->dispatchEvent(new StageError($this, $stage, $payload, $config, $e)); + + foreach ($this->catchCallbacks as $callback) { + $callback($e, $config->setException($e)); + } + + throw $e; + } finally { + if ($this->finally !== null) { + ($this->finally)($config, $payload, $this); + } + } }; } - protected function getInvokablePipeline(Closure $next): Closure + /** + * Invoke a callable stage. + */ + protected function invokeCallable(Closure $stage, mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $stage($payload, $config, $next); + } + + /** + * Get the invokable pipeline. + */ + protected function getInvokablePipeline(Closure $next, RuntimeConfig $config): Closure { return array_reduce( array_reverse($this->stages), - $this->carry(...), + fn(Closure $carry, Pipeable|Closure $stage): Closure => $this->carry($carry, $stage, $config), $next, ); } @@ -169,5 +361,42 @@ protected function setLLMStreaming(mixed $stage, bool $streaming = true): void $this->setLLMStreaming($subStage, $streaming); } } + + $this->streaming = $streaming; + } + + /** + * Register a listener for when the last LLM's stream ends in this pipeline. + * This tracks LLM stages as they execute and registers the listener on the last one. + * This is useful for knowing when a streaming pipeline has fully completed, + * especially when there are multiple steps that create multiple LLM calls. + */ + public function onLastLLMStreamEnd(Closure $listener): self + { + $streamEndDispatched = false; + $lastLLM = null; + + // Track LLM stages as they execute by listening to StageEnd events + $this->onStageEnd(function (StageEnd $event) use (&$lastLLM, $listener, &$streamEndDispatched): void { + if ($event->stage instanceof LLM && $event->stage->isStreaming()) { + // Register listener on this LLM for when its stream ends + // Each time a new LLM stage ends, it becomes the "last" one + // So we register the listener on each one, but only the last one's will fire + $currentLLM = $event->stage; + $lastLLM = $currentLLM; + + // Register listener on the LLM instance for ChatModelStreamEnd events + // AbstractLLM implements onStreamEnd, so we can call it via method_exists check + $currentLLM->onStreamEnd(function (ChatModelStreamEnd $streamEndEvent) use ($listener, &$streamEndDispatched, &$lastLLM, $currentLLM, $event): void { + // Only dispatch if this is the last LLM we tracked and it hasn't been dispatched yet + if (! $streamEndDispatched && $streamEndEvent->llm === $lastLLM && $streamEndEvent->llm === $currentLLM) { + $streamEndDispatched = true; + $listener(new PipelineEnd($this, $event->payload, $event->config, $event->result)); + } + }); + } + }); + + return $this; } } diff --git a/src/Pipeline/Context.php b/src/Pipeline/Context.php new file mode 100644 index 0000000..624ac41 --- /dev/null +++ b/src/Pipeline/Context.php @@ -0,0 +1,164 @@ + + */ +class Context extends Fluent +{ + public const string STEPS_KEY = 'steps'; + + public const string CURRENT_STEP_KEY = 'current_step'; + + public const string USAGE_SO_FAR_KEY = 'usage_so_far'; + + public const string MESSAGE_HISTORY_KEY = 'message_history'; + + public const string SHOULD_PARSE_OUTPUT_KEY = 'should_parse_output'; + + /** + * Get the steps from the context. + * + * @return \Illuminate\Support\Collection + */ + public function getSteps(): Collection + { + return $this->get(self::STEPS_KEY, new Collection()); + } + + /** + * Determines if the context has any steps. + */ + public function hasSteps(): bool + { + return $this->getSteps()->isNotEmpty(); + } + + /** + * Set the steps in the context. + * + * @param \Illuminate\Support\Collection $steps + */ + public function setSteps(Collection $steps): void + { + $this->set(self::STEPS_KEY, $steps); + } + + /** + * Get the current step from the context. + */ + public function getCurrentStep(): Step + { + return $this->get(self::CURRENT_STEP_KEY, new Step(number: 1)); + } + + /** + * Get the current step number from the context. + */ + public function getCurrentStepNumber(): int + { + return $this->getCurrentStep()->number; + } + + /** + * Set the current step in the context. + */ + public function setCurrentStep(Step $step): void + { + $this->set(self::CURRENT_STEP_KEY, $step); + } + + /** + * Add a step to the context. + */ + public function addStep(Step $step): void + { + $steps = $this->getSteps()->push($step); + $this->setCurrentStep($step); + $this->setSteps($steps); + } + + /** + * Add the initial step to the context. + */ + public function addInitialStep(): void + { + $this->addStep(new Step(number: 1)); + } + + /** + * Add a new step whilst incrementing the current step number. + */ + public function addNextStep(): void + { + $this->addStep(new Step(number: $this->getCurrentStepNumber() + 1)); + } + + /** + * Get the usage so far from the context. + */ + public function getUsageSoFar(): Usage + { + return $this->get(self::USAGE_SO_FAR_KEY, Usage::empty()); + } + + /** + * Set the usage so far in the context. + */ + public function setUsageSoFar(Usage $usage): void + { + $this->set(self::USAGE_SO_FAR_KEY, $usage); + } + + /** + * Append usage to the context. + */ + public function appendUsage(Usage $usage): static + { + $usageSoFar = $this->getUsageSoFar()->add($usage); + $this->setUsageSoFar($usageSoFar); + + return $this; + } + + /** + * Get the message history from the context. + */ + public function getMessageHistory(): MessageCollection + { + return $this->get(self::MESSAGE_HISTORY_KEY, new MessageCollection()); + } + + /** + * Set the message history in the context. + */ + public function setMessageHistory(MessageCollection $messages): void + { + $this->set(self::MESSAGE_HISTORY_KEY, $messages); + } + + /** + * Determine if the output should be parsed. + */ + public function shouldParseOutput(): bool + { + return $this->get(self::SHOULD_PARSE_OUTPUT_KEY) ?? true; + } + + /** + * Set whether the output should be parsed. + */ + public function setShouldParseOutput(bool $shouldParseOutput): void + { + $this->set(self::SHOULD_PARSE_OUTPUT_KEY, $shouldParseOutput); + } +} diff --git a/src/Pipeline/Metadata.php b/src/Pipeline/Metadata.php new file mode 100644 index 0000000..fa8eaf3 --- /dev/null +++ b/src/Pipeline/Metadata.php @@ -0,0 +1,12 @@ + + */ +class Metadata extends Fluent {} diff --git a/src/Pipeline/RuntimeConfig.php b/src/Pipeline/RuntimeConfig.php new file mode 100644 index 0000000..2b6a4a6 --- /dev/null +++ b/src/Pipeline/RuntimeConfig.php @@ -0,0 +1,169 @@ + + */ +class RuntimeConfig implements Arrayable +{ + use Conditionable; + use DispatchesEvents; + + public string $threadId; + + public string $runId; + + public bool $streaming = false; + + public Request $request; + + /** + * @var Closure(\Cortex\LLM\Contracts\LLM): \Cortex\LLM\Contracts\LLM|null + */ + protected ?Closure $llmConfigurator = null; + + /** + * Whether the LLM configurator should be cleared after first use. + */ + protected bool $llmConfiguratorOnce = false; + + /** + * @param Closure(string $threadId): Store|null $storeFactory Factory to create stores for the given threadId + */ + public function __construct( + public Context $context = new Context(), + public Metadata $metadata = new Metadata(), + public StreamBuffer $stream = new StreamBuffer(), + public ?Throwable $exception = null, + ?string $threadId = null, + ?string $runId = null, + public ?Closure $storeFactory = null, + ) { + $this->runId = $runId ?? Str::uuid7()->toString(); + $this->threadId = $threadId ?? Str::uuid7()->toString(); + $this->request = Request::capture(); + } + + public function onStreamChunk(Closure $listener, bool $once = true): self + { + return $this->on(RuntimeConfigStreamChunk::class, $listener, $once); + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'run_id' => $this->runId, + 'thread_id' => $this->threadId, + 'context' => $this->context->toArray(), + 'metadata' => $this->metadata->toArray(), + ]; + } + + protected function eventBelongsToThisInstance(object $event): bool + { + return $event instanceof RuntimeConfigEvent && $event->config->runId === $this->runId; + } + + public function setStreaming(bool $streaming = true): self + { + $this->streaming = $streaming; + + return $this; + } + + public function setException(Throwable $exception): self + { + $this->exception = $exception; + + return $this; + } + + /** + * Push a chunk to the stream when streaming, otherwise call the provided callback. + */ + public function pushChunkWhenStreaming( + ChatGenerationChunk $chunk, + callable $whenNotStreaming, + ): self { + return $this->when( + $this->streaming, + fn() => $this->stream->push($chunk), + $whenNotStreaming, + ); + } + + /** + * Configure the LLM instance with a closure that receives the LLM and returns a modified version. + * This allows middleware to dynamically adjust LLM parameters before invocation. + * + * Example: + * ```php + * // Configure for all subsequent LLM calls + * $config->configureLLM(function (LLM $llm) { + * return $llm->withTemperature(0.7)->withMaxTokens(1000); + * }); + * + * // Configure only for the next LLM call + * $config->configureLLM(function (LLM $llm) { + * return $llm->withTemperature(0.9); + * }, once: true); + * ``` + * + * @param Closure(\Cortex\LLM\Contracts\LLM): \Cortex\LLM\Contracts\LLM $configurator + * @param bool $once If true, the configurator will be cleared after first use + */ + public function configureLLM(Closure $configurator, bool $once = false): self + { + $this->llmConfigurator = $configurator; + $this->llmConfiguratorOnce = $once; + + return $this; + } + + /** + * Get the LLM configurator closure if one has been set. + * + * @return null|Closure(\Cortex\LLM\Contracts\LLM): \Cortex\LLM\Contracts\LLM + */ + public function getLLMConfigurator(): ?Closure + { + return $this->llmConfigurator; + } + + /** + * Check if the LLM configurator should be cleared after first use. + */ + public function shouldClearLLMConfigurator(): bool + { + return $this->llmConfiguratorOnce; + } + + /** + * Clear the LLM configurator. + */ + public function clearLLMConfigurator(): self + { + $this->llmConfigurator = null; + $this->llmConfiguratorOnce = false; + + return $this; + } +} diff --git a/src/Pipeline/StreamBuffer.php b/src/Pipeline/StreamBuffer.php new file mode 100644 index 0000000..ab98510 --- /dev/null +++ b/src/Pipeline/StreamBuffer.php @@ -0,0 +1,60 @@ + + */ + protected array $buffer = []; + + /** + * Add an item to the buffer. + */ + public function push(mixed $item): void + { + $this->buffer[] = $item; + } + + /** + * Prepend an item to the buffer. + */ + public function prepend(mixed $item): void + { + array_unshift($this->buffer, $item); + } + + /** + * Return all items and clear the buffer. + * + * @return array + */ + public function drain(): array + { + $items = $this->buffer; + $this->buffer = []; + + return $items; + } + + /** + * Check if the buffer is empty. + */ + public function isEmpty(): bool + { + return $this->buffer === []; + } + + /** + * Check if the buffer is not empty. + */ + public function isNotEmpty(): bool + { + return ! $this->isEmpty(); + } +} diff --git a/src/Prompts/BladePromptContext.php b/src/Prompts/BladePromptContext.php new file mode 100644 index 0000000..7c6e277 --- /dev/null +++ b/src/Prompts/BladePromptContext.php @@ -0,0 +1,136 @@ +|null + */ + protected static ?array $config = null; + + /** + * Start capturing configuration. + */ + public static function start(): void + { + self::$config = [ + 'llm' => [ + 'provider' => null, + 'model' => null, + 'parameters' => [], + ], + 'inputSchema' => null, + 'tools' => [], + 'structuredOutput' => null, + 'structuredOutputMode' => StructuredOutputMode::Auto, + ]; + } + + /** + * Set the LLM provider and model. + */ + public static function setLLM(string $provider, ?string $model = null): void + { + self::ensureStarted(); + self::$config['llm']['provider'] = $provider; + self::$config['llm']['model'] = $model; + } + + /** + * Set the LLM parameters. + * + * @param array $parameters + */ + public static function setParameters(array $parameters): void + { + self::ensureStarted(); + self::$config['llm']['parameters'] = array_merge( + self::$config['llm']['parameters'] ?? [], + $parameters, + ); + } + + /** + * Set the input schema. + * + * @param array|ObjectSchema $schema + */ + public static function setInputSchema(array|ObjectSchema $schema): void + { + self::ensureStarted(); + self::$config['inputSchema'] = $schema; + } + + /** + * Set the tools. + * + * @param array $tools + */ + public static function setTools(array $tools): void + { + self::ensureStarted(); + self::$config['tools'] = array_merge(self::$config['tools'] ?? [], $tools); + } + + /** + * Set the structured output configuration. + */ + public static function setStructuredOutput( + ObjectSchema|string $schema, + ?string $name = null, + ?string $description = null, + bool $strict = true, + StructuredOutputMode $mode = StructuredOutputMode::Auto, + ): void { + self::ensureStarted(); + self::$config['structuredOutput'] = $schema; + self::$config['structuredOutputName'] = $name; + self::$config['structuredOutputDescription'] = $description; + self::$config['structuredOutputStrict'] = $strict; + self::$config['structuredOutputMode'] = $mode; + } + + /** + * Get the captured configuration. + * + * @return array + */ + public static function getConfig(): array + { + self::ensureStarted(); + + return self::$config; + } + + /** + * Clear the captured configuration. + */ + public static function clear(): void + { + self::$config = null; + } + + /** + * Check if context has been started. + */ + public static function isStarted(): bool + { + return self::$config !== null; + } + + /** + * Ensure context has been started. + */ + protected static function ensureStarted(): void + { + if (! self::isStarted()) { + self::start(); + } + } +} diff --git a/src/Prompts/Builders/ChatPromptBuilder.php b/src/Prompts/Builders/ChatPromptBuilder.php index 8cbfcd5..deed4f4 100644 --- a/src/Prompts/Builders/ChatPromptBuilder.php +++ b/src/Prompts/Builders/ChatPromptBuilder.php @@ -6,7 +6,7 @@ use Cortex\Contracts\Pipeable; use Cortex\Prompts\Contracts\PromptBuilder; -use Cortex\Prompts\Contracts\PromptTemplate; +use Cortex\Agents\Prebuilt\GenericAgentBuilder; use Cortex\LLM\Data\Messages\MessageCollection; use Cortex\Prompts\Templates\ChatPromptTemplate; use Cortex\Prompts\Builders\Concerns\BuildsPrompts; @@ -20,7 +20,7 @@ class ChatPromptBuilder implements PromptBuilder */ protected MessageCollection|array|string $messages = []; - public function build(): PromptTemplate&Pipeable + public function build(): ChatPromptTemplate&Pipeable { return new ChatPromptTemplate( $this->messages, @@ -41,6 +41,14 @@ public function messages(MessageCollection|array|string $messages): self return $this; } + /** + * Convenience method to build a generic agent builder from the chat prompt builder. + */ + public function agentBuilder(): GenericAgentBuilder + { + return new GenericAgentBuilder()->withPrompt($this); + } + /** * Convenience method to build and then format the prompt. * diff --git a/src/Prompts/Builders/Concerns/BuildsPrompts.php b/src/Prompts/Builders/Concerns/BuildsPrompts.php index b62b0dd..f2bed17 100644 --- a/src/Prompts/Builders/Concerns/BuildsPrompts.php +++ b/src/Prompts/Builders/Concerns/BuildsPrompts.php @@ -6,16 +6,19 @@ use Closure; use Cortex\Pipeline; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Contracts\LLM; use Cortex\Contracts\Pipeable; -use Cortex\JsonSchema\SchemaFactory; -use Cortex\JsonSchema\Contracts\Schema; use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\LLM\Enums\StructuredOutputMode; +use Cortex\JsonSchema\Contracts\JsonSchema; use Cortex\LLM\Data\StructuredOutputConfig; use Cortex\Prompts\Contracts\PromptTemplate; -use Cortex\Tasks\Enums\StructuredOutputMode; +/** + * @mixin \Cortex\Prompts\Contracts\PromptBuilder + */ trait BuildsPrompts { /** @@ -40,12 +43,14 @@ public function initialVariables(array $initialVariables): self } /** + * Define the metadata for the prompt. + * * @param array $parameters * @param array $tools * @param array $additional */ public function metadata( - ?string $provider = null, + LLM|string|null $provider = null, ?string $model = null, array $parameters = [], array $tools = [], @@ -88,16 +93,16 @@ public function inputSchema(ObjectSchema $inputSchema): self * Set the input schema properties for the prompt (will be enforced in strict mode). * All properties will be set as required and no additional properties will be allowed. */ - public function inputSchemaProperties(Schema ...$properties): self + public function inputSchemaProperties(JsonSchema ...$properties): self { // Ensure all properties are required. $properties = array_map( - fn(Schema $property): Schema => $property->required(), + fn(JsonSchema $property): JsonSchema => $property->required(), $properties, ); return $this->inputSchema( - SchemaFactory::object()->properties(...$properties), + Schema::object()->properties(...$properties), ); } @@ -124,7 +129,7 @@ public function llm( /** * Convenience method to build and pipe the prompt template to a given pipeable. */ - public function pipe(Pipeable|callable $pipeable): Pipeline + public function pipe(Pipeable|Closure $pipeable): Pipeline { return $this->build()->pipe($pipeable); } diff --git a/src/Prompts/Builders/TextPromptBuilder.php b/src/Prompts/Builders/TextPromptBuilder.php index 13690ff..d267e62 100644 --- a/src/Prompts/Builders/TextPromptBuilder.php +++ b/src/Prompts/Builders/TextPromptBuilder.php @@ -7,7 +7,6 @@ use Cortex\Contracts\Pipeable; use Cortex\Exceptions\PromptException; use Cortex\Prompts\Contracts\PromptBuilder; -use Cortex\Prompts\Contracts\PromptTemplate; use Cortex\Prompts\Templates\TextPromptTemplate; use Cortex\Prompts\Builders\Concerns\BuildsPrompts; @@ -17,7 +16,7 @@ class TextPromptBuilder implements PromptBuilder protected ?string $text = null; - public function build(): PromptTemplate&Pipeable + public function build(): TextPromptTemplate&Pipeable { if ($this->text === null) { throw new PromptException('Text is required.'); diff --git a/src/Prompts/Compilers/BladeCompiler.php b/src/Prompts/Compilers/BladeCompiler.php new file mode 100644 index 0000000..7d0e9e5 --- /dev/null +++ b/src/Prompts/Compilers/BladeCompiler.php @@ -0,0 +1,34 @@ + $variables + */ + public function compile(string $input, array $variables): string + { + return Blade::render($input, $variables); + } + + /** + * @return array + */ + public function variables(string $input): array + { + preg_match_all(self::BLADE_VARIABLE_REGEX, $input, $matches); + + // @phpstan-ignore nullCoalesce.offset + $variables = $matches[1] ?? []; + + return array_values(array_unique($variables)); + } +} diff --git a/src/Prompts/Compilers/TextCompiler.php b/src/Prompts/Compilers/TextCompiler.php new file mode 100644 index 0000000..184e714 --- /dev/null +++ b/src/Prompts/Compilers/TextCompiler.php @@ -0,0 +1,41 @@ + $variables + */ + public function compile(string $input, array $variables): string + { + return preg_replace_callback( + self::VARIABLE_REGEX, + fn(array $matches) => $variables[$matches[1]] ?? $matches[0], + $input, + ); + } + + /** + * Get the variables from the input string. + * + * @return array + */ + public function variables(string $input): array + { + preg_match_all(self::VARIABLE_REGEX, $input, $matches); + + // @phpstan-ignore nullCoalesce.offset + $variables = $matches[1] ?? []; + + return array_values(array_unique($variables)); + } +} diff --git a/src/Prompts/Contracts/PromptBuilder.php b/src/Prompts/Contracts/PromptBuilder.php index e47a1fd..bb86bb9 100644 --- a/src/Prompts/Contracts/PromptBuilder.php +++ b/src/Prompts/Contracts/PromptBuilder.php @@ -8,8 +8,8 @@ use Cortex\Pipeline; use Cortex\LLM\Contracts\LLM; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\LLM\Data\StructuredOutputConfig; -use Cortex\Tasks\Enums\StructuredOutputMode; interface PromptBuilder { diff --git a/src/Prompts/Contracts/PromptCompiler.php b/src/Prompts/Contracts/PromptCompiler.php new file mode 100644 index 0000000..2574f04 --- /dev/null +++ b/src/Prompts/Contracts/PromptCompiler.php @@ -0,0 +1,18 @@ + $variables + */ + public function compile(string $input, array $variables): string; + + /** + * @return array + */ + public function variables(string $input): array; +} diff --git a/src/Prompts/Contracts/PromptTemplate.php b/src/Prompts/Contracts/PromptTemplate.php index e26cd18..bfb9a39 100644 --- a/src/Prompts/Contracts/PromptTemplate.php +++ b/src/Prompts/Contracts/PromptTemplate.php @@ -34,5 +34,10 @@ public function llm(LLM|string|null $provider = null, Closure|string|null $model /** * Convenience method to build and pipe the prompt template to a given pipeable. */ - public function pipe(Pipeable|callable $pipeable): Pipeline; + public function pipe(Pipeable|Closure $pipeable): Pipeline; + + /** + * Get the compiler for the prompt template. + */ + public function getCompiler(): PromptCompiler; } diff --git a/src/Prompts/Data/PromptMetadata.php b/src/Prompts/Data/PromptMetadata.php index 1a9aaf9..48523a9 100644 --- a/src/Prompts/Data/PromptMetadata.php +++ b/src/Prompts/Data/PromptMetadata.php @@ -4,9 +4,11 @@ namespace Cortex\Prompts\Data; +use Cortex\Facades\LLM; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\LLM\Data\StructuredOutputConfig; -use Cortex\Tasks\Enums\StructuredOutputMode; +use Cortex\LLM\Contracts\LLM as LLMContract; readonly class PromptMetadata { @@ -16,7 +18,7 @@ * @param array $additional */ public function __construct( - public ?string $provider = null, + public LLMContract|string|null $provider = null, public ?string $model = null, public array $parameters = [], public array $tools = [], @@ -24,4 +26,42 @@ public function __construct( public StructuredOutputMode $structuredOutputMode = StructuredOutputMode::Auto, public array $additional = [], ) {} + + public function llm(): LLMContract + { + $llm = $this->provider instanceof LLMContract + ? $this->provider + : LLM::provider($this->provider); + + if ($this->model !== null) { + $llm->withModel($this->model); + } + + if ($this->parameters !== []) { + $llm->withParameters($this->parameters); + } + + if ($this->tools !== []) { + $llm->withTools($this->tools); + } + + if ($this->structuredOutput !== null) { + if ($this->structuredOutput instanceof StructuredOutputConfig) { + $llm->withStructuredOutput( + $this->structuredOutput->schema, + $this->structuredOutput->name, + $this->structuredOutput->description, + $this->structuredOutput->strict, + $this->structuredOutputMode, + ); + } else { + $llm->withStructuredOutput( + output: $this->structuredOutput, + outputMode: $this->structuredOutputMode, + ); + } + } + + return $llm; + } } diff --git a/src/Prompts/Factories/BladePromptFactory.php b/src/Prompts/Factories/BladePromptFactory.php new file mode 100644 index 0000000..f921f90 --- /dev/null +++ b/src/Prompts/Factories/BladePromptFactory.php @@ -0,0 +1,472 @@ +pathAdded || $this->path === null) { + return; + } + + $finder = app('view')->getFinder(); + $finder->addLocation($this->path); + + $this->pathAdded = true; + } + + /** + * Make a prompt template from the given Blade view. + * + * @param string $name The name of the Blade view (e.g. 'prompts.chat') + * @param array $options Initial data to pass to the view + */ + public function make(string $name, array $options = []): PromptTemplate + { + // Ensure the custom path is added to Laravel's view finder + $this->ensurePathAdded(); + + // Get the Blade file path + try { + $finder = app('view')->getFinder(); + $viewPath = $finder->find($name); + } catch (InvalidArgumentException $e) { + throw new PromptException('Blade view not found: ' . $name, 0, $e); + } + + $contents = file_get_contents($viewPath); + + if ($contents === false) { + throw new PromptException('Could not read Blade view: ' . $name); + } + + // Start capturing configuration + BladePromptContext::start(); + + try { + // Check if the Blade file contains conditionals + $hasConditionals = $this->hasConditionals($contents); + + if ($hasConditionals) { + // For files with conditionals, capture config by rendering once + // then create a template that re-renders during format() + $this->captureConfigFromRender($name, $contents); + $config = BladePromptContext::getConfig(); + + return $this->createConditionalTemplate($name, $config, $options); + } + + // For non-conditional templates, render once with minimal variables to capture config + // The PHP block will execute during compilation to capture configuration + // We need to provide some variables to avoid Blade errors, but we'll parse messages directly + // Extract minimal placeholder variables just for rendering (to avoid errors) + $placeholderVars = $this->extractPlaceholderVariables($contents); + view($name, $placeholderVars)->render(); + + // Get captured configuration (from PHP block execution during Blade compilation) + $config = BladePromptContext::getConfig(); + + // Parse messages directly from file contents without rendering variables + $messages = $this->parseMessagesFromBladeFile($contents); + + // Build the prompt using ChatPromptBuilder + $builder = new ChatPromptBuilder(); + $builder->initialVariables($options); + + if (isset($config['inputSchema'])) { + $builder->inputSchema($config['inputSchema']); + } + + if (isset($config['llm']) && ! empty($config['llm']['provider'])) { + $metadata = $this->configToMetadata($config); + + if ($metadata !== null) { + $builder->setMetadata($metadata); + } + } + + $builder->messages($messages); + + return $builder->build()->withCompiler(new BladeCompiler()); + } finally { + // Always clear context + BladePromptContext::clear(); + } + } + + /** + * Capture configuration by rendering the view (Blade will execute the PHP block). + * + * @param string $viewName The name of the Blade view + * @param string $contents The contents of the Blade file + */ + protected function captureConfigFromRender(string $viewName, string $contents): void + { + // Extract placeholder variables so conditionals don't fail during render + $placeholderVars = $this->extractPlaceholderVariables($contents); + + // Render with placeholder variables to execute the PHP block + // We don't care about the output, just need the config captured + view($viewName, $placeholderVars)->render(); + } + + /** + * Parse messages directly from Blade file contents without rendering variables. + * + * @param string $contents The Blade file contents + * + * @return array + */ + protected function parseMessagesFromBladeFile(string $contents): array + { + $messages = []; + $contentWithoutPhp = preg_replace('/<\?php.*?\?>/s', '', $contents); + + if (preg_match_all('/@system\s*(.*?)@endsystem/s', (string) $contentWithoutPhp, $systemMatches, PREG_SET_ORDER)) { + foreach ($systemMatches as $match) { + $content = trim($match[1]); + + if ($content !== '') { + $messages[] = new SystemMessage($content); + } + } + } + + if (preg_match_all('/@user\s*(.*?)@enduser/s', (string) $contentWithoutPhp, $userMatches, PREG_SET_ORDER)) { + foreach ($userMatches as $match) { + $content = trim($match[1]); + + if ($content !== '') { + $messages[] = new UserMessage($content); + } + } + } + + if (preg_match_all('/@assistant\s*(.*?)@endassistant/s', (string) $contentWithoutPhp, $assistantMatches, PREG_SET_ORDER)) { + foreach ($assistantMatches as $match) { + $content = trim($match[1]); + + if ($content !== '') { + $messages[] = new AssistantMessage($content); + } + } + } + + if (preg_match_all('/@tool\s*\(([^)]+)\)\s*(.*?)@endtool/s', (string) $contentWithoutPhp, $toolMatches, PREG_SET_ORDER)) { + foreach ($toolMatches as $match) { + $toolCallId = trim($match[1], ' "\''); + $content = trim($match[2]); + + if ($content !== '') { + $messages[] = new ToolMessage($content, $toolCallId); + } + } + } + + if ($messages === []) { + $content = trim((string) $contentWithoutPhp); + $content = preg_replace('/@(system|user|assistant|tool).*?@end(?:system|user|assistant|tool)/s', '', $content); + $content = trim((string) $content); + + if ($content !== '') { + $messages[] = new UserMessage($content); + } + } + + return $messages; + } + + /** + * Parse messages from the rendered Blade output. + * + * @param string $rendered The rendered Blade output + * @param array $variables The variables used for rendering + * + * @return array + */ + protected function parseMessagesFromRendered(string $rendered, array $variables): array + { + $messages = []; + + // Parse the rendered output for tags + preg_match_all( + '/(.*?)<\/cortex-message>/s', + $rendered, + $matches, + PREG_SET_ORDER, + ); + + if ($matches === []) { + // Fallback: treat entire rendered output as single user message + $content = trim($rendered); + + if ($content !== '') { + $content = $this->restoreVariablePlaceholders($content, $variables); + $messages[] = new UserMessage($content); + } + + return $messages; + } + + foreach ($matches as $match) { + $role = $match[1]; + $toolCallId = $match[2] !== '' ? $match[2] : null; + $content = trim($match[3]); + + // Restore variable placeholders if needed + if (str_contains($content, '__VAR_')) { + $content = $this->restoreVariablePlaceholders($content, $variables); + } + + $message = match ($role) { + 'system' => new SystemMessage($content), + 'user' => new UserMessage($content), + 'assistant' => new AssistantMessage($content), + 'tool' => new ToolMessage($content, $toolCallId ?? ''), + default => throw new PromptException('Unknown message role: ' . $role), + }; + + $messages[] = $message; + } + + return $messages; + } + + /** + * Restore variable placeholders in content. + * Replaces __VAR_variable__ with {{ $variable }} syntax (Blade format). + * + * @param string $content The content with placeholders + * @param array $variables The variables mapping + */ + protected function restoreVariablePlaceholders(string $content, array $variables): string + { + // Restore placeholder variables to Blade syntax + foreach ($variables as $varName => $placeholder) { + if (is_string($placeholder)) { + $content = str_replace($placeholder, '{{ $' . $varName . ' }}', $content); + } + } + + return $content; + } + + /** + * Check if the Blade file contains conditionals that need dynamic evaluation. + * + * @param string $contents The contents of the Blade file + */ + protected function hasConditionals(string $contents): bool + { + // Check for common Blade conditional directives + return (bool) preg_match('/@(if|unless|isset|empty|switch|foreach|for|while)\s*\(/', $contents); + } + + /** + * Create a template that re-renders the Blade view during format() to handle conditionals. + * + * @param string $viewName The name of the Blade view + * @param array $config The captured configuration + * @param array $options Initial variables + */ + protected function createConditionalTemplate(string $viewName, array $config, array $options): ChatPromptTemplate + { + // Create a template that overrides format() to re-render the Blade view + return new class ($viewName, $config, $options, $this) extends ChatPromptTemplate { + /** + * @param string $viewName The name of the Blade view + * @param array $config The captured configuration + * @param array $options Initial variables + */ + public function __construct( + private readonly string $viewName, + private array $config, + array $options, + private readonly BladePromptFactory $factory, + ) { + // Create a placeholder message collection - will be replaced during format() + parent::__construct( + messages: [new UserMessage('__PLACEHOLDER__')], + initialVariables: $options, + metadata: $this->buildMetadata(), + inputSchema: $this->buildInputSchema(), + ); + + // Set the Blade compiler for this template + $this->compiler = new BladeCompiler(); + } + + /** + * @param array|null $variables Variables to format the template with + */ + public function format(?array $variables = null): MessageCollection + { + $variables = array_merge($this->initialVariables, $variables ?? []); + + if ($this->strict && $variables !== []) { + $this->inputSchema->validate($variables); + } + + // Re-render the Blade view with actual variables + $messages = $this->factory->renderBladeView($this->viewName, $variables); + + return new MessageCollection($messages); + } + + public function variables(): Collection + { + // Extract variables from the Blade file + try { + $finder = app('view')->getFinder(); + $viewPath = $finder->find($this->viewName); + $contents = file_get_contents($viewPath); + + preg_match_all('/\$(\w+)/', $contents, $matches); + $variableNames = array_unique($matches[1]); + + return collect($variableNames)->filter(function (string $name): bool { + return ! in_array($name, BladePromptFactory::LARAVEL_RESERVED_VARIABLES, true); + }); + } catch (Throwable) { + return collect(); + } + } + + private function buildMetadata(): ?PromptMetadata + { + if (! isset($this->config['llm']) || empty($this->config['llm']['provider'])) { + return null; + } + + $llm = $this->config['llm']; + $structuredOutput = $this->config['structuredOutput'] ?? null; + + return new PromptMetadata( + provider: $llm['provider'] ?? null, + model: $llm['model'] ?? null, + parameters: $llm['parameters'] ?? [], + tools: $this->config['tools'] ?? [], + structuredOutput: $structuredOutput, + structuredOutputMode: $this->config['structuredOutputMode'] ?? StructuredOutputMode::Auto, + ); + } + + private function buildInputSchema(): ?ObjectSchema + { + return $this->config['inputSchema'] ?? null; + } + }; + } + + /** + * Extract placeholder variables from the Blade file for initial parsing. + * + * @param string $originalContents The contents of the Blade file + * + * @return array Map of variable names to placeholder values + */ + protected function extractPlaceholderVariables(string $originalContents): array + { + // Extract all variable names from the original Blade file + preg_match_all('/\$(\w+)/', $originalContents, $varMatches); + $allVariables = array_unique($varMatches[1]); + + // Remove PHP reserved variables and common Laravel variables + $variableNames = array_filter($allVariables, function (string $name): bool { + return ! in_array($name, BladePromptFactory::LARAVEL_RESERVED_VARIABLES, true); + }); + + // Check which variables are used in conditionals + preg_match_all('/@(if|unless|isset|empty)\s*\(\s*\$(\w+)/', $originalContents, $conditionalMatches); + $conditionalVariables = array_unique($conditionalMatches[2]); + + // Create placeholder variables + $variables = []; + foreach ($variableNames as $varName) { + $variables[$varName] = in_array($varName, $conditionalVariables, true) ? false : '__VAR_' . $varName . '__'; + } + + return $variables; + } + + /** + * Render the Blade view and extract messages from the output. + * Used by conditional templates during format(). + * + * @param string $viewName The name of the Blade view + * @param array $variables The variables to pass to the view + * + * @return array + */ + public function renderBladeView(string $viewName, array $variables): array + { + // Render the Blade view with actual variables + $rendered = view($viewName, $variables)->render(); + + // Parse messages from rendered output + return $this->parseMessagesFromRendered($rendered, $variables); + } + + /** + * Convert captured config to PromptMetadata. + * + * @param array $config + */ + protected function configToMetadata(array $config): ?PromptMetadata + { + if (! isset($config['llm']) || empty($config['llm']['provider'])) { + return null; + } + + $llm = $config['llm']; + $structuredOutput = $config['structuredOutput'] ?? null; + + return new PromptMetadata( + provider: $llm['provider'] ?? null, + model: $llm['model'] ?? null, + parameters: $llm['parameters'] ?? [], + tools: $config['tools'] ?? [], + structuredOutput: $structuredOutput, + structuredOutputMode: $config['structuredOutputMode'] ?? StructuredOutputMode::Auto, + ); + } +} diff --git a/src/Prompts/Factories/LangfusePromptFactory.php b/src/Prompts/Factories/LangfusePromptFactory.php index a343927..808e791 100644 --- a/src/Prompts/Factories/LangfusePromptFactory.php +++ b/src/Prompts/Factories/LangfusePromptFactory.php @@ -7,17 +7,17 @@ use Closure; use JsonException; use SensitiveParameter; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Contracts\Message; use Psr\SimpleCache\CacheInterface; -use Cortex\JsonSchema\SchemaFactory; use Psr\Http\Client\ClientInterface; use Cortex\Exceptions\PromptException; use Cortex\Prompts\Data\PromptMetadata; use Cortex\LLM\Data\Messages\UserMessage; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\SystemMessage; use Cortex\Prompts\Contracts\PromptFactory; use Cortex\Prompts\Contracts\PromptTemplate; -use Cortex\Tasks\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\Prompts\Builders\ChatPromptBuilder; use Cortex\Prompts\Builders\TextPromptBuilder; @@ -210,7 +210,7 @@ protected static function defaultMetadataResolver(): Closure $structuredOutput = $config['structured_output'] ?? null; if (is_string($structuredOutput) || is_array($structuredOutput)) { - $structuredOutput = SchemaFactory::fromJson($structuredOutput); + $structuredOutput = Schema::fromJson($structuredOutput); } $structuredOutputMode = StructuredOutputMode::tryFrom($config['structured_output_mode'] ?? ''); diff --git a/src/Prompts/Factories/McpPromptFactory.php b/src/Prompts/Factories/McpPromptFactory.php index 352963e..31c334b 100644 --- a/src/Prompts/Factories/McpPromptFactory.php +++ b/src/Prompts/Factories/McpPromptFactory.php @@ -8,8 +8,8 @@ use PhpMcp\Client\Client; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Contracts\Message; -use Cortex\JsonSchema\SchemaFactory; use Cortex\Exceptions\PromptException; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\UserMessage; @@ -67,7 +67,7 @@ protected function buildInputSchema(PromptDefinition $prompt): ObjectSchema $properties = []; foreach ($prompt->arguments as $argument) { - $property = SchemaFactory::string($argument->name) + $property = Schema::string($argument->name) ->description($argument->description); if ($argument->required) { diff --git a/src/Prompts/PromptFactoryManager.php b/src/Prompts/PromptFactoryManager.php index cccff33..d8f7a36 100644 --- a/src/Prompts/PromptFactoryManager.php +++ b/src/Prompts/PromptFactoryManager.php @@ -6,6 +6,7 @@ use Illuminate\Support\Manager; use Cortex\Prompts\Factories\McpPromptFactory; +use Cortex\Prompts\Factories\BladePromptFactory; use Cortex\Prompts\Factories\LangfusePromptFactory; class PromptFactoryManager extends Manager @@ -38,4 +39,14 @@ public function createMcpDriver(): McpPromptFactory $this->container->make('cortex.mcp_server')->driver($config['server'] ?? null), ); } + + public function createBladeDriver(): BladePromptFactory + { + /** @var array{path?: string} $config */ + $config = $this->config->get('cortex.prompt_factory.blade'); + + return new BladePromptFactory( + $config['path'] ?? base_path('resources/views/prompts'), + ); + } } diff --git a/src/Prompts/Templates/AbstractPromptTemplate.php b/src/Prompts/Templates/AbstractPromptTemplate.php index 63f8186..33c36fd 100644 --- a/src/Prompts/Templates/AbstractPromptTemplate.php +++ b/src/Prompts/Templates/AbstractPromptTemplate.php @@ -7,17 +7,20 @@ use Closure; use Cortex\Pipeline; use Cortex\Facades\LLM; +use Cortex\JsonSchema\Schema; use Cortex\Contracts\Pipeable; +use Cortex\Pipeline\RuntimeConfig; use Cortex\Support\Traits\CanPipe; -use Cortex\JsonSchema\SchemaFactory; use Cortex\Exceptions\PromptException; use Cortex\JsonSchema\Enums\SchemaType; use Cortex\Prompts\Data\PromptMetadata; use Cortex\Exceptions\PipelineException; use Cortex\JsonSchema\Types\UnionSchema; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\Prompts\Compilers\TextCompiler; use Cortex\LLM\Data\StructuredOutputConfig; use Cortex\LLM\Contracts\LLM as LLMContract; +use Cortex\Prompts\Contracts\PromptCompiler; use Cortex\Prompts\Contracts\PromptTemplate; abstract class AbstractPromptTemplate implements PromptTemplate, Pipeable @@ -26,7 +29,9 @@ abstract class AbstractPromptTemplate implements PromptTemplate, Pipeable public ?PromptMetadata $metadata = null; - public function handlePipeable(mixed $payload, Closure $next): mixed + public ?PromptCompiler $compiler = null; + + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { $variables = $this->variables(); @@ -35,14 +40,14 @@ public function handlePipeable(mixed $payload, Closure $next): mixed if (is_string($payload) && $variables->containsOneItem()) { return $next($this->format([ $variables->first() => $payload, - ])); + ]), $config); } if (! is_array($payload) && $payload !== null) { throw new PipelineException('A prompt template must be passed null or an array of variables.'); } - return $next($this->format($payload)); + return $next($this->format($payload), $config); } /** @@ -51,7 +56,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed public function defaultInputSchema(): ObjectSchema { $properties = $this->variables() - ->map(fn(string $variable): UnionSchema => SchemaFactory::union([ + ->map(fn(string $variable): UnionSchema => Schema::union([ SchemaType::String, SchemaType::Number, SchemaType::Boolean, @@ -60,7 +65,7 @@ public function defaultInputSchema(): ObjectSchema ->values() ->toArray(); - return SchemaFactory::object()->properties(...$properties); + return Schema::object()->properties(...$properties); } /** @@ -71,15 +76,17 @@ public function llm( Closure|string|null $model = null, ): Pipeline { if ($provider === null && $this->metadata === null) { - throw new PromptException('No LLM driver provided.'); + throw new PromptException('No LLM provider or metadata provided.'); } if ($provider instanceof LLMContract) { $llm = $provider; } elseif ($provider === null) { - $llm = $this->metadata->provider !== null - ? LLM::provider($this->metadata->provider) - : LLM::provider($provider); + if (is_string($this->metadata?->provider)) { + $llm = LLM::provider($this->metadata->provider); + } else { + $llm = $this->metadata->provider; + } } else { $llm = LLM::provider($provider); } @@ -120,4 +127,21 @@ public function llm( return $this->pipe($llm); } + + public function withCompiler(PromptCompiler $compiler): self + { + $this->compiler = $compiler; + + return $this; + } + + public function getCompiler(): PromptCompiler + { + return $this->compiler ?? self::defaultCompiler(); + } + + public static function defaultCompiler(): PromptCompiler + { + return new TextCompiler(); + } } diff --git a/src/Prompts/Templates/ChatPromptTemplate.php b/src/Prompts/Templates/ChatPromptTemplate.php index b7d4451..0a03550 100644 --- a/src/Prompts/Templates/ChatPromptTemplate.php +++ b/src/Prompts/Templates/ChatPromptTemplate.php @@ -6,14 +6,17 @@ use Override; use Cortex\Support\Utils; +use Cortex\JsonSchema\Schema; +use Cortex\LLM\Contracts\Message; use Illuminate\Support\Collection; -use Cortex\JsonSchema\SchemaFactory; use Cortex\Exceptions\PromptException; use Cortex\JsonSchema\Enums\SchemaType; use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\UnionSchema; use Cortex\JsonSchema\Types\ObjectSchema; +use Cortex\Agents\Prebuilt\GenericAgentBuilder; use Cortex\LLM\Data\Messages\MessageCollection; +use Cortex\LLM\Data\Messages\MessagePlaceholder; class ChatPromptTemplate extends AbstractPromptTemplate { @@ -35,6 +38,8 @@ public function __construct( if ($this->messages->isEmpty()) { throw new PromptException('Messages cannot be empty.'); } + + $this->inputSchema ??= $this->defaultInputSchema(); } public function format(?array $variables = null): MessageCollection @@ -42,28 +47,48 @@ public function format(?array $variables = null): MessageCollection $variables = array_merge($this->initialVariables, $variables ?? []); if ($this->strict && $variables !== []) { - $inputSchema = $this->inputSchema ?? $this->defaultInputSchema(); - $inputSchema->validate($variables); + $this->inputSchema->validate($variables); } // Replace any placeholders with the actual messages and variables with the actual values - return $this->messages->replacePlaceholders($variables)->replaceVariables($variables); + return $this->messages->replacePlaceholders($variables) + ->replaceVariables($variables, $this->getCompiler()); } public function variables(): Collection { - return $this->messages->variables() + return $this->messages->variables($this->getCompiler()) ->merge(array_keys($this->initialVariables)) ->unique(); } + /** + * Add a message to the prompt template. + */ + public function addMessage(Message|MessagePlaceholder $message): self + { + $this->messages->add($message); + + return $this; + } + + /** + * Keep only the message placeholders in the prompt template and remove all other messages. + */ + public function keepOnlyPlaceholders(): self + { + $this->messages = $this->messages->onlyPlaceholders(); + + return $this; + } + #[Override] public function defaultInputSchema(): ObjectSchema { $properties = $this->variables() // Remove message placeholder variables ->diff($this->messages->placeholderVariables()) - ->map(fn(string $variable): UnionSchema => SchemaFactory::union([ + ->map(fn(string $variable): UnionSchema => Schema::union([ SchemaType::String, SchemaType::Number, SchemaType::Boolean, @@ -72,7 +97,15 @@ public function defaultInputSchema(): ObjectSchema ->values() ->toArray(); - return SchemaFactory::object() + return Schema::object() ->properties(...$properties); } + + /** + * Convenience method to build a generic agent builder from the prompt template. + */ + public function agentBuilder(): GenericAgentBuilder + { + return new GenericAgentBuilder()->withPrompt($this); + } } diff --git a/src/Prompts/Templates/TextPromptTemplate.php b/src/Prompts/Templates/TextPromptTemplate.php index 55fc827..d1e2d1d 100644 --- a/src/Prompts/Templates/TextPromptTemplate.php +++ b/src/Prompts/Templates/TextPromptTemplate.php @@ -4,7 +4,6 @@ namespace Cortex\Prompts\Templates; -use Cortex\Support\Utils; use Illuminate\Support\Collection; use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\ObjectSchema; @@ -20,23 +19,24 @@ public function __construct( public ?PromptMetadata $metadata = null, public ?ObjectSchema $inputSchema = null, public bool $strict = true, - ) {} + ) { + $this->inputSchema ??= $this->defaultInputSchema(); + } public function format(?array $variables = null): string { $variables = array_merge($this->initialVariables, $variables ?? []); if ($this->strict) { - $inputSchema = $this->inputSchema ?? $this->defaultInputSchema(); - $inputSchema->validate($variables); + $this->inputSchema->validate($variables); } - return Utils::replaceVariables($this->text, $variables); + return $this->getCompiler()->compile($this->text, $variables); } public function variables(): Collection { - return collect(Utils::findVariables($this->text)) + return collect($this->getCompiler()->variables($this->text)) ->merge(array_keys($this->initialVariables)) ->unique(); } diff --git a/src/Prompts/helpers.php b/src/Prompts/helpers.php new file mode 100644 index 0000000..fe1b93d --- /dev/null +++ b/src/Prompts/helpers.php @@ -0,0 +1,78 @@ +|null $parameters + */ +function llm(string $provider, ?string $model = null, ?array $parameters = null): void +{ + BladePromptContext::setLLM($provider, $model); + + if ($parameters !== null) { + BladePromptContext::setParameters($parameters); + } +} + +/** + * Helper function to set LLM parameters for Blade prompts. + * + * @param array $params + */ +function parameters(array $params): void +{ + BladePromptContext::setParameters($params); +} + +/** + * Helper function to set input schema for Blade prompts. + * + * @param array|ObjectSchema $schema + */ +function inputSchema(array|ObjectSchema $schema): void +{ + if (is_array($schema)) { + $schema = Schema::object()->properties(...$schema); + } + + BladePromptContext::setInputSchema($schema); +} + +/** + * Helper function to set tools for Blade prompts. + * + * @param array $tools + */ +function tools(array $tools): void +{ + BladePromptContext::setTools($tools); +} + +/** + * Helper function to set structured output for Blade prompts. + * + * @param array|StructuredOutputConfig|ObjectSchema|string $output + */ +function structuredOutput( + array|StructuredOutputConfig|ObjectSchema|string $output, + ?string $name = null, + ?string $description = null, + bool $strict = true, + StructuredOutputMode $outputMode = StructuredOutputMode::Auto, +): void { + if (is_array($output)) { + $output = Schema::object()->properties(...$output); + } + + BladePromptContext::setStructuredOutput($output, $name, $description, $strict, $outputMode); +} diff --git a/src/Support/Traits/CanPipe.php b/src/Support/Traits/CanPipe.php index 8691420..abd7a3b 100644 --- a/src/Support/Traits/CanPipe.php +++ b/src/Support/Traits/CanPipe.php @@ -4,6 +4,7 @@ namespace Cortex\Support\Traits; +use Closure; use Cortex\Pipeline; use Cortex\Contracts\Pipeable; @@ -12,7 +13,7 @@ */ trait CanPipe { - public function pipe(Pipeable|callable $next): Pipeline + public function pipe(Pipeable|Closure $next): Pipeline { $pipeline = new Pipeline($this); diff --git a/src/Support/Traits/DispatchesEvents.php b/src/Support/Traits/DispatchesEvents.php index 9a06237..318efa3 100644 --- a/src/Support/Traits/DispatchesEvents.php +++ b/src/Support/Traits/DispatchesEvents.php @@ -4,6 +4,7 @@ namespace Cortex\Support\Traits; +use Closure; use Psr\EventDispatcher\EventDispatcherInterface; trait DispatchesEvents @@ -12,6 +13,13 @@ trait DispatchesEvents protected ?EventDispatcherInterface $eventDispatcher = null; + /** + * Instance-specific event listeners. + * + * @var array> + */ + protected array $instanceListeners = []; + /** * Get the event dispatcher. */ @@ -31,8 +39,62 @@ public function setEventDispatcher(EventDispatcherInterface $eventDispatcher): v /** * Dispatch an event. */ - public function dispatchEvent(object $event): void + public function dispatchEvent(object $event, bool $dispatchToGlobalDispatcher = true): void + { + // Check instance-specific listeners first + $eventClass = $event::class; + + if (isset($this->instanceListeners[$eventClass])) { + foreach ($this->instanceListeners[$eventClass] as $listener) { + if ($this->eventBelongsToThisInstance($event)) { + $listener($event); + } + } + } + + // Then dispatch to global dispatcher + if ($dispatchToGlobalDispatcher) { + $this->getEventDispatcher()?->dispatch($event); + } + } + + /** + * Register an instance-specific listener. + */ + public function on(string $eventClass, Closure $listener, bool $once = false): static + { + if ($once && $this->hasListener($eventClass)) { + return $this; + } + + if (! isset($this->instanceListeners[$eventClass])) { + $this->instanceListeners[$eventClass] = []; + } + + $this->instanceListeners[$eventClass][] = $listener; + + return $this; + } + + public function hasListener(string $eventClass): bool + { + return isset($this->instanceListeners[$eventClass]) + && count($this->instanceListeners[$eventClass]) > 0; + } + + /** + * Register an instance-specific listener only if it hasn't been registered yet. + */ + public function once(string $eventClass, Closure $listener): static { - $this->getEventDispatcher()?->dispatch($event); + return $this->on($eventClass, $listener, once: true); } + + /** + * Check if an event belongs to this instance. + * + * Each class using this trait MUST implement this method to define + * how to identify events that belong to this specific instance. + */ + abstract protected function eventBelongsToThisInstance(object $event): bool; } diff --git a/src/Support/Utils.php b/src/Support/Utils.php index 4b0a14a..8754003 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -5,6 +5,8 @@ namespace Cortex\Support; use Closure; +use Cortex\Facades\LLM; +use Illuminate\Support\Str; use Cortex\Tools\SchemaTool; use Cortex\Tools\ClosureTool; use Cortex\LLM\Contracts\Tool; @@ -14,35 +16,13 @@ use Cortex\Exceptions\GenericException; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\UserMessage; +use Cortex\LLM\Contracts\LLM as LLMContract; use Cortex\LLM\Data\Messages\MessageCollection; use Cortex\LLM\Data\Messages\MessagePlaceholder; class Utils { - protected const string VARIABLE_REGEX = '/\{(\w+)\}/'; - - /** - * @param array $variables - */ - public static function replaceVariables(string $input, array $variables): string - { - return preg_replace_callback( - self::VARIABLE_REGEX, - fn(array $matches) => $variables[$matches[1]] ?? $matches[0], - $input, - ); - } - - /** - * @return array - */ - public static function findVariables(string $input): array - { - preg_match_all(self::VARIABLE_REGEX, $input, $matches); - - // @phpstan-ignore nullCoalesce.offset - return $matches[1] ?? []; - } + public const string SHORTCUT_SEPARATOR = '/'; /** * Resolve tool instances from the given array. @@ -85,6 +65,86 @@ public static function toMessageCollection(MessageCollection|Message|array|strin return $messages->ensure([Message::class, MessagePlaceholder::class]); } + /** + * Determine if the given string is an LLM shortcut. + */ + public static function isLLMShortcut(string $input): bool + { + return self::isShortcut($input, 'cortex.llm.%s'); + } + + /** + * Split the given LLM shortcut into provider and model. + * + * @return array{provider: string, model: string|null} + */ + public static function splitLLMShortcut(string $input): array + { + $split = Str::of($input)->explode(self::SHORTCUT_SEPARATOR, 2); + + $provider = $split->first(); + + $model = $split->count() === 1 + ? null + : $split->last(); + + return [ + 'provider' => $provider, + 'model' => $model, + ]; + } + + /** + * Determine if the given string is a prompt factory shortcut. + */ + public static function isPromptShortcut(string $input): bool + { + return self::isShortcut($input, 'cortex.prompt_factory.%s'); + } + + /** + * Split the given prompt factory shortcut into factory and driver. + * + * @return array{factory: string, driver: string|null} + */ + public static function splitPromptShortcut(string $input): array + { + $split = Str::of($input)->explode(self::SHORTCUT_SEPARATOR, 2); + + $factory = $split->first(); + + $driver = $split->count() === 1 + ? null + : $split->last(); + + return [ + 'factory' => $factory, + 'driver' => $driver, + ]; + } + + /** + * Convert the given provider to an LLM instance. + */ + public static function llm(LLMContract|string|null $provider): LLMContract + { + if (is_string($provider)) { + ['provider' => $provider, 'model' => $model] = self::splitLLMShortcut($provider); + + $llm = LLM::provider($provider); + + if ($model !== null) { + $llm->withModel($model); + } + + return $llm; + } + + return $provider instanceof LLMContract + ? $provider + : LLM::provider($provider); + } + /** * Determine if the given string is a URL. */ @@ -168,4 +228,15 @@ public static function resolveMimeType(string $value): string throw new ContentException('Invalid content.'); } + + /** + * Determine if the given string is a shortcut. + */ + protected static function isShortcut(string $input, string $configPath): bool + { + $values = Str::of($input)->explode(self::SHORTCUT_SEPARATOR, 2); + + return $values->count() > 1 + && config(sprintf($configPath, $values->first())) !== null; + } } diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 4c8a46d..9e8f517 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -6,14 +6,16 @@ use Closure; use Cortex\Cortex; +use Cortex\Agents\Agent; use Cortex\Prompts\Prompt; use Cortex\LLM\Contracts\LLM; use Cortex\Tools\ClosureTool; -use Cortex\Tasks\Enums\TaskType; -use Cortex\Tasks\Builders\TextTaskBuilder; use Cortex\Prompts\Contracts\PromptBuilder; +use Cortex\Agents\Prebuilt\GenericAgentBuilder; use Cortex\LLM\Data\Messages\MessageCollection; -use Cortex\Tasks\Builders\StructuredTaskBuilder; +use Cortex\Agents\Middleware\AfterModelClosureMiddleware; +use Cortex\Agents\Middleware\BeforeModelClosureMiddleware; +use Cortex\Agents\Middleware\BeforePromptClosureMiddleware; /** * Helper function to create a chat prompt builder. @@ -30,17 +32,19 @@ function prompt(MessageCollection|array|string|null $messages): Prompt|PromptBui /** * Helper function to create an LLM instance. */ -function llm(?string $provider = null, ?string $model = null): LLM +function llm(?string $provider = null, Closure|string|null $model = null): LLM { return Cortex::llm($provider, $model); } /** - * Helper function to build tasks. + * Helper function to get an agent instance from the registry. + * + * @return ($name is null ? \Cortex\Agents\Prebuilt\GenericAgentBuilder : \Cortex\Agents\Agent) */ -function task(?string $name = null, TaskType $type = TaskType::Text): TextTaskBuilder|StructuredTaskBuilder +function agent(?string $name = null): Agent|GenericAgentBuilder { - return Cortex::task($name, $type); + return Cortex::agent($name); } /** @@ -50,3 +54,30 @@ function tool(string $name, string $description, Closure $closure): ClosureTool { return new ClosureTool($closure, $name, $description); } + +/** + * Helper function to wrap a closure as before-model middleware. + * The closure signature should match: fn(mixed $payload, RuntimeConfig $config, Closure $next): mixed + */ +function beforeModel(Closure $closure): BeforeModelClosureMiddleware +{ + return new BeforeModelClosureMiddleware($closure); +} + +/** + * Helper function to wrap a closure as after-model middleware. + * The closure signature should match: fn(mixed $payload, RuntimeConfig $config, Closure $next): mixed + */ +function afterModel(Closure $closure): AfterModelClosureMiddleware +{ + return new AfterModelClosureMiddleware($closure); +} + +/** + * Helper function to wrap a closure as before-prompt middleware. + * The closure signature should match: fn(mixed $payload, RuntimeConfig $config, Closure $next): mixed + */ +function beforePrompt(Closure $closure): BeforePromptClosureMiddleware +{ + return new BeforePromptClosureMiddleware($closure); +} diff --git a/src/Tasks/AbstractTask.php b/src/Tasks/AbstractTask.php deleted file mode 100644 index aa24b7a..0000000 --- a/src/Tasks/AbstractTask.php +++ /dev/null @@ -1,231 +0,0 @@ - $initialPromptVariables - */ - public function __construct( - protected array $initialPromptVariables = [], - ) { - $this->memory = new ChatMemory(new InMemoryStore($this->messages())); - $this->usage = Usage::empty(); - } - - /** - * Define the initial messages for the task. - */ - abstract public function messages(): MessageCollection; - - /** - * Get the prompt for the task. - */ - public function prompt(): ChatPromptTemplate - { - return new ChatPromptTemplate([ - new MessagePlaceholder('messages'), - ], $this->initialPromptVariables); - } - - public function llm(): LLMContract - { - return LLM::provider(); - } - - public function outputParser(): ?OutputParser - { - return null; - } - - public function pipeline(): Pipeline - { - $tools = $this->getTools(); - $outputParser = $this->outputParser(); - - // If the output parser is set, we don't want to parse the output - // as part of the ChatResult, as it will be parsed as part of the next pipeable. - $shouldParseOutput = $outputParser === null; - - return $this->executionPipeline($shouldParseOutput) - ->when( - $tools->isNotEmpty(), - fn(Pipeline $pipeline): Pipeline => $pipeline->pipe( - new HandleToolCalls( - $tools, - $this->memory, - $this->executionPipeline($shouldParseOutput), - $this->maxIterations, - ), - ), - ) - ->when( - $outputParser !== null, - fn(Pipeline $pipeline): Pipeline => $pipeline->pipe($outputParser), - ); - } - - /** - * This is the main pipeline that will be used to generate the output. - */ - public function executionPipeline(bool $shouldParseOutput = true): Pipeline - { - $llm = $this->llm(); - - if ($shouldParseOutput === false) { - $llm = $llm->shouldParseOutput(false); - } - - return $this->prompt() - ->pipe($llm) - ->pipe(new AddMessageToMemory($this->memory)) - ->pipe(new AppendUsage($this->usage)); - } - - public function pipe(Pipeable|callable $pipeable): Pipeline - { - return $this->pipeline()->pipe($pipeable); - } - - /** - * @param array $input - */ - public function invoke(array $input = []): mixed - { - $this->id ??= $this->generateId(); - $this->memory->setVariables([ - ...$this->initialPromptVariables, - ...$input, - ]); - - return $this->pipeline()->invoke([ - ...$input, - 'messages' => $this->memory->getMessages(), - ]); - } - - /** - * @param array $input - */ - public function stream(array $input = []): ChatStreamResult - { - $this->id ??= $this->generateId(); - $this->memory->setVariables([ - ...$this->initialPromptVariables, - ...$input, - ]); - - /** @var \Cortex\LLM\Data\ChatStreamResult $result */ - $result = $this->pipeline()->stream([ - ...$input, - 'messages' => $this->memory->getMessages(), - ]); - - // Ensure that any nested ChatStreamResults are flattened - // so that the stream is a single stream of chunks. - // TODO: This breaks things like the JSON output parser. - // return $result->flatten(); - - return $result; - } - - /** - * @param array $input - */ - public function __invoke(array $input = []): mixed - { - return $this->invoke($input); - } - - public function handlePipeable(mixed $payload, Closure $next): mixed - { - $payload = match (true) { - $payload === null => [], - is_array($payload) => $payload, - $payload instanceof Arrayable => $payload->toArray(), - is_object($payload) => get_object_vars($payload), - default => throw new PipelineException('Invalid input for task.'), - }; - - return $next($this->invoke($payload)); - } - - public function memory(): Memory - { - return $this->memory; - } - - public function usage(): Usage - { - return $this->usage; - } - - public function setId(string $id): static - { - $this->id = $id; - - return $this; - } - - public function getId(): string - { - return $this->id; - } - - protected function generateId(): string - { - return 'task_' . bin2hex(random_bytes(16)); - } -} diff --git a/src/Tasks/Builders/Concerns/BuildsTasks.php b/src/Tasks/Builders/Concerns/BuildsTasks.php deleted file mode 100644 index 99d6d69..0000000 --- a/src/Tasks/Builders/Concerns/BuildsTasks.php +++ /dev/null @@ -1,309 +0,0 @@ - - */ - protected array $initialPromptVariables = []; - - /** - * @var array - */ - protected array $messages = []; - - /** - * The output parser for the task. - */ - protected ?OutputParser $outputParser = null; - - /** - * @var array - */ - protected array $tools = []; - - /** - * The tool choice for the task. - */ - protected ToolChoice|string $toolChoice = ToolChoice::Auto; - - /** - * Whether to return raw assistant message from the llm. - */ - protected bool $rawOutput = false; - - /** - * The maximum number of tool call iterations for the task. - */ - protected int $maxIterations = 1; - - /** - * Set the name of the task. - */ - public function name(string $name): self - { - $this->name = $name; - - return $this; - } - - /** - * Set the description of the task. - */ - public function description(string $description): self - { - $this->description = $description; - - return $this; - } - - /** - * Set the system message for the task. - */ - public function system(string $system): self - { - $this->system = $system; - - return $this; - } - - /** - * Set the user message for the task. - * - * @param string|\Cortex\LLM\Contracts\Content[] $userMessage - */ - public function user(string|array $userMessage): self - { - $this->userMessage = $userMessage; - - return $this; - } - - /** - * Set the prompt for the task. - */ - public function prompt(ChatPromptTemplate $prompt): self - { - $this->prompt = $prompt; - - return $this; - } - - /** - * Set the messages for the task. - * - * @param \Cortex\LLM\Data\Messages\MessageCollection|array $messages - */ - public function messages(MessageCollection|array $messages): self - { - $this->messages = Utils::toMessageCollection($messages)->all(); - - return $this; - } - - /** - * Set the LLM instance for the task. - */ - public function llm(LLMContract|string $provider, Closure|string|null $model = null): self - { - $llm = $provider instanceof LLMContract - ? $provider - : LLM::provider($provider); - - if (is_string($model)) { - $llm->withModel($model); - } elseif ($model instanceof Closure) { - $llm = $model($llm); - } - - $this->llm = $llm; - - return $this; - } - - /** - * Set the initial prompt variables for the task. - * This will be passed to the prompt on initialisation. - * - * @param array $variables - */ - public function initialPromptVariables(array $variables): self - { - $this->initialPromptVariables = $variables; - - return $this; - } - - /** - * Set the available tools for the task. - * - * @param array $tools - */ - public function tools(array $tools, ToolChoice|string|null $toolChoice = null): self - { - $this->tools = $tools; - - if ($toolChoice !== null) { - $this->toolChoice($toolChoice); - } - - return $this; - } - - /** - * Set the tool choice for the task. - */ - public function toolChoice(ToolChoice|string $toolChoice): self - { - $this->toolChoice = $toolChoice; - - return $this; - } - - /** - * Set the output parser for the task. - */ - public function outputParser(?OutputParser $outputParser): self - { - $this->outputParser = $outputParser; - - return $this; - } - - /** - * Set the maximum number of tool call iterations for the task. - */ - public function maxIterations(int $maxIterations): self - { - $this->maxIterations = $maxIterations; - - return $this; - } - - /** - * Convenience method to build and pipe the task. - */ - public function pipe(Pipeable|callable $pipeable): Pipeline - { - return $this->build()->pipe($pipeable); - } - - /** - * Convenience method to build and invoke the task. - * - * @param array $payload - */ - public function invoke(array $payload = []): mixed - { - return $this->build()->invoke($payload); - } - - /** - * Convenience method to build and stream the task. - * - * @param array $payload - */ - public function stream(array $payload = []): ChatStreamResult - { - return $this->build()->stream($payload); - } - - /** - * @param array $payload - */ - public function __invoke(array $payload = []): mixed - { - return $this->invoke($payload); - } - - /** - * Make the builder pipeable by deferring to the built task - */ - public function handlePipeable(mixed $payload, Closure $next): mixed - { - return $this->build()->handlePipeable($payload, $next); - } - - /** - * Set whether to return raw assistant message from the llm. - */ - public function raw(bool $rawOutput = true): self - { - $this->rawOutput = $rawOutput; - - return $this; - } - - /** - * Get the output parser for the task. - */ - public function getOutputParser(): ?OutputParser - { - // If raw output is enabled, the output parser will be null. - return $this->rawOutput - ? null - : $this->outputParser ?? static::defaultOutputParser(); - } - - /** - * Build the task. - */ - abstract public function build(): Task; - - /** - * Get the default output parser for the task. - */ - abstract protected function defaultOutputParser(): OutputParser; -} diff --git a/src/Tasks/Builders/StructuredTaskBuilder.php b/src/Tasks/Builders/StructuredTaskBuilder.php deleted file mode 100644 index ffa6c07..0000000 --- a/src/Tasks/Builders/StructuredTaskBuilder.php +++ /dev/null @@ -1,193 +0,0 @@ -schema = $schema; - - return $this; - } - - /** - * Specify the schema, class or enum that the LLM should output. - * - * @param class-string|\Cortex\JsonSchema\Types\ObjectSchema $outputType - */ - public function output(ObjectSchema|string $outputType): self - { - if (is_string($outputType)) { - return match (true) { - enum_exists($outputType) && is_subclass_of($outputType, BackedEnum::class) => $this->enum($outputType), - class_exists($outputType) => $this->class($outputType), - default => throw new InvalidArgumentException('Unsupported output type: ' . $outputType), - }; - } - - return $this->schema($outputType); - } - - /** - * Determine how the structured output should be handled by the LLM. - */ - public function outputMode(StructuredOutputMode $outputMode): self - { - $this->outputMode = $outputMode; - - return $this; - } - - /** - * Convenience method to define required properties for an object schema. - * All properties will be set as required and no additional properties will be allowed. - */ - public function properties(Schema ...$properties): self - { - // Ensure all properties are required. - $properties = array_map( - fn(Schema $property): Schema => $property->required(), - $properties, - ); - - return $this->schema( - SchemaFactory::object()->properties(...$properties), - ); - } - - /** - * Specify the enum that the LLM should output. - * - * @param class-string<\BackedEnum> $enum - */ - public function enum(string $enum): self - { - if (! enum_exists($enum)) { - throw new InvalidArgumentException(sprintf('Enum %s does not exist', $enum)); - } - - $reflection = new ReflectionEnum($enum); - - $objectSchema = SchemaFactory::object(); - $values = array_column($enum::cases(), 'value'); - - $schema = match ($reflection->getBackingType()?->getName()) { - 'string' => $objectSchema->properties( - SchemaFactory::string(class_basename($enum))->enum($values), - ), - 'int' => $objectSchema->properties( - SchemaFactory::integer(class_basename($enum))->enum($values), - ), - default => throw new InvalidArgumentException('Unsupported enum backing type. "int" or "string" are supported.'), - }; - - $this->outputParser = new EnumOutputParser($enum); - - return $this->schema($schema); - } - - /** - * Specify the class that the LLM should output. - */ - public function class(string $class): self - { - $this->outputParser = new ClassOutputParser($class); - - return $this->schema(SchemaFactory::fromClass($class)); - } - - /** - * Whether to throw an exception if the LLM does not output a valid response. - */ - public function strict(bool $strict = true): self - { - $this->strict = $strict; - - return $this; - } - - public function build(): Task - { - if ($this->schema === null) { - throw new InvalidArgumentException('Task schema is required'); - } - - if ($this->name === null) { - throw new InvalidArgumentException('Task name is required'); - } - - // if ($this->prompt === null) { - // throw new InvalidArgumentException('Task prompt is required'); - // } - - if ($this->messages === []) { - if ($this->userMessage === null) { - throw new InvalidArgumentException('Task user message is required'); - } - - $messages = []; - - if ($this->system !== null) { - $messages[] = new SystemMessage($this->system); - } - - $messages[] = new UserMessage($this->userMessage); - - $this->messages = $messages; - } - - return new StructuredOutputTask( - name: $this->name, - schema: $this->schema, - llm: $this->llm ?? LLM::provider(), - messages: $this->messages, - description: $this->description, - outputParser: $this->getOutputParser(), - initialPromptVariables: $this->initialPromptVariables, - outputMode: $this->outputMode, - tools: $this->tools, - toolChoice: $this->toolChoice, - strict: $this->strict, - maxIterations: $this->maxIterations, - ); - } - - protected function defaultOutputParser(): OutputParser - { - return new StructuredOutputParser($this->schema, $this->strict); - } -} diff --git a/src/Tasks/Builders/TextTaskBuilder.php b/src/Tasks/Builders/TextTaskBuilder.php deleted file mode 100644 index 56056e5..0000000 --- a/src/Tasks/Builders/TextTaskBuilder.php +++ /dev/null @@ -1,58 +0,0 @@ -messages === []) { - if ($this->userMessage === null) { - throw new InvalidArgumentException('Task user message is required'); - } - - $messages = []; - - if ($this->system !== null) { - $messages[] = new SystemMessage($this->system); - } - - $messages[] = new UserMessage($this->userMessage); - - $this->messages = $messages; - } - - return new TextOutputTask( - name: $this->name, - llm: $this->llm ?? LLM::provider(), - messages: $this->messages, - description: $this->description, - outputParser: $this->getOutputParser(), - initialPromptVariables: $this->initialPromptVariables, - tools: $this->tools, - toolChoice: $this->toolChoice, - maxIterations: $this->maxIterations, - ); - } - - protected function defaultOutputParser(): OutputParser - { - return new StringOutputParser(); - } -} diff --git a/src/Tasks/Concerns/HasTools.php b/src/Tasks/Concerns/HasTools.php deleted file mode 100644 index 292e3f9..0000000 --- a/src/Tasks/Concerns/HasTools.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ - public function tools(): array - { - return []; - } - - /** - * Get the tools for the task. - * - * @return \Illuminate\Support\Collection - */ - public function getTools(): Collection - { - return Utils::toToolCollection($this->tools()); - } -} diff --git a/src/Tasks/Contracts/Task.php b/src/Tasks/Contracts/Task.php deleted file mode 100644 index c8914ec..0000000 --- a/src/Tasks/Contracts/Task.php +++ /dev/null @@ -1,67 +0,0 @@ - $input - */ - public function invoke(array $input = []): mixed; - - /** - * Invoke the task and stream the output. - * - * @param array $input - */ - public function stream(array $input = []): ChatStreamResult; - - /** - * Get the message memory for the task. - */ - public function memory(): Memory; - - /** - * Get the usage for the task. - */ - public function usage(): Usage; -} diff --git a/src/Tasks/Contracts/TaskBuilder.php b/src/Tasks/Contracts/TaskBuilder.php deleted file mode 100644 index 6e6fbea..0000000 --- a/src/Tasks/Contracts/TaskBuilder.php +++ /dev/null @@ -1,13 +0,0 @@ - new TextTaskBuilder(), - self::Structured => new StructuredTaskBuilder(), - }; - } -} diff --git a/src/Tasks/Stages/AddMessageToMemory.php b/src/Tasks/Stages/AddMessageToMemory.php deleted file mode 100644 index ee57443..0000000 --- a/src/Tasks/Stages/AddMessageToMemory.php +++ /dev/null @@ -1,38 +0,0 @@ - $payload->message, - $payload instanceof ChatGenerationChunk && $payload->isFinal => $payload->message->cloneWithContent($payload->contentSoFar), - $payload instanceof ChatResult => $payload->generation->message, - default => null, - }; - - if ($message !== null) { - $this->memory->addMessage($message); - } - - return $next($payload); - } -} diff --git a/src/Tasks/Stages/AppendUsage.php b/src/Tasks/Stages/AppendUsage.php deleted file mode 100644 index f3e2d19..0000000 --- a/src/Tasks/Stages/AppendUsage.php +++ /dev/null @@ -1,35 +0,0 @@ -isFinal => $payload->usage, - default => null, - }; - - if ($usage !== null) { - $this->usage->add($usage); - } - - return $next($payload); - } -} diff --git a/src/Tasks/Stages/HandleToolCalls.php b/src/Tasks/Stages/HandleToolCalls.php deleted file mode 100644 index fff803a..0000000 --- a/src/Tasks/Stages/HandleToolCalls.php +++ /dev/null @@ -1,91 +0,0 @@ - $tools - */ - public function __construct( - protected Collection $tools, - protected Memory $memory, - protected Pipeline $executionPipeline, - protected int $maxIterations, - ) {} - - public function handlePipeable(mixed $payload, Closure $next): mixed - { - $generation = $this->getGeneration($payload); - - // if ($generation instanceof ChatStreamResult) { - // dump('generation is chat stream result'); - // } - - while ($generation?->message?->hasToolCalls() && $this->iterations++ < $this->maxIterations) { - // Get the results of the tool calls, represented as tool messages. - $toolMessages = $generation->message->toolCalls->invokeAsToolMessages($this->tools); - - // If there are any tool messages, add them to the memory. - // And send them to the execution pipeline to get a new generation. - if ($toolMessages->isNotEmpty()) { - // @phpstan-ignore argument.type - $toolMessages->each(fn(ToolMessage $message) => $this->memory->addMessage($message)); - - // Send the tool messages to the execution pipeline to get a new generation. - $payload = $this->executionPipeline->invoke([ - 'messages' => $this->memory->getMessages(), - ...$this->memory->getVariables(), - ]); - - // Update the generation so that the loop can check the new generation for tool calls. - $generation = $this->getGeneration($payload); - } - } - - return $next($payload); - } - - /** - * Get the generation from the payload. - */ - protected function getGeneration(mixed $payload): ChatGeneration|ChatGenerationChunk|null - { - // This is not ideal, since it's not going to stream the - // tool calls as they come in, but rather wait until the end. - // But it works for the purpose of this use case, since we're only - // grabbing the last generation from the stream when the tool calls - // have all streamed in - // if ($payload instanceof ChatStreamResult) { - // dump('generation received chat stream result'); - // $payload = $payload->last(); - - // $payload = $payload->first(fn (ChatGenerationChunk $chunk) => $chunk->isFinal); - // } - - return match (true) { - $payload instanceof ChatGeneration => $payload, - // When streaming, only the final chunk will contain the completed tool calls and content. - $payload instanceof ChatGenerationChunk && $payload->isFinal => $payload, - $payload instanceof ChatResult => $payload->generation, - default => null, - }; - } -} diff --git a/src/Tasks/StructuredOutputTask.php b/src/Tasks/StructuredOutputTask.php deleted file mode 100644 index 79d7a14..0000000 --- a/src/Tasks/StructuredOutputTask.php +++ /dev/null @@ -1,140 +0,0 @@ - $messages - * @param array $initialPromptVariables - * @param array $tools - */ - public function __construct( - protected string $name, - protected ObjectSchema $schema, - protected LLM $llm, - protected MessageCollection|array $messages, - protected ?string $description = null, - protected ?OutputParser $outputParser = null, - protected array $initialPromptVariables = [], - protected StructuredOutputMode $outputMode = StructuredOutputMode::Auto, - protected array $tools = [], - protected ToolChoice|string $toolChoice = ToolChoice::Auto, - public bool $strict = true, - protected int $maxIterations = 1, - ) { - parent::__construct($initialPromptVariables); - } - - public function name(): string - { - return $this->name; - } - - public function description(): ?string - { - return $this->description; - } - - #[Override] - public function messages(): MessageCollection - { - $messages = is_array($this->messages) - ? new MessageCollection($this->messages) - : $this->messages; - - $supportsStructuredOutput = $this->llm()->supportsFeature(ModelFeature::StructuredOutput); - $formatInstructions = $this->outputParser()?->formatInstructions(); - - if ($formatInstructions !== null) { - // Add format instructions to the system message if the LLM does not support structured output. - if (! $messages->hasMessageByRole(MessageRole::System) && ! $supportsStructuredOutput) { - $messages->prepend(new SystemMessage($formatInstructions)); - } else { - $messages = $messages->map(function (Message $message) use ($supportsStructuredOutput, $formatInstructions): Message { - if ($message->role() === MessageRole::System && ! $supportsStructuredOutput) { - return $message->cloneWithContent($message->text() . "\n\n" . $formatInstructions); - } - - return $message; - }); - } - } - - /** @var MessageCollection $messages */ - return $messages; - } - - public function schema(): ObjectSchema - { - return $this->schema; - } - - #[Override] - public function llm(): LLM - { - $schema = $this->schema(); - $name = $this->name(); - $description = $this->description(); - $llm = $this->llm; - - if ($this->tools !== []) { - $llm = $llm->withTools($this->tools, $this->toolChoice); - } - - if ($this->outputMode === StructuredOutputMode::Auto) { - $llm = match (true) { - $this->llm->supportsFeature(ModelFeature::StructuredOutput) => $this->llm->withStructuredOutputConfig( - $schema, - $name, - $description, - ), - $this->llm->supportsFeature(ModelFeature::JsonOutput) => $this->llm->forceJsonOutput(), - default => $this->llm, - }; - } - - if ($this->outputMode === StructuredOutputMode::Json && $this->llm->supportsFeature(ModelFeature::JsonOutput)) { - $llm = $llm->forceJsonOutput(); - } - - if ($this->outputMode === StructuredOutputMode::Tool && $this->llm->supportsFeature(ModelFeature::ToolCalling)) { - $this->outputParser = new JsonOutputToolsParser(key: $name, singleToolCall: true); - - // TODO: pipe a schema validator to the pipeline for the tool arguments - $llm = $llm->addTool(new SchemaTool($schema, $name, $description)); - } - - return $llm; - } - - /** - * @return array - */ - public function tools(): array - { - return $this->tools; - } - - #[Override] - public function outputParser(): ?OutputParser - { - return $this->outputParser; - } -} diff --git a/src/Tasks/TextOutputTask.php b/src/Tasks/TextOutputTask.php deleted file mode 100644 index 545d8c5..0000000 --- a/src/Tasks/TextOutputTask.php +++ /dev/null @@ -1,77 +0,0 @@ - $messages - * @param array $initialPromptVariables - * @param array $tools - */ - public function __construct( - protected string $name, - protected LLM $llm, - protected MessageCollection|array $messages, - protected ?string $description = null, - protected ?OutputParser $outputParser = null, - protected array $initialPromptVariables = [], - protected array $tools = [], - protected ToolChoice|string $toolChoice = ToolChoice::Auto, - protected int $maxIterations = 1, - ) { - parent::__construct($initialPromptVariables); - } - - public function name(): string - { - return $this->name; - } - - public function description(): ?string - { - return $this->description; - } - - #[Override] - public function messages(): MessageCollection - { - return is_array($this->messages) - ? new MessageCollection($this->messages) - : $this->messages; - } - - /** - * @return array - */ - public function tools(): array - { - return $this->tools; - } - - #[Override] - public function llm(): LLM - { - $llm = $this->llm; - - if ($this->tools() !== []) { - return $llm->withTools($this->tools(), $this->toolChoice); - } - - return $llm; - } - - #[Override] - public function outputParser(): ?OutputParser - { - return $this->outputParser; - } -} diff --git a/src/Tools/AbstractTool.php b/src/Tools/AbstractTool.php index 9d03049..30f97fb 100644 --- a/src/Tools/AbstractTool.php +++ b/src/Tools/AbstractTool.php @@ -8,6 +8,7 @@ use Cortex\LLM\Data\ToolCall; use Cortex\Contracts\Pipeable; use Cortex\LLM\Contracts\Tool; +use Cortex\Pipeline\RuntimeConfig; use Cortex\Support\Traits\CanPipe; use Cortex\Exceptions\PipelineException; use Cortex\LLM\Data\Messages\ToolMessage; @@ -21,20 +22,28 @@ abstract class AbstractTool implements Tool, Pipeable */ public function format(): array { - return [ + $output = [ 'name' => $this->name(), 'description' => $this->description(), - 'parameters' => $this->schema()->toArray(includeSchemaRef: false, includeTitle: false), ]; + + $schema = $this->schema(); + + // If the schema has no properties, then we don't need to include the parameters. + if ($schema->getPropertyKeys() !== []) { + $output['parameters'] = $schema->toArray(includeSchemaRef: false, includeTitle: false); + } + + return $output; } - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { if (! is_array($payload)) { throw new PipelineException('Input must be an array.'); } - return $next($this->invoke($payload)); + return $next($this->invoke($payload, $config), $config); } /** @@ -51,9 +60,9 @@ public function getArguments(ToolCall|array $toolCall): array : $toolCall->function->arguments; } - public function invokeAsToolMessage(ToolCall $toolCall): ToolMessage + public function invokeAsToolMessage(ToolCall $toolCall, ?RuntimeConfig $config = null): ToolMessage { - $result = $this->invoke($toolCall); + $result = $this->invoke($toolCall, $config); if (is_array($result)) { $result = json_encode($result); diff --git a/src/Tools/ClosureTool.php b/src/Tools/ClosureTool.php index 4e1d4d8..19e7039 100644 --- a/src/Tools/ClosureTool.php +++ b/src/Tools/ClosureTool.php @@ -6,9 +6,11 @@ use Closure; use ReflectionFunction; +use ReflectionNamedType; use Cortex\Attributes\Tool; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Data\ToolCall; -use Cortex\JsonSchema\SchemaFactory; +use Cortex\Pipeline\RuntimeConfig; use Cortex\JsonSchema\Support\DocParser; use Cortex\JsonSchema\Types\ObjectSchema; @@ -24,7 +26,7 @@ public function __construct( protected ?string $description = null, ) { $this->reflection = new ReflectionFunction($closure); - $this->schema = SchemaFactory::fromClosure($closure); + $this->schema = Schema::fromClosure($closure, ignoreUnknownTypes: true); } public function name(): string @@ -45,13 +47,24 @@ public function schema(): ObjectSchema /** * @param ToolCall|array $toolCall */ - public function invoke(ToolCall|array $toolCall = []): mixed + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed { // Get the arguments from the given tool call. $arguments = $this->getArguments($toolCall); // Ensure arguments are valid as per the tool's schema. - $this->schema->validate($arguments); + if ($arguments !== []) { + $this->schema->validate($arguments); + } + + // Add the context to the arguments if it is a parameter on the closure. + foreach ($this->reflection->getParameters() as $parameter) { + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && $type->getName() === RuntimeConfig::class) { + $arguments[$parameter->getName()] = $config; + } + } // Invoke the closure with the arguments. return $this->reflection->invokeArgs($arguments); diff --git a/src/Tools/GoogleSerper.php b/src/Tools/GoogleSerper.php deleted file mode 100644 index ca07393..0000000 --- a/src/Tools/GoogleSerper.php +++ /dev/null @@ -1,55 +0,0 @@ -properties( - SchemaFactory::string('query')->description('The search query.'), - ); - } - - /** - * @param ToolCall|array $toolCall - */ - public function invoke(ToolCall|array $toolCall = []): string - { - $arguments = $this->getArguments($toolCall); - $searchQuery = $arguments['query']; - - $response = Http::post('https://google.serper.dev/search', [ - 'q' => $searchQuery, - ]) - ->withHeader('x-api-key', $this->apiKey) - ->withHeader('Content-Type', 'application/json'); - - // @phpstan-ignore method.notFound - return $response->collect('knowledgeGraph')->toJson(); - } -} diff --git a/src/Tools/McpTool.php b/src/Tools/McpTool.php index 838cdcd..3063983 100644 --- a/src/Tools/McpTool.php +++ b/src/Tools/McpTool.php @@ -6,8 +6,9 @@ use PhpMcp\Client\Client; use Cortex\Facades\McpServer; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Data\ToolCall; -use Cortex\JsonSchema\SchemaFactory; +use Cortex\Pipeline\RuntimeConfig; use Cortex\Exceptions\GenericException; use Cortex\JsonSchema\Types\ObjectSchema; use PhpMcp\Client\Model\Content\TextContent; @@ -32,7 +33,7 @@ public function __construct( // If a tool definition is provided then we don't need to connect to the MCP server yet. $this->toolDefinition ??= $this->getToolDefinition(); - $schema = SchemaFactory::from($this->toolDefinition->inputSchema); + $schema = Schema::from($this->toolDefinition->inputSchema); if (! $schema instanceof ObjectSchema) { throw new GenericException(sprintf('Schema for tool %s is not an object', $this->name)); @@ -65,7 +66,7 @@ public function schema(): ObjectSchema /** * @param ToolCall|array $toolCall */ - public function invoke(ToolCall|array $toolCall = []): mixed + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed { try { $this->client->initialize(); diff --git a/src/Tools/OpenAITool.php b/src/Tools/OpenAITool.php new file mode 100644 index 0000000..9f601d5 --- /dev/null +++ b/src/Tools/OpenAITool.php @@ -0,0 +1,16 @@ + $parameters + */ + public function __construct( + protected string $type, + protected array $parameters, + ) {} +} diff --git a/src/Tools/Prebuilt/OpenMeteoWeatherTool.php b/src/Tools/Prebuilt/OpenMeteoWeatherTool.php new file mode 100644 index 0000000..6033ccc --- /dev/null +++ b/src/Tools/Prebuilt/OpenMeteoWeatherTool.php @@ -0,0 +1,118 @@ +properties( + Schema::string('location')->required(), + ); + } + + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed + { + $arguments = $this->getArguments($toolCall); + + if ($arguments !== []) { + $this->schema()->validate($arguments); + } + + $location = $arguments['location']; + + /** @var \Illuminate\Http\Client\Response $geocodeResponse */ + // @phpstan-ignore staticMethod.void + $geocodeResponse = Http::get('https://geocoding-api.open-meteo.com/v1/search', [ + 'name' => $location, + 'count' => 1, + 'language' => $config?->context?->get('language') ?? 'en', + 'format' => 'json', + ]); + + $latitude = $geocodeResponse->json('results.0.latitude'); + $longitude = $geocodeResponse->json('results.0.longitude'); + + if (! $latitude || ! $longitude) { + return 'Could not find location for: ' . $location; + } + + $windSpeedUnit = $config?->context?->get('wind_speed_unit') ?? 'mph'; + + /** @var \Illuminate\Http\Client\Response $weatherResponse */ + // @phpstan-ignore staticMethod.void + $weatherResponse = Http::get('https://api.open-meteo.com/v1/forecast', [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'current' => 'temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_gusts_10m,weather_code', + 'wind_speed_unit' => $config?->context?->get('wind_speed_unit') ?? 'mph', + ]); + + $data = $weatherResponse->collect('current'); + + return [ + 'temperature' => $data->get('temperature_2m'), + 'feels_like' => $data->get('apparent_temperature'), + 'humidity' => $data->get('relative_humidity_2m'), + 'wind_speed' => $data->get('wind_speed_10m') . $windSpeedUnit, + 'wind_gusts' => $data->get('wind_gusts_10m') . $windSpeedUnit, + 'conditions' => $this->getWeatherConditions($data->get('weather_code')), + 'location' => $location, + ]; + } + + protected function getWeatherConditions(int $code): string + { + $conditions = [ + 0 => 'Clear sky', + 1 => 'Mainly clear', + 2 => 'Partly cloudy', + 3 => 'Overcast', + 45 => 'Foggy', + 48 => 'Depositing rime fog', + 51 => 'Light drizzle', + 53 => 'Moderate drizzle', + 55 => 'Dense drizzle', + 56 => 'Light freezing drizzle', + 57 => 'Dense freezing drizzle', + 61 => 'Slight rain', + 63 => 'Moderate rain', + 65 => 'Heavy rain', + 66 => 'Light freezing rain', + 67 => 'Heavy freezing rain', + 71 => 'Slight snow fall', + 73 => 'Moderate snow fall', + 75 => 'Heavy snow fall', + 77 => 'Snow grains', + 80 => 'Slight rain showers', + 81 => 'Moderate rain showers', + 82 => 'Violent rain showers', + 85 => 'Slight snow showers', + 86 => 'Heavy snow showers', + 95 => 'Thunderstorm', + 96 => 'Thunderstorm with slight hail', + 99 => 'Thunderstorm with heavy hail', + ]; + + return $conditions[$code] ?? 'Unknown'; + } +} diff --git a/src/Tools/SchemaTool.php b/src/Tools/SchemaTool.php index ba9ead8..2512114 100644 --- a/src/Tools/SchemaTool.php +++ b/src/Tools/SchemaTool.php @@ -5,22 +5,22 @@ namespace Cortex\Tools; use Cortex\LLM\Data\ToolCall; +use Cortex\Pipeline\RuntimeConfig; use Cortex\Exceptions\GenericException; use Cortex\JsonSchema\Types\ObjectSchema; class SchemaTool extends AbstractTool { + public const string NAME = 'schema_output'; + public function __construct( protected ObjectSchema $schema, - protected ?string $name = null, protected ?string $description = null, ) {} public function name(): string { - return $this->name - ?? $this->schema->getTitle() - ?? 'schema_output'; + return self::NAME; } public function description(): string @@ -38,7 +38,7 @@ public function schema(): ObjectSchema /** * @param ToolCall|array $toolCall */ - public function invoke(ToolCall|array $toolCall = []): mixed + public function invoke(ToolCall|array $toolCall = [], ?RuntimeConfig $config = null): mixed { throw new GenericException( 'The Schema tool does not support invocation. It is only used for structured output.', diff --git a/src/Tools/TavilySearch.php b/src/Tools/TavilySearch.php deleted file mode 100644 index 513db32..0000000 --- a/src/Tools/TavilySearch.php +++ /dev/null @@ -1,56 +0,0 @@ -properties( - SchemaFactory::string('query')->description('The search query.'), - ); - } - - /** - * @param ToolCall|array $toolCall - */ - public function invoke(ToolCall|array $toolCall = []): string - { - $arguments = $this->getArguments($toolCall); - $searchQuery = $arguments['query']; - - $response = Http::withHeaders([ - 'Authorization' => 'Bearer ' . $this->apiKey, - 'Content-Type' => 'application/json', - ])->post('https://api.tavily.com/search', [ - 'query' => $searchQuery, - 'include_answer' => 'basic', - ]); - - return $response->json('answer') ?? 'No answer found'; - } -} diff --git a/src/Tools/ToolKits/McpToolKit.php b/src/Tools/ToolKits/McpToolKit.php index f5eef72..76754e7 100644 --- a/src/Tools/ToolKits/McpToolKit.php +++ b/src/Tools/ToolKits/McpToolKit.php @@ -6,10 +6,11 @@ use Cortex\Tools\McpTool; use PhpMcp\Client\Client; +use Cortex\Contracts\ToolKit; use Cortex\Facades\McpServer; use PhpMcp\Client\Model\Definitions\ToolDefinition; -class McpToolKit +class McpToolKit implements ToolKit { protected Client $client; diff --git a/testbench.yaml b/testbench.yaml index cb2879c..b73b245 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -1,2 +1,23 @@ providers: - Cortex\CortexServiceProvider + - Workbench\App\Providers\CortexServiceProvider + +migrations: + - workbench/database/migrations + +seeders: + - Workbench\Database\Seeders\DatabaseSeeder + +workbench: + start: "/" + install: true + health: false + discovers: + web: true + api: true + commands: false + components: false + views: false + build: [] + assets: [] + sync: [] diff --git a/tests/ArchitectureTest.php b/tests/ArchitectureTest.php index 213e94d..0501ec3 100644 --- a/tests/ArchitectureTest.php +++ b/tests/ArchitectureTest.php @@ -8,7 +8,7 @@ use Cortex\Contracts\OutputParser; use Illuminate\Support\Facades\Facade; -arch()->preset()->php(); +// arch()->preset()->php(); arch()->preset()->security(); arch()->expect('Cortex\Contracts')->toBeInterfaces(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 9dbd21f..15f260c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,7 +4,6 @@ namespace Cortex\Tests; -use Dotenv\Dotenv; use Illuminate\Contracts\Config\Repository; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase as BaseTestCase; @@ -17,15 +16,8 @@ abstract class TestCase extends BaseTestCase protected function defineEnvironment($app) { - Dotenv::createImmutable(__DIR__ . '/../')->safeLoad(); - tap($app['config'], function (Repository $config): void { - $config->set('cortex.llm.openai.options.api_key', env('OPENAI_API_KEY')); - $config->set('cortex.llm.groq.options.api_key', env('GROQ_API_KEY')); - $config->set('cortex.llm.xai.options.api_key', env('XAI_API_KEY')); - $config->set('cortex.llm.anthropic.options.api_key', env('ANTHROPIC_API_KEY')); - $config->set('cortex.llm.github.options.api_key', env('GITHUB_API_KEY')); - // $config->set('cache.default', 'file'); + $config->set('cortex.model_info.ignore_features', true); }); } } diff --git a/tests/Unit/Agents/AgentMiddlewareTest.php b/tests/Unit/Agents/AgentMiddlewareTest.php new file mode 100644 index 0000000..2de99df --- /dev/null +++ b/tests/Unit/Agents/AgentMiddlewareTest.php @@ -0,0 +1,1717 @@ + 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'beforeModel'; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $agent->invoke(); + + expect($executionOrder)->toContain('beforeModel') + ->and($executionOrder[0])->toBe('beforeModel'); +}); + +test('afterModel middleware runs after LLM call', function (): void { + $executionOrder = []; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $afterMiddleware = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'afterModel'; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$afterMiddleware], + ); + + $agent->invoke(); + + expect($executionOrder)->toContain('afterModel'); +}); + +test('beforeModel middleware runs before afterModel middleware', function (): void { + $executionOrder = []; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'before1'; + + return $next($payload, $config); + }); + + $afterMiddleware = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'after1'; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware, $afterMiddleware], + ); + + $agent->invoke(); + + $beforeIndex = array_search('before1', $executionOrder, true); + $afterIndex = array_search('after1', $executionOrder, true); + + expect($beforeIndex)->not->toBeFalse() + ->and($afterIndex)->not->toBeFalse() + ->and($beforeIndex)->toBeLessThan($afterIndex); +}); + +test('class-based beforeModel middleware works', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $middleware = new class () extends AbstractMiddleware implements BeforeModelMiddleware { + public function beforeModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + // Verify middleware ran by setting a context value + $config->context->set('before_model_middleware_ran', true); + + return $next($payload, $config); + } + }; + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$middleware], + ); + + $result = $agent->invoke(); + $runtimeConfig = $agent->getRuntimeConfig(); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($runtimeConfig->context->get('before_model_middleware_ran'))->toBeTrue(); +}); + +test('class-based afterModel middleware works', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $middleware = new class () extends AbstractMiddleware implements AfterModelMiddleware { + public function afterModel(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + // Verify middleware ran by setting a context value + $config->context->set('after_model_middleware_ran', true); + + return $next($payload, $config); + } + }; + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$middleware], + ); + + $result = $agent->invoke(); + $runtimeConfig = $agent->getRuntimeConfig(); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($runtimeConfig->context->get('after_model_middleware_ran'))->toBeTrue(); +}); + +test('beforeModel middleware receives RuntimeConfig', function (): void { + $receivedConfig = null; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$receivedConfig): mixed { + $receivedConfig = $config; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $agent->invoke(); + + expect($receivedConfig)->not->toBeNull(); + + assert($receivedConfig !== null); + + expect($receivedConfig)->toBeInstanceOf(RuntimeConfig::class) + ->and($receivedConfig->context)->not->toBeNull(); +}); + +test('beforeModel middleware can modify payload', function (): void { + $payloadWasModified = false; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$payloadWasModified): mixed { + // Modify payload to add a custom key and verify it was set + if (is_array($payload)) { + $payload['middleware_modified'] = true; + } + + $payloadWasModified = true; // Mark that middleware ran and attempted modification + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($payloadWasModified)->toBeTrue(); +}); + +test('multiple beforeModel middleware execute in order', function (): void { + $executionOrder = []; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $before1 = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'before1'; + + return $next($payload, $config); + }); + + $before2 = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'before2'; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$before1, $before2], + ); + + $agent->invoke(); + + $before1Index = array_search('before1', $executionOrder, true); + $before2Index = array_search('before2', $executionOrder, true); + + expect($before1Index)->not->toBeFalse() + ->and($before2Index)->not->toBeFalse() + ->and($before1Index)->toBeLessThan($before2Index); +}); + +test('multiple afterModel middleware execute in order', function (): void { + $executionOrder = []; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $after1 = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'after1'; + + return $next($payload, $config); + }); + + $after2 = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'after2'; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$after1, $after2], + ); + + $agent->invoke(); + + $after1Index = array_search('after1', $executionOrder, true); + $after2Index = array_search('after2', $executionOrder, true); + + expect($after1Index)->not->toBeFalse() + ->and($after2Index)->not->toBeFalse() + ->and($after1Index)->toBeLessThan($after2Index); +}); + +test('beforeModel middleware works with agent streaming', function (): void { + $executionOrder = []; + + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'beforeModel'; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $result = $agent->stream(); + + expect($result)->toBeInstanceOf(ChatStreamResult::class) + ->and($executionOrder)->toContain('beforeModel'); +}); + +test('beforeModel middleware works with tools', function (): void { + $executionOrder = []; + + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: tool call + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: final answer + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 12', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'beforeModel'; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Calculate 3 * 4', + llm: $llm, + tools: [$multiplyTool], + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($executionOrder)->toContain('beforeModel'); +}); + +test('beforeModel middleware can set context values that propagate to subsequent stages', function (): void { + $contextValueInAfterMiddleware = null; + $contextValueAfterMemoryStage = null; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $config->context->set('before_model_value', 'set_by_before_middleware'); + $config->context->set('counter', 1); + + return $next($payload, $config); + }); + + $afterMiddleware = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$contextValueInAfterMiddleware): mixed { + // Verify value set in beforeModel is available in afterModel + $contextValueInAfterMiddleware = $config->context->get('before_model_value'); + $config->context->set('counter', $config->context->get('counter', 0) + 1); + // Set a marker to verify this middleware ran + $config->context->set('after_middleware_ran', true); + + return $next($payload, $config); + }); + + // Use an additional afterModel middleware to verify context propagates through multiple stages + $finalAfterMiddleware = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$contextValueAfterMemoryStage): mixed { + // Verify value from beforeModel is still available after AddMessageToMemory stage + $contextValueAfterMemoryStage = $config->context->get('before_model_value'); + // Verify the counter was incremented by the previous afterModel middleware + $config->context->set('final_counter', $config->context->get('counter')); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware, $afterMiddleware, $finalAfterMiddleware], + ); + + $agent->invoke(); + + $runtimeConfig = $agent->getRuntimeConfig(); + + // Verify context value propagates from beforeModel to afterModel + expect($contextValueInAfterMiddleware)->toBe('set_by_before_middleware') + // Verify context value persists through all stages + ->and($contextValueAfterMemoryStage)->toBe('set_by_before_middleware') + // Verify context values are available in final runtime config + ->and($runtimeConfig->context->get('before_model_value'))->toBe('set_by_before_middleware') + ->and($runtimeConfig->context->get('counter'))->toBe(2) + ->and($runtimeConfig->context->get('final_counter'))->toBe(2) + ->and($runtimeConfig->context->get('after_middleware_ran'))->toBeTrue(); +}); + +test('afterModel middleware can set context values that propagate to subsequent stages', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $afterMiddleware = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $config->context->set('after_model_value', 'set_by_after_middleware'); + $config->context->set('llm_executed', true); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$afterMiddleware], + ); + + $result = $agent->invoke(); + $runtimeConfig = $agent->getRuntimeConfig(); + + expect($runtimeConfig->context->get('after_model_value'))->toBe('set_by_after_middleware') + ->and($runtimeConfig->context->get('llm_executed'))->toBeTrue(); +}); + +test('multiple middleware can read and modify context values in sequence', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $before1 = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $config->context->set('chain_value', 'before1'); + $config->context->set('chain_count', 1); + + return $next($payload, $config); + }); + + $before2 = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $previousValue = $config->context->get('chain_value'); + $config->context->set('chain_value', $previousValue . '->before2'); + $config->context->set('chain_count', $config->context->get('chain_count', 0) + 1); + + return $next($payload, $config); + }); + + $after1 = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $previousValue = $config->context->get('chain_value'); + $config->context->set('chain_value', $previousValue . '->after1'); + $config->context->set('chain_count', $config->context->get('chain_count', 0) + 1); + + return $next($payload, $config); + }); + + $after2 = afterModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $previousValue = $config->context->get('chain_value'); + $config->context->set('chain_value', $previousValue . '->after2'); + $config->context->set('chain_count', $config->context->get('chain_count', 0) + 1); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$before1, $before2, $after1, $after2], + ); + + $agent->invoke(); + + $runtimeConfig = $agent->getRuntimeConfig(); + + expect($runtimeConfig->context->get('chain_value'))->toBe('before1->before2->after1->after2') + ->and($runtimeConfig->context->get('chain_count'))->toBe(4); +}); + +test('context modifications persist across tool call iterations', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: tool call + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: final answer + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 12', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $iterationCount = 0; + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$iterationCount): mixed { + $iterationCount++; + $config->context->set('iteration_count', $iterationCount); + $config->context->set('last_iteration', $iterationCount); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Calculate 3 * 4', + llm: $llm, + tools: [$multiplyTool], + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + $runtimeConfig = $agent->getRuntimeConfig(); + + // The middleware should run twice: once for initial call, once after tool call + expect($runtimeConfig->context->get('last_iteration'))->toBe(2); +}); + +test('beforePrompt middleware runs before prompt processing', function (): void { + $executionOrder = []; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforePromptMiddleware = beforePrompt(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'beforePrompt'; + + return $next($payload, $config); + }); + + $beforeModelMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'beforeModel'; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforePromptMiddleware, $beforeModelMiddleware], + ); + + $agent->invoke(); + + $beforePromptIndex = array_search('beforePrompt', $executionOrder, true); + $beforeModelIndex = array_search('beforeModel', $executionOrder, true); + + expect($beforePromptIndex)->not->toBeFalse() + ->and($beforeModelIndex)->not->toBeFalse() + ->and($beforePromptIndex)->toBeLessThan($beforeModelIndex); +}); + +test('beforePrompt middleware can modify input variables', function (): void { + $originalValue = null; + $modifiedValue = null; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforePromptMiddleware = beforePrompt(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$originalValue, &$modifiedValue): mixed { + if (is_array($payload)) { + $originalValue = $payload['value'] ?? null; + $payload['modified_by_middleware'] = true; + $payload['value'] = 'modified'; + $modifiedValue = $payload['value']; + } + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforePromptMiddleware], + ); + + $result = $agent->invoke(input: [ + 'value' => 'original', + ]); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($originalValue)->toBe('original') + ->and($modifiedValue)->toBe('modified'); +}); + +test('beforePrompt middleware can set context values', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforePromptMiddleware = beforePrompt(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $config->context->set('before_prompt_value', 'set_by_before_prompt'); + + return $next($payload, $config); + }); + + $beforeModelMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + // Verify value set in beforePrompt is available + expect($config->context->get('before_prompt_value'))->toBe('set_by_before_prompt'); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforePromptMiddleware, $beforeModelMiddleware], + ); + + $agent->invoke(); + + $runtimeConfig = $agent->getRuntimeConfig(); + + expect($runtimeConfig->context->get('before_prompt_value'))->toBe('set_by_before_prompt'); +}); + +test('class-based beforePrompt middleware works', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $middleware = new class () implements BeforePromptMiddleware { + use CanPipe; + + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + return $next($payload, $config); + } + + public function beforePrompt(mixed $payload, RuntimeConfig $config, Closure $next): mixed + { + // Verify middleware ran by setting a context value + $config->context->set('before_prompt_middleware_ran', true); + + return $this->handlePipeable($payload, $config, $next); + } + }; + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$middleware], + ); + + $result = $agent->invoke(); + $runtimeConfig = $agent->getRuntimeConfig(); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($runtimeConfig->context->get('before_prompt_middleware_ran'))->toBeTrue(); +}); + +test('beforePrompt middleware receives RuntimeConfig', function (): void { + $receivedConfig = null; + + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforePromptMiddleware = beforePrompt(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$receivedConfig): mixed { + $receivedConfig = $config; + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforePromptMiddleware], + ); + + $agent->invoke(); + + expect($receivedConfig)->not->toBeNull(); + + assert($receivedConfig !== null); + + expect($receivedConfig)->toBeInstanceOf(RuntimeConfig::class) + ->and($receivedConfig->context)->not->toBeNull(); +}); + +test('beforeModel middleware can configure LLM parameters', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + // Set initial parameters + $llm->withTemperature(0.5)->withMaxTokens(500); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + // Configure LLM to use different parameters + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM + ->withTemperature(0.9) + ->withMaxTokens(1000); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + // Verify the LLM was invoked (result is a ChatResult) + expect($result)->toBeInstanceOf(ChatResult::class); + + // Verify original LLM parameters are unchanged + expect($llm->getParameters())->toBeArray(); +}); + +test('RuntimeConfig configureLLM method sets and retrieves configurator', function (): void { + $config = new RuntimeConfig(); + + expect($config->getLLMConfigurator())->toBeNull(); + + $configurator = function ($llm) { + return $llm->withTemperature(0.7); + }; + + $config->configureLLM($configurator); + + expect($config->getLLMConfigurator())->toBe($configurator); +}); + +test('beforeModel middleware can configure LLM with multiple parameters', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM + ->withTemperature(0.8) + ->withMaxTokens(2000) + ->withParameters([ + 'top_p' => 0.95, + ]); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class); +}); + +test('beforeModel middleware LLM configurator applies to each step', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: tool call + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: final answer + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 12', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $configuratorCallCount = 0; + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$configuratorCallCount): mixed { + $configuratorCallCount++; + $stepNumber = $config->context->getCurrentStepNumber(); + + // Configure LLM differently based on step number + $config->configureLLM(function ($configuredLLM) use ($stepNumber): LLM { + // Increase temperature for later steps + $temperature = 0.5 + ($stepNumber * 0.1); + + return $configuredLLM->withTemperature($temperature); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Calculate 3 * 4', + llm: $llm, + tools: [$multiplyTool], + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + // Configurator should be called for each LLM invocation (2 steps: initial + after tool call) + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($configuratorCallCount)->toBe(2); +}); + +test('beforeModel middleware LLM configurator clones LLM instance', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + // Set initial parameters + $originalTemperature = 0.3; + $llm->withTemperature($originalTemperature); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + // Configure LLM with different temperature + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM->withTemperature(0.9); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + // Verify original LLM instance is unchanged + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($llm->getParameters())->toBeArray(); +}); + +test('beforeModel middleware can configure LLM without affecting subsequent invocations', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello again', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM->withTemperature(0.7); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + // First invocation + $result1 = $agent->invoke(); + + // Second invocation + $result2 = $agent->invoke(); + + // Both should succeed + expect($result1)->toBeInstanceOf(ChatResult::class) + ->and($result2)->toBeInstanceOf(ChatResult::class); +}); + +test('beforeModel middleware LLM configurator works with streaming', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM->withTemperature(0.8); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $result = $agent->stream(); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); +}); + +test('multiple beforeModel middleware can chain LLM configurations', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $before1 = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM->withTemperature(0.6); + }); + + return $next($payload, $config); + }); + + $before2 = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + // This will override the previous configurator + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM->withTemperature(0.9)->withMaxTokens(1500); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$before1, $before2], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class); +}); + +test('beforeModel middleware LLM configurator actually applies parameters during execution', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + // Set initial parameters that should be overridden + $llm->withTemperature(0.3)->withMaxTokens(500); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + // Configure LLM with specific parameters + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM + ->withTemperature(0.85) + ->withMaxTokens(1500); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class); + + // Verify the configured parameters were actually sent to the API + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $client->chat()->assertSent(function (string $method, array $parameters): bool { + // Verify middleware-configured parameters are used, not original LLM parameters + return $parameters['temperature'] === 0.85 + && $parameters['max_completion_tokens'] === 1500; + }); +}); + +test('beforeModel middleware LLM configurator preserves original LLM parameters when not configured', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + // Set initial parameters + $originalTemperature = 0.4; + $originalMaxTokens = 800; + $llm->withTemperature($originalTemperature)->withMaxTokens($originalMaxTokens); + + // Middleware that doesn't configure LLM + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + // Just pass through without configuring + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class); + + // Verify original parameters are used when no configurator is set + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $client->chat()->assertSent(function (string $method, array $parameters) use ($originalTemperature, $originalMaxTokens): bool { + return $parameters['temperature'] === $originalTemperature + && $parameters['max_completion_tokens'] === $originalMaxTokens; + }); +}); + +test('beforeModel middleware LLM configurator applies parameters for each step execution', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: tool call + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: final answer + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 12', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $stepTemperatures = []; + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$stepTemperatures): mixed { + $stepNumber = $config->context->getCurrentStepNumber(); + + // Configure different temperature for each step + $temperature = 0.5 + ($stepNumber * 0.2); + $stepTemperatures[$stepNumber] = $temperature; + + $config->configureLLM(function ($configuredLLM) use ($temperature): LLM { + return $configuredLLM->withTemperature($temperature); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Calculate 3 * 4', + llm: $llm, + tools: [$multiplyTool], + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($stepTemperatures)->toHaveCount(2); // Should configure for both steps + + // Verify each step used the configured temperature + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $sentCalls = []; + $client->chat()->assertSent(function (string $method, array $parameters) use (&$sentCalls): bool { + $sentCalls[] = $parameters['temperature']; + + return true; + }); + + // Verify both calls used different temperatures + expect($sentCalls)->toHaveCount(2) + ->and($sentCalls[0])->toBe($stepTemperatures[1]) // First step + ->and($sentCalls[1])->toBe($stepTemperatures[2]); // Second step +}); + +test('beforeModel middleware LLM configurator clones LLM instance preserving original', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + // Set initial parameters + $originalTemperature = 0.3; + $originalMaxTokens = 500; + $llm->withTemperature($originalTemperature)->withMaxTokens($originalMaxTokens); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next): mixed { + // Configure LLM with different parameters + $config->configureLLM(function ($configuredLLM): LLM { + return $configuredLLM + ->withTemperature(0.9) + ->withMaxTokens(2000); + }); + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Say hello', + llm: $llm, + middleware: [$beforeMiddleware], + ); + + // Verify original LLM parameters before invocation + expect($llm->getParameters())->toBeArray(); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class); + + // Verify configured parameters were sent to API + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $client->chat()->assertSent(function (string $method, array $parameters): bool { + // Should use configured parameters, not original + return $parameters['temperature'] === 0.9 + && $parameters['max_completion_tokens'] === 2000; + }); + + // Verify original LLM instance still has original parameters + // (Note: getParameters() might return merged params, but the original instance is unchanged) + expect($llm->getParameters())->toBeArray(); +}); + +test('beforeModel middleware LLM configurator with once flag applies only to first LLM call', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: tool call + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: final answer + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 12', + ], + ], + ], + ]), + ], 'gpt-4o'); + + // Set original parameters + $originalTemperature = 0.3; + $configuredTemperature = 0.9; + $llm->withTemperature($originalTemperature); + + $configuratorSet = false; + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use ($configuredTemperature, &$configuratorSet): mixed { + // Configure LLM with once flag - should only apply to first call + // Only set it once (on first middleware execution) + if (! $configuratorSet) { + $config->configureLLM(function ($configuredLLM) use ($configuredTemperature): LLM { + return $configuredLLM->withTemperature($configuredTemperature); + }, once: true); + $configuratorSet = true; + } + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Calculate 3 * 4', + llm: $llm, + tools: [$multiplyTool], + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class); + + // Verify configurator was cleared after first use + $runtimeConfig = $agent->getRuntimeConfig(); + expect($runtimeConfig->getLLMConfigurator())->toBeNull() + ->and($runtimeConfig->shouldClearLLMConfigurator())->toBeFalse(); + + // Verify first call used configured temperature, second call used original + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $sentTemperatures = []; + $client->chat()->assertSent(function (string $method, array $parameters) use (&$sentTemperatures): bool { + $sentTemperatures[] = $parameters['temperature']; + + return true; + }); + + expect($sentTemperatures)->toHaveCount(2) + ->and($sentTemperatures[0])->toBe($configuredTemperature) // First call: configured + ->and($sentTemperatures[1])->toBe($originalTemperature); // Second call: original (configurator cleared) +}); + +test('beforeModel middleware LLM configurator without once flag applies to all LLM calls', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: tool call + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: final answer + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 12', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $configuredTemperature = 0.8; + $llm->withTemperature(0.3); + + $beforeMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use ($configuredTemperature): mixed { + // Configure LLM without once flag - should apply to all calls + $config->configureLLM(function ($configuredLLM) use ($configuredTemperature): LLM { + return $configuredLLM->withTemperature($configuredTemperature); + }); // Default: once = false + + return $next($payload, $config); + }); + + $agent = new Agent( + name: 'TestAgent', + prompt: 'Calculate 3 * 4', + llm: $llm, + tools: [$multiplyTool], + middleware: [$beforeMiddleware], + ); + + $result = $agent->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class); + + // Verify configurator persists after first use + $runtimeConfig = $agent->getRuntimeConfig(); + expect($runtimeConfig->getLLMConfigurator())->not->toBeNull() + ->and($runtimeConfig->shouldClearLLMConfigurator())->toBeFalse(); + + // Verify both calls used configured temperature + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $sentTemperatures = []; + $client->chat()->assertSent(function (string $method, array $parameters) use (&$sentTemperatures): bool { + $sentTemperatures[] = $parameters['temperature']; + + return true; + }); + + expect($sentTemperatures)->toHaveCount(2) + ->and($sentTemperatures[0])->toBe($configuredTemperature) // First call: configured + ->and($sentTemperatures[1])->toBe($configuredTemperature); // Second call: still configured +}); diff --git a/tests/Unit/Agents/AgentOldTest.php b/tests/Unit/Agents/AgentOldTest.php new file mode 100644 index 0000000..d04bbee --- /dev/null +++ b/tests/Unit/Agents/AgentOldTest.php @@ -0,0 +1,214 @@ +messages([ + new SystemMessage('You are a comedian.'), + new UserMessage('Tell me a joke about {topic}.'), + ]) + ->metadata( + provider: 'ollama', + model: 'ministral-3:14b', + structuredOutput: Schema::object()->properties( + Schema::string('setup')->required(), + Schema::string('punchline')->required(), + ), + ), + ); + + $result = $agent->stream(input: [ + 'topic' => 'dragons', + ]); + + foreach ($result->text() as $chunk) { + dump([ + 'type' => $chunk->type, + 'content' => $chunk->content(), + ]); + } + + dd($agent->getMemory()->getMessages()->toArray()); +})->todo(); + +test('it can create an agent with tools', function (): void { + // Event::listen(ChatModelStart::class, function (ChatModelStart $event): void { + // dump('llm start: ', $event->parameters); + // }); + + // Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { + // dump('llm end: ', $event->result); + // }); + + $agent = new Agent( + name: 'Weather Forecaster', + prompt: 'You are a weather forecaster. Use the tool to get the weather for a given location.', + llm: llm('ollama', 'qwen2.5:14b')->ignoreFeatures(), + tools: [ + tool( + 'get_weather', + 'Get the current weather for a given location', + fn(string $location): string => + vsprintf('{"location": "%s", "conditions": "%s", "temperature": %s, "unit": "celsius"}', [ + $location, + Arr::random(['sunny', 'cloudy', 'rainy', 'snowing']), + 14, + ]), + ), + ], + ); + + // $result = $agent->invoke([ + // new UserMessage('What is the weather in London?'), + // ]); + + // dump($result->generation->message->content()); + // dump($agent->getMemory()->getMessages()->toArray()); + // dd($agent->getTotalUsage()->toArray()); + + // $result = $agent->invoke([ + // new UserMessage('What about Manchester?'), + // ]); + + // dump($result->generation->message->content()); + // dump($agent->getMemory()->getMessages()->toArray()); + + $result = $agent->stream([ + new UserMessage('What is the weather in London?'), + ]); + + foreach ($result as $chunk) { + dump($chunk->toArray()); + } + + dump($agent->getMemory()->getMessages()->toArray()); +})->todo(); + +test('it can create an agent with a prompt instance', function (): void { + Event::listen(ChatModelStart::class, function (ChatModelStart $event): void { + dump('llm start: ', $event->parameters); + }); + + Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { + dump('llm end: ', $event->result); + }); + + $londonWeatherTool = tool( + 'london-weather-tool', + 'Returns year-to-date historical weather data for London', + function (): string { + $url = vsprintf('https://archive-api.open-meteo.com/v1/archive?latitude=51.5072&longitude=-0.1276&start_date=%s&end_date=%s&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,windspeed_10m_max,snowfall_sum&timezone=auto', [ + today()->startOfYear()->format('Y-m-d'), + today()->format('Y-m-d'), + ]); + + $response = Http::get($url)->collect(); + + dd($response); + + return $response->mapWithKeys(fn(array $item): array => [ + 'date' => $item['daily']['time'], + 'temp_max' => $item['daily']['temperature_2m_max'], + 'temp_min' => $item['daily']['temperature_2m_min'], + 'rainfall' => $item['daily']['precipitation_sum'], + 'windspeed' => $item['daily']['windspeed_10m_max'], + 'snowfall' => $item['daily']['snowfall_sum'], + ])->toJson(); + }, + ); + + $weatherAgent = new Agent( + name: 'london-weather-agent', + prompt: <<ignoreFeatures(), + tools: [$londonWeatherTool], + ); + + $result = $weatherAgent->invoke([ + new UserMessage('How many times has it rained this year?'), + ]); + + // dd($result); + dump($result->generation->message->content()); + dump($weatherAgent->getMemory()->getMessages()->toArray()); + dd($weatherAgent->getTotalUsage()->toArray()); +})->todo(); + +test('it can pipe agents', function (): void { + // $result = WeatherAgent::make()->invoke(input: [ + // 'location' => 'Manchester', + // ]); + + $weatherAgent = new Agent( + name: 'weather', + prompt: Cortex::prompt([ + new SystemMessage('You are a weather assistant. Call the tool to get the weather for a given location.'), + new UserMessage('What is the weather in {location}?'), + ]), + // llm: 'lmstudio/gpt-oss:20b', + llm: 'openai/gpt-4.1-mini', + tools: [ + OpenMeteoWeatherTool::class, + ], + output: [ + Schema::string('summary')->required(), + ], + ); + $umbrellaAgent = new Agent( + name: 'umbrella-agent', + prompt: Cortex::prompt([ + new SystemMessage("You are a helpful assistant that determines if an umbrella is needed based on the following information:\n{summary}"), + ]), + // llm: 'lmstudio/gpt-oss:20b', + llm: 'openai/gpt-4.1-mini', + output: [ + Schema::boolean('umbrella_needed')->required(), + Schema::string('reasoning')->required(), + ], + ); + + // dd(array_map(fn(object $stage): string => get_class($stage), $weatherAgent->pipe($umbrellaAgent)->getStages())); + + $umbrellaNeededResult = $weatherAgent + ->pipe($umbrellaAgent) + ->pipe(new JsonOutputParser()) + ->stream([ + 'location' => 'Manchester', + ]); + + foreach ($umbrellaNeededResult as $chunk) { + dump($chunk->message->content()); + } + + // dump($umbrellaAgent->getMemory()->getMessages()->toArray()); + // dd($umbrellaNeededResult); +})->todo(); diff --git a/tests/Unit/Agents/AgentTest.php b/tests/Unit/Agents/AgentTest.php new file mode 100644 index 0000000..7438359 --- /dev/null +++ b/tests/Unit/Agents/AgentTest.php @@ -0,0 +1,1070 @@ + 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"setup":"Why did the scarecrow win an award?","punchline":"Because he was outstanding in his field!"}', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Comedian', + prompt: 'You are a comedian. Tell me a joke about {topic}.', + llm: $llm, + output: [ + Schema::string('setup')->required(), + Schema::string('punchline')->required(), + ], + ); + + $result = $agent->invoke(input: [ + 'topic' => 'farmers', + ]); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($result->content())->toBe([ + 'setup' => 'Why did the scarecrow win an award?', + 'punchline' => 'Because he was outstanding in his field!', + ]); +}); + +test('it can invoke and stream with string or UserMessage', function (): void { + $llm = OpenAIChat::fake([ + // Response for invoke with string + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello!', + ], + ], + ], + ]), + // Response for invoke with UserMessage + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hi there!', + ], + ], + ], + ]), + // Response for stream with string + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + // Response for stream with UserMessage + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + // Test invoke with string + $result1 = $agent->invoke('Hello'); + expect($result1)->toBeInstanceOf(ChatResult::class) + ->and($result1->content())->toBe('Hello!'); + + // Test invoke with UserMessage + $result2 = $agent->invoke(new UserMessage('Hi there')); + expect($result2)->toBeInstanceOf(ChatResult::class) + ->and($result2->content())->toBe('Hi there!'); + + // Test stream with string + $result3 = $agent->stream('How are you?'); + expect($result3)->toBeInstanceOf(ChatStreamResult::class); + + // Test stream with UserMessage + $result4 = $agent->stream(new UserMessage('Tell me something')); + expect($result4)->toBeInstanceOf(ChatStreamResult::class); +}); + +test('it can invoke an agent with tool calls', function (): void { + $toolCalled = false; + $toolArguments = null; + + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers together', + function (int $x, int $y) use (&$toolCalled, &$toolArguments): int { + $toolCalled = true; + $toolArguments = [ + 'x' => $x, + 'y' => $y, + ]; + + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: LLM decides to call the tool + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: LLM responds after tool execution + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result of multiplying 3 and 4 is 12.', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $agent = new Agent( + name: 'Calculator', + prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', + llm: $llm, + tools: [$multiplyTool], + ); + + $result = $agent->invoke(input: [ + 'query' => 'What is 3 times 4?', + ]); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($result->content())->toBe('The result of multiplying 3 and 4 is 12.') + ->and($toolCalled)->toBeTrue('Tool should have been called') + ->and($toolArguments)->toBe([ + 'x' => 3, + 'y' => 4, + ]); +}); + +test('it tracks agent steps in RuntimeConfig', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers together', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // Step 1: LLM decides to call the tool + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'usage' => [ + 'prompt_tokens' => 50, + 'completion_tokens' => 30, + 'total_tokens' => 80, + ], + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":5,"y":6}', + ], + ], + ], + ], + ], + ], + ]), + // Step 2: LLM responds after tool execution + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'usage' => [ + 'prompt_tokens' => 60, + 'completion_tokens' => 25, + 'total_tokens' => 85, + ], + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 30.', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $agent = new Agent( + name: 'Calculator', + prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', + llm: $llm, + tools: [$multiplyTool], + maxSteps: 5, + ); + + $result = $agent->invoke(input: [ + 'query' => 'What is 5 times 6?', + ]); + + $runtimeConfig = $agent->getRuntimeConfig(); + + expect($runtimeConfig)->not->toBeNull() + ->and($result)->toBeInstanceOf(ChatResult::class) + ->and($result->content())->toBe('The result is 30.'); + + // Verify steps are tracked + $steps = $runtimeConfig->context->getSteps(); + expect($steps)->toHaveCount(2, 'Should have 2 steps (initial call + follow-up after tool call)'); + + // Verify Step 1 (with tool calls) + $step1 = $steps[0]; + expect($step1)->toBeInstanceOf(Step::class) + ->and($step1->number)->toBe(1) + ->and($step1->hasToolCalls())->toBeTrue('Step 1 should have tool calls') + ->and($step1->toolCalls)->toBeInstanceOf(ToolCallCollection::class) + ->and($step1->toolCalls)->toHaveCount(1) + ->and($step1->toolCalls[0]->id)->toBe('call_123') + ->and($step1->toolCalls[0]->function->name)->toBe('multiply'); + + // Verify Step 2 (final response, no tool calls) + $step2 = $steps[1]; + expect($step2)->toBeInstanceOf(Step::class) + ->and($step2->number)->toBe(2) + ->and($step2->hasToolCalls())->toBeFalse('Step 2 should not have tool calls') + ->and($step2->toolCalls)->toBeInstanceOf(ToolCallCollection::class) + ->and($step2->toolCalls)->toHaveCount(0); + + // Verify current_step is set to the last step + expect($runtimeConfig->context->getCurrentStep()->number)->toBe(2); + + // Verify usage is tracked per step + expect($step1->usage)->not->toBeNull('Step 1 should have usage') + ->and($step1->usage->promptTokens)->toBe(50) + ->and($step1->usage->completionTokens)->toBe(30) + ->and($step1->usage->totalTokens)->toBe(80); + + expect($step2->usage)->not->toBeNull('Step 2 should have usage') + ->and($step2->usage->promptTokens)->toBe(60) + ->and($step2->usage->completionTokens)->toBe(25) + ->and($step2->usage->totalTokens)->toBe(85); + + // Verify total usage is accumulated correctly + $totalUsage = $agent->getTotalUsage(); + expect($totalUsage)->toBeInstanceOf(Usage::class) + ->and($totalUsage->promptTokens)->toBe(110, 'Total prompt tokens should be sum of both steps (50 + 60)') + ->and($totalUsage->completionTokens)->toBe(55, 'Total completion tokens should be sum of both steps (30 + 25)') + ->and($totalUsage->totalTokens)->toBe(165, 'Total tokens should be sum of both steps (80 + 85)'); + + // Verify usage is also accessible via context + $contextUsage = $runtimeConfig->context->getUsageSoFar(); + expect($contextUsage->promptTokens)->toBe(110) + ->and($contextUsage->completionTokens)->toBe(55) + ->and($contextUsage->totalTokens)->toBe(165); +}); + +test('it tracks steps correctly when agent completes without tool calls', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello, how can I help you?', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + maxSteps: 3, + ); + + $result = $agent->invoke(input: [ + 'query' => 'Hello', + ]); + + $runtimeConfig = $agent->getRuntimeConfig(); + + expect($runtimeConfig)->not->toBeNull() + ->and($result)->toBeInstanceOf(ChatResult::class); + + // Verify steps are tracked + $steps = $runtimeConfig->context->getSteps(); + expect($steps)->toHaveCount(1, 'Should have 1 step when no tool calls occur'); + + // Verify Step 1 (no tool calls) + $step1 = $steps[0]; + expect($step1)->toBeInstanceOf(Step::class) + ->and($step1->number)->toBe(1) + ->and($step1->hasToolCalls())->toBeFalse('Step 1 should not have tool calls') + ->and($step1->toolCalls)->toBeInstanceOf(ToolCallCollection::class) + ->and($step1->toolCalls)->toHaveCount(0); + + // Verify current_step is set + expect($runtimeConfig->context->getCurrentStepNumber())->toBe(1); +}); + +test('it dispatches AgentStart event when agent is invoked', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello, how can I help you?', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + $startCalled = false; + $startEvent = null; + + $agent->onStart(function (AgentStart $event) use ($agent, &$startCalled, &$startEvent): void { + $startCalled = true; + $startEvent = $event; + expect($event->agent)->toBe($agent); + }); + + $result = $agent->invoke(input: [ + 'query' => 'Hello', + ]); + + expect($startCalled)->toBeTrue('AgentStart event should have been dispatched') + ->and($startEvent)->not->toBeNull('Start event should be set') + ->and($startEvent)->toBeInstanceOf(AgentStart::class) + ->and($startEvent?->agent)->toBe($agent) + ->and($result)->toBeInstanceOf(ChatResult::class); +}); + +test('it dispatches AgentEnd event when agent completes', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello, how can I help you?', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + $endCalled = 0; + $endEvent = null; + + $agent->onEnd(function (AgentEnd $event) use ($agent, &$endCalled, &$endEvent): void { + $endCalled++; + $endEvent = $event; + expect($event->agent)->toBe($agent); + }); + + $result = $agent->invoke(input: [ + 'query' => 'Hello', + ]); + + expect($endCalled)->toBe(1, 'AgentEnd event should have been dispatched') + ->and($endEvent)->not->toBeNull('End event should be set') + ->and($endEvent)->toBeInstanceOf(AgentEnd::class) + ->and($endEvent?->agent)->toBe($agent) + ->and($endEvent?->config)->not->toBeNull('RuntimeConfig should be set') + ->and($result)->toBeInstanceOf(ChatResult::class); +}); + +test('it dispatches both AgentStart and AgentEnd events in correct order', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello, how can I help you?', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + $eventOrder = []; + + $agent->onStart(function (AgentStart $event) use ($agent, &$eventOrder): void { + $eventOrder[] = 'start'; + expect($event->agent)->toBe($agent); + }); + + $agent->onEnd(function (AgentEnd $event) use ($agent, &$eventOrder): void { + $eventOrder[] = 'end'; + expect($event->agent)->toBe($agent); + }); + + $result = $agent->invoke(input: [ + 'query' => 'Hello', + ]); + + expect($eventOrder)->toBe(['start', 'end'], 'Events should be dispatched in order: start, then end') + ->and($result)->toBeInstanceOf(ChatResult::class); +}); + +test('it dispatches AgentStart and AgentEnd events with tool calls', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers together', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: LLM decides to call the tool + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: LLM responds after tool execution + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 12.', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $agent = new Agent( + name: 'Calculator', + prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', + llm: $llm, + tools: [$multiplyTool], + ); + + $startCalled = false; + $endCalled = false; + + $agent->onStart(function (AgentStart $event) use ($agent, &$startCalled): void { + $startCalled = true; + expect($event->agent)->toBe($agent); + }); + + $agent->onEnd(function (AgentEnd $event) use ($agent, &$endCalled): void { + $endCalled = true; + expect($event->agent)->toBe($agent); + expect($event->config)->not->toBeNull('RuntimeConfig should be set'); + }); + + $result = $agent->invoke(input: [ + 'query' => 'What is 3 times 4?', + ]); + + expect($startCalled)->toBeTrue('AgentStart event should have been dispatched') + ->and($endCalled)->toBeTrue('AgentEnd event should have been dispatched') + ->and($result)->toBeInstanceOf(ChatResult::class) + ->and($result->content())->toBe('The result is 12.'); +}); + +test('it interleaves step start and end chunks with LLM stream chunks', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + $result = $agent->stream(input: [ + 'query' => 'Hello, how are you?', + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Collect all chunks + $chunks = []; + foreach ($result as $chunk) { + $chunks[] = $chunk; + } + + // Verify we have chunks + expect($chunks)->not->toBeEmpty('Should have at least some chunks'); + + // Verify ordering: RunStart should be first, then StepStart + $firstChunk = $chunks[0]; + expect($firstChunk)->toBeInstanceOf(ChatGenerationChunk::class) + ->and($firstChunk->type)->toBe(ChunkType::RunStart); + + // StepStart should be second + $secondChunk = $chunks[1]; + expect($secondChunk)->toBeInstanceOf(ChatGenerationChunk::class) + ->and($secondChunk->type)->toBe(ChunkType::StepStart); + + // Verify StepEnd appears after all LLM chunks + // Find the last chunk that is not StepEnd (should be the last LLM chunk) + $lastLLMChunkIndex = null; + for ($i = count($chunks) - 1; $i >= 0; $i--) { + if ($chunks[$i]->type !== ChunkType::StepEnd) { + $lastLLMChunkIndex = $i; + break; + } + } + + expect($lastLLMChunkIndex)->not->toBeNull('Should have LLM chunks'); + + // Verify RunEnd is the last chunk (StepEnd should be second to last) + $lastChunk = $chunks[count($chunks) - 1]; + expect($lastChunk)->toBeInstanceOf(ChatGenerationChunk::class) + ->and($lastChunk->type)->toBe(ChunkType::RunEnd); + + // StepEnd should be second to last + $secondLastChunk = $chunks[count($chunks) - 2]; + expect($secondLastChunk)->toBeInstanceOf(ChatGenerationChunk::class) + ->and($secondLastChunk->type)->toBe(ChunkType::StepEnd); + + // Verify there are LLM chunks between StepStart and StepEnd + $llmChunks = array_filter($chunks, fn(ChatGenerationChunk $chunk): bool => $chunk->type !== ChunkType::StepStart && $chunk->type !== ChunkType::StepEnd); + expect($llmChunks)->not->toBeEmpty('Should have LLM chunks between step markers'); + $finalChunk = array_find($llmChunks, fn($chunk): bool => $chunk instanceof ChatGenerationChunk && $chunk->isFinal); + + expect($finalChunk)->not->toBeNull('Should have a final chunk') + ->and($finalChunk->contentSoFar)->toContain('Hello!') + ->and($finalChunk->contentSoFar)->toContain('program') + ->and($finalChunk->contentSoFar)->toContain('assist you today'); +}); + +test('it dispatches AgentStart and AgentEnd events only once when streaming', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + $startCalled = 0; + $endCalled = 0; + $startEvent = null; + $endEvent = null; + + $agent->onStart(function (AgentStart $event) use ($agent, &$startCalled, &$startEvent): void { + $startCalled++; + $startEvent = $event; + expect($event->agent)->toBe($agent); + }); + + $agent->onEnd(function (AgentEnd $event) use ($agent, &$endCalled, &$endEvent): void { + $endCalled++; + $endEvent = $event; + expect($event->agent)->toBe($agent); + }); + + $result = $agent->stream(input: [ + 'query' => 'Hello, how are you?', + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Consume the stream to trigger all events + $chunks = []; + foreach ($result as $chunk) { + $chunks[] = $chunk; + } + + // Verify events were dispatched exactly once + expect($startCalled)->toBe(1, 'AgentStart should be dispatched exactly once') + ->and($endCalled)->toBe(1, 'AgentEnd should be dispatched exactly once') + ->and($startEvent)->not->toBeNull('Start event should be set') + ->and($endEvent)->not->toBeNull('End event should be set') + ->and($startEvent)->toBeInstanceOf(AgentStart::class) + ->and($endEvent)->toBeInstanceOf(AgentEnd::class); + + if ($endEvent !== null) { + expect($endEvent->config)->not->toBeNull('RuntimeConfig should be set in end event'); + } +}); + +test('it dispatches AgentStart and AgentEnd events only once when streaming with multiple chunks', function (): void { + // This test verifies that even when consuming many chunks from the stream, + // AgentStart and AgentEnd are only dispatched once + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + $startCalled = 0; + $endCalled = 0; + + $agent->onStart(function (AgentStart $event) use ($agent, &$startCalled): void { + $startCalled++; + expect($event->agent)->toBe($agent); + }); + + $agent->onEnd(function (AgentEnd $event) use ($agent, &$endCalled): void { + $endCalled++; + expect($event->agent)->toBe($agent); + }); + + $result = $agent->stream(input: [ + 'query' => 'Hello, how are you?', + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Consume chunks one by one to simulate real streaming consumption + $chunkCount = 0; + foreach ($result as $chunk) { + $chunkCount++; + // Verify events haven't been called multiple times during consumption + expect($startCalled)->toBeLessThanOrEqual(1, sprintf('AgentStart should not be called more than once, but was called %d times after %d chunks', $startCalled, $chunkCount)); + expect($endCalled)->toBeLessThanOrEqual(1, sprintf('AgentEnd should not be called more than once, but was called %d times after %d chunks', $endCalled, $chunkCount)); + } + + // Verify final counts after stream is fully consumed + expect($chunkCount)->toBeGreaterThan(0, 'Should have consumed some chunks') + ->and($startCalled)->toBe(1, 'AgentStart should be dispatched exactly once') + ->and($endCalled)->toBe(1, 'AgentEnd should be dispatched exactly once'); +}); + +test('it dispatches AgentStart, AgentEnd, AgentStepStart, and AgentStepEnd events only once when streaming with tool calls and multiple steps', function (): void { + // This test verifies that even with multiple steps (tool call + final response), + // AgentStart, AgentEnd, AgentStepStart, and AgentStepEnd are only dispatched exactly once. + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers together', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: LLM decides to call the tool (streaming with tool calls) + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream-tool-calls.txt', 'r')), + // Second response: LLM responds after tool execution (streaming) + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $agent = new Agent( + name: 'Calculator', + prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', + llm: $llm, + tools: [$multiplyTool], + ); + + $startCalled = 0; + $endCalled = 0; + $stepStartCalled = 0; + $stepEndCalled = 0; + + $agent->onStart(function (AgentStart $event) use ($agent, &$startCalled): void { + $startCalled++; + expect($event->agent)->toBe($agent); + }); + + $agent->onEnd(function (AgentEnd $event) use ($agent, &$endCalled): void { + $endCalled++; + expect($event->agent)->toBe($agent); + }); + + $agent->onStepStart(function (AgentStepStart $event) use ($agent, &$stepStartCalled): void { + $stepStartCalled++; + expect($event->agent)->toBe($agent); + }); + + $agent->onStepEnd(function (AgentStepEnd $event) use ($agent, &$stepEndCalled): void { + $stepEndCalled++; + expect($event->agent)->toBe($agent); + }); + + $result = $agent->stream(input: [ + 'query' => 'What is 3 times 4?', + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Consume chunks one by one to simulate real streaming consumption + $chunkCount = 0; + foreach ($result as $chunk) { + $chunkCount++; + // Verify events haven't been called multiple times during consumption + expect($startCalled)->toBeLessThanOrEqual(1, sprintf('AgentStart should not be called more than once, but was called %d times after %d chunks', $startCalled, $chunkCount)); + expect($endCalled)->toBeLessThanOrEqual(1, sprintf('AgentEnd should not be called more than once, but was called %d times after %d chunks', $endCalled, $chunkCount)); + } + + // Verify final counts after stream is fully consumed + expect($chunkCount)->toBeGreaterThan(0, 'Should have consumed some chunks') + ->and($startCalled)->toBe(1, 'AgentStart should be dispatched exactly once even with multiple steps') + ->and($endCalled)->toBe(1, 'AgentEnd should be dispatched exactly once even with multiple steps') + ->and($stepStartCalled)->toBe(2, 'AgentStepStart should be dispatched exactly twice even with multiple steps') + ->and($stepEndCalled)->toBe(2, 'AgentStepEnd should be dispatched exactly twice even with multiple steps'); + + // Verify runtime config shows multiple steps + $runtimeConfig = $agent->getRuntimeConfig(); + expect($runtimeConfig)->not->toBeNull() + ->and($runtimeConfig->context->getSteps())->toHaveCount(2, 'Should have 2 steps (tool call + final response)'); +}); + +test('it dispatches step events in correct order relative to chunks when streaming', function (): void { + // This test verifies that: + // - StepStart events fire BEFORE the step_start chunk is received by consumers + // - StepEnd events fire AFTER the step_end chunk is received by consumers + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $agent = new Agent( + name: 'Helper', + prompt: 'You are a helpful assistant.', + llm: $llm, + ); + + $eventLog = []; + + $agent->onStepStart(function (AgentStepStart $event) use (&$eventLog): void { + $eventLog[] = 'step_start_event'; + }); + + $agent->onStepEnd(function (AgentStepEnd $event) use (&$eventLog): void { + $eventLog[] = 'step_end_event'; + }); + + $result = $agent->stream(input: [ + 'query' => 'Hello, how are you?', + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Consume chunks and log when we receive step chunks + foreach ($result as $chunk) { + if ($chunk->type === ChunkType::StepStart) { + $eventLog[] = 'step_start_chunk'; + } elseif ($chunk->type === ChunkType::StepEnd) { + $eventLog[] = 'step_end_chunk'; + } + } + + // Verify the order: + // 1. StepStart event should fire BEFORE the step_start chunk is received + // 2. StepEnd chunk should be received BEFORE the step_end event fires + expect($eventLog)->toContain('step_start_event') + ->and($eventLog)->toContain('step_start_chunk') + ->and($eventLog)->toContain('step_end_chunk') + ->and($eventLog)->toContain('step_end_event'); + + $stepStartEventIndex = array_search('step_start_event', $eventLog, true); + $stepStartChunkIndex = array_search('step_start_chunk', $eventLog, true); + $stepEndChunkIndex = array_search('step_end_chunk', $eventLog, true); + $stepEndEventIndex = array_search('step_end_event', $eventLog, true); + + expect($stepStartEventIndex)->toBeLessThan($stepStartChunkIndex, 'StepStart event should fire before step_start chunk is received') + ->and($stepEndChunkIndex)->toBeLessThan($stepEndEventIndex, 'step_end chunk should be received before StepEnd event fires'); +}); + +test('it emits tool_output_end chunk after tool_input_end but before step_end when streaming with tool calls', function (): void { + // This test verifies the chunk ordering: + // - tool_input_end appears first (when tool call is complete) + // - tool_output_end appears after tool_input_end (after tool execution) + // - step_end appears after tool_output_end (end of the step) + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers together', + function (int $x, int $y): int { + return $x * $y; + }, + ); + + $llm = OpenAIChat::fake([ + // First response: LLM decides to call the tool (streaming with tool calls) + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream-tool-calls.txt', 'r')), + // Second response: LLM responds after tool execution (streaming) + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $agent = new Agent( + name: 'Calculator', + prompt: 'You are a helpful calculator assistant. Use the multiply tool to calculate the answer.', + llm: $llm, + tools: [$multiplyTool], + ); + + $result = $agent->stream(input: [ + 'query' => 'What is 3 times 4?', + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Collect all chunks + $chunks = []; + foreach ($result as $chunk) { + $chunks[] = $chunk; + } + + // Find indices of the relevant chunks for the first step (with tool calls) + $toolInputEndIndex = null; + $toolOutputEndIndex = null; + $firstStepEndIndex = null; + + foreach ($chunks as $index => $chunk) { + if ($chunk->type === ChunkType::ToolInputEnd && $toolInputEndIndex === null) { + $toolInputEndIndex = $index; + expect($chunk->message)->toBeInstanceOf(AssistantMessage::class) + ->and($chunk->message->toolCalls)->toBeInstanceOf(ToolCallCollection::class) + ->and($chunk->message->toolCalls)->toHaveCount(1) + ->and($chunk->message->toolCalls[0]->function->name)->toBe('multiply') + ->and($chunk->message->toolCalls[0]->function->arguments)->toBe([ + 'x' => 3, + 'y' => 4, + ]); + } + + if ($chunk->type === ChunkType::ToolOutputEnd && $toolOutputEndIndex === null) { + $toolOutputEndIndex = $index; + expect($chunk->message)->toBeInstanceOf(ToolMessage::class) + ->and($chunk->message->content())->toBe('12'); + } + + if ($chunk->type === ChunkType::StepEnd && $firstStepEndIndex === null) { + $firstStepEndIndex = $index; + } + } + + // Verify all chunks exist + expect($toolInputEndIndex)->not->toBeNull('Should have tool_input_end chunk') + ->and($toolOutputEndIndex)->not->toBeNull('Should have tool_output_end chunk') + ->and($firstStepEndIndex)->not->toBeNull('Should have step_end chunk'); + + // Verify ordering: tool_input_end < tool_output_end < step_end + expect($toolInputEndIndex)->toBeLessThan($toolOutputEndIndex, 'tool_input_end should appear before tool_output_end') + ->and($toolOutputEndIndex)->toBeLessThan($firstStepEndIndex, 'tool_output_end should appear before step_end'); +}); + +test('agent memory is initialized with prompt messages and avoids duplication in LLM call', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello there!', + ], + ], + ], + ]), + ], 'gpt-4o-mini'); + + $agent = new Agent( + name: 'Test Agent', + prompt: Cortex::prompt([ + new SystemMessage('You are a helpful assistant.'), + new UserMessage('What is the weather in {location}?'), + ])->build(), + llm: $llm, + ); + + // 1. Verify Memory Initialization + $memoryMessages = $agent->getMemory()->getMessages(); + expect($memoryMessages)->toHaveCount(2) + ->and($memoryMessages[0]->role()->value)->toBe('system') + ->and($memoryMessages[0]->content())->toBe('You are a helpful assistant.') + ->and($memoryMessages[1]->role()->value)->toBe('user') + ->and($memoryMessages[1]->content())->toBe('What is the weather in {location}?'); + + // 2. Verify InputSchema is Preserved and Necessary + // After Agent modifies the prompt to only contain MessagePlaceholder, the schema must be preserved + // because defaultInputSchema() would calculate from current messages (only placeholder) and have no required properties + $prompt = $agent->getPrompt(); + + expect($prompt->messages)->toHaveCount(1) + ->and($prompt->messages->first())->toBeInstanceOf(MessagePlaceholder::class) + ->and($prompt->inputSchema)->not->toBeNull('InputSchema should be preserved'); + + // Verify preserved schema still requires 'location' + expect(function () use ($prompt): void { + $prompt->inputSchema->validate([]); + })->toThrow(SchemaException::class); + + // Verify that without preserved schema, defaultInputSchema() would have no required properties + $promptWithoutSchema = clone $prompt; + $promptWithoutSchema->inputSchema = null; + + $defaultSchema = $promptWithoutSchema->defaultInputSchema(); + + expect($defaultSchema->getPropertyKeys())->toBeEmpty('Without preserved schema, defaultInputSchema() should have no properties'); + expect(function () use ($defaultSchema): void { + $defaultSchema->validate([ + 'location' => 'Manchester', + ]); + })->not->toThrow(SchemaException::class, 'Empty schema should accept any input'); + + // 3. Verify No Duplication in LLM Call + $agent->invoke(input: [ + 'location' => 'Manchester', + ]); + + /** @var ClientFake $fakeClient */ + $fakeClient = $llm->getClient(); + $capturedParams = null; + + $fakeClient->chat()->assertSent(function (string $method, array $parameters) use (&$capturedParams): bool { + $capturedParams = $parameters; + + return true; + }); + + $sentMessages = $capturedParams['messages']; + expect($sentMessages)->toHaveCount(2) // Should be 2, NOT 4 + ->and($sentMessages[0]['role'])->toBe('system') + ->and($sentMessages[0]['content'])->toBe('You are a helpful assistant.') + ->and($sentMessages[1]['role'])->toBe('user') + ->and($sentMessages[1]['content'])->toBe('What is the weather in Manchester?'); + + // 4. Verify Assistant Message is Added to Memory + $memoryMessagesAfterInvoke = $agent->getMemory()->getMessages(); + expect($memoryMessagesAfterInvoke)->toHaveCount(3, 'Memory should contain system, user, and assistant messages') + ->and($memoryMessagesAfterInvoke[0]->role()->value)->toBe('system') + ->and($memoryMessagesAfterInvoke[0]->content())->toBe('You are a helpful assistant.') + ->and($memoryMessagesAfterInvoke[1]->role()->value)->toBe('user') + ->and($memoryMessagesAfterInvoke[1]->content())->toBe('What is the weather in Manchester?') + ->and($memoryMessagesAfterInvoke[2]->role()->value)->toBe('assistant') + ->and($memoryMessagesAfterInvoke[2]->content())->toBe('Hello there!'); + + // 5. Verify Step Has Assistant Message Set + $runtimeConfig = $agent->getRuntimeConfig(); + expect($runtimeConfig)->not->toBeNull(); + + $step = $runtimeConfig->context->getCurrentStep(); + expect($step->message)->not->toBeNull('Step should have assistant message') + ->and($step->message)->toBeInstanceOf(AssistantMessage::class) + ->and($step->message->content())->toBe('Hello there!'); + + // 6. Verify Message History in Context is Updated + $messageHistory = $runtimeConfig->context->getMessageHistory(); + expect($messageHistory)->toHaveCount(3, 'Message history should contain all messages') + ->and($messageHistory[2]->role()->value)->toBe('assistant') + ->and($messageHistory[2]->content())->toBe('Hello there!'); +}); diff --git a/tests/Unit/Agents/GenericAgentBuilderTest.php b/tests/Unit/Agents/GenericAgentBuilderTest.php new file mode 100644 index 0000000..a49f270 --- /dev/null +++ b/tests/Unit/Agents/GenericAgentBuilderTest.php @@ -0,0 +1,593 @@ +build(); + + expect($agent)->toBeInstanceOf(Agent::class) + ->and($agent->getName())->toBe('generic_agent') + ->and($agent->getPrompt())->toBeInstanceOf(ChatPromptTemplate::class); +}); + +test('it can build an agent with custom prompt', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $promptText = 'You are a helpful assistant.'; + $builder = new GenericAgentBuilder(); + $builder->withPrompt($promptText); + + expect($builder->prompt())->toBe($promptText); + + $agent = $builder->withLLM($llm)->build(); + + expect($agent)->toBeInstanceOf(Agent::class) + ->and($agent->getPrompt())->toBeInstanceOf(ChatPromptTemplate::class) + ->and($agent->getMemory()->getMessages()->first()->text())->toContain('helpful assistant'); +}); + +test('it can build an agent with ChatPromptBuilder', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $promptBuilder = Cortex::prompt([ + new UserMessage('You are a helpful assistant.'), + ]); + + $builder = new GenericAgentBuilder(); + $agent = $builder + ->withPrompt($promptBuilder) + ->withLLM($llm) + ->build(); + + expect($agent)->toBeInstanceOf(Agent::class) + ->and($agent->getPrompt())->toBeInstanceOf(ChatPromptTemplate::class); +}); + +test('it can build an agent with LLM', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $builder = new GenericAgentBuilder(); + $agent = $builder + ->withPrompt('Say hello') + ->withLLM($llm) + ->build(); + + expect($agent->getLLM())->toBe($llm); +}); + +test('it can build an agent with LLM string', function (): void { + $builder = new GenericAgentBuilder(); + $agent = $builder + ->withPrompt('Say hello') + ->withLLM('openai/gpt-4o') + ->build(); + + expect($agent->getLLM())->toBeInstanceOf(LLM::class); +}); + +test('it can build an agent with tools', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + fn(int $x, int $y): int => $x * $y, + ); + + $builder = new GenericAgentBuilder(); + $agent = $builder + ->withPrompt('Say hello') + ->withTools([$multiplyTool]) + ->build(); + + expect($agent->getTools())->toHaveCount(1); +}); + +test('it can build an agent with tool choice', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + fn(int $x, int $y): int => $x * $y, + ); + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Say hello') + ->withTools([$multiplyTool]) + ->withToolChoice(ToolChoice::Required); + + expect($builder->toolChoice())->toBe(ToolChoice::Required); + + $agent = $builder->build(); + + expect($agent->getLLM())->toBeInstanceOf(LLM::class) + ->and($agent->getTools())->toHaveCount(1); +}); + +test('it can build an agent with output schema', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"name":"John","age":30}', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $outputSchema = [ + Schema::string('name')->required(), + Schema::integer('age')->required(), + ]; + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Say hello') + ->withLLM($llm) + ->withOutput($outputSchema); + + expect($builder->output())->toBe($outputSchema); + + $agent = $builder->build(); + + expect($agent)->toBeInstanceOf(Agent::class); + + // Verify output schema is applied by invoking and checking parsed output + $result = $agent->invoke(); + expect($result->content())->toHaveKeys(['name', 'age']) + ->and($result->content()['name'])->toBe('John') + ->and($result->content()['age'])->toBe(30); +}); + +test('it can build an agent with output mode', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Say hello') + ->withLLM($llm) + ->withOutputMode(StructuredOutputMode::Json); + + expect($builder->outputMode())->toBe(StructuredOutputMode::Json); + + $agent = $builder->build(); + + expect($agent)->toBeInstanceOf(Agent::class); +}); + +test('it can build an agent with max steps', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + fn(int $x, int $y): int => $x * $y, + ); + + $llm = OpenAIChat::fake([ + // First response: tool call + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + // Second response: final answer + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'The result is 12', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Calculate 3 * 4') + ->withLLM($llm) + ->withTools([$multiplyTool]) + ->withMaxSteps(10); + + expect($builder->maxSteps())->toBe(10); + + $agent = $builder->build(); + + expect($agent)->toBeInstanceOf(Agent::class); + + // Verify maxSteps is respected by checking steps don't exceed limit + $result = $agent->invoke(); + expect($agent->getSteps())->toHaveCount(2) // Initial step + step after tool call + ->and($agent->getSteps()->count())->toBeLessThanOrEqual(10); +}); + +test('it can build an agent with strict mode', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Say hello to {name}') + ->withLLM($llm) + ->withStrict(false); + + expect($builder->strict())->toBeFalse(); + + $agent = $builder->build(); + + expect($agent)->toBeInstanceOf(Agent::class); + + // Verify strict mode is applied - non-strict should allow missing variables + $result = $agent->invoke(input: []); // Missing 'name' variable + expect($result)->toBeInstanceOf(ChatResult::class); +}); + +test('it can build an agent with initial prompt variables', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello John', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $variables = [ + 'name' => 'John', + ]; + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Say hello to {name}') + ->withLLM($llm) + ->withInitialPromptVariables($variables); + + expect($builder->initialPromptVariables())->toBe($variables); + + $agent = $builder->build(); + + expect($agent)->toBeInstanceOf(Agent::class); + + // Verify initial prompt variables are used by checking prompt formatting with memory + $formattedMessages = $agent->getPrompt()->format([ + 'messages' => $agent->getMemory()->getMessages(), + ]); + expect($formattedMessages->first()->text())->toContain('John'); +}); + +test('it can build an agent with middleware', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $executionOrder = []; + + $beforePromptMiddleware = beforePrompt(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'beforePrompt'; + + return $next($payload, $config); + }); + + $beforeModelMiddleware = beforeModel(function (mixed $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder): mixed { + $executionOrder[] = 'beforeModel'; + + return $next($payload, $config); + }); + + $middleware = [$beforePromptMiddleware, $beforeModelMiddleware]; + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Say hello') + ->withLLM($llm) + ->withMiddleware($middleware); + + expect($builder->middleware())->toBe($middleware) + ->and($builder->middleware())->toHaveCount(2); + + $agent = $builder->build(); + + $agent->invoke(); + + expect($executionOrder)->toContain('beforePrompt') + ->and($executionOrder)->toContain('beforeModel'); +}); + +test('it can chain fluent methods', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + fn(int $x, int $y): int => $x * $y, + ); + + $builder = new GenericAgentBuilder(); + $agent = $builder + ->withPrompt('You are a helpful assistant.') + ->withLLM($llm) + ->withTools([$multiplyTool]) + ->withToolChoice(ToolChoice::Auto) + ->withMaxSteps(10) + ->withStrict(true) + ->withInitialPromptVariables([ + 'name' => 'John', + ]) + ->build(); + + expect($agent)->toBeInstanceOf(Agent::class) + ->and($agent->getTools())->toHaveCount(1) + ->and($agent->getLLM())->toBe($llm); +}); + +test('it can invoke the built agent', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $builder = new GenericAgentBuilder(); + $result = $builder + ->withPrompt('Say hello') + ->withLLM($llm) + ->invoke(); + + expect($result)->toBeInstanceOf(ChatResult::class); +}); + +test('it can stream from the built agent', function (): void { + $llm = OpenAIChat::fake([ + CreateStreamedResponse::fake(fopen(__DIR__ . '/../../fixtures/openai/chat-stream.txt', 'r')), + ], 'gpt-4o'); + + $builder = new GenericAgentBuilder(); + $result = $builder + ->withPrompt('Say hello') + ->withLLM($llm) + ->stream(); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); +}); + +test('it can use withName to change the agent name', function (): void { + $builder = new GenericAgentBuilder(); + $agent = $builder + ->withName('custom_agent') + ->withPrompt('Say hello') + ->build(); + + expect($agent->getName())->toBe('custom_agent'); +}); + +test('it can use withTools with tool choice', function (): void { + $multiplyTool = tool( + 'multiply', + 'Multiply two numbers', + fn(int $x, int $y): int => $x * $y, + ); + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Say hello') + ->withTools([$multiplyTool], ToolChoice::Required); + + expect($builder->tools())->toHaveCount(1) + ->and($builder->toolChoice())->toBe(ToolChoice::Required); + + $agent = $builder->build(); + + expect($agent->getTools())->toHaveCount(1) + ->and($agent->getTools()[0]->name())->toBe('multiply'); +}); + +test('it can use withOutput with output mode', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"name":"John"}', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $outputSchema = [Schema::string('name')->required()]; + + $builder = new GenericAgentBuilder(); + $builder + ->withPrompt('Say hello') + ->withLLM($llm) + ->withOutput($outputSchema, StructuredOutputMode::Json); + + expect($builder->output())->toBe($outputSchema) + ->and($builder->outputMode())->toBe(StructuredOutputMode::Json); + + $agent = $builder->build(); + + expect($agent)->toBeInstanceOf(Agent::class); + + // Verify output schema and mode are applied + $result = $agent->invoke(); + expect($result->content())->toHaveKey('name') + ->and($result->content()['name'])->toBe('John'); +}); + +test('it returns default values when methods are not called', function (): void { + $builder = new GenericAgentBuilder(); + + expect($builder->llm())->toBeNull() + ->and($builder->tools())->toBe([]) + ->and($builder->toolChoice())->toBe(ToolChoice::Auto) + ->and($builder->output())->toBeNull() + ->and($builder->outputMode())->toBe(StructuredOutputMode::Auto) + ->and($builder->maxSteps())->toBe(5) + ->and($builder->strict())->toBeTrue() + ->and($builder->initialPromptVariables())->toBe([]) + ->and($builder->middleware())->toBe([]); +}); + +test('it can use static make method', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $agent = GenericAgentBuilder::make([ + 'prompt' => 'Say hello', + 'llm' => $llm, + ]); + + expect($agent)->toBeInstanceOf(Agent::class); +}); diff --git a/tests/Unit/CortexTest.php b/tests/Unit/CortexTest.php new file mode 100644 index 0000000..c1ad688 --- /dev/null +++ b/tests/Unit/CortexTest.php @@ -0,0 +1,164 @@ +toBeInstanceOf(Prompt::class); + }); + + test('it can create a text prompt builder with a string', function (): void { + $builder = Cortex::prompt('Hello, world!'); + + expect($builder)->toBeInstanceOf(TextPromptBuilder::class); + }); + + test('it can create a chat prompt builder with an array of messages', function (): void { + $builder = Cortex::prompt([ + new UserMessage('Hello'), + ]); + + expect($builder)->toBeInstanceOf(ChatPromptBuilder::class); + }); + + test('it can create a chat prompt builder with a MessageCollection', function (): void { + $messages = new MessageCollection([ + new UserMessage('Hello'), + ]); + + $builder = Cortex::prompt($messages); + + expect($builder)->toBeInstanceOf(ChatPromptBuilder::class); + }); + }); + + describe('llm()', function (): void { + test('it can get an LLM instance with provider and model', function (): void { + expect(Cortex::llm('openai', 'gpt-4o'))->toBeInstanceOf(OpenAIChat::class); + }); + + test('it can get an LLM instance via shortcut string', function (): void { + expect(Cortex::llm('openai/gpt-4o'))->toBeInstanceOf(OpenAIChat::class); + }); + + test('it can get an LLM instance with a closure', function (): void { + $llm = Cortex::llm('openai', function (LLM $llm): LLM { + return $llm->withModel('gpt-4o'); + }); + + expect($llm)->toBeInstanceOf(OpenAIChat::class) + ->and($llm->getModel())->toBe('gpt-4o'); + }); + + test('it can get an LLM instance with null provider', function (): void { + $llm = Cortex::llm(null); + + expect($llm)->toBeInstanceOf(LLM::class); + }); + }); + + describe('embeddings()', function (): void { + beforeEach(function (): void { + // Configure embeddings settings for tests + config([ + 'cortex.embeddings.default' => 'openai', + 'cortex.embeddings.openai' => [ + 'default_model' => 'text-embedding-3-small', + 'default_dimensions' => 1536, + ], + 'cortex.providers.openai' => [ + 'api_key' => env('OPENAI_API_KEY', 'test-key'), + 'organization' => null, + 'base_uri' => null, + ], + ]); + }); + + test('it can get an embeddings instance with driver', function (): void { + $embeddings = Cortex::embeddings('openai'); + + expect($embeddings)->toBeInstanceOf(EmbeddingsContract::class); + }); + + test('it can get an embeddings instance with driver and model', function (): void { + $embeddings = Cortex::embeddings('openai', 'text-embedding-3-small'); + + expect($embeddings)->toBeInstanceOf(EmbeddingsContract::class); + }); + + test('it can get an embeddings instance with a closure', function (): void { + $embeddings = Cortex::embeddings('openai', function (EmbeddingsContract $embeddings): EmbeddingsContract { + return $embeddings->withModel('text-embedding-3-small'); + }); + + expect($embeddings)->toBeInstanceOf(EmbeddingsContract::class); + }); + + test('it can get an embeddings instance with null driver', function (): void { + $embeddings = Cortex::embeddings(null); + + expect($embeddings)->toBeInstanceOf(EmbeddingsContract::class); + }); + }); + + describe('agent()', function (): void { + test('it can get a GenericAgentBuilder with no name', function (): void { + expect(Cortex::agent())->toBeInstanceOf(GenericAgentBuilder::class); + }); + + test('it can get an agent from registry by name', function (): void { + // Register a test agent first + $testAgent = new Agent( + name: 'test-agent', + prompt: 'You are a test agent.', + ); + + Cortex::registerAgent($testAgent); + + $agent = Cortex::agent('test-agent'); + + expect($agent)->toBeInstanceOf(Agent::class); + expect($agent->getName())->toBe('test-agent'); + }); + }); + + describe('registerAgent()', function (): void { + test('it can register an agent instance', function (): void { + $agent = new Agent( + name: 'registered-agent', + prompt: 'You are a registered agent.', + ); + + Cortex::registerAgent($agent); + + expect(Cortex::agent('registered-agent'))->toBeInstanceOf(Agent::class); + }); + + test('it can register an agent with a name override', function (): void { + $agent = new Agent( + name: 'original-name', + prompt: 'You are an agent.', + ); + + Cortex::registerAgent($agent, 'override-name'); + + expect(Cortex::agent('override-name'))->toBeInstanceOf(Agent::class); + expect(Cortex::agent('override-name')->getName())->toBe('original-name'); + }); + }); +}); diff --git a/tests/Unit/Experimental/PlaygroundTest.php b/tests/Unit/Experimental/PlaygroundTest.php deleted file mode 100644 index 55c1a4e..0000000 --- a/tests/Unit/Experimental/PlaygroundTest.php +++ /dev/null @@ -1,444 +0,0 @@ -withModel('gemma3:12b'); - - // dd($llm->getModelInfo()); - - $imageUrl = 'https://fastly.picsum.photos/id/998/536/354.jpg?hmac=cNFC6nFRlL4sRw1cTQAwmISZD2dkM-nvceFSIqTVKOA'; - // $base64Image = base64_encode(file_get_contents($imageUrl)); - - // $dataUrl = DataUrl::create(file_get_contents($imageUrl), 'image/jpeg', base64Encode: true); - - $prompt = prompt([ - new UserMessage([ - new TextContent('What is in this image?'), - new FileContent('data:{mime_type};base64,{base64_data}'), - // new FileContent('{base64_image}', 'image/jpeg'), - // new FileContent('https://fastly.picsum.photos/id/237/200/300.jpg?hmac=TmmQSbShHz9CdQm0NkEjx1Dyh_Y984R9LpNrpvH2D_U', 'image/jpeg'), - ]), - ]); - - $describeImage = $prompt->llm( - 'ollama', - fn(LLMContract $llm): \Cortex\LLM\Contracts\LLM => $llm - ->withModel('gemma3:12b') - ->withStructuredOutput( - SchemaFactory::object()->properties(SchemaFactory::string('description')->required()), - ), - ); - - /** @var \Cortex\LLM\Data\ChatStreamResult $result */ - $result = $describeImage->stream([ - 'mime_type' => 'image/jpeg', - 'base64_data' => base64_encode(file_get_contents($imageUrl)), - ]); - - foreach ($result as $chunk) { - dump($chunk->parsedOutput); - } - - dd('done'); -})->skip(); - -test('audio', function (): void { - $audioUrl = 'https://cdn.openai.com/API/docs/audio/alloy.wav'; - $audioData = file_get_contents($audioUrl); - $base64Data = base64_encode($audioData); - - $prompt = prompt([ - new UserMessage([ - new TextContent('What is in this recording?'), - new AudioContent($base64Data, 'wav'), - ]), - ]); - - $analyseAudio = $prompt->llm( - 'openai', - fn(LLMContract $llm): \Cortex\LLM\Contracts\LLM => $llm->withModel('gpt-4o-audio-preview')->ignoreFeatures(), - ); - - foreach ($analyseAudio->stream() as $chunk) { - dump($chunk->contentSoFar); - } - - dd('done'); -})->skip(); - -test('piping tasks with structured output', function (): void { - Event::listen(ChatModelStart::class, function (ChatModelStart $event): void { - dump($event->parameters); - }); - - Event::listen(ChatModelEnd::class, function (ChatModelEnd $event): void { - dump($event->result); - }); - - // $generateStoryIdea = task('generate_story_idea', TaskType::Structured) - // // ->llm('ollama', 'qwen2.5:14b') - // ->llm('github') - // ->system('You are an expert story ideator.') - // ->user('Generate a story idea about {topic}. Only answer in a single sentence.') - // ->properties(new StringSchema('story_idea')); - - $prompt = Cortex::prompt([ - new SystemMessage('You are an expert story ideator.'), - new UserMessage('Generate a story idea about {topic}. Only answer in a single sentence.'), - ]); - - $generateStoryIdea = $prompt->llm('openai', function (LLMContract $llm): LLMContract { - return $llm->withModel('gpt-5-mini') - ->withStructuredOutput( - output: SchemaFactory::object()->properties(SchemaFactory::string('story_idea')->required()), - outputMode: StructuredOutputMode::Auto, - ); - }); - - // $generateStoryIdea = $prompt->llm('github', function (LLMContract $llm): LLMContract { - // return $llm->withFeatures(ModelFeature::ToolCalling, ModelFeature::StructuredOutput, ModelFeature::JsonOutput) - // // ->withModel('xai/grok-3-mini') - // // ->withModel('mistral-small3.1') - // ->withStructuredOutput( - // output: SchemaFactory::object()->properties(SchemaFactory::string('story_idea')->required()), - // outputMode: StructuredOutputMode::Auto, - // ); - // }); - - // dd($generateStoryIdea->invoke([ - // 'topic' => 'a dragon', - // ])); - - foreach ($generateStoryIdea->stream([ - 'topic' => 'a dragon', - ]) as $chunk) { - dump($chunk->parsedOutput); - } - - dd('done'); - - $writeStoryAboutIdea = task('write_story', TaskType::Structured) - ->llm('ollama', 'qwen2.5:14b') - ->messages([ - new SystemMessage('You are an adept story writer.'), - new UserMessage("Write a story about the following idea. The story should only be 3 paragraphs.\n\nIdea: {story_idea}"), - ]) - ->properties(new StringSchema('story')); - - dd($generateStoryIdea->pipe($writeStoryAboutIdea)->invoke([ - 'topic' => 'a dragon', - ])); - - // foreach ($generateStoryIdea->pipe($writeStoryAboutIdea)->stream(['topic' => 'laravel']) as $chunk) { - // dump($chunk); - // } - - // dd('done'); - - $rewriteStory = task('rewrite_story', TaskType::Structured) - ->llm('lmstudio') - ->system('You are an expert novelist that writes in the style of Hemmingway.') - ->user("Make a final revision of this story in your voice:\n\n{story}") - ->properties(new StringSchema('revised_story')); - - $result = $generateStoryIdea - ->pipe($writeStoryAboutIdea) - ->pipe($rewriteStory) - ->invoke([ - 'topic' => 'a troll', - ]); - - dd($result); - - // TODO: add a new class of TaskPipeline, that allows the above to be composed like: - // $createStory = new CreateAStory(); - // $result = $createStory->invoke(['topic' => 'a troll']); - - expect($result)->toBeArray()->toHaveKey('revised_story'); - -})->skip(); - -test('task builder types', function (): void { - $simpleTellAJoke = task('tell_a_joke', TaskType::Text) - ->system('You are a comedian.') - ->user('Tell a joke about {topic}.') - ->llm('ollama', 'nemotron-mini'); - - $tellAJokeStructured = task('tell_a_joke', TaskType::Structured) - ->system('You are a comedian.') - ->user('Tell a joke about {topic}.') - ->llm('ollama', 'nemotron-mini') - ->properties( - new StringSchema('setup'), - new StringSchema('punchline'), - ); - - dump($simpleTellAJoke->invoke([ - 'topic' => 'elvis presley', - ])); - dd($tellAJokeStructured->invoke([ - 'topic' => 'michael jackson', - ])); -})->skip(); - -test('task to extract structured output from image', function (): void { - $extractFromImage = task('extract_from_image', TaskType::Structured) - ->messages([ - new UserMessage([ - new TextContent('Extract the data from the invoice.'), - new FileContent('{image_url}'), - ]), - ]) - ->llm('openai', 'gpt-4o-mini') - ->properties( - new StringSchema('invoice_number'), - new StringSchema('invoice_date'), - new StringSchema('due_date'), - new StringSchema('total_amount'), - new StringSchema('currency'), - new StringSchema('payment_terms'), - new StringSchema('customer_name'), - ); - - $result = $extractFromImage->invoke([ - 'image_url' => 'https://i.imgur.com/g7GeLCe.png', - ]); - - dd($result); -})->skip(); - -test('parallel group 1', function (): void { - $dogJoke = task('tell_a_dog_joke', TaskType::Structured) - ->system('You are a comedian.') - ->user('Tell a joke about dogs.') - ->llm('groq') - ->properties( - new StringSchema('setup_dog'), - new StringSchema('punchline_dog'), - ); - - $catJoke = task('tell_a_cat_joke', TaskType::Structured) - ->system('You are a comedian.') - ->user('Tell a joke about cats.') - ->llm('groq') - ->properties( - new StringSchema('setup_cat'), - new StringSchema('punchline_cat'), - ); - - $mouseJoke = task('tell_a_mouse_joke', TaskType::Structured) - ->system('You are a comedian.') - ->user('Tell a joke about mice.') - ->llm('groq') - ->properties( - new StringSchema('setup_mouse'), - new StringSchema('punchline_mouse'), - ); - - $pipeline = new Pipeline(); - - $pipeline->pipe([$dogJoke, $catJoke, $mouseJoke]); - // $pipeline->pipe($dogJoke)->pipe($catJoke)->pipe($mouseJoke); - - // dd($pipeline); - - // $result = $pipeline->invoke(); - // dump($result); - - // Benchmark::dd(function () use ($pipeline) { - // $result = $pipeline->invoke(); - // dump($result); - // }); - - $result = $pipeline->stream(); - dd($result); -})->skip(); - -test('reasoning output', function (): void { - $pipeline = prompt('What is the weight of the moon?') - ->llm('ollama', 'deepseek-r1:32b') - ->pipe(new XmlTagOutputParser('think')); - - $result = $pipeline->stream(); - - foreach ($result as $chunk) { - dump($chunk); - } -})->skip(); - -test('guardrails', function (): void { - enum Sentiment: string - { - case Positive = 'positive'; - case Negative = 'negative'; - case Neutral = 'neutral'; - } - - $analyseSentiment = task('sentiment_analysis', TaskType::Structured) - ->llm('ollama', 'mistral-small') - ->user('Analyze the sentiment of this text: {input}') - ->output(Sentiment::class) - // ->pipe(function (Sentiment $sentiment, Closure $next) { - // if ($sentiment === Sentiment::Negative) { - // throw new \Exception('Negative sentiment detected'); - // } - - // $response = $next($sentiment); - - // return $response . ' (after pipe)'; - // }) - ->pipe(function (Sentiment $sentiment, Closure $next) { - return $next('The sentiment is: ' . $sentiment->value); - }); - - $result = $analyseSentiment->invoke([ - 'input' => 'I am happy', - ]); - - dd($result); -})->skip(); - -test('reasoning task', function (): void { - $task = task('reasoning') - ->messages([ - new DeveloperMessage( - <<<'PROMPT' - {goal} - - {return_format} - - {warnings} - - {context} - PROMPT, - ), - ]) - ->llm('openai', 'o1-mini') - ->build(); - - $result = $task->invoke([ - 'goal' => 'What is the weight of the moon?', - 'return_format' => 'xml', - 'warnings' => 'Do not hallucinate.', - 'context' => 'The moon is a natural satellite of the Earth.', - ]); - - dd($result); -})->skip(); - -test('anthropic real', function (): void { - Event::listen(ChatModelStart::class, function (ChatModelStart $event): void { - dump($event->parameters); - }); - - $prompt = Cortex::prompt() - ->builder() - ->messages([ - new SystemMessage('You are an expert story ideator.'), - new UserMessage('Generate a story idea about {topic}. Only answer in a single sentence.'), - // new UserMessage('What is the weight of the moon? Think step by step.'), - ]) - ->metadata( - provider: 'anthropic', - model: 'claude-sonnet-4-20250514', - structuredOutput: SchemaFactory::object()->properties( - SchemaFactory::string('story_idea')->required(), - ), - structuredOutputMode: StructuredOutputMode::Json, - // parameters: [ - // 'max_tokens' => 3000, - // 'thinking' => [ - // 'type' => 'enabled', - // 'budget_tokens' => 2000, - // ], - // ], - ); - - // dd($prompt->llm()->invoke([ - // 'topic' => 'dragons', - // ])); - - $result = $prompt->llm()->stream([ - 'topic' => 'trolls', - ]); - - foreach ($result as $chunk) { - dump($chunk->parsedOutput); - } - - dd('done'); - - $tellAJoke = $prompt->llm( - 'anthropic', - fn(LLMContract $llm): \Cortex\LLM\Contracts\LLM => $llm->withStructuredOutput( - output: SchemaFactory::object() - ->properties( - SchemaFactory::string('setup')->required(), - SchemaFactory::string('punchline')->required(), - ), - outputMode: StructuredOutputMode::Json, - ), - ); - - dd($tellAJoke->invoke([ - 'topic' => 'dragons', - ])->parsedOutput); -})->skip(); - -test('model info', function (): void { - $result = ModelInfo::getModels(ModelProvider::XAI); - // $result = ModelInfo::getModels(ModelProvider::Ollama); - // $result = ModelInfo::getModels(ModelProvider::Gemini); - // $result = ModelInfo::getModelInfo(ModelProvider::Gemini, 'gemini-2.5-pro-preview-tts'); - // $result = ModelInfo::getModelInfo(ModelProvider::Ollama, 'llama3.2-vision:latest'); - // $result = ModelInfo::getModelInfo(ModelProvider::OpenAI, 'gpt-4.1'); - // $result = ModelInfo::getModelInfo(ModelProvider::XAI, 'grok-3'); - - // $result = ModelProvider::OpenAI->info('gpt-4o'); - - dd($result); -})->skip(); - -test('openai responses', function (): void { - Event::listen(ChatModelStart::class, function (ChatModelStart $event): void { - dump($event->parameters); - }); - - $result = LLM::provider('openai') - ->withModel('gpt-5-mini') - ->withParameters([ - 'reasoning' => [ - 'effort' => 'low', - ], - ]) - ->invoke('How much wood would a woodchuck chuck?'); - - dd($result); -})->skip(); diff --git a/tests/Unit/LLM/Drivers/AnthropicChatTest.php b/tests/Unit/LLM/Drivers/Anthropic/AnthropicChatTest.php similarity index 94% rename from tests/Unit/LLM/Drivers/AnthropicChatTest.php rename to tests/Unit/LLM/Drivers/Anthropic/AnthropicChatTest.php index bda75c9..e06d13e 100644 --- a/tests/Unit/LLM/Drivers/AnthropicChatTest.php +++ b/tests/Unit/LLM/Drivers/Anthropic/AnthropicChatTest.php @@ -2,24 +2,26 @@ declare(strict_types=1); -namespace Cortex\Tests\Unit\LLM\Drivers; +namespace Cortex\Tests\Unit\LLM\Drivers\Anthropic; use Cortex\Cortex; use Cortex\LLM\Data\Usage; use Cortex\Attributes\Tool; +use Cortex\Tools\SchemaTool; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Data\ToolCall; use Cortex\LLM\Data\ChatResult; use Cortex\LLM\Data\FunctionCall; -use Cortex\JsonSchema\SchemaFactory; +use Cortex\LLM\Data\ChatGeneration; use Cortex\LLM\Data\ChatStreamResult; -use Cortex\LLM\Drivers\AnthropicChat; use Cortex\LLM\Data\ToolCallCollection; use Cortex\LLM\Data\ChatGenerationChunk; use Cortex\ModelInfo\Enums\ModelFeature; use Cortex\LLM\Data\Messages\UserMessage; -use Cortex\Tasks\Enums\StructuredOutputMode; +use Cortex\LLM\Enums\StructuredOutputMode; use Anthropic\Responses\Meta\MetaInformation; use Cortex\LLM\Data\Messages\AssistantMessage; +use Cortex\LLM\Drivers\Anthropic\AnthropicChat; use Cortex\LLM\Data\Messages\Content\TextContent; use Anthropic\Responses\Messages\CreateResponse as ChatCreateResponse; use Anthropic\Responses\Messages\CreateStreamedResponse as ChatCreateStreamedResponse; @@ -43,8 +45,8 @@ expect($result)->toBeInstanceOf(ChatResult::class) ->and($result->rawResponse) ->toBeArray()->not->toBeEmpty() - ->and($result->generations) - ->toHaveCount(1) + ->and($result->generation) + ->toBeInstanceOf(ChatGeneration::class) ->and($result->generation->message) ->toBeInstanceOf(AssistantMessage::class) ->and($result->generation->message->content) @@ -56,7 +58,7 @@ test('it can stream', function (): void { $llm = AnthropicChat::fake([ - ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../fixtures/anthropic/chat-stream.txt', 'r')), + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/anthropic/chat-stream.txt', 'r')), ]); $llm->withStreaming(); @@ -140,9 +142,9 @@ $llm->addFeature(ModelFeature::StructuredOutput); $llm->withStructuredOutput( - SchemaFactory::object()->properties( - SchemaFactory::string('name'), - SchemaFactory::integer('age'), + Schema::object()->properties( + Schema::string('name'), + Schema::integer('age'), ), name: 'Person', description: 'A person with a name and age', @@ -171,7 +173,7 @@ [ 'type' => 'tool_use', 'id' => 'call_123', - 'name' => 'Person', + 'name' => SchemaTool::NAME, 'input' => [ 'name' => 'John Doe', 'age' => 30, @@ -186,9 +188,9 @@ $llm->addFeature(ModelFeature::ToolCalling); - $schema = SchemaFactory::object('Person')->properties( - SchemaFactory::string('name'), - SchemaFactory::integer('age'), + $schema = Schema::object('Person')->properties( + Schema::string('name'), + Schema::integer('age'), ); $llm->withStructuredOutput($schema, outputMode: StructuredOutputMode::Tool); @@ -211,7 +213,7 @@ && $parameters['messages'][0]['role'] === 'user' && $parameters['messages'][0]['content'] === 'Tell me about a person' && $parameters['tools'][0]['type'] === 'custom' - && $parameters['tools'][0]['name'] === 'Person' + && $parameters['tools'][0]['name'] === SchemaTool::NAME && $parameters['tools'][0]['input_schema']['properties']['name']['type'] === 'string' && $parameters['tools'][0]['input_schema']['properties']['age']['type'] === 'integer'; }); @@ -219,7 +221,7 @@ test('it can stream with structured output', function (): void { $llm = AnthropicChat::fake([ - ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../fixtures/anthropic/chat-stream-json.txt', 'r')), + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/anthropic/chat-stream-json.txt', 'r')), ], 'claude-3-5-sonnet-20241022'); $llm->addFeature(ModelFeature::StructuredOutput); @@ -267,7 +269,7 @@ public function __construct( test('it can stream with tool calls', function (): void { $llm = AnthropicChat::fake([ - ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../fixtures/anthropic/chat-stream-tool-calls.txt', 'r')), + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/anthropic/chat-stream-tool-calls.txt', 'r')), ], 'claude-3-5-sonnet-20241022'); $llm->addFeature(ModelFeature::ToolCalling); diff --git a/tests/Unit/LLM/Drivers/FakeChatTest.php b/tests/Unit/LLM/Drivers/FakeChatTest.php index 5773425..955edfe 100644 --- a/tests/Unit/LLM/Drivers/FakeChatTest.php +++ b/tests/Unit/LLM/Drivers/FakeChatTest.php @@ -25,7 +25,7 @@ ]); expect($result)->toBeInstanceOf(ChatResult::class) - ->and($result->generations)->toHaveCount(1) + ->and($result->generation)->toBeInstanceOf(ChatGeneration::class) ->and($result->generation->message)->toBeInstanceOf(AssistantMessage::class) ->and($result->generation->message->content)->toBe('I am doing well, thank you for asking!'); diff --git a/tests/Unit/LLM/Drivers/OpenAI/OpenAIChatTest.php b/tests/Unit/LLM/Drivers/OpenAI/OpenAIChatTest.php new file mode 100644 index 0000000..5020ecd --- /dev/null +++ b/tests/Unit/LLM/Drivers/OpenAI/OpenAIChatTest.php @@ -0,0 +1,761 @@ + [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'I am doing well, thank you for asking!', + ], + ], + ], + ]), + ]); + + $result = $llm->includeRaw()->invoke([ + new UserMessage('Hello, how are you?'), + ]); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($result->rawResponse)->toBeArray()->not->toBeEmpty() + ->and($result->generation)->toBeInstanceOf(ChatGeneration::class) + ->and($result->generation->message)->toBeInstanceOf(AssistantMessage::class) + ->and($result->generation->message->text())->toBe('I am doing well, thank you for asking!'); +}); + +test('it can stream', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/chat-stream.txt', 'r')), + ]); + + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('Hello, how are you?'), + ]); + + $expectedOutput = 'Hello! I’m just a program, so I don’t have feelings, but I’m here and ready to help you with whatever you need. How can I assist you today?'; + + $chunkTypes = []; + $output = $chunks->reduce(function (string $carry, ChatGenerationChunk $chunk) use (&$chunkTypes, $expectedOutput) { + $chunkTypes[] = $chunk->type; + expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class) + ->and($chunk->message)->toBeInstanceOf(AssistantMessage::class) + ->and($expectedOutput)->toContain($chunk->message->content); + + return $carry . $chunk->message->content; + }, ''); + + expect($output)->toBe($expectedOutput); + + // Verify chunk types are correctly mapped + expect($chunkTypes)->toHaveCount(39) + ->and($chunkTypes[0])->toBe(ChunkType::TextStart) // First text content + ->and($chunkTypes[1])->toBe(ChunkType::TextDelta) // Subsequent text + ->and($chunkTypes[37])->toBe(ChunkType::TextDelta) // Last text content + ->and($chunkTypes[38])->toBe(ChunkType::TextEnd); // Text end in flush +}); + +test('it can use tools', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + ], + ], + ], + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $llm->withTools([ + #[Tool(name: 'multiply', description: 'Multiply two numbers')] + fn(int $x, int $y): int => $x * $y, + ]); + + $result = $llm->invoke([ + new UserMessage('What is 3 times 4?'), + ]); + + expect($result->generation->message->toolCalls) + ->toBeInstanceOf(ToolCallCollection::class) + ->and($result->generation->message->toolCalls) + ->toHaveCount(1) + ->and($result->generation->message->toolCalls[0]) + ->toBeInstanceOf(ToolCall::class) + ->and($result->generation->message->toolCalls[0]->function) + ->toBeInstanceOf(FunctionCall::class) + ->and($result->generation->message->toolCalls[0]->function->name) + ->toBe('multiply') + ->and($result->generation->message->toolCalls[0]->function->arguments) + ->toBe([ + 'x' => 3, + 'y' => 4, + ]); +}); + +test('it can use structured output', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => $expected = '{"name":"John Doe","age":30}', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::StructuredOutput); + + $llm->withStructuredOutput( + Schema::object()->properties( + Schema::string('name'), + Schema::integer('age'), + ), + name: 'Person', + description: 'A person with a name and age', + ); + + $result = $llm->invoke([ + new UserMessage('Tell me about a person'), + ]); + + expect($result->generation->message->text()) + ->toBe($expected) + ->and($result->generation->message->content()) + ->toBe($expected); + + expect($result->generation->parsedOutput)->toBe([ + 'name' => 'John Doe', + 'age' => 30, + ]); +}); + +test('it can use structured output using the schema tool', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => SchemaTool::NAME, + 'arguments' => '{"name":"John Doe","age":30}', + ], + ], + ], + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $schema = Schema::object('Person')->properties( + Schema::string('name'), + Schema::integer('age'), + ); + + $llm->withStructuredOutput($schema, outputMode: StructuredOutputMode::Tool); + + $result = $llm->invoke([ + new UserMessage('Tell me about a person'), + ]); + + expect($result->parsedOutput) + ->toBe([ + 'name' => 'John Doe', + 'age' => 30, + ]); + + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $client->chat()->assertSent(function (string $method, array $parameters): bool { + return $parameters['model'] === 'gpt-4o' + && $parameters['messages'][0]['role'] === 'user' + && $parameters['messages'][0]['content'] === 'Tell me about a person' + && $parameters['tools'][0]['type'] === 'function' + && $parameters['tools'][0]['function']['name'] === SchemaTool::NAME + && $parameters['tools'][0]['function']['parameters']['properties']['name']['type'] === 'string' + && $parameters['tools'][0]['function']['parameters']['properties']['age']['type'] === 'integer'; + }); +}); + +test('it can stream with structured output', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/chat-stream-json.txt', 'r')), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::StructuredOutput); + + $llm->withStreaming(); + + class Joke + { + public function __construct( + public ?string $setup = null, + public ?string $punchline = null, + ) {} + } + + $result = $llm->withStructuredOutput(Joke::class)->invoke([ + new UserMessage('Tell me a joke about dogs'), + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + $finalCalled = false; + + $result->each(function (ChatGenerationChunk $chunk) use (&$finalCalled): void { + expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class); + + if ($chunk->isFinal) { + expect($chunk->parsedOutput)->toBeInstanceOf(Joke::class) + ->and($chunk->parsedOutput->setup)->toBe('Why did the dog sit in the shade?') + ->and($chunk->parsedOutput->punchline)->toBe("Because it didn't want to be a hot dog!"); + + $finalCalled = true; + } + }); + + expect($finalCalled)->toBeTrue(); + + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $client->chat()->assertSent(function (string $method, array $parameters): bool { + return $parameters['model'] === 'gpt-4o' + && $parameters['messages'][0]['role'] === 'user' + && $parameters['messages'][0]['content'] === 'Tell me a joke about dogs'; + }); +}); + +test('it can use structured output with an enum', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"Sentiment":"positive"}', + ], + ], + ], + ]), + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"Sentiment":"neutral"}', + ], + ], + ], + ]), + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"Sentiment":"negative"}', + ], + ], + ], + ]), + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"Sentiment":"neutral"}', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::StructuredOutput); + + enum Sentiment: string + { + case Positive = 'positive'; + case Negative = 'negative'; + case Neutral = 'neutral'; + } + + $llm->withStructuredOutput(Sentiment::class); + + expect($llm->invoke('Analyze the sentiment of this text: This pizza is awesome')->content()) + ->toBe(Sentiment::Positive) + ->and($llm->invoke('Analyze the sentiment of this text: This pizza is okay')->content()) + ->toBe(Sentiment::Neutral) + ->and($llm->invoke('Analyze the sentiment of this text: This pizza is terrible')->content()) + ->toBe(Sentiment::Negative); + + $getSentiment = Cortex::prompt('Analyze the sentiment of this text: {input}')->llm($llm); + + $result = $getSentiment->invoke('This pizza is average'); + + expect($result) + ->toBeInstanceOf(ChatResult::class) + ->and($result->parsedOutput)->toBe(Sentiment::Neutral); +}); + +test('it can force json output', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => $expected = '{"setup":"Why did the scarecrow win an award?","punchline":"Because he was outstanding in his field!"}', + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::JsonOutput); + + $llm->forceJsonOutput(); + + $result = $llm->invoke([ + new UserMessage('Tell me a joke'), + ]); + + expect($result->generation->message->text()) + ->toBe($expected) + ->and($result->generation->message->content()) + ->toBe($expected); +}); + +test('it can set temperature and max tokens', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake(), + ]); + + $llm->withTemperature(0.7)->withMaxTokens(100)->invoke([ + new UserMessage('Hello'), + ]); + + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $client->chat()->assertSent(function (string $method, array $parameters): bool { + return $parameters['temperature'] === 0.7 && $parameters['max_completion_tokens'] === 100; + }); +}); + +test('it tracks token usage', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 20, + 'total_tokens' => 30, + ], + ]), + ]); + + $result = $llm->invoke([ + new UserMessage('Hello'), + ]); + + expect($result->usage) + ->toBeInstanceOf(Usage::class) + ->and($result->usage->promptTokens)->toBe(10) + ->and($result->usage->completionTokens)->toBe(20) + ->and($result->usage->totalTokens)->toBe(30); +}); + +test('it correctly maps chunk types for streaming', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/chat-stream.txt', 'r')), + ]); + + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('Hello, how are you?'), + ]); + + $chunksData = []; + $chunks->each(function (ChatGenerationChunk $chunk) use (&$chunksData): void { + $chunksData[] = $chunk; + }); + + // Verify the expected chunk type sequence + expect($chunksData)->toHaveCount(39) + ->and($chunksData[0]->type)->toBe(ChunkType::TextStart) + ->and($chunksData[1]->type)->toBe(ChunkType::TextDelta) + ->and($chunksData[37]->type)->toBe(ChunkType::TextDelta) + ->and($chunksData[38]->type)->toBe(ChunkType::TextEnd); +}); + +test('it correctly maps chunk types for tool calls streaming', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/chat-stream-tool-calls.txt', 'r')), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $llm->withTools([ + #[Tool(name: 'multiply', description: 'Multiply two numbers')] + fn(int $x, int $y): int => $x * $y, + ]); + + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('What is 3 times 4?'), + ]); + + $chunksData = []; + $finalChunk = null; + + $chunks->each(function (ChatGenerationChunk $chunk) use (&$chunksData, &$finalChunk): void { + $chunksData[] = $chunk; + + if ($chunk->isFinal) { + $finalChunk = $chunk; + } + }); + + // Verify the expected chunk type sequence for tool calls + expect($chunksData)->toHaveCount(12) + ->and($chunksData[0]->type)->toBe(ChunkType::ToolInputStart) + ->and($chunksData[1]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[2]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[3]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[4]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[5]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[6]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[7]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[8]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[9]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[10]->type)->toBe(ChunkType::ToolInputDelta) + ->and($chunksData[11]->type)->toBe(ChunkType::ToolInputEnd); + + // // Verify the final chunk has tool calls + expect($finalChunk)->not->toBeNull(); + expect($finalChunk?->message->toolCalls)->not->toBeNull() + ->toHaveCount(1); + expect($finalChunk?->message->toolCalls?->first()->function->name)->toBe('multiply'); + expect($finalChunk?->message->toolCalls?->first()->function->arguments)->toBe([ + 'x' => 3, + 'y' => 4, + ]); +}); + +test('LLM instance-specific listeners work correctly', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello World!', + ], + ], + ], + ]), + ]); + + $startCalled = false; + $endCalled = false; + + $llm->onStart(function (ChatModelStart $event) use ($llm, &$startCalled): void { + $startCalled = true; + expect($event->llm)->toBe($llm); + expect($event->messages)->toBeInstanceOf(MessageCollection::class); + }); + + $llm->onEnd(function (ChatModelEnd $event) use ($llm, &$endCalled): void { + $endCalled = true; + expect($event->llm)->toBe($llm); + expect($event->result)->toBeInstanceOf(ChatResult::class); + }); + + $result = $llm->invoke([ + new UserMessage('Hello'), + ]); + + expect($result)->toBeInstanceOf(ChatResult::class); + expect($startCalled)->toBeTrue(); + expect($endCalled)->toBeTrue(); +}); + +test('LLM instance-specific error listeners work correctly', function (): void { + $llm = OpenAIChat::fake([ + new Exception('API Error'), + ]); + + $errorCalled = false; + + $llm->onError(function (ChatModelError $event) use ($llm, &$errorCalled): void { + $errorCalled = true; + expect($event->llm)->toBe($llm); + expect($event->exception)->toBeInstanceOf(Exception::class); + expect($event->exception->getMessage())->toBe('API Error'); + }); + + try { + $llm->invoke([new UserMessage('Hello')]); + } catch (Exception) { + // Expected + } + + expect($errorCalled)->toBeTrue(); +}); + +test('LLM instance-specific stream listeners work correctly', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/chat-stream.txt', 'r')), + ]); + + $llm->withStreaming(); + + $streamCalls = []; + + $llm->onStream(function (ChatModelStream $event) use ($llm, &$streamCalls): void { + expect($event->llm)->toBe($llm); + expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); + $streamCalls[] = $event->chunk; + }); + + $result = $llm->invoke([ + new UserMessage('Hello'), + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Iterate over the stream to trigger stream events + foreach ($result as $chunk) { + // Events are dispatched during iteration + } + + expect($streamCalls)->not->toBeEmpty(); +}); + +test('LLM instance-specific stream and stream end listeners work correctly', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/chat-stream.txt', 'r')), + ]); + + $llm->withStreaming(); + + $streamCalls = []; + $streamEndCalled = false; + $streamEndChunk = null; + $eventOrder = []; + + $llm->onStream(function (ChatModelStream $event) use ($llm, &$streamCalls, &$eventOrder): void { + expect($event->llm)->toBe($llm); + expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); + $streamCalls[] = $event->chunk; + $eventOrder[] = 'stream'; + }); + + $llm->onStreamEnd(function (ChatModelStreamEnd $event) use ($llm, &$streamEndCalled, &$streamEndChunk, &$eventOrder): void { + $streamEndCalled = true; + expect($event->llm)->toBe($llm); + expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); + + $streamEndChunk = $event->chunk; + $eventOrder[] = 'streamEnd'; + }); + + $result = $llm->invoke([ + new UserMessage('Hello'), + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Iterate over the stream to trigger stream events + foreach ($result as $chunk) { + // Events are dispatched during iteration + } + + // Verify stream events were dispatched + expect($streamCalls)->not->toBeEmpty() + ->and($streamCalls)->toBeArray() + ->and(count($streamCalls))->toBeGreaterThan(0); + + // Verify stream end event was dispatched after streaming completes + expect($streamEndCalled)->toBeTrue() + ->and($streamEndChunk)->not->toBeNull() + ->and($streamEndChunk)->toBeInstanceOf(ChatGenerationChunk::class); + + // Verify that stream end event is the final event (after all stream events) + expect($eventOrder)->not->toBeEmpty() + ->and($eventOrder)->toContain('stream') + ->and($eventOrder)->toContain('streamEnd') + ->and($eventOrder[count($eventOrder) - 1])->toBe('streamEnd'); + + // Verify all stream events occurred before the stream end event + $streamEndIndex = array_search('streamEnd', $eventOrder, true); + $streamEventsBeforeEnd = array_slice($eventOrder, 0, $streamEndIndex); + expect($streamEventsBeforeEnd)->toHaveCount(count($streamCalls)) + ->and($streamEventsBeforeEnd)->toContain('stream') + ->and($streamEventsBeforeEnd)->not->toContain('streamEnd'); +}); + +test('multiple LLM instances have separate listeners', function (): void { + $llm1 = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Response 1', + ], + ], + ], + ]), + ]); + + $llm2 = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Response 2', + ], + ], + ], + ]), + ]); + + $llm1StartCalled = false; + $llm1EndCalled = false; + $llm2StartCalled = false; + $llm2EndCalled = false; + + $llm1->onStart(function (ChatModelStart $event) use ($llm1, &$llm1StartCalled): void { + expect($event->llm)->toBe($llm1); + $llm1StartCalled = true; + }); + + $llm1->onEnd(function (ChatModelEnd $event) use ($llm1, &$llm1EndCalled): void { + expect($event->llm)->toBe($llm1); + $llm1EndCalled = true; + }); + + $llm2->onStart(function (ChatModelStart $event) use ($llm2, &$llm2StartCalled): void { + expect($event->llm)->toBe($llm2); + $llm2StartCalled = true; + }); + + $llm2->onEnd(function (ChatModelEnd $event) use ($llm2, &$llm2EndCalled): void { + expect($event->llm)->toBe($llm2); + $llm2EndCalled = true; + }); + + $result1 = $llm1->invoke([new UserMessage('Hello')]); + $result2 = $llm2->invoke([new UserMessage('Hello')]); + + expect($result1)->toBeInstanceOf(ChatResult::class); + expect($result2)->toBeInstanceOf(ChatResult::class); + expect($llm1StartCalled)->toBeTrue(); + expect($llm1EndCalled)->toBeTrue(); + expect($llm2StartCalled)->toBeTrue(); + expect($llm2EndCalled)->toBeTrue(); +}); + +test('LLM can chain multiple instance-specific listeners', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello World!', + ], + ], + ], + ]), + ]); + + $callOrder = []; + + $llm + ->onStart(function (ChatModelStart $event) use (&$callOrder): void { + $callOrder[] = 'start1'; + }) + ->onStart(function (ChatModelStart $event) use (&$callOrder): void { + $callOrder[] = 'start2'; + }) + ->onEnd(function (ChatModelEnd $event) use (&$callOrder): void { + $callOrder[] = 'end1'; + }) + ->onEnd(function (ChatModelEnd $event) use (&$callOrder): void { + $callOrder[] = 'end2'; + }); + + $llm->invoke([new UserMessage('Hello')]); + + expect($callOrder)->toBe(['start1', 'start2', 'end1', 'end2']); +}); diff --git a/tests/Unit/LLM/Drivers/OpenAI/OpenAIResponsesTest.php b/tests/Unit/LLM/Drivers/OpenAI/OpenAIResponsesTest.php new file mode 100644 index 0000000..d2acb27 --- /dev/null +++ b/tests/Unit/LLM/Drivers/OpenAI/OpenAIResponsesTest.php @@ -0,0 +1,744 @@ + 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'I am doing well, thank you for asking!', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'total_tokens' => 30, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ]); + + $result = $llm->includeRaw()->invoke([ + new UserMessage('Hello, how are you?'), + ]); + + expect($result)->toBeInstanceOf(ChatResult::class) + ->and($result->rawResponse)->toBeArray()->not->toBeEmpty() + ->and($result->generation)->toBeInstanceOf(ChatGeneration::class) + ->and($result->generation->message)->toBeInstanceOf(AssistantMessage::class) + ->and($result->generation->message->text())->toContain('I am doing well, thank you for asking!'); +}); + +test('it can use tools', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => '', + 'annotations' => [], + ], + ], + ], + [ + 'type' => 'function_call', + 'id' => 'call_123', + 'call_id' => 'call_123', + 'name' => 'multiply', + 'arguments' => '{"x":3,"y":4}', + 'status' => 'completed', + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'total_tokens' => 30, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + + $llm->withTools([ + #[Tool(name: 'multiply', description: 'Multiply two numbers')] + fn(int $x, int $y): int => $x * $y, + ]); + + $result = $llm->invoke([ + new UserMessage('What is 3 times 4?'), + ]); + + expect($result->generation->message->toolCalls) + ->toBeInstanceOf(ToolCallCollection::class) + ->and($result->generation->message->toolCalls) + ->toHaveCount(1) + ->and($result->generation->message->toolCalls[0]) + ->toBeInstanceOf(ToolCall::class) + ->and($result->generation->message->toolCalls[0]->function) + ->toBeInstanceOf(FunctionCall::class) + ->and($result->generation->message->toolCalls[0]->function->name) + ->toBe('multiply') + ->and($result->generation->message->toolCalls[0]->function->arguments) + ->toBe([ + 'x' => 3, + 'y' => 4, + ]); +}); + +test('it can use structured output', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => $expected = '{"name":"John Doe","age":30}', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'total_tokens' => 30, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::StructuredOutput); + + $llm->withStructuredOutput( + Schema::object()->properties( + Schema::string('name'), + Schema::integer('age'), + ), + name: 'Person', + description: 'A person with a name and age', + ); + + $result = $llm->invoke([ + new UserMessage('Tell me about a person'), + ]); + + expect($result->generation->message->text()) + ->toContain('John Doe') + ->and($result->generation->parsedOutput)->toBe([ + 'name' => 'John Doe', + 'age' => 30, + ]); +}); + +test('it tracks token usage with reasoning tokens', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'Hello!', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 25, + 'total_tokens' => 35, + 'input_token_details' => [ + 'cached_tokens' => 5, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 15, + ], + ], + ]), + ]); + + $result = $llm->invoke([ + new UserMessage('Hello'), + ]); + + expect($result->usage) + ->toBeInstanceOf(Usage::class) + ->and($result->usage->promptTokens)->toBe(10) + ->and($result->usage->completionTokens)->toBe(25) + ->and($result->usage->totalTokens)->toBe(35) + // Note: The OpenAI fake response doesn't properly set nested token details + // This would work with real API responses where inputTokensDetails and outputTokensDetails are properly set + ->and($result->usage->cachedTokens)->toBeInt() + ->and($result->usage->reasoningTokens)->toBeInt(); +}); + +test('it handles reasoning content', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'reasoning', + 'id' => 'reasoning_123', + 'summary' => [ + [ + 'type' => 'output_text', + 'text' => 'Let me think about this step by step...', + 'annotations' => [], + ], + ], + ], + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'The answer is 42.', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'total_tokens' => 30, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 10, + ], + ], + ]), + ]); + + $result = $llm->invoke([ + new UserMessage('What is the meaning of life?'), + ]); + + expect($result->generation->message->content())->toBeArray() + ->and($result->generation->message->content())->toHaveCount(2) + ->and($result->generation->message->text())->toContain('The answer is 42.'); +}); + +test('it handles refusal responses', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'refusal', + 'refusal' => 'I cannot help with that request.', + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 5, + 'total_tokens' => 15, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ]); + + expect(fn(): ChatResult|ChatStreamResult => $llm->invoke([ + new UserMessage('Help me do something bad'), + ]))->toThrow(LLMException::class, 'LLM refusal'); +}); + +test('it converts max_tokens to max_output_tokens', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'Hello!', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 5, + 'total_tokens' => 15, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ]); + + $llm->withMaxTokens(100)->invoke([ + new UserMessage('Hello'), + ]); + + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + $client->responses()->assertSent(function (string $method, array $parameters): bool { + return $parameters['max_output_tokens'] === 100 + && ! array_key_exists('max_tokens', $parameters); + }); +}); + +test('LLM instance-specific listeners work correctly', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'Hello World!', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'total_tokens' => 30, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ]); + + $startCalled = false; + $endCalled = false; + + $llm->onStart(function (ChatModelStart $event) use ($llm, &$startCalled): void { + $startCalled = true; + expect($event->llm)->toBe($llm); + expect($event->messages)->toBeInstanceOf(MessageCollection::class); + }); + + $llm->onEnd(function (ChatModelEnd $event) use ($llm, &$endCalled): void { + $endCalled = true; + expect($event->llm)->toBe($llm); + expect($event->result)->toBeInstanceOf(ChatResult::class); + }); + + $result = $llm->invoke([ + new UserMessage('Hello'), + ]); + + expect($result)->toBeInstanceOf(ChatResult::class); + expect($startCalled)->toBeTrue(); + expect($endCalled)->toBeTrue(); +}); + +test('LLM instance-specific error listeners work correctly', function (): void { + $llm = OpenAIResponses::fake([ + new Exception('API Error'), + ]); + + $errorCalled = false; + + $llm->onError(function (ChatModelError $event) use ($llm, &$errorCalled): void { + $errorCalled = true; + expect($event->llm)->toBe($llm); + expect($event->exception)->toBeInstanceOf(Exception::class); + expect($event->exception->getMessage())->toBe('API Error'); + }); + + try { + $llm->invoke([new UserMessage('Hello')]); + } catch (Exception) { + // Expected + } + + expect($errorCalled)->toBeTrue(); +}); + +test('LLM instance-specific stream listeners work correctly', function (): void { + $llm = OpenAIResponses::fake([ + CreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/responses-stream.txt', 'r')), + ]); + + $llm->withStreaming(); + + $streamCalls = []; + $streamEndCalled = false; + $eventOrder = []; + + $llm->onStream(function (ChatModelStream $event) use ($llm, &$streamCalls, &$eventOrder): void { + expect($event->llm)->toBe($llm); + expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); + $streamCalls[] = $event->chunk; + $eventOrder[] = 'stream'; + }); + + $llm->onStreamEnd(function (ChatModelStreamEnd $event) use ($llm, &$streamEndCalled, &$eventOrder): void { + $streamEndCalled = true; + expect($event->llm)->toBe($llm); + expect($event->chunk)->toBeInstanceOf(ChatGenerationChunk::class); + $eventOrder[] = 'streamEnd'; + }); + + $result = $llm->invoke([ + new UserMessage('Hello'), + ]); + + expect($result)->toBeInstanceOf(ChatStreamResult::class); + + // Iterate over the stream to trigger stream events + $chunkTypes = []; + foreach ($result as $chunk) { + $chunkTypes[] = $chunk->type; + } + + // Verify stream events were dispatched + expect($streamCalls)->not->toBeEmpty() + ->and($streamCalls)->toBeArray() + ->and(count($streamCalls))->toBeGreaterThan(0); + + // Verify stream end event was dispatched after streaming completes + expect($streamEndCalled)->toBeTrue(); + + // Verify chunk types are correctly mapped + expect($chunkTypes)->toContain(ChunkType::MessageStart) + ->and($chunkTypes)->toContain(ChunkType::TextDelta) + ->and($chunkTypes)->toContain(ChunkType::Done); + + // Verify that stream end event is the final event + expect($eventOrder)->not->toBeEmpty() + ->and(end($eventOrder))->toBe('streamEnd'); +}); + +test('multiple LLM instances have separate listeners', function (): void { + $llm1 = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_1', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_1', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'Response 1', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'total_tokens' => 30, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ]); + + $llm2 = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_2', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_2', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'Response 2', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'total_tokens' => 30, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ]); + + $llm1StartCalled = false; + $llm1EndCalled = false; + $llm2StartCalled = false; + $llm2EndCalled = false; + + $llm1->onStart(function (ChatModelStart $event) use ($llm1, &$llm1StartCalled): void { + expect($event->llm)->toBe($llm1); + $llm1StartCalled = true; + }); + + $llm1->onEnd(function (ChatModelEnd $event) use ($llm1, &$llm1EndCalled): void { + expect($event->llm)->toBe($llm1); + $llm1EndCalled = true; + }); + + $llm2->onStart(function (ChatModelStart $event) use ($llm2, &$llm2StartCalled): void { + expect($event->llm)->toBe($llm2); + $llm2StartCalled = true; + }); + + $llm2->onEnd(function (ChatModelEnd $event) use ($llm2, &$llm2EndCalled): void { + expect($event->llm)->toBe($llm2); + $llm2EndCalled = true; + }); + + $result1 = $llm1->invoke([new UserMessage('Hello')]); + $result2 = $llm2->invoke([new UserMessage('Hello')]); + + expect($result1)->toBeInstanceOf(ChatResult::class); + expect($result2)->toBeInstanceOf(ChatResult::class); + expect($llm1StartCalled)->toBeTrue(); + expect($llm1EndCalled)->toBeTrue(); + expect($llm2StartCalled)->toBeTrue(); + expect($llm2EndCalled)->toBeTrue(); +}); + +test('LLM can chain multiple instance-specific listeners', function (): void { + $llm = OpenAIResponses::fake([ + CreateResponse::fake([ + 'id' => 'resp_123', + 'model' => 'gpt-4o', + 'created_at' => 1234567890, + 'status' => 'completed', + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg_123', + 'role' => 'assistant', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'Hello World!', + 'annotations' => [], + ], + ], + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'total_tokens' => 30, + 'input_token_details' => [ + 'cached_tokens' => 0, + ], + 'output_token_details' => [ + 'reasoning_tokens' => 0, + ], + ], + ]), + ]); + + $callOrder = []; + + $llm + ->onStart(function (ChatModelStart $event) use (&$callOrder): void { + $callOrder[] = 'start1'; + }) + ->onStart(function (ChatModelStart $event) use (&$callOrder): void { + $callOrder[] = 'start2'; + }) + ->onEnd(function (ChatModelEnd $event) use (&$callOrder): void { + $callOrder[] = 'end1'; + }) + ->onEnd(function (ChatModelEnd $event) use (&$callOrder): void { + $callOrder[] = 'end2'; + }); + + $llm->invoke([new UserMessage('Hello')]); + + expect($callOrder)->toBe(['start1', 'start2', 'end1', 'end2']); +}); + +test('it correctly maps chunk types for streaming with tool calls', function (): void { + $llm = OpenAIResponses::fake([ + CreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/responses-stream-tool-calls.txt', 'r')), + ], 'gpt-4o'); + + $llm->addFeature(ModelFeature::ToolCalling); + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('What is 3 times 4?'), + ]); + + $chunkTypes = []; + foreach ($chunks as $chunk) { + $chunkTypes[] = $chunk->type; + } + + // Verify chunk types for tool calls + expect($chunkTypes)->toContain(ChunkType::ToolInputStart) // When output_item.added with function_call + ->and($chunkTypes)->toContain(ChunkType::ToolInputDelta) // function_call_arguments.delta + ->and($chunkTypes)->toContain(ChunkType::ToolInputEnd) // function_call_arguments.done + ->and($chunkTypes)->toContain(ChunkType::Done); // response.completed +}); + +test('it correctly maps chunk types for streaming with reasoning', function (): void { + $llm = OpenAIResponses::fake([ + CreateStreamedResponse::fake(fopen(__DIR__ . '/../../../../fixtures/openai/responses-stream-reasoning.txt', 'r')), + ]); + + $llm->withStreaming(); + + $chunks = $llm->invoke([ + new UserMessage('What is the meaning of life?'), + ]); + + $chunkTypes = []; + foreach ($chunks as $chunk) { + $chunkTypes[] = $chunk->type; + } + + // Verify chunk types for reasoning + expect($chunkTypes)->toContain(ChunkType::ReasoningStart) // reasoning_summary_part.added + ->and($chunkTypes)->toContain(ChunkType::ReasoningDelta) // reasoning_summary_text.delta + ->and($chunkTypes)->toContain(ChunkType::ReasoningEnd) // reasoning_summary_text.done + ->and($chunkTypes)->toContain(ChunkType::TextDelta) // output_text.delta + ->and($chunkTypes)->toContain(ChunkType::Done); // response.completed +}); diff --git a/tests/Unit/LLM/Drivers/OpenAIChatTest.php b/tests/Unit/LLM/Drivers/OpenAIChatTest.php deleted file mode 100644 index 479fc69..0000000 --- a/tests/Unit/LLM/Drivers/OpenAIChatTest.php +++ /dev/null @@ -1,424 +0,0 @@ - [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => 'I am doing well, thank you for asking!', - ], - ], - ], - ]), - ]); - - $result = $llm->invoke([ - new UserMessage('Hello, how are you?'), - ]); - - expect($result)->toBeInstanceOf(ChatResult::class) - ->and($result->rawResponse)->toBeArray()->not->toBeEmpty() - ->and($result->generations)->toHaveCount(1) - ->and($result->generation->message)->toBeInstanceOf(AssistantMessage::class) - ->and($result->generation->message->text())->toBe('I am doing well, thank you for asking!'); -}); - -test('it can stream', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../fixtures/openai/chat-stream.txt', 'r')), - ]); - - $llm->withStreaming(); - - $chunks = $llm->invoke([ - new UserMessage('Hello, how are you?'), - ]); - - $output = $chunks->reduce(function (string $carry, ChatGenerationChunk $chunk) { - expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class) - ->and($chunk->message)->toBeInstanceOf(AssistantMessage::class) - ->and('I am doing well, thank you for asking!')->toContain($chunk->message->content); - - return $carry . $chunk->message->content; - }, ''); - - expect($output)->toBe('I am doing well, thank you for asking!'); -}); - -test('it can use tools', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateResponse::fake([ - 'model' => 'gpt-4o', - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => null, - 'tool_calls' => [ - [ - 'id' => 'call_123', - 'type' => 'function', - 'function' => [ - 'name' => 'multiply', - 'arguments' => '{"x":3,"y":4}', - ], - ], - ], - ], - ], - ], - ]), - ], 'gpt-4o'); - - $llm->addFeature(ModelFeature::ToolCalling); - - $llm->withTools([ - #[Tool(name: 'multiply', description: 'Multiply two numbers')] - fn(int $x, int $y): int => $x * $y, - ]); - - $result = $llm->invoke([ - new UserMessage('What is 3 times 4?'), - ]); - - expect($result->generation->message->toolCalls) - ->toBeInstanceOf(ToolCallCollection::class) - ->and($result->generation->message->toolCalls) - ->toHaveCount(1) - ->and($result->generation->message->toolCalls[0]) - ->toBeInstanceOf(ToolCall::class) - ->and($result->generation->message->toolCalls[0]->function) - ->toBeInstanceOf(FunctionCall::class) - ->and($result->generation->message->toolCalls[0]->function->name) - ->toBe('multiply') - ->and($result->generation->message->toolCalls[0]->function->arguments) - ->toBe([ - 'x' => 3, - 'y' => 4, - ]); -}); - -test('it can use structured output', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateResponse::fake([ - 'model' => 'gpt-4o', - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => '{"name":"John Doe","age":30}', - ], - ], - ], - ]), - ], 'gpt-4o'); - - $llm->addFeature(ModelFeature::StructuredOutput); - - $llm->withStructuredOutput( - SchemaFactory::object()->properties( - SchemaFactory::string('name'), - SchemaFactory::integer('age'), - ), - name: 'Person', - description: 'A person with a name and age', - ); - - $result = $llm->invoke([ - new UserMessage('Tell me about a person'), - ]); - - expect($result->generation->message->text()) - ->toBe('{"name":"John Doe","age":30}') - ->and($result->generation->message->content()) - ->toBeArray() - ->toContainOnlyInstancesOf(TextContent::class); - - expect($result->generation->parsedOutput)->toBe([ - 'name' => 'John Doe', - 'age' => 30, - ]); -}); - -test('it can use structured output using the schema tool', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateResponse::fake([ - 'model' => 'gpt-4o', - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => '', - 'tool_calls' => [ - [ - 'id' => 'call_123', - 'type' => 'function', - 'function' => [ - 'name' => 'Person', - 'arguments' => '{"name":"John Doe","age":30}', - ], - ], - ], - ], - ], - ], - ]), - ], 'gpt-4o'); - - $llm->addFeature(ModelFeature::ToolCalling); - - $schema = SchemaFactory::object('Person')->properties( - SchemaFactory::string('name'), - SchemaFactory::integer('age'), - ); - - $llm->withStructuredOutput($schema, outputMode: StructuredOutputMode::Tool); - - $result = $llm->invoke([ - new UserMessage('Tell me about a person'), - ]); - - expect($result->parsedOutput) - ->toBe([ - 'name' => 'John Doe', - 'age' => 30, - ]); - - /** @var \OpenAI\Testing\ClientFake $client */ - $client = $llm->getClient(); - - $client->chat()->assertSent(function (string $method, array $parameters): bool { - return $parameters['model'] === 'gpt-4o' - && $parameters['messages'][0]['role'] === 'user' - && $parameters['messages'][0]['content'] === 'Tell me about a person' - && $parameters['tools'][0]['type'] === 'function' - && $parameters['tools'][0]['function']['name'] === 'Person' - && $parameters['tools'][0]['function']['parameters']['properties']['name']['type'] === 'string' - && $parameters['tools'][0]['function']['parameters']['properties']['age']['type'] === 'integer'; - }); -}); - -test('it can stream with structured output', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateStreamedResponse::fake(fopen(__DIR__ . '/../../../fixtures/openai/chat-stream-json.txt', 'r')), - ], 'gpt-4o'); - - $llm->addFeature(ModelFeature::StructuredOutput); - - $llm->withStreaming(); - - class Joke - { - public function __construct( - public ?string $setup = null, - public ?string $punchline = null, - ) {} - } - - $result = $llm->withStructuredOutput(Joke::class)->invoke([ - new UserMessage('Tell me a joke about dogs'), - ]); - - expect($result)->toBeInstanceOf(ChatStreamResult::class); - - $finalCalled = false; - - $result->each(function (ChatGenerationChunk $chunk) use (&$finalCalled): void { - expect($chunk)->toBeInstanceOf(ChatGenerationChunk::class); - - if ($chunk->isFinal) { - expect($chunk->parsedOutput)->toBeInstanceOf(Joke::class) - ->and($chunk->parsedOutput->setup)->toBe('Why did the dog sit in the shade?') - ->and($chunk->parsedOutput->punchline)->toBe("Because he didn't want to be a hot dog!"); - - $finalCalled = true; - } - }); - - expect($finalCalled)->toBeTrue(); - - /** @var \OpenAI\Testing\ClientFake $client */ - $client = $llm->getClient(); - - $client->chat()->assertSent(function (string $method, array $parameters): bool { - return $parameters['model'] === 'gpt-4o' - && $parameters['messages'][0]['role'] === 'user' - && $parameters['messages'][0]['content'] === 'Tell me a joke about dogs'; - }); -}); - -test('it can use structured output with an enum', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateResponse::fake([ - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => '{"Sentiment":"positive"}', - ], - ], - ], - ]), - ChatCreateResponse::fake([ - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => '{"Sentiment":"neutral"}', - ], - ], - ], - ]), - ChatCreateResponse::fake([ - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => '{"Sentiment":"negative"}', - ], - ], - ], - ]), - ChatCreateResponse::fake([ - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => '{"Sentiment":"neutral"}', - ], - ], - ], - ]), - ], 'gpt-4o'); - - $llm->addFeature(ModelFeature::StructuredOutput); - - enum Sentiment: string - { - case Positive = 'positive'; - case Negative = 'negative'; - case Neutral = 'neutral'; - } - - // $result = LLM::provider('ollama') - // ->withModel('mistral-small') - // ->withStructuredOutput(Sentiment::class) - // ->invoke([ - // new SystemMessage('You are a helpful assistant that analyzes the sentiment of text.'), - // new UserMessage('Analyze this: This pizza is awful'), - // ]); - - // dd($result); - - $llm->withStructuredOutput(Sentiment::class); - - expect($llm->invoke('Analyze the sentiment of this text: This pizza is awesome')->parsedOutput) - ->toBe(Sentiment::Positive); - - expect($llm->invoke('Analyze the sentiment of this text: This pizza is okay')->parsedOutput) - ->toBe(Sentiment::Neutral); - - expect($llm->invoke('Analyze the sentiment of this text: This pizza is terrible')->parsedOutput) - ->toBe(Sentiment::Negative); - - $getSentiment = Cortex::prompt('Analyze the sentiment of this text: {input}')->llm($llm); - - $result = $getSentiment->invoke('This pizza is average'); - - expect($result)->toBeInstanceOf(ChatResult::class) - ->and($result->parsedOutput)->toBe(Sentiment::Neutral); -}); - -test('it can force json output', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateResponse::fake([ - 'model' => 'gpt-4o', - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => '{"setup":"Why did the scarecrow win an award?","punchline":"Because he was outstanding in his field!"}', - ], - ], - ], - ]), - ], 'gpt-4o'); - - $llm->addFeature(ModelFeature::JsonOutput); - - $llm->forceJsonOutput(); - - $result = $llm->invoke([ - new UserMessage('Tell me a joke'), - ]); - - expect($result->generation->message->text()) - ->toBe('{"setup":"Why did the scarecrow win an award?","punchline":"Because he was outstanding in his field!"}') - ->and($result->generation->message->content()) - ->toBeArray() - ->toContainOnlyInstancesOf(TextContent::class); -}); - -test('it can set temperature and max tokens', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateResponse::fake(), - ]); - - $llm->withTemperature(0.7)->withMaxTokens(100)->invoke([ - new UserMessage('Hello'), - ]); - - /** @var \OpenAI\Testing\ClientFake $client */ - $client = $llm->getClient(); - - $client->chat()->assertSent(function (string $method, array $parameters): bool { - return $parameters['temperature'] === 0.7 && $parameters['max_completion_tokens'] === 100; - }); -}); - -test('it tracks token usage', function (): void { - $llm = OpenAIChat::fake([ - ChatCreateResponse::fake([ - 'usage' => [ - 'prompt_tokens' => 10, - 'completion_tokens' => 20, - 'total_tokens' => 30, - ], - ]), - ]); - - $result = $llm->invoke([ - new UserMessage('Hello'), - ]); - - expect($result->usage) - ->toBeInstanceOf(Usage::class) - ->and($result->usage->promptTokens)->toBe(10) - ->and($result->usage->completionTokens)->toBe(20) - ->and($result->usage->totalTokens)->toBe(30); -}); diff --git a/tests/Unit/LLM/StreamBufferTest.php b/tests/Unit/LLM/StreamBufferTest.php new file mode 100644 index 0000000..c5d771f --- /dev/null +++ b/tests/Unit/LLM/StreamBufferTest.php @@ -0,0 +1,103 @@ +stream->push('start'); + + // Create a minimal stream response file for our test chunks + $streamContent = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"A\"},\"finish_reason\":null}]}\n\n"; + $streamContent .= "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"B\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":2,\"total_tokens\":12}}\n\n"; + $streamContent .= "data: [DONE]\n\n"; + + $streamHandle = fopen('php://memory', 'r+'); + fwrite($streamHandle, $streamContent); + rewind($streamHandle); + + // Use real OpenAIChat with MapsStreamResponse - it will handle buffer draining + $llm = OpenAIChat::fake([ + CreateStreamedResponse::fake($streamHandle), + ]); + $llm->withStreaming(); + + // Next closure (simulating next stage in pipeline that pushes to buffer) + $next = function (mixed $payload, RuntimeConfig $config): mixed { + if ($payload instanceof ChatGenerationChunk) { + // Push item during stream + $config->stream->push('mid-' . $payload->contentSoFar); + } + + return $payload; + }; + + // Execute + $result = $llm->handlePipeable('input', $config, $next); + + // Collect results + $items = []; + foreach ($result as $item) { + $items[] = $item; + } + + expect($items)->toHaveCount(5); + + expect($items[0])->toBe('start'); + + expect($items[1])->toBeInstanceOf(ChatGenerationChunk::class); + expect($items[1]->contentSoFar)->toBe('A'); // First chunk has contentSoFar = 'A' + + expect($items[2])->toBe('mid-A'); + + expect($items[3])->toBeInstanceOf(ChatGenerationChunk::class); + expect($items[3]->contentSoFar)->toBe('AB'); // Second chunk accumulates: 'A' + 'B' = 'AB' + + expect($items[4])->toBe('mid-AB'); // Buffer item uses contentSoFar from the chunk ('AB') +}); + +it('handles buffer items pushed after stream completion', function (): void { + $config = new RuntimeConfig(); + + // Create a stream response with one chunk + $streamContent = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1234567890,\"model\":\"gpt-4\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":1,\"total_tokens\":11}}\n\n"; + $streamContent .= "data: [DONE]\n\n"; + + $streamHandle = fopen('php://memory', 'r+'); + fwrite($streamHandle, $streamContent); + rewind($streamHandle); + + // Use real OpenAIChat with MapsStreamResponse - it will handle buffer draining + $llm = OpenAIChat::fake([ + CreateStreamedResponse::fake($streamHandle), + ]); + $llm->withStreaming(); + + // Push item to buffer AFTER the stream completes (during iteration, after last chunk) + $next = function (mixed $payload, RuntimeConfig $config): mixed { + if ($payload instanceof ChatGenerationChunk && $payload->isFinal) { + // Push item after the final chunk (stream completion) + $config->stream->push('after-completion'); + } + + return $payload; + }; + + $result = $llm->handlePipeable('input', $config, $next); + + $items = iterator_to_array($result, false); + + // MapsStreamResponse drains buffer after stream completes (line 179-181) + // So items pushed after the final chunk should be drained and yielded + expect($items)->toHaveCount(2); + expect($items[0])->toBeInstanceOf(ChatGenerationChunk::class); + expect($items[0]->isFinal)->toBeTrue(); // Final chunk + expect($items[1])->toBe('after-completion'); // Buffer item drained after stream completion +}); diff --git a/tests/Unit/LLM/Streaming/AgUiDataStreamTest.php b/tests/Unit/LLM/Streaming/AgUiDataStreamTest.php new file mode 100644 index 0000000..49a605e --- /dev/null +++ b/tests/Unit/LLM/Streaming/AgUiDataStreamTest.php @@ -0,0 +1,306 @@ +stream = new AgUiDataStream(); +}); + +it('maps MessageStart chunk to RunStarted and TextMessageStart events', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'RunStarted') + ->and($payload)->toHaveKey('runId') + ->and($payload)->toHaveKey('threadId') + ->and($payload)->toHaveKey('timestamp'); +}); + +it('maps TextDelta chunk to TextMessageContent event', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello, '), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(1) + ->and($events[0])->toHaveKey('type', 'TextMessageContent') + ->and($events[0])->toHaveKey('delta', 'Hello, ') + ->and($events[0])->toHaveKey('messageId') + ->and($events[0])->toHaveKey('timestamp'); +}); + +it('maps TextEnd chunk to TextMessageEnd event', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(1) + ->and($events[0])->toHaveKey('type', 'TextMessageEnd') + ->and($events[0])->toHaveKey('messageId') + ->and($events[0])->toHaveKey('timestamp'); +}); + +it('maps ReasoningStart chunk to ReasoningStart event', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(1) + ->and($events[0])->toHaveKey('type', 'ReasoningStart') + ->and($events[0])->toHaveKey('messageId') + ->and($events[0])->toHaveKey('timestamp'); +}); + +it('maps ReasoningDelta chunk to ReasoningMessageContent event', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Thinking...'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(1) + ->and($events[0])->toHaveKey('type', 'ReasoningMessageContent') + ->and($events[0])->toHaveKey('delta', 'Thinking...') + ->and($events[0])->toHaveKey('messageId') + ->and($events[0])->toHaveKey('timestamp'); +}); + +it('maps ReasoningEnd chunk to ReasoningEnd event', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(1) + ->and($events[0])->toHaveKey('type', 'ReasoningEnd') + ->and($events[0])->toHaveKey('messageId') + ->and($events[0])->toHaveKey('timestamp'); +}); + +it('maps StepStart chunk to StepStarted event', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::StepStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(1) + ->and($events[0])->toHaveKey('type', 'StepStarted') + ->and($events[0])->toHaveKey('stepName', 'step_msg_123') + ->and($events[0])->toHaveKey('timestamp'); +}); + +it('maps StepEnd chunk to StepFinished event', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::StepEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(1) + ->and($events[0])->toHaveKey('type', 'StepFinished') + ->and($events[0])->toHaveKey('stepName', 'step_msg_123') + ->and($events[0])->toHaveKey('timestamp'); +}); + +it('maps Error chunk to RunError event', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::Error, + id: 'msg_123', + message: new AssistantMessage(content: 'Something went wrong'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(1) + ->and($events[0])->toHaveKey('type', 'RunError') + ->and($events[0])->toHaveKey('message', 'Something went wrong') + ->and($events[0])->toHaveKey('timestamp'); +}); + +it('maps MessageEnd final chunk to TextMessageEnd and RunFinished events', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: FinishReason::Stop, + isFinal: true, + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + // Simulate that message was started + $messageStartedProperty = $reflection->getProperty('messageStarted'); + $messageStartedProperty->setValue($this->stream, true); + + $runStartedProperty = $reflection->getProperty('runStarted'); + $runStartedProperty->setValue($this->stream, true); + + $currentMessageIdProperty = $reflection->getProperty('currentMessageId'); + $currentMessageIdProperty->setValue($this->stream, 'msg_123'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toHaveCount(2) + ->and($events[0])->toHaveKey('type', 'TextMessageEnd') + ->and($events[1])->toHaveKey('type', 'RunFinished') + ->and($events[1])->toHaveKey('runId') + ->and($events[1])->toHaveKey('threadId'); +}); + +it('includes usage information in RunFinished result when available', function (): void { + $usage = new Usage( + promptTokens: 10, + completionTokens: 20, + ); + + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: FinishReason::Stop, + usage: $usage, + isFinal: true, + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + // Simulate that run was started + $runStartedProperty = $reflection->getProperty('runStarted'); + $runStartedProperty->setValue($this->stream, true); + + $events = $method->invoke($this->stream, $chunk); + + $runFinishedEvent = collect($events)->firstWhere('type', 'RunFinished'); + + expect($runFinishedEvent)->toHaveKey('result') + ->and($runFinishedEvent['result'])->toHaveKey('usage'); +}); + +it('does not emit text content events for empty deltas', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('mapChunkToEvents'); + + $events = $method->invoke($this->stream, $chunk); + + expect($events)->toBeEmpty(); +}); + +it('returns streamResponse closure that can be invoked', function (): void { + $chunks = [ + new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + ]; + + $result = new ChatStreamResult( + new ArrayIterator($chunks), + ); + + $closure = $this->stream->streamResponse($result); + + expect($closure)->toBeInstanceOf(Closure::class); + + // Note: We cannot reliably test the actual output here because flush() + // bypasses output buffering. The closure invocation is sufficient to + // verify it works without errors. + ob_start(); + + try { + $closure(); + } finally { + ob_end_clean(); + } +})->group('stream'); diff --git a/tests/Unit/LLM/Streaming/VercelDataStreamTest.php b/tests/Unit/LLM/Streaming/VercelDataStreamTest.php new file mode 100644 index 0000000..ea02bbd --- /dev/null +++ b/tests/Unit/LLM/Streaming/VercelDataStreamTest.php @@ -0,0 +1,579 @@ +stream = new VercelDataStream(); +}); + +it('maps MessageStart chunk to start type with messageId', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'start') + ->and($payload)->toHaveKey('messageId', 'msg_123'); +}); + +it('maps MessageEnd chunk to finish type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'finish'); +}); + +it('maps TextStart chunk to text-start type with id', function (): void { + $metadata = new ResponseMetadata( + id: 'resp_456', + model: 'gpt-4', + provider: ModelProvider::OpenAI, + ); + $chunk = new ChatGenerationChunk( + type: ChunkType::TextStart, + id: 'msg_123', + message: new AssistantMessage(content: '', metadata: $metadata), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'text-start') + ->and($payload)->toHaveKey('id', 'resp_456'); +}); + +it('maps TextDelta chunk to text-delta type with delta and id', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello, '), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'text-delta') + ->and($payload)->toHaveKey('delta', 'Hello, ') + ->and($payload)->toHaveKey('id', 'msg_123') + ->and($payload)->not->toHaveKey('content'); +}); + +it('maps TextEnd chunk to text-end type with id', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'text-end') + ->and($payload)->toHaveKey('id', 'msg_123'); +}); + +it('maps ReasoningStart chunk to reasoning-start type with id', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'reasoning-start') + ->and($payload)->toHaveKey('id', 'msg_123'); +}); + +it('maps ReasoningDelta chunk to reasoning-delta type with delta', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Thinking step 1...'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'reasoning-delta') + ->and($payload)->toHaveKey('delta', 'Thinking step 1...') + ->and($payload)->toHaveKey('id', 'msg_123') + ->and($payload)->not->toHaveKey('content'); +}); + +it('maps ReasoningEnd chunk to reasoning-end type with id', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'reasoning-end') + ->and($payload)->toHaveKey('id', 'msg_123'); +}); + +it('maps ToolInputStart chunk to tool-input-start type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ToolInputStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'tool-input-start'); +}); + +it('maps ToolInputDelta chunk to tool-input-delta type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ToolInputDelta, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'tool-input-delta'); +}); + +it('maps ToolInputEnd chunk to tool-input-available type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ToolInputEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'tool-input-available'); +}); + +it('maps ToolOutputEnd chunk to tool-output-available type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ToolOutputEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'tool-output-available'); +}); + +it('maps StepStart chunk to start-step type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::StepStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'start-step'); +}); + +it('maps StepEnd chunk to finish-step type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::StepEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'finish-step'); +}); + +it('maps Error chunk to error type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::Error, + id: 'msg_123', + message: new AssistantMessage(content: 'An error occurred'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'error') + ->and($payload)->toHaveKey('content', 'An error occurred'); +}); + +it('maps SourceDocument chunk to source-document type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::SourceDocument, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'source-document'); +}); + +it('maps File chunk to file type', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::File, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'file'); +}); + +it('includes tool calls in payload when present', function (): void { + $toolCall = new ToolCall( + id: 'call_123', + function: new FunctionCall( + name: 'get_weather', + arguments: [ + 'city' => 'San Francisco', + ], + ), + ); + + $toolCalls = new ToolCallCollection([$toolCall]); + + $chunk = new ChatGenerationChunk( + type: ChunkType::ToolInputEnd, + id: 'msg_123', + message: new AssistantMessage(content: '', toolCalls: $toolCalls), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('toolCalls') + ->and($payload['toolCalls'])->toBeArray() + ->and($payload['toolCalls'])->toHaveCount(1) + ->and($payload['toolCalls'][0])->toHaveKey('id', 'call_123') + ->and($payload['toolCalls'][0])->toHaveKey('function'); +}); + +it('includes usage information when available', function (): void { + $usage = new Usage( + promptTokens: 10, + completionTokens: 20, + ); + + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + usage: $usage, + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('usage') + ->and($payload['usage'])->toHaveKey('prompt_tokens', 10) + ->and($payload['usage'])->toHaveKey('completion_tokens', 20) + ->and($payload['usage'])->toHaveKey('total_tokens', 30); +}); + +it('includes finish reason for final chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: FinishReason::Stop, + isFinal: true, + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('finishReason', 'stop'); +}); + +it('does not include finish reason for non-final chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: null, + isFinal: false, + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->not->toHaveKey('finishReason'); +}); + +it('uses metadata id over chunk id for text blocks when available', function (): void { + $metadata = new ResponseMetadata( + id: 'resp_meta_id', + model: 'gpt-4', + provider: ModelProvider::OpenAI, + ); + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_chunk_id', + message: new AssistantMessage(content: 'test', metadata: $metadata), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('id', 'resp_meta_id'); +}); + +it('falls back to chunk id when metadata id is not available', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_chunk_id', + message: new AssistantMessage(content: 'test'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('id', 'msg_chunk_id'); +}); + +it('does not add content key when delta is present', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('delta', 'Hello') + ->and($payload)->not->toHaveKey('content'); +}); + +it('adds content key when delta is not present and content is available', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: 'Full message'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('content', 'Full message') + ->and($payload)->not->toHaveKey('delta'); +}); + +it('does not add content or delta when content is null', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->not->toHaveKey('content') + ->and($payload)->not->toHaveKey('delta'); +}); + +it('returns streamResponse closure that can be invoked', function (): void { + $chunks = [ + new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + ]; + + $result = new ChatStreamResult( + new ArrayIterator($chunks), + ); + + $closure = $this->stream->streamResponse($result); + + expect($closure)->toBeInstanceOf(Closure::class); + + // Note: We cannot reliably test the actual output here because flush() + // bypasses output buffering. The closure invocation is sufficient to + // verify it works without errors. + ob_start(); + + try { + $closure(); + } finally { + ob_end_clean(); + } +})->group('stream'); + +it('handles multiple tool calls in a single chunk', function (): void { + $toolCall1 = new ToolCall( + id: 'call_1', + function: new FunctionCall('tool_one', [ + 'arg' => 'value1', + ]), + ); + $toolCall2 = new ToolCall( + id: 'call_2', + function: new FunctionCall('tool_two', [ + 'arg' => 'value2', + ]), + ); + + $toolCalls = new ToolCallCollection([$toolCall1, $toolCall2]); + + $chunk = new ChatGenerationChunk( + type: ChunkType::ToolInputEnd, + id: 'msg_123', + message: new AssistantMessage(content: '', toolCalls: $toolCalls), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('toolCalls') + ->and($payload['toolCalls'])->toHaveCount(2) + ->and($payload['toolCalls'][0]['id'])->toBe('call_1') + ->and($payload['toolCalls'][1]['id'])->toBe('call_2'); +}); + +it('includes finish reason stop correctly', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: FinishReason::Stop, + isFinal: true, + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('finishReason', 'stop'); +}); + +it('includes finish reason length correctly', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: FinishReason::Length, + isFinal: true, + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('finishReason', 'length'); +}); + +it('includes finish reason tool_calls correctly', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: FinishReason::ToolCalls, + isFinal: true, + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('finishReason', 'tool_calls'); +}); + +it('includes finish reason content_filter correctly', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: FinishReason::ContentFilter, + isFinal: true, + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('finishReason', 'content_filter'); +}); + +it('handles unknown chunk types by using the enum value', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::Done, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('type', 'done'); +}); + +it('only includes messageId for MessageStart events', function (): void { + $startChunk = new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $deltaChunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'text'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $startPayload = $this->stream->mapChunkToPayload($startChunk); + $deltaPayload = $this->stream->mapChunkToPayload($deltaChunk); + + expect($startPayload)->toHaveKey('messageId', 'msg_123') + ->and($deltaPayload)->not->toHaveKey('messageId'); +}); diff --git a/tests/Unit/LLM/Streaming/VercelTextStreamTest.php b/tests/Unit/LLM/Streaming/VercelTextStreamTest.php new file mode 100644 index 0000000..97c76e3 --- /dev/null +++ b/tests/Unit/LLM/Streaming/VercelTextStreamTest.php @@ -0,0 +1,428 @@ +stream = new VercelTextStream(); +}); + +it('outputs text content for TextDelta chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello, '), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeTrue(); +}); + +it('outputs text content for ReasoningDelta chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Thinking...'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeTrue(); +}); + +it('does not output MessageStart chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeFalse(); +}); + +it('does not output MessageEnd chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeFalse(); +}); + +it('does not output TextStart chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeFalse(); +}); + +it('does not output TextEnd chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeFalse(); +}); + +it('does not output ReasoningStart chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeFalse(); +}); + +it('does not output ReasoningEnd chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ReasoningEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeFalse(); +}); + +it('does not output ToolInputStart chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::ToolInputStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeFalse(); +}); + +it('does not output Error chunks', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::Error, + id: 'msg_123', + message: new AssistantMessage(content: 'Error occurred'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeFalse(); +}); + +it('mapChunkToPayload returns content', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello, world!'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('content', 'Hello, world!'); +}); + +it('mapChunkToPayload returns empty string for null content', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $payload = $this->stream->mapChunkToPayload($chunk); + + expect($payload)->toHaveKey('content', ''); +}); + +it('returns streamResponse closure that can be invoked', function (): void { + $chunks = [ + new ChatGenerationChunk( + type: ChunkType::MessageStart, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: ', world!'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + new ChatGenerationChunk( + type: ChunkType::MessageEnd, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + ]; + + $result = new ChatStreamResult( + new ArrayIterator($chunks), + ); + + $closure = $this->stream->streamResponse($result); + + expect($closure)->toBeInstanceOf(Closure::class); + + // Note: We cannot reliably test the actual output here because flush() + // bypasses output buffering. The closure invocation is sufficient to + // verify it works without errors. + ob_start(); + + try { + $closure(); + } finally { + ob_end_clean(); + } +})->group('stream'); + +it('streams only text content without metadata or JSON', function (): void { + $chunks = [ + new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Part 1'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: ' Part 2'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + ]; + + $result = new ChatStreamResult( + new ArrayIterator($chunks), + ); + + $closure = $this->stream->streamResponse($result); + + // Verify closure is created + expect($closure)->toBeInstanceOf(Closure::class); +}); + +it('ignores chunks with null content', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + // Should not output (returns false for empty content in streamResponse logic) + expect($method->invoke($this->stream, $chunk))->toBeTrue(); // Type is correct + expect($chunk->message->content)->toBeNull(); // But content is null, so won't output +}); + +it('ignores chunks with empty string content', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: ''), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + // Type check passes, but empty content won't be output in streamResponse + expect($method->invoke($this->stream, $chunk))->toBeTrue(); + expect($chunk->message->content)->toBe(''); +}); + +it('handles whitespace content correctly', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: ' '), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + // Whitespace is valid content and should be output + expect($method->invoke($this->stream, $chunk))->toBeTrue(); + expect($chunk->message->content)->toBe(' '); +}); + +it('handles special characters in content', function (): void { + $specialContent = "Line 1\nLine 2\tTabbed\r\nWindows line"; + + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: $specialContent), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeTrue(); + expect($chunk->message->content)->toBe($specialContent); +}); + +it('handles unicode characters in content', function (): void { + $unicodeContent = 'Hello 👋 世界 🌍'; + + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: $unicodeContent), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeTrue(); + expect($chunk->message->content)->toBe($unicodeContent); +}); + +it('handles very long content strings', function (): void { + $longContent = str_repeat('A', 10000); + + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: $longContent), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + expect($method->invoke($this->stream, $chunk))->toBeTrue(); + expect(strlen($chunk->message->content))->toBe(10000); +}); + +it('ignores usage metadata when streaming text', function (): void { + $usage = new Usage( + promptTokens: 10, + completionTokens: 20, + ); + + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Hello'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + usage: $usage, + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + // Should still output, usage is just ignored + expect($method->invoke($this->stream, $chunk))->toBeTrue(); +}); + +it('ignores finish reason when streaming text', function (): void { + $chunk = new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Final text'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + finishReason: FinishReason::Stop, + ); + + $reflection = new ReflectionClass($this->stream); + $method = $reflection->getMethod('shouldOutputChunk'); + + // Should still output, finish reason is ignored + expect($method->invoke($this->stream, $chunk))->toBeTrue(); +}); + +it('streams mixed text and reasoning deltas', function (): void { + $chunks = [ + new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Text part'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:00'), + ), + new ChatGenerationChunk( + type: ChunkType::ReasoningDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'Reasoning part'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:01'), + ), + new ChatGenerationChunk( + type: ChunkType::TextDelta, + id: 'msg_123', + message: new AssistantMessage(content: 'More text'), + createdAt: new DateTimeImmutable('2024-01-01 12:00:02'), + ), + ]; + + $result = new ChatStreamResult( + new ArrayIterator($chunks), + ); + + $closure = $this->stream->streamResponse($result); + + expect($closure)->toBeInstanceOf(Closure::class); +}); diff --git a/tests/Unit/Memory/ChatMemoryTest.php b/tests/Unit/Memory/ChatMemoryTest.php index 3d746cd..efe45cf 100644 --- a/tests/Unit/Memory/ChatMemoryTest.php +++ b/tests/Unit/Memory/ChatMemoryTest.php @@ -14,9 +14,12 @@ use Cortex\LLM\Data\Messages\MessageCollection; test('messages can be added to memory', function (): void { - $store = new InMemoryStore(new MessageCollection([ - new SystemMessage('You are a helpful assistant.'), - ])); + $store = new InMemoryStore( + threadId: 'test-thread-1', + messages: new MessageCollection([ + new SystemMessage('You are a helpful assistant.'), + ]), + ); $memory = new ChatMemory(store: $store); @@ -57,6 +60,9 @@ ->once() ->andReturn($messages); + $store->shouldReceive('getThreadId') + ->andReturn('custom-thread-id'); + $message = new UserMessage('Hello!'); $store->shouldReceive('addMessage') ->once() @@ -66,6 +72,7 @@ $memory->addMessage($message); expect($memory->getMessages())->toEqual($messages); + expect($memory->getThreadId())->toBe('custom-thread-id'); }); test('it defaults to in memory store', function (): void { @@ -77,3 +84,28 @@ expect($memory->getMessages()[0])->toBeInstanceOf(UserMessage::class); expect($memory->getMessages()[0]->content())->toBe('Hello!'); }); + +test('threadId is auto-generated if not provided', function (): void { + $memory = new ChatMemory(); + + expect($memory->getThreadId())->not->toBeNull() + ->and($memory->getThreadId())->toBeString() + ->and(strlen($memory->getThreadId()))->toBeGreaterThan(0); +}); + +test('threadId is auto-generated when store is not provided', function (): void { + $memory = new ChatMemory(); + + // threadId should be auto-generated + expect($memory->getThreadId())->not->toBeNull() + ->and($memory->getThreadId())->toBeString() + ->and(strlen($memory->getThreadId()))->toBeGreaterThan(0); +}); + +test('it can use threadId from store when not explicitly set', function (): void { + $store = new InMemoryStore(threadId: 'store-thread-id'); + $memory = new ChatMemory(store: $store); + + // When store has threadId, it should be used (not auto-generated) + expect($memory->getThreadId())->toBe('store-thread-id'); +}); diff --git a/tests/Unit/Memory/ChatSummaryMemoryTest.php b/tests/Unit/Memory/ChatSummaryMemoryTest.php deleted file mode 100644 index f7c2edc..0000000 --- a/tests/Unit/Memory/ChatSummaryMemoryTest.php +++ /dev/null @@ -1,73 +0,0 @@ - [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => 'You asked if I knew your favourite food, and I said I do not. You then told me it was pizza.', - ], - ], - ], - ]), - ]); - - $memory = new ChatSummaryMemory( - llm: new OpenAIChat($client, 'gpt-4o-mini', ModelProvider::OpenAI), - summariseAfter: 2, - ); - - $memory->addMessage(new UserMessage('Can you guess my favourite food?')); - $memory->addMessage(new AssistantMessage('I am not sure, can you tell me?')); - $memory->addMessage(new UserMessage('My favourite food is pizza')); - - $messages = $memory->getMessages(); - - expect($messages)->toHaveCount(1); - - $message = $messages->first(); - - expect($message)->toBeInstanceOf(AssistantMessage::class); - expect($message->text())->toBe('You asked if I knew your favourite food, and I said I do not. You then told me it was pizza.'); -}); - -test('it will not summarise if the number of messages is less than the summariseAfter limit', function (): void { - $client = new ClientFake([ - CreateResponse::fake([ - 'choices' => [ - [ - 'message' => [ - 'role' => 'assistant', - 'content' => 'You asked if I knew your favourite food, and I said I do not. You then told me it was pizza.', - ], - ], - ], - ]), - ]); - - $memory = new ChatSummaryMemory( - llm: new OpenAIChat($client, 'gpt-4o', ModelProvider::OpenAI), - summariseAfter: 2, - ); - - $memory->addMessage(new UserMessage('Can you guess my favourite food?')); - $memory->addMessage(new AssistantMessage('I am not sure, can you tell me?')); - - $messages = $memory->getMessages(); - - expect($messages)->toHaveCount(2); -}); diff --git a/tests/Unit/Memory/Stores/CacheStoreTest.php b/tests/Unit/Memory/Stores/CacheStoreTest.php index 68a645d..f4f0a73 100644 --- a/tests/Unit/Memory/Stores/CacheStoreTest.php +++ b/tests/Unit/Memory/Stores/CacheStoreTest.php @@ -21,10 +21,10 @@ $cache->shouldReceive('get') ->once() - ->with('cortex:memory:messages') + ->with('cortex:memory:messages:thread:thread-1') ->andReturn($messages); - $store = new CacheStore($cache); + $store = new CacheStore($cache, 'thread-1'); $result = $store->getMessages(); expect($result)->toBeInstanceOf(MessageCollection::class) @@ -38,15 +38,15 @@ $cache = mock(CacheInterface::class); $cache->shouldReceive('get') ->once() - ->with('cortex:memory:messages') + ->with('cortex:memory:messages:thread:thread-1') ->andReturn(null); $cache->shouldReceive('set') ->once() - ->with('cortex:memory:messages', Mockery::type(MessageCollection::class), null) + ->with('cortex:memory:messages:thread:thread-1', Mockery::type(MessageCollection::class), null) ->andReturnTrue(); - $store = new CacheStore($cache); + $store = new CacheStore($cache, 'thread-1'); $store->addMessage(new UserMessage('Hello!')); }); @@ -65,15 +65,15 @@ $cache->shouldReceive('get') ->twice() - ->with('cortex:memory:messages') + ->with('cortex:memory:messages:thread:thread-1') ->andReturn($initialMessages, $expectedMessages); $cache->shouldReceive('set') ->once() - ->with('cortex:memory:messages', Mockery::type(MessageCollection::class), null) + ->with('cortex:memory:messages:thread:thread-1', Mockery::type(MessageCollection::class), null) ->andReturnTrue(); - $store = new CacheStore($cache); + $store = new CacheStore($cache, 'thread-1'); $store->addMessages([ new UserMessage('Hello!'), new AssistantMessage('Hi there!'), @@ -95,15 +95,15 @@ $cache = mock(CacheInterface::class); $cache->shouldReceive('get') ->once() - ->with('custom:key') + ->with('custom:key:thread:thread-1') ->andReturn(null); $cache->shouldReceive('set') ->once() - ->with('custom:key', Mockery::type(MessageCollection::class), null) + ->with('custom:key:thread:thread-1', Mockery::type(MessageCollection::class), null) ->andReturnTrue(); - $store = new CacheStore($cache, 'custom:key'); + $store = new CacheStore($cache, 'thread-1', 'custom:key'); $store->addMessage(new UserMessage('Hello!')); }); @@ -112,14 +112,38 @@ $cache = mock(CacheInterface::class); $cache->shouldReceive('get') ->once() - ->with('cortex:memory:messages') + ->with('cortex:memory:messages:thread:thread-1') ->andReturn(null); $cache->shouldReceive('set') ->once() - ->with('cortex:memory:messages', Mockery::type(MessageCollection::class), 3600) + ->with('cortex:memory:messages:thread:thread-1', Mockery::type(MessageCollection::class), 3600) ->andReturnTrue(); - $store = new CacheStore($cache, ttl: 3600); + $store = new CacheStore($cache, 'thread-1', ttl: 3600); $store->addMessage(new UserMessage('Hello!')); }); + +test('threadId is used in cache key', function (): void { + /** @var CacheInterface&Mockery\MockInterface $cache */ + $cache = mock(CacheInterface::class); + $cache->shouldReceive('get') + ->once() + ->with('cortex:memory:messages:thread:thread-123') + ->andReturn(null); + + $cache->shouldReceive('set') + ->once() + ->with('cortex:memory:messages:thread:thread-123', Mockery::type(MessageCollection::class), null) + ->andReturnTrue(); + + $store = new CacheStore($cache, 'thread-123'); + $store->addMessage(new UserMessage('Hello!')); +}); + +test('getThreadId returns the thread ID', function (): void { + $cache = mock(CacheInterface::class); + $store = new CacheStore($cache, 'thread-456'); + + expect($store->getThreadId())->toBe('thread-456'); +}); diff --git a/tests/Unit/OutputParsers/EnumOutputParserTest.php b/tests/Unit/OutputParsers/EnumOutputParserTest.php index 8315a84..7fd6c96 100644 --- a/tests/Unit/OutputParsers/EnumOutputParserTest.php +++ b/tests/Unit/OutputParsers/EnumOutputParserTest.php @@ -31,7 +31,6 @@ $parser = new EnumOutputParser(TestEnum::class); $generation = new ChatGeneration( message: new AssistantMessage(content: 'two'), - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); @@ -47,7 +46,6 @@ message: new AssistantMessage(content: json_encode([ 'TestEnum' => 'three', ])), - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); @@ -70,7 +68,6 @@ message: new AssistantMessage(content: json_encode([ 'OtherKey' => 'one', ])), - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); diff --git a/tests/Unit/OutputParsers/JsonOutputParserTest.php b/tests/Unit/OutputParsers/JsonOutputParserTest.php index af44288..e28cdc7 100644 --- a/tests/Unit/OutputParsers/JsonOutputParserTest.php +++ b/tests/Unit/OutputParsers/JsonOutputParserTest.php @@ -4,6 +4,10 @@ namespace Cortex\Tests\Unit\OutputParsers; +use Cortex\Pipeline; +use Cortex\Events\OutputParserEnd; +use Cortex\Events\OutputParserError; +use Cortex\Events\OutputParserStart; use Cortex\OutputParsers\JsonOutputParser; use Cortex\Exceptions\OutputParserException; @@ -115,3 +119,122 @@ expect(fn(): array => $parser->parse('foo')) ->toThrow(OutputParserException::class, 'Could not parse JSON from output.'); }); + +test('OutputParser instance-specific listeners work correctly', function (): void { + $parser = new JsonOutputParser(); + + $startCalled = false; + $endCalled = false; + + $parser->onStart(function (OutputParserStart $event) use ($parser, &$startCalled): void { + $startCalled = true; + expect($event->outputParser)->toBe($parser); + expect($event->payload)->toBeString(); + }); + + $parser->onEnd(function (OutputParserEnd $event) use ($parser, &$endCalled): void { + $endCalled = true; + expect($event->outputParser)->toBe($parser); + expect($event->parsed)->toBeArray(); + }); + + $pipeline = new Pipeline($parser); + $output = '{"foo": "bar"}'; + $result = $pipeline->invoke($output); + + expect($result)->toBeArray(); + expect($result['foo'])->toBe('bar'); + expect($startCalled)->toBeTrue(); + expect($endCalled)->toBeTrue(); +}); + +test('OutputParser instance-specific error listeners work correctly', function (): void { + $parser = new JsonOutputParser(); + + $errorCalled = false; + + $parser->onError(function (OutputParserError $event) use ($parser, &$errorCalled): void { + $errorCalled = true; + expect($event->outputParser)->toBe($parser); + expect($event->exception)->toBeInstanceOf(OutputParserException::class); + expect($event->payload)->toBe('invalid json'); + }); + + $pipeline = new Pipeline($parser); + + try { + $pipeline->invoke('invalid json'); + } catch (OutputParserException) { + // Expected + } + + expect($errorCalled)->toBeTrue(); +}); + +test('multiple OutputParser instances have separate listeners', function (): void { + $parser1 = new JsonOutputParser(); + $parser2 = new JsonOutputParser(); + + $parser1StartCalled = false; + $parser1EndCalled = false; + $parser2StartCalled = false; + $parser2EndCalled = false; + + $parser1->onStart(function (OutputParserStart $event) use ($parser1, &$parser1StartCalled): void { + expect($event->outputParser)->toBe($parser1); + $parser1StartCalled = true; + }); + + $parser1->onEnd(function (OutputParserEnd $event) use ($parser1, &$parser1EndCalled): void { + expect($event->outputParser)->toBe($parser1); + $parser1EndCalled = true; + }); + + $parser2->onStart(function (OutputParserStart $event) use ($parser2, &$parser2StartCalled): void { + expect($event->outputParser)->toBe($parser2); + $parser2StartCalled = true; + }); + + $parser2->onEnd(function (OutputParserEnd $event) use ($parser2, &$parser2EndCalled): void { + expect($event->outputParser)->toBe($parser2); + $parser2EndCalled = true; + }); + + $pipeline1 = new Pipeline($parser1); + $pipeline2 = new Pipeline($parser2); + + $result1 = $pipeline1->invoke('{"foo": "bar"}'); + $result2 = $pipeline2->invoke('{"baz": "qux"}'); + + expect($result1)->toBeArray(); + expect($result2)->toBeArray(); + expect($parser1StartCalled)->toBeTrue(); + expect($parser1EndCalled)->toBeTrue(); + expect($parser2StartCalled)->toBeTrue(); + expect($parser2EndCalled)->toBeTrue(); +}); + +test('OutputParser can chain multiple instance-specific listeners', function (): void { + $parser = new JsonOutputParser(); + + $callOrder = []; + + $parser + ->onStart(function (OutputParserStart $event) use (&$callOrder): void { + $callOrder[] = 'start1'; + }) + ->onStart(function (OutputParserStart $event) use (&$callOrder): void { + $callOrder[] = 'start2'; + }) + ->onEnd(function (OutputParserEnd $event) use (&$callOrder): void { + $callOrder[] = 'end1'; + }) + ->onEnd(function (OutputParserEnd $event) use (&$callOrder): void { + $callOrder[] = 'end2'; + }); + + $pipeline = new Pipeline($parser); + $pipeline->invoke('{"foo": "bar"}'); + + expect($callOrder)->toBe(['start1', 'start2', 'end1', 'end2']); +}); diff --git a/tests/Unit/OutputParsers/JsonOutputToolsParserTest.php b/tests/Unit/OutputParsers/JsonOutputToolsParserTest.php index 91c67a5..e019d86 100644 --- a/tests/Unit/OutputParsers/JsonOutputToolsParserTest.php +++ b/tests/Unit/OutputParsers/JsonOutputToolsParserTest.php @@ -30,7 +30,6 @@ $generation = new ChatGeneration( message: $message, - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); @@ -59,7 +58,6 @@ function: new FunctionCall( $generation = new ChatGeneration( message: $message, - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); @@ -100,7 +98,6 @@ function: new FunctionCall( $generation = new ChatGeneration( message: $message, - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); @@ -130,7 +127,6 @@ function: new FunctionCall( $generation = new ChatGeneration( message: $message, - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); @@ -170,7 +166,6 @@ function: new FunctionCall( $generation = new ChatGeneration( message: $message, - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); diff --git a/tests/Unit/OutputParsers/StructuredOutputParserTest.php b/tests/Unit/OutputParsers/StructuredOutputParserTest.php index b17fe08..2201553 100644 --- a/tests/Unit/OutputParsers/StructuredOutputParserTest.php +++ b/tests/Unit/OutputParsers/StructuredOutputParserTest.php @@ -4,15 +4,15 @@ namespace Cortex\Tests\Unit\OutputParsers; -use Cortex\JsonSchema\SchemaFactory; +use Cortex\JsonSchema\Schema; use Cortex\Exceptions\OutputParserException; use Cortex\OutputParsers\StructuredOutputParser; use Cortex\JsonSchema\Exceptions\SchemaException; test('it can parse json from a markdown formatted json code block', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::string('foo'), + Schema::object()->properties( + Schema::string('foo'), ), ); $output = <<<'OUTPUT' @@ -31,11 +31,11 @@ test('it can parse complex nested objects', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::object('user')->properties( - SchemaFactory::string('name'), - SchemaFactory::integer('age'), - SchemaFactory::array('hobbies'), + Schema::object()->properties( + Schema::object('user')->properties( + Schema::string('name'), + Schema::integer('age'), + Schema::array('hobbies'), ), ), ); @@ -63,8 +63,8 @@ test('it can parse arrays of objects', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::array('items'), + Schema::object()->properties( + Schema::array('items'), ), ); $output = <<<'OUTPUT' @@ -88,8 +88,8 @@ test('it throws exception for invalid JSON', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::string('foo'), + Schema::object()->properties( + Schema::string('foo'), ), ); $output = <<<'OUTPUT' @@ -106,11 +106,11 @@ test('it handles different data types correctly', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::string('string'), - SchemaFactory::integer('integer'), - SchemaFactory::boolean('boolean'), - SchemaFactory::number('float'), + Schema::object()->properties( + Schema::string('string'), + Schema::integer('integer'), + Schema::boolean('boolean'), + Schema::number('float'), ), ); $output = <<<'OUTPUT' @@ -134,9 +134,9 @@ test('it throws exception for missing required properties', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::string('required')->required(), - SchemaFactory::string('optional'), + Schema::object()->properties( + Schema::string('required')->required(), + Schema::string('optional'), ), ); $output = <<<'OUTPUT' @@ -152,8 +152,8 @@ test('it allows extra properties when strict mode is disabled', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::string('required'), + Schema::object()->properties( + Schema::string('required'), ), strict: false, ); @@ -174,8 +174,8 @@ test('it doesnt allow extra properties when strict mode is enabled', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::string('required'), + Schema::object()->properties( + Schema::string('required'), ), strict: true, ); @@ -196,7 +196,7 @@ test('it can handle empty objects when schema allows', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object(), + Schema::object(), strict: false, ); $output = <<<'OUTPUT' @@ -213,10 +213,10 @@ test('it validates nested object properties', function (): void { $parser = new StructuredOutputParser( - SchemaFactory::object()->properties( - SchemaFactory::object('person')->properties( - SchemaFactory::string('name')->pattern('^[A-Za-z\s]+$')->required(), - SchemaFactory::integer('age')->minimum(0)->maximum(150)->required(), + Schema::object()->properties( + Schema::object('person')->properties( + Schema::string('name')->pattern('^[A-Za-z\s]+$')->required(), + Schema::integer('age')->minimum(0)->maximum(150)->required(), ), ), ); diff --git a/tests/Unit/OutputParsers/XmlTagOutputParserTest.php b/tests/Unit/OutputParsers/XmlTagOutputParserTest.php index 71b5cfb..593c8e7 100644 --- a/tests/Unit/OutputParsers/XmlTagOutputParserTest.php +++ b/tests/Unit/OutputParsers/XmlTagOutputParserTest.php @@ -92,7 +92,6 @@ $generation = new ChatGeneration( message: $message, - index: 0, createdAt: new DateTime(), finishReason: FinishReason::Stop, ); diff --git a/tests/Unit/Pipeline/RuntimeConfigTest.php b/tests/Unit/Pipeline/RuntimeConfigTest.php new file mode 100644 index 0000000..8fb9412 --- /dev/null +++ b/tests/Unit/Pipeline/RuntimeConfigTest.php @@ -0,0 +1,360 @@ +context)->toBeInstanceOf(Context::class) + ->and($config->metadata)->toBeInstanceOf(Metadata::class) + ->and($config->stream)->toBeInstanceOf(StreamBuffer::class) + ->and($config->exception)->toBeNull() + ->and($config->storeFactory)->toBeNull() + ->and($config->streaming)->toBeFalse() + ->and($config->runId)->toBeString() + ->and($config->threadId)->toBeString() + ->and(strlen($config->runId))->toBeGreaterThan(0) + ->and(strlen($config->threadId))->toBeGreaterThan(0); +}); + +test('RuntimeConfig constructor accepts custom threadId', function (): void { + $customThreadId = 'custom-thread-123'; + $config = new RuntimeConfig(threadId: $customThreadId); + + expect($config->threadId)->toBe($customThreadId) + ->and($config->runId)->toBeString() + ->and($config->runId)->not->toBe($customThreadId); // runId should still be auto-generated +}); + +test('RuntimeConfig constructor accepts custom context metadata and stream', function (): void { + $context = new Context([ + 'custom' => 'value', + ]); + $metadata = new Metadata([ + 'key' => 'data', + ]); + $stream = new StreamBuffer(); + + $config = new RuntimeConfig( + context: $context, + metadata: $metadata, + stream: $stream, + ); + + expect($config->context)->toBe($context) + ->and($config->metadata)->toBe($metadata) + ->and($config->stream)->toBe($stream) + ->and($config->context->get('custom'))->toBe('value') + ->and($config->metadata->get('key'))->toBe('data'); +}); + +test('RuntimeConfig generates unique runId for each instance', function (): void { + $config1 = new RuntimeConfig(); + $config2 = new RuntimeConfig(); + + expect($config1->runId)->not->toBe($config2->runId); +}); + +test('RuntimeConfig generates unique threadId when not provided', function (): void { + $config1 = new RuntimeConfig(); + $config2 = new RuntimeConfig(); + + expect($config1->threadId)->not->toBe($config2->threadId); +}); + +test('RuntimeConfig setStreaming sets streaming flag', function (): void { + $config = new RuntimeConfig(); + + expect($config->streaming)->toBeFalse(); + + $result = $config->setStreaming(true); + + expect($result)->toBe($config) // Should return self + ->and($config->streaming)->toBeTrue(); + + $config->setStreaming(false); + + expect($config->streaming)->toBeFalse(); +}); + +test('RuntimeConfig setStreaming defaults to true', function (): void { + $config = new RuntimeConfig(); + + $config->setStreaming(); + + expect($config->streaming)->toBeTrue(); +}); + +test('RuntimeConfig setException sets exception', function (): void { + $config = new RuntimeConfig(); + $exception = new Exception('Test error'); + + expect($config->exception)->toBeNull(); + + $result = $config->setException($exception); + + expect($result)->toBe($config) // Should return self + ->and($config->exception)->toBe($exception) + ->and($config->exception->getMessage())->toBe('Test error'); +}); + +test('RuntimeConfig toArray returns array representation', function (): void { + $threadId = 'test-thread-123'; + $config = new RuntimeConfig(threadId: $threadId); + $config->context->set('test_key', 'test_value'); + $config->metadata->set('meta_key', 'meta_value'); + + $array = $config->toArray(); + + expect($array)->toBeArray() + ->and($array)->toHaveKeys(['run_id', 'thread_id', 'context', 'metadata']) + ->and($array['thread_id'])->toBe($threadId) + ->and($array['run_id'])->toBe($config->runId) + ->and($array['context'])->toBeArray() + ->and($array['metadata'])->toBeArray(); +}); + +test('RuntimeConfig onStreamChunk registers event listener', function (): void { + $config = new RuntimeConfig(); + $called = false; + + $listener = function (RuntimeConfigStreamChunk $event) use (&$called): void { + $called = true; + }; + + $result = $config->onStreamChunk($listener); + + expect($result)->toBe($config); // Should return self + + // Trigger the event + $chunk = new ChatGenerationChunk(ChunkType::TextDelta, message: new AssistantMessage('test')); + $config->dispatchEvent(new RuntimeConfigStreamChunk($config, $chunk)); + + expect($called)->toBeTrue(); +}); + +test('RuntimeConfig onStreamChunk with once flag prevents duplicate listeners', function (): void { + $config = new RuntimeConfig(); + $callCount = 0; + + $listener = function (RuntimeConfigStreamChunk $event) use (&$callCount): void { + $callCount++; + }; + + // Register listener with once flag + $config->onStreamChunk($listener, once: true); + + // Try to register the same listener again - should be prevented + $config->onStreamChunk($listener, once: true); + + // Trigger the event + $chunk = new ChatGenerationChunk(ChunkType::TextDelta, message: new AssistantMessage('test')); + $config->dispatchEvent(new RuntimeConfigStreamChunk($config, $chunk)); + + // Listener should be called once (not twice, since duplicate was prevented) + expect($callCount)->toBe(1); +}); + +test('RuntimeConfig pushChunkWhenStreaming pushes chunk when streaming is enabled', function (): void { + $config = new RuntimeConfig(); + $config->setStreaming(true); + + $chunk = new ChatGenerationChunk(ChunkType::TextDelta, message: new AssistantMessage('test')); + $callbackCalled = false; + + $result = $config->pushChunkWhenStreaming($chunk, function () use (&$callbackCalled): void { + $callbackCalled = true; + }); + + $drained = $config->stream->drain(); + + expect($result)->toBe($config) // Should return self + ->and($drained)->toHaveCount(1) + ->and($drained[0])->toBe($chunk) + ->and($callbackCalled)->toBeFalse(); // Callback should not be called when streaming +}); + +test('RuntimeConfig pushChunkWhenStreaming calls callback when streaming is disabled', function (): void { + $config = new RuntimeConfig(); + $config->setStreaming(false); + + $chunk = new ChatGenerationChunk(ChunkType::TextDelta, message: new AssistantMessage('test')); + $callbackCalled = false; + + $config->pushChunkWhenStreaming($chunk, function () use (&$callbackCalled): void { + $callbackCalled = true; + }); + + expect($callbackCalled)->toBeTrue() + ->and($config->stream->isEmpty())->toBeTrue(); // Stream should be empty when not streaming +}); + +test('RuntimeConfig pushChunkWhenStreaming handles multiple chunks', function (): void { + $config = new RuntimeConfig(); + $config->setStreaming(true); + + $chunk1 = new ChatGenerationChunk(ChunkType::TextStart, message: new AssistantMessage('test1')); + $chunk2 = new ChatGenerationChunk(ChunkType::TextDelta, message: new AssistantMessage('test2')); + + $config->pushChunkWhenStreaming($chunk1, fn(): null => null); + $config->pushChunkWhenStreaming($chunk2, fn(): null => null); + + $drained = $config->stream->drain(); + expect($drained)->toHaveCount(2) + ->and($drained[0])->toBe($chunk1) + ->and($drained[1])->toBe($chunk2); +}); + +test('RuntimeConfig can chain method calls', function (): void { + $config = new RuntimeConfig(); + $exception = new Exception('Test'); + + $result = $config + ->setStreaming(true) + ->setException($exception) + ->configureLLM(function (LLM $llm): LLM { + return $llm->withTemperature(0.7); + }); + + expect($result)->toBe($config) + ->and($config->streaming)->toBeTrue() + ->and($config->exception)->toBe($exception) + ->and($config->getLLMConfigurator())->not->toBeNull(); +}); + +test('RuntimeConfig configureLLM sets configurator closure', function (): void { + $config = new RuntimeConfig(); + + expect($config->getLLMConfigurator())->toBeNull(); + + $configurator = function (LLM $llm): LLM { + return $llm->withTemperature(0.7); + }; + + $result = $config->configureLLM($configurator); + + expect($result)->toBe($config) // Should return self for method chaining + ->and($config->getLLMConfigurator())->toBe($configurator); +}); + +test('RuntimeConfig configureLLM can be called multiple times', function (): void { + $config = new RuntimeConfig(); + + $configurator1 = function (LLM $llm): LLM { + return $llm->withTemperature(0.5); + }; + + $configurator2 = function (LLM $llm): LLM { + return $llm->withTemperature(0.9); + }; + + $config->configureLLM($configurator1); + expect($config->getLLMConfigurator())->toBe($configurator1); + + $config->configureLLM($configurator2); + expect($config->getLLMConfigurator())->toBe($configurator2); // Should replace previous +}); + +test('RuntimeConfig configureLLM returns self for method chaining', function (): void { + $config = new RuntimeConfig(); + + $configurator = function (LLM $llm): LLM { + return $llm->withTemperature(0.7); + }; + + $result = $config->configureLLM($configurator); + + expect($result)->toBeInstanceOf(RuntimeConfig::class) + ->and($result)->toBe($config); +}); + +test('RuntimeConfig getLLMConfigurator returns null when not set', function (): void { + $config = new RuntimeConfig(); + + expect($config->getLLMConfigurator())->toBeNull(); +}); + +test('RuntimeConfig LLM configurator closure receives and returns LLM instance', function (): void { + $config = new RuntimeConfig(); + $llm = new FakeChat([ + ChatGeneration::fromMessage(new AssistantMessage('Test')), + ]); + + $receivedLLM = null; + $configurator = function (LLM $llm) use (&$receivedLLM): LLM { + $receivedLLM = $llm; + + return $llm->withTemperature(0.8); + }; + + $config->configureLLM($configurator); + + $storedConfigurator = $config->getLLMConfigurator(); + expect($storedConfigurator)->not->toBeNull(); + + assert($storedConfigurator !== null); + + // Call the configurator to verify it works + $configuredLLM = $storedConfigurator($llm); + + expect($receivedLLM)->toBe($llm) + ->and($configuredLLM)->toBeInstanceOf(LLM::class); +}); + +test('RuntimeConfig configureLLM with once flag marks configurator for auto-clearing', function (): void { + $config = new RuntimeConfig(); + + $configurator = function (LLM $llm): LLM { + return $llm->withTemperature(0.7); + }; + + $config->configureLLM($configurator, once: true); + + expect($config->getLLMConfigurator())->toBe($configurator) + ->and($config->shouldClearLLMConfigurator())->toBeTrue(); +}); + +test('RuntimeConfig configureLLM defaults to persistent configurator', function (): void { + $config = new RuntimeConfig(); + + $configurator = function (LLM $llm): LLM { + return $llm->withTemperature(0.7); + }; + + $config->configureLLM($configurator); + + expect($config->shouldClearLLMConfigurator())->toBeFalse(); +}); + +test('RuntimeConfig clearLLMConfigurator removes configurator and once flag', function (): void { + $config = new RuntimeConfig(); + + $configurator = function (LLM $llm): LLM { + return $llm->withTemperature(0.7); + }; + + $config->configureLLM($configurator, once: true); + + expect($config->getLLMConfigurator())->not->toBeNull() + ->and($config->shouldClearLLMConfigurator())->toBeTrue(); + + $config->clearLLMConfigurator(); + + expect($config->getLLMConfigurator())->toBeNull() + ->and($config->shouldClearLLMConfigurator())->toBeFalse(); +}); diff --git a/tests/Unit/PipelineTest.php b/tests/Unit/PipelineTest.php index 5e620c0..2d46744 100644 --- a/tests/Unit/PipelineTest.php +++ b/tests/Unit/PipelineTest.php @@ -6,9 +6,14 @@ use Closure; use Exception; +use Throwable; use Cortex\Pipeline; use Cortex\Facades\LLM; use Cortex\Attributes\Tool; +use Cortex\Events\StageEnd; +use Cortex\Pipeline\Context; +use Cortex\Events\StageError; +use Cortex\Events\StageStart; use Cortex\Tools\ClosureTool; use Cortex\Contracts\Pipeable; use Cortex\Events\PipelineEnd; @@ -18,7 +23,7 @@ use Cortex\Events\PipelineStart; use Cortex\LLM\Drivers\FakeChat; use League\Event\EventDispatcher; -use Cortex\LLM\Drivers\OpenAIChat; +use Cortex\Pipeline\RuntimeConfig; use Cortex\Support\Traits\CanPipe; use Cortex\LLM\Data\ChatGeneration; use Cortex\LLM\Data\ToolCallCollection; @@ -29,6 +34,7 @@ use Cortex\LLM\Data\Messages\SystemMessage; use Cortex\OutputParsers\StringOutputParser; use Cortex\LLM\Data\Messages\AssistantMessage; +use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; use Cortex\OutputParsers\JsonOutputToolsParser; use Cortex\Prompts\Templates\ChatPromptTemplate; use OpenAI\Responses\Chat\CreateStreamedResponse; @@ -38,16 +44,16 @@ test('pipeline processes callable stages', function (): void { $pipeline = new Pipeline(); - $pipeline->pipe(function (array $payload, $next) { + $pipeline->pipe(function (array $payload, RuntimeConfig $config, $next) { $payload['first'] = true; - return $next($payload); + return $next($payload, $config); }); - $pipeline->pipe(function (array $payload, $next) { + $pipeline->pipe(function (array $payload, RuntimeConfig $config, $next) { $payload['second'] = true; - return $next($payload); + return $next($payload, $config); }); $result = $pipeline->invoke([ @@ -68,11 +74,11 @@ $stage = new class () implements Pipeable { use CanPipe; - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { $payload['stage_interface'] = true; - return $next($payload); + return $next($payload, $config); } }; @@ -92,17 +98,17 @@ public function handlePipeable(mixed $payload, Closure $next): mixed $pipeline = new Pipeline(); $order = []; - $pipeline->pipe(function ($payload, $next) use (&$order) { + $pipeline->pipe(function ($payload, RuntimeConfig $config, $next) use (&$order) { $order[] = 1; - $result = $next($payload); + $result = $next($payload, $config); $order[] = 4; return $result; }); - $pipeline->pipe(function ($payload, $next) use (&$order) { + $pipeline->pipe(function ($payload, RuntimeConfig $config, $next) use (&$order) { $order[] = 2; - $result = $next($payload); + $result = $next($payload, $config); $order[] = 3; return $result; @@ -116,17 +122,17 @@ public function handlePipeable(mixed $payload, Closure $next): mixed test('pipeline can modify payload at any stage', function (): void { $pipeline = new Pipeline(); - $pipeline->pipe(function (array $payload, $next) { + $pipeline->pipe(function (array $payload, RuntimeConfig $config, $next) { $payload['value'] *= 2; - $result = $next($payload); + $result = $next($payload, $config); ++$result['value']; return $result; }); - $pipeline->pipe(function (array $payload, $next) { + $pipeline->pipe(function (array $payload, RuntimeConfig $config, $next) { $payload['value'] += 3; - $result = $next($payload); + $result = $next($payload, $config); $result['value'] *= 2; return $result; @@ -159,20 +165,20 @@ public function handlePipeable(mixed $payload, Closure $next): mixed test('pipeline accepts stages in constructor', function (): void { // Create stages of different types - $callableStage = function (array $payload, $next) { + $callableStage = function (array $payload, RuntimeConfig $config, $next) { $payload['callable'] = true; - return $next($payload); + return $next($payload, $config); }; $interfaceStage = new class () implements Pipeable { use CanPipe; - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { $payload['interface'] = true; - return $next($payload); + return $next($payload, $config); } }; @@ -196,14 +202,14 @@ public function handlePipeable(mixed $payload, Closure $next): mixed test('pipeline can be used as a stage in another pipeline', function (): void { // Create a pipeline that appends text $appendPipeline = new Pipeline(); - $appendPipeline->pipe(function (string $payload, $next) { - return $next($payload . ' appended'); + $appendPipeline->pipe(function (string $payload, RuntimeConfig $config, $next) { + return $next($payload . ' appended', $config); }); // Create a pipeline that prepends text $prependPipeline = new Pipeline(); - $prependPipeline->pipe(function (string $payload, $next) { - return $next('prepended ' . $payload); + $prependPipeline->pipe(function (string $payload, RuntimeConfig $config, $next) { + return $next('prepended ' . $payload, $config); }); // Create a pipeline that uses both pipelines as stages @@ -220,15 +226,15 @@ public function handlePipeable(mixed $payload, Closure $next): mixed test('pipeline supports multiple levels of nesting', function (): void { // Level 3 (innermost) $level3 = new Pipeline(); - $level3->pipe(fn($payload, $next) => $next($payload . ' level3')); + $level3->pipe(fn($payload, RuntimeConfig $config, $next) => $next($payload . ' level3', $config)); // Level 2 $level2 = new Pipeline(); - $level2->pipe($level3)->pipe(fn($payload, $next) => $next($payload . ' level2')); + $level2->pipe($level3)->pipe(fn($payload, RuntimeConfig $config, $next) => $next($payload . ' level2', $config)); // Level 1 (outermost) $level1 = new Pipeline(); - $level1->pipe($level2)->pipe(fn($payload, $next) => $next($payload . ' level1')); + $level1->pipe($level2)->pipe(fn($payload, RuntimeConfig $config, $next) => $next($payload . ' level1', $config)); $result = $level1->invoke('start'); @@ -240,27 +246,27 @@ public function handlePipeable(mixed $payload, Closure $next): mixed $appendStage = new class () implements Pipeable { use CanPipe; - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { - return $next($payload . ' appended'); + return $next($payload . ' appended', $config); } }; $prependStage = new class () implements Pipeable { use CanPipe; - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { - return $next('prepended ' . $payload); + return $next('prepended ' . $payload, $config); } }; $upperStage = new class () implements Pipeable { use CanPipe; - public function handlePipeable(mixed $payload, Closure $next): mixed + public function handlePipeable(mixed $payload, RuntimeConfig $config, Closure $next): mixed { - return strtoupper((string) $next($payload)); + return strtoupper((string) $next($payload, $config)); } }; @@ -322,10 +328,10 @@ public function handlePipeable(mixed $payload, Closure $next): mixed $pipeline = new Pipeline(); $pipeline - ->when(true, fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(fn($payload, $next) => $next($payload . ' first'))) - ->when(false, fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(fn($payload, $next) => $next($payload . ' skipped'))) - ->unless(false, fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(fn($payload, $next) => $next($payload . ' second'))) - ->unless(true, fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(fn($payload, $next) => $next($payload . ' also_skipped'))); + ->when(true, fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(fn($payload, RuntimeConfig $config, $next) => $next($payload . ' first', $config))) + ->when(false, fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(fn($payload, RuntimeConfig $config, $next) => $next($payload . ' skipped', $config))) + ->unless(false, fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(fn($payload, RuntimeConfig $config, $next) => $next($payload . ' second', $config))) + ->unless(true, fn(Pipeline $pipeline): Pipeline => $pipeline->pipe(fn($payload, RuntimeConfig $config, $next) => $next($payload . ' also_skipped', $config))); $result = $pipeline->invoke('start'); @@ -336,7 +342,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed $prompt = new ChatPromptTemplate([new UserMessage('Tell me a joke about {topic}')]); $model = new FakeChat([ ChatGeneration::fromMessage( - new AssistantMessage("Why did the dog sit in the shade? Because he didn't want to be a hot dog!"), + new AssistantMessage("Why did the dog sit in the shade? Because it didn't want to be a hot dog!"), ), ]); $outputParser = new StringOutputParser(); @@ -365,7 +371,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed expect($event->payload)->toBe([ 'topic' => 'dogs', ]); - expect($event->result)->toBe("Why did the dog sit in the shade? Because he didn't want to be a hot dog!"); + expect($event->result)->toBe("Why did the dog sit in the shade? Because it didn't want to be a hot dog!"); }); $pipeline->setEventDispatcher($eventDispatcher); @@ -374,13 +380,13 @@ public function handlePipeable(mixed $payload, Closure $next): mixed 'topic' => 'dogs', ]); - expect($result)->toBe("Why did the dog sit in the shade? Because he didn't want to be a hot dog!"); + expect($result)->toBe("Why did the dog sit in the shade? Because it didn't want to be a hot dog!"); expect($startCalled)->toBeTrue(); expect($endCalled)->toBeTrue(); }); test('it can run a pipeline via chat with an error event', function (): void { - $pipeline = new Pipeline(function (): void { + $pipeline = new Pipeline(function (mixed $payload, RuntimeConfig $config, Closure $next): void { throw new Exception('Test error'); }); @@ -414,11 +420,315 @@ public function handlePipeable(mixed $payload, Closure $next): mixed expect($errorCalled)->toBeTrue(); }); +test('pipeline instance-specific listeners work correctly', function (): void { + $pipeline = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => $next($payload . ' processed', $config), + ); + + $startCalled = false; + $endCalled = false; + + $pipeline->onStart(function (PipelineStart $event) use ($pipeline, &$startCalled): void { + $startCalled = true; + expect($event->pipeline)->toBe($pipeline); + expect($event->payload)->toBe('test'); + }); + + $pipeline->onEnd(function (PipelineEnd $event) use ($pipeline, &$endCalled): void { + $endCalled = true; + expect($event->pipeline)->toBe($pipeline); + expect($event->result)->toBe('test processed'); + }); + + $result = $pipeline->invoke('test'); + + expect($result)->toBe('test processed'); + expect($startCalled)->toBeTrue(); + expect($endCalled)->toBeTrue(); +}); + +test('pipeline instance-specific error listeners work correctly', function (): void { + $pipeline = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => throw new Exception('Test error'), + ); + + $errorCalled = false; + + $pipeline->onError(function (PipelineError $event) use ($pipeline, &$errorCalled): void { + $errorCalled = true; + expect($event->pipeline)->toBe($pipeline); + expect($event->exception)->toBeInstanceOf(Exception::class); + expect($event->exception->getMessage())->toBe('Test error'); + }); + + try { + $pipeline->invoke('test'); + } catch (Exception $e) { + expect($e->getMessage())->toBe('Test error'); + } + + expect($errorCalled)->toBeTrue(); +}); + +test('pipeline instance-specific stage listeners work correctly', function (): void { + $pipeline = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => $next($payload . ' stage1', $config), + fn($payload, RuntimeConfig $config, $next) => $next($payload . ' stage2', $config), + ); + + $stageStartCalls = []; + $stageEndCalls = []; + + $pipeline->onStageStart(function (StageStart $event) use ($pipeline, &$stageStartCalls): void { + expect($event->pipeline)->toBe($pipeline); + $stageStartCalls[] = $event->stage; + }); + + $pipeline->onStageEnd(function (StageEnd $event) use ($pipeline, &$stageEndCalls): void { + expect($event->pipeline)->toBe($pipeline); + $stageEndCalls[] = $event->stage; + }); + + $result = $pipeline->invoke('test'); + + expect($result)->toBe('test stage1 stage2'); + expect($stageStartCalls)->toHaveCount(2); + expect($stageEndCalls)->toHaveCount(2); +}); + +test('pipeline instance-specific stage error listeners work correctly', function (): void { + $pipeline = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => throw new Exception('Stage error'), + ); + + $stageErrorCalled = false; + + $pipeline->onStageError(function (StageError $event) use ($pipeline, &$stageErrorCalled): void { + $stageErrorCalled = true; + expect($event->pipeline)->toBe($pipeline); + expect($event->exception)->toBeInstanceOf(Exception::class); + expect($event->exception->getMessage())->toBe('Stage error'); + }); + + try { + $pipeline->invoke('test'); + } catch (Exception $e) { + expect($e->getMessage())->toBe('Stage error'); + } + + expect($stageErrorCalled)->toBeTrue(); +}); + +test('pipeline catch method receives exception and RuntimeConfig', function (): void { + $pipeline = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => throw new Exception('Test error'), + ); + + $catchCalled = false; + $receivedException = null; + $receivedConfig = null; + + $pipeline->catch(function (Throwable $exception, RuntimeConfig $config) use (&$catchCalled, &$receivedException, &$receivedConfig): void { + $catchCalled = true; + $receivedException = $exception; + $receivedConfig = $config; + }); + + $config = new RuntimeConfig(); + + try { + $pipeline->invoke('test', $config); + } catch (Exception $e) { + expect($e->getMessage())->toBe('Test error'); + } + + expect($catchCalled)->toBeTrue(); + expect($receivedException)->not->toBeNull(); + + if ($receivedException !== null) { + expect($receivedException)->toBeInstanceOf(Exception::class); + expect($receivedException->getMessage())->toBe('Test error'); + } + + expect($receivedConfig)->toBe($config); +}); + +test('pipeline catch method works for stage-level exceptions', function (): void { + $pipeline = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => $next($payload, $config), + fn($payload, RuntimeConfig $config, $next) => throw new Exception('Stage error'), + ); + + $catchCalled = false; + $receivedException = null; + $receivedConfig = null; + + $pipeline->catch(function (Throwable $exception, RuntimeConfig $config) use (&$catchCalled, &$receivedException, &$receivedConfig): void { + $catchCalled = true; + $receivedException = $exception; + $receivedConfig = $config; + }); + + $config = new RuntimeConfig(); + + try { + $pipeline->invoke('test', $config); + } catch (Exception $e) { + expect($e->getMessage())->toBe('Stage error'); + } + + expect($catchCalled)->toBeTrue(); + expect($receivedException)->not->toBeNull(); + + if ($receivedException !== null) { + expect($receivedException)->toBeInstanceOf(Exception::class); + expect($receivedException->getMessage())->toBe('Stage error'); + } + + expect($receivedConfig)->toBe($config); +}); + +test('pipeline catch method can register multiple callbacks', function (): void { + $pipeline = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => throw new Exception('Test error'), + ); + + $catch1Called = false; + $catch2Called = false; + $catch3Called = false; + + $pipeline + ->catch(function (Throwable $exception, RuntimeConfig $config) use (&$catch1Called): void { + $catch1Called = true; + }) + ->catch(function (Throwable $exception, RuntimeConfig $config) use (&$catch2Called): void { + $catch2Called = true; + }) + ->catch(function (Throwable $exception, RuntimeConfig $config) use (&$catch3Called): void { + $catch3Called = true; + }); + + try { + $pipeline->invoke('test'); + } catch (Exception $e) { + expect($e->getMessage())->toBe('Test error'); + } + + // All catch callbacks should be called + expect($catch1Called)->toBeTrue(); + expect($catch2Called)->toBeTrue(); + expect($catch3Called)->toBeTrue(); +}); + +test('multiple pipeline instances have separate listeners', function (): void { + $pipeline1 = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => $next($payload . ' p1', $config), + ); + + $pipeline2 = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => $next($payload . ' p2', $config), + ); + + $p1StartCalled = false; + $p1EndCalled = false; + $p2StartCalled = false; + $p2EndCalled = false; + + $pipeline1->onStart(function (PipelineStart $event) use ($pipeline1, &$p1StartCalled): void { + expect($event->pipeline)->toBe($pipeline1); + $p1StartCalled = true; + }); + + $pipeline1->onEnd(function (PipelineEnd $event) use ($pipeline1, &$p1EndCalled): void { + expect($event->pipeline)->toBe($pipeline1); + $p1EndCalled = true; + }); + + $pipeline2->onStart(function (PipelineStart $event) use ($pipeline2, &$p2StartCalled): void { + expect($event->pipeline)->toBe($pipeline2); + $p2StartCalled = true; + }); + + $pipeline2->onEnd(function (PipelineEnd $event) use ($pipeline2, &$p2EndCalled): void { + expect($event->pipeline)->toBe($pipeline2); + $p2EndCalled = true; + }); + + $result1 = $pipeline1->invoke('test'); + $result2 = $pipeline2->invoke('test'); + + expect($result1)->toBe('test p1'); + expect($result2)->toBe('test p2'); + expect($p1StartCalled)->toBeTrue(); + expect($p1EndCalled)->toBeTrue(); + expect($p2StartCalled)->toBeTrue(); + expect($p2EndCalled)->toBeTrue(); +}); + +test('pipeline can chain multiple instance-specific listeners', function (): void { + $pipeline = new Pipeline( + fn($payload, RuntimeConfig $config, $next) => $next($payload . ' processed', $config), + ); + + $callOrder = []; + + $pipeline + ->onStart(function (PipelineStart $event) use (&$callOrder): void { + $callOrder[] = 'start1'; + }) + ->onStart(function (PipelineStart $event) use (&$callOrder): void { + $callOrder[] = 'start2'; + }) + ->onEnd(function (PipelineEnd $event) use (&$callOrder): void { + $callOrder[] = 'end1'; + }) + ->onEnd(function (PipelineEnd $event) use (&$callOrder): void { + $callOrder[] = 'end2'; + }); + + $pipeline->invoke('test'); + + expect($callOrder)->toBe(['start1', 'start2', 'end1', 'end2']); +}); + +test('pipeline instance-specific listeners work with pipeable stages', function (): void { + $prompt = new ChatPromptTemplate([new UserMessage('Hello {name}')]); + $model = new FakeChat([ + ChatGeneration::fromMessage( + new AssistantMessage('Hello World!'), + ), + ]); + $outputParser = new StringOutputParser(); + + $pipeline = new Pipeline($prompt, $model, $outputParser); + + $stageStartCalls = []; + $stageEndCalls = []; + + $pipeline->onStageStart(function (StageStart $event) use ($pipeline, &$stageStartCalls): void { + expect($event->pipeline)->toBe($pipeline); + $stageStartCalls[] = $event->stage::class; + }); + + $pipeline->onStageEnd(function (StageEnd $event) use ($pipeline, &$stageEndCalls): void { + expect($event->pipeline)->toBe($pipeline); + $stageEndCalls[] = $event->stage::class; + }); + + $result = $pipeline->invoke([ + 'name' => 'World', + ]); + + expect($result)->toBe('Hello World!'); + expect($stageStartCalls)->toHaveCount(3); // prompt, model, parser + expect($stageEndCalls)->toHaveCount(3); +}); + test('it can run a pipeline via chat without an output parser', function (): void { $prompt = new ChatPromptTemplate([new UserMessage('Tell me a joke about {topic}')]); $model = new FakeChat([ ChatGeneration::fromMessage( - new AssistantMessage("Why did the dog sit in the shade? Because he didn't want to be a hot dog!"), + new AssistantMessage("Why did the dog sit in the shade? Because it didn't want to be a hot dog!"), ), ]); @@ -429,7 +739,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed ]); expect($result)->toBeInstanceOf(ChatResult::class); - expect($result->generation->message->text())->toBe("Why did the dog sit in the shade? Because he didn't want to be a hot dog!"); + expect($result->generation->message->text())->toBe("Why did the dog sit in the shade? Because it didn't want to be a hot dog!"); }); test('it can run a pipeline with a prompt that returns json', function (): void { @@ -439,7 +749,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed new AssistantMessage('```json { "setup": "Why did the dog sit in the shade?", - "punchline": "Because he didn\'t want to be a hot dog!" + "punchline": "Because it didn\'t want to be a hot dog!" } ```'), ), @@ -458,7 +768,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed expect($result)->toBeArray(); expect($result['setup'])->toBe('Why did the dog sit in the shade?'); - expect($result['punchline'])->toBe("Because he didn't want to be a hot dog!"); + expect($result['punchline'])->toBe("Because it didn't want to be a hot dog!"); }); test('it can run a pipeline with a closure added', function (): void { @@ -468,7 +778,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed new AssistantMessage('```json { "setup": "Why did the dog sit in the shade?", - "punchline": "Because he didn\'t want to be a hot dog!" + "punchline": "Because it didn\'t want to be a hot dog!" } ```'), ), @@ -477,13 +787,13 @@ public function handlePipeable(mixed $payload, Closure $next): mixed $tellAJoke = $prompt->pipe($model) ->pipe($outputParser) - ->pipe(fn(array $result): string => implode(' | ', $result)); + ->pipe(fn(array $result, RuntimeConfig $config, $next): string => $next(implode(' | ', $result), $config)); $result = $tellAJoke([ 'topic' => 'dogs', ]); - expect($result)->toBe("Why did the dog sit in the shade? | Because he didn't want to be a hot dog!"); + expect($result)->toBe("Why did the dog sit in the shade? | Because it didn't want to be a hot dog!"); }); test('it throws an exception if the prompt template input is given and not an array', function (): void { @@ -527,7 +837,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed expect($final)->toBeArray(); expect($final['setup'])->toBe('Why did the dog sit in the shade?'); - expect($final['punchline'])->toBe("Because he didn't want to be a hot dog!"); + expect($final['punchline'])->toBe("Because it didn't want to be a hot dog!"); }); test('it can stream a pipeline with a string output parser', function (): void { @@ -556,7 +866,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed return $carry . $chunk; }, ''); - expect($final)->toBe('{"setup": "Why did the dog sit in the shade?", "punchline": "Because he didn\'t want to be a hot dog!"}'); + expect($final)->toBe('{"setup":"Why did the dog sit in the shade?","punchline":"Because it didn\'t want to be a hot dog!"}'); }); test('it can run tools', function (): void { @@ -636,7 +946,7 @@ public function handlePipeable(mixed $payload, Closure $next): mixed $executionOrder = []; $pipeline->pipe([ - function (array $payload, Closure $next) use (&$executionOrder) { + function (array $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder) { $executionOrder[] = [ 'stage' => 'a', 'time' => microtime(true), @@ -644,9 +954,9 @@ function (array $payload, Closure $next) use (&$executionOrder) { usleep(1000); // Tiny 1ms delay to ensure measurable time difference $payload['a'] = 1; - return $next($payload); + return $next($payload, $config); }, - function (array $payload, Closure $next) use (&$executionOrder) { + function (array $payload, RuntimeConfig $config, Closure $next) use (&$executionOrder) { $executionOrder[] = [ 'stage' => 'b', 'time' => microtime(true), @@ -654,15 +964,15 @@ function (array $payload, Closure $next) use (&$executionOrder) { usleep(1000); // Tiny 1ms delay to ensure measurable time difference $payload['b'] = 2; - return $next($payload); + return $next($payload, $config); }, - ])->pipe(function (array $results, Closure $next) use (&$executionOrder) { + ])->pipe(function (array $results, RuntimeConfig $config, Closure $next) use (&$executionOrder) { $executionOrder[] = [ 'stage' => 'merge', 'time' => microtime(true), ]; - return $next(array_merge(...array_values($results))); + return $next(array_merge(...array_values($results)), $config); }); $result = $pipeline->invoke([]); @@ -686,3 +996,164 @@ function (array $payload, Closure $next) use (&$executionOrder) { 'b' => 2, ]); }); + +test('pipeline context can be referenced, adjusted, and passed through stages', function (): void { + $pipeline = new Pipeline(); + + // Stage 1: Read from context and set a value + $pipeline->pipe(function (mixed $payload, RuntimeConfig $config, Closure $next) { + // Reference context - read initial value + $initialValue = $config->context->get('counter', 0); + expect($initialValue)->toBe(0); + + // Adjust context - set a new value + $config->context->set('counter', $initialValue + 1); + $config->context->set('stage1_executed', true); + + // Pass context through to next stage + return $next($payload, $config); + }); + + // Stage 2: Read modified context, adjust it further, and pass through + $pipeline->pipe(function (mixed $payload, RuntimeConfig $config, Closure $next) { + // Reference context - read value set by previous stage + $counter = $config->context->get('counter'); + expect($counter)->toBe(1); + expect($config->context->get('stage1_executed'))->toBeTrue(); + + // Adjust context - increment counter and add new value + $config->context->set('counter', $counter + 1); + $config->context->set('stage2_executed', true); + + // Pass context through to next stage + return $next($payload, $config); + }); + + // Stage 3: Read final context values + $pipeline->pipe(function (mixed $payload, RuntimeConfig $config, Closure $next) { + // Reference context - verify values from previous stages + expect($config->context->get('counter'))->toBe(2); + expect($config->context->get('stage1_executed'))->toBeTrue(); + expect($config->context->get('stage2_executed'))->toBeTrue(); + + // Adjust context - add final value + $config->context->set('stage3_executed', true); + $config->context->set('final_counter', $config->context->get('counter')); + + // Pass context through + return $next($payload, $config); + }); + + // Create initial context with some settings + $initialConfig = new RuntimeConfig( + context: new Context([ + 'initial_setting' => 'test', + ]), + ); + + $result = $pipeline->invoke([ + 'data' => 'test', + ], $initialConfig); + + // Verify result + expect($result)->toBe([ + 'data' => 'test', + ]); + + // Verify context was modified through all stages + expect($initialConfig->context->get('counter'))->toBe(2); + expect($initialConfig->context->get('stage1_executed'))->toBeTrue(); + expect($initialConfig->context->get('stage2_executed'))->toBeTrue(); + expect($initialConfig->context->get('stage3_executed'))->toBeTrue(); + expect($initialConfig->context->get('final_counter'))->toBe(2); + expect($initialConfig->context->get('initial_setting'))->toBe('test'); // Original value preserved +}); + +test('pipeline context modifications persist across stages', function (): void { + $pipeline = new Pipeline(); + + // Stage 1: Modify context + $pipeline->pipe(function (mixed $payload, RuntimeConfig $config, Closure $next) { + $config->context->set('visited_stages', [1]); + $config->context->set('total_modifications', 1); + + return $next($payload, $config); + }); + + // Stage 2: Read and modify context + $pipeline->pipe(function (mixed $payload, RuntimeConfig $config, Closure $next) { + $visited = $config->context->get('visited_stages', []); + $visited[] = 2; + $config->context->set('visited_stages', $visited); + $config->context->set('total_modifications', $config->context->get('total_modifications', 0) + 1); + + return $next($payload, $config); + }); + + // Stage 3: Read and modify context + $pipeline->pipe(function (mixed $payload, RuntimeConfig $config, Closure $next) { + $visited = $config->context->get('visited_stages', []); + $visited[] = 3; + $config->context->set('visited_stages', $visited); + $config->context->set('total_modifications', $config->context->get('total_modifications', 0) + 1); + + return $next($payload, $config); + }); + + $config = new RuntimeConfig(); + $result = $pipeline->invoke([ + 'data' => 'test', + ], $config); + + // Verify all modifications persisted + expect($config->context->get('visited_stages'))->toBe([1, 2, 3]); + expect($config->context->get('total_modifications'))->toBe(3); +}); + +test('pipeline context can use fill to add multiple settings at once', function (): void { + $pipeline = new Pipeline(); + + $pipeline->pipe(function (mixed $payload, RuntimeConfig $config, Closure $next) { + // Merge multiple settings at once + $config->context->fill([ + 'setting1' => 'value1', + 'setting2' => 'value2', + 'setting3' => 'value3', + ]); + + return $next($payload, $config); + }); + + $pipeline->pipe(function (mixed $payload, RuntimeConfig $config, Closure $next) { + // Verify merged settings are available + expect($config->context->get('setting1'))->toBe('value1'); + expect($config->context->get('setting2'))->toBe('value2'); + expect($config->context->get('setting3'))->toBe('value3'); + + // Fill additional settings (should not overwrite existing) + $config->context->fill([ + 'setting4' => 'value4', + ]); + + return $next($payload, $config); + }); + + $config = new RuntimeConfig( + context: new Context([ + 'existing' => 'preserved', + ]), + ); + + $result = $pipeline->invoke([ + 'data' => 'test', + ], $config); + + // Verify all settings are present + expect($config->context->get('existing'))->toBe('preserved'); + expect($config->context->get('setting1'))->toBe('value1'); + expect($config->context->get('setting2'))->toBe('value2'); + expect($config->context->get('setting3'))->toBe('value3'); + expect($config->context->get('setting4'))->toBe('value4'); + expect($config->context->has('setting1'))->toBeTrue(); + expect($config->context->has('nonexistent'))->toBeFalse(); +}); diff --git a/tests/Unit/Prompts/Builders/ChatPromptBuilderTest.php b/tests/Unit/Prompts/Builders/ChatPromptBuilderTest.php index 0a197e7..4a92ad0 100644 --- a/tests/Unit/Prompts/Builders/ChatPromptBuilderTest.php +++ b/tests/Unit/Prompts/Builders/ChatPromptBuilderTest.php @@ -4,14 +4,18 @@ namespace Cortex\Tests\Unit\Prompts\Builders; -use Cortex\JsonSchema\SchemaFactory; +use Cortex\JsonSchema\Schema; +use Cortex\LLM\Data\ChatResult; use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\JsonSchema\Types\StringSchema; use Cortex\LLM\Data\Messages\UserMessage; +use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; use Cortex\Prompts\Builders\ChatPromptBuilder; +use Cortex\Agents\Prebuilt\GenericAgentBuilder; use Cortex\LLM\Data\Messages\MessageCollection; use Cortex\JsonSchema\Exceptions\SchemaException; +use OpenAI\Responses\Chat\CreateResponse as ChatCreateResponse; test('it can build a chat prompt template', function (): void { $builder = new ChatPromptBuilder(); @@ -40,6 +44,11 @@ expect($result->first())->toBeInstanceOf(UserMessage::class); expect($result->first()->text())->toBe('What is the capital of France?'); + $agentBuilder = $prompt->agentBuilder(); + + expect($agentBuilder)->toBeInstanceOf(GenericAgentBuilder::class) + ->and($agentBuilder->prompt())->toBe($prompt); + expect(fn(): MessageCollection => $prompt->format([ 'country' => 123, ])) @@ -47,27 +56,45 @@ }); test('it can build a chat prompt template with structured output', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"setup":"Why did the dog sit in the shade?","punchline":"Because he didn\'t want to be a hot dog!"}', + ], + ], + ], + ]), + ], 'gpt-4o'); + $builder = new ChatPromptBuilder(); $prompt = $builder ->messages([ - new UserMessage('Write a poem about {topic}'), + new UserMessage('Tell me a joke about {topic}'), ]) ->inputSchemaProperties( new StringSchema('topic'), ) ->metadata( - provider: 'ollama', - model: 'gemma3:12b', - structuredOutput: SchemaFactory::object()->properties(SchemaFactory::string('poem')), + provider: $llm, + structuredOutput: Schema::object()->properties( + Schema::string('setup')->required(), + Schema::string('punchline')->required(), + ), ) ->build(); - $writePoem = $prompt->llm(); + $result = $prompt->llm()->invoke([ + 'topic' => 'dogs', + ]); - foreach ($writePoem->stream([ - 'topic' => 'Dragons', - ]) as $chunk) { - dump($chunk->parsedOutput); - } -})->todo('mock output'); + expect($result)->toBeInstanceOf(ChatResult::class); + expect($result->content())->toBe([ + 'setup' => 'Why did the dog sit in the shade?', + 'punchline' => "Because he didn't want to be a hot dog!", + ]); +}); diff --git a/tests/Unit/Prompts/Compilers/BladeCompilerTest.php b/tests/Unit/Prompts/Compilers/BladeCompilerTest.php new file mode 100644 index 0000000..f65cc20 --- /dev/null +++ b/tests/Unit/Prompts/Compilers/BladeCompilerTest.php @@ -0,0 +1,141 @@ +compile('Hello {{ $name }}, welcome to {{ $place }}!', [ + 'name' => 'John', + 'place' => 'Paris', + ]); + + expect($result)->toBe('Hello John, welcome to Paris!'); + }); + + test('it can compile with variables without spaces', function (): void { + $compiler = new BladeCompiler(); + + $result = $compiler->compile('Hello {{$name}}, welcome to {{$place}}!', [ + 'name' => 'John', + 'place' => 'Paris', + ]); + + expect($result)->toBe('Hello John, welcome to Paris!'); + }); + + test('it throws exception when variable is undefined', function (): void { + $compiler = new BladeCompiler(); + + expect(fn(): string => $compiler->compile('Hello {{ $name }}!', [])) + ->toThrow(ViewException::class); + }); + + test('it handles string without variables', function (): void { + $compiler = new BladeCompiler(); + + $result = $compiler->compile('Hello world!', [ + 'name' => 'John', + ]); + + expect($result)->toBe('Hello world!'); + }); + + test('it handles empty string', function (): void { + $compiler = new BladeCompiler(); + + $result = $compiler->compile('', [ + 'name' => 'John', + ]); + + expect($result)->toBe(''); + }); + + test('it can find variables in a string', function (): void { + $compiler = new BladeCompiler(); + + $variables = $compiler->variables('Hello {{ $name }}, welcome to {{ $place }}!'); + + expect($variables)->toBe(['name', 'place']); + }); + + test('it can find variables without spaces', function (): void { + $compiler = new BladeCompiler(); + + $variables = $compiler->variables('Hello {{$name}}, welcome to {{$place}}!'); + + expect($variables)->toBe(['name', 'place']); + }); + + test('it returns empty array for string without variables', function (): void { + $compiler = new BladeCompiler(); + + $variables = $compiler->variables('Hello world!'); + + expect($variables)->toBe([]); + }); + + test('it handles duplicate variable names', function (): void { + $compiler = new BladeCompiler(); + + $variables = $compiler->variables('Hello {{ $name }}, {{ $name }} again!'); + + expect($variables)->toBe(['name']); + }); + + test('it handles empty string when finding variables', function (): void { + $compiler = new BladeCompiler(); + + $variables = $compiler->variables(''); + + expect($variables)->toBe([]); + }); + + test('it can compile with numeric values', function (): void { + $compiler = new BladeCompiler(); + + $result = $compiler->compile('I am {{ $age }} years old.', [ + 'age' => 30, + ]); + + expect($result)->toBe('I am 30 years old.'); + }); + + test('it can compile with boolean values', function (): void { + $compiler = new BladeCompiler(); + + $result = $compiler->compile('The value is {{ $value }}.', [ + 'value' => true, + ]); + + expect($result)->toBe('The value is 1.'); + }); + + test('it can compile with mixed spacing', function (): void { + $compiler = new BladeCompiler(); + + $result = $compiler->compile('Hello {{$name}}, welcome to {{ $place }}!', [ + 'name' => 'John', + 'place' => 'Paris', + ]); + + expect($result)->toBe('Hello John, welcome to Paris!'); + }); + + test('it can compile Blade expressions', function (): void { + $compiler = new BladeCompiler(); + + $result = $compiler->compile('Hello {{ $name }}, you have {{ $count }} items.', [ + 'name' => 'John', + 'count' => 5, + ]); + + expect($result)->toBe('Hello John, you have 5 items.'); + }); +}); diff --git a/tests/Unit/Prompts/Compilers/TextCompilerTest.php b/tests/Unit/Prompts/Compilers/TextCompilerTest.php new file mode 100644 index 0000000..c00014e --- /dev/null +++ b/tests/Unit/Prompts/Compilers/TextCompilerTest.php @@ -0,0 +1,110 @@ +compile('Hello {name}, welcome to {place}!', [ + 'name' => 'John', + 'place' => 'Paris', + ]); + + expect($result)->toBe('Hello John, welcome to Paris!'); + }); + + test('it leaves unmatched variables unchanged', function (): void { + $compiler = new TextCompiler(); + + $result = $compiler->compile('Hello {name}, welcome to {place}!', [ + 'name' => 'John', + ]); + + expect($result)->toBe('Hello John, welcome to {place}!'); + }); + + test('it handles empty variables array', function (): void { + $compiler = new TextCompiler(); + + $result = $compiler->compile('Hello {name}!', []); + + expect($result)->toBe('Hello {name}!'); + }); + + test('it handles string without variables', function (): void { + $compiler = new TextCompiler(); + + $result = $compiler->compile('Hello world!', [ + 'name' => 'John', + ]); + + expect($result)->toBe('Hello world!'); + }); + + test('it handles empty string', function (): void { + $compiler = new TextCompiler(); + + $result = $compiler->compile('', [ + 'name' => 'John', + ]); + + expect($result)->toBe(''); + }); + + test('it can find variables in a string', function (): void { + $compiler = new TextCompiler(); + + $variables = $compiler->variables('Hello {name}, welcome to {place}!'); + + expect($variables)->toBe(['name', 'place']); + }); + + test('it returns empty array for string without variables', function (): void { + $compiler = new TextCompiler(); + + $variables = $compiler->variables('Hello world!'); + + expect($variables)->toBe([]); + }); + + test('it handles duplicate variable names', function (): void { + $compiler = new TextCompiler(); + + $variables = $compiler->variables('Hello {name}, {name} again!'); + + expect($variables)->toBe(['name']); + }); + + test('it handles empty string when finding variables', function (): void { + $compiler = new TextCompiler(); + + $variables = $compiler->variables(''); + + expect($variables)->toBe([]); + }); + + test('it can compile with numeric values', function (): void { + $compiler = new TextCompiler(); + + $result = $compiler->compile('I am {age} years old.', [ + 'age' => 30, + ]); + + expect($result)->toBe('I am 30 years old.'); + }); + + test('it can compile with boolean values', function (): void { + $compiler = new TextCompiler(); + + $result = $compiler->compile('The value is {value}.', [ + 'value' => true, + ]); + + expect($result)->toBe('The value is 1.'); + }); +}); diff --git a/tests/Unit/Prompts/Factories/BladePromptFactoryTest.php b/tests/Unit/Prompts/Factories/BladePromptFactoryTest.php new file mode 100644 index 0000000..0673088 --- /dev/null +++ b/tests/Unit/Prompts/Factories/BladePromptFactoryTest.php @@ -0,0 +1,251 @@ +make('test-prompt'); + + expect($template)->toBeInstanceOf(ChatPromptTemplate::class); +}); + +test('it sets BladeCompiler on the template', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + $template = $factory->make('test-prompt'); + + expect($template->getCompiler())->toBeInstanceOf(BladeCompiler::class); +}); + +test('it stores raw Blade template strings in messages before formatting', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + $template = $factory->make('test-prompt'); + + // Before formatting, messages should contain raw Blade syntax + expect($template->messages)->toHaveCount(2); + expect($template->messages[0]->text())->toBe('You are a professional comedian.'); + expect($template->messages[1]->text())->toBe('Tell me a joke about {{ $topic }}.'); +}); + +test('it can create template without requiring variables', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + + // Should not throw when making without variables + $template = $factory->make('test-prompt'); + + expect($template)->toBeInstanceOf(ChatPromptTemplate::class); + expect($template->messages)->toHaveCount(2); + + // Messages should contain raw Blade syntax + expect($template->messages[1]->text())->toContain('{{ $topic }}'); +}); + +test('it parses messages directly from Blade file without rendering variables', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + + // Create template - should work even if Blade file has undefined variables + $template = $factory->make('test-prompt'); + + // Messages should be extracted directly from file, not from rendered output + // The raw Blade template strings should be preserved + expect($template->messages[0]->text())->toBe('You are a professional comedian.'); + expect($template->messages[1]->text())->toBe('Tell me a joke about {{ $topic }}.'); + + // Now format with actual variables - should compile correctly + $formatted = $template->format([ + 'topic' => 'programming', + ]); + expect($formatted[1]->text())->toBe('Tell me a joke about programming.'); +}); + +test('it handles complex Blade expressions without requiring variable extraction', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + + // Should be able to create template even with complex Blade expressions + // that would be hard to extract variables from + $template = $factory->make('test-complex'); + + expect($template)->toBeInstanceOf(ChatPromptTemplate::class); + expect($template->messages)->toHaveCount(2); + + // Message should contain the raw Blade expression + expect($template->messages[1]->text())->toContain('{{ $name }}'); + expect($template->messages[1]->text())->toContain('{{ count(explode(\',\', $items)) }}'); + + // Format should work correctly with actual variables + $formatted = $template->format([ + 'name' => 'Alice', + 'items' => 'apple,banana,cherry', + ]); + + expect($formatted[1]->text())->toBe('Hello Alice, you have 3 items: apple,banana,cherry.'); +}); + +test('it can format a blade prompt with variables', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + $template = $factory->make('test-prompt'); + + $messages = $template->format([ + 'topic' => 'programming', + ]); + + $variables = $template->variables(); + + expect($messages)->toBeInstanceOf(MessageCollection::class); + expect($messages)->toHaveCount(2); + expect($messages[0]->role()->value)->toBe('system'); + expect($messages[0]->text())->toBe('You are a professional comedian.'); + expect($messages[1]->role()->value)->toBe('user'); + expect($messages[1]->text())->toBe('Tell me a joke about programming.'); + + expect($template->metadata)->not->toBeNull(); + expect($template->metadata->provider)->toBe('lmstudio'); + expect($template->metadata->model)->toBe('gpt-oss:20b'); + expect($template->metadata->parameters)->toHaveKey('temperature', 1.5); + expect($template->metadata->parameters)->toHaveKey('max_tokens', 500); + + expect($template->metadata)->not->toBeNull(); + expect($template->metadata->structuredOutput)->not->toBeNull(); + + expect($variables)->toContain('topic'); +}); + +test('it can format a blade prompt with multiple messages', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + $template = $factory->make('test-example'); + + $messages = $template->format([ + 'name' => 'Alice', + 'language' => 'Python', + ]); + + expect($messages)->toBeInstanceOf(MessageCollection::class); + expect($messages)->toHaveCount(2); + expect($messages[0]->role()->value)->toBe('system'); + expect($messages[0]->text())->toBe('You are a helpful coding assistant who writes clean, well-documented code.'); + expect($messages[1]->role()->value)->toBe('user'); + expect($messages[1]->text())->toBe('Write a hello world program in Python for a person named Alice.'); +}); + +test('it can use blade prompt with llm', function (): void { + $llm = OpenAIChat::fake([ + ChatCreateResponse::fake([ + 'model' => 'gpt-4o', + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => '{"setup":"Why do programmers prefer dark mode?","punchline":"Because light attracts bugs!"}', + 'refusal' => null, + ], + ], + ], + ]), + ], 'gpt-4o'); + + $llm->ignoreFeatures(); + + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + $template = $factory->make('test-prompt'); + + $template->format([ + 'topic' => 'programming', + ]); + + /** @var \Cortex\LLM\Data\ChatResult $result */ + $result = $template->llm($llm)->invoke([ + 'topic' => 'programming', + ]); + + expect($result)->toBeInstanceOf(ChatResult::class); + expect($result->generation->message->text())->toContain('programmers'); + + /** @var \OpenAI\Testing\ClientFake $client */ + $client = $llm->getClient(); + + // Verify that metadata parameters were applied + $client->chat()->assertSent(function (string $method, array $parameters): bool { + return $parameters['temperature'] === 1.5 + && $parameters['max_completion_tokens'] === 500; + }); +}); + +test('it falls back to single user message when no directives are used', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + $template = $factory->make('test-simple'); + + $messages = $template->format([ + 'query' => 'world', + ]); + + expect($messages)->toBeInstanceOf(MessageCollection::class); + expect($messages)->toHaveCount(1); + expect($messages[0]->role()->value)->toBe('user'); + expect($messages[0]->text())->toBe('Hello world!'); +}); + +test('it handles blade conditionals correctly', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + $template = $factory->make('test-conditional'); + + // Verify compiler is set on conditional templates + expect($template->getCompiler())->toBeInstanceOf(BladeCompiler::class); + + // Test formal + $messages = $template->format([ + 'name' => 'Alice', + 'formal' => true, + ]); + + expect($messages)->toHaveCount(2); + expect($messages[0]->role()->value)->toBe('system'); + expect($messages[0]->text())->toBe('You are a helpful assistant.'); + expect($messages[1]->role()->value)->toBe('user'); + expect($messages[1]->text())->toContain('Good day, Alice.'); + + // Test informal + $messages = $template->format([ + 'name' => 'Bob', + 'formal' => false, + ]); + + expect($messages[1]->text())->toContain('Hey Bob!'); +}); + +test('it captures tools configuration from blade prompt', function (): void { + $factory = new BladePromptFactory(__DIR__ . '/../../../fixtures/prompts'); + $template = $factory->make('test-tools'); + + $messages = $template->format([ + 'query' => 'What is the weather in Manchester?', + ]); + + expect($messages)->toBeInstanceOf(MessageCollection::class); + expect($messages)->toHaveCount(2); + expect($messages[0]->role()->value)->toBe('system'); + expect($messages[0]->text())->toBe('You are a helpful assistant with access to tools.'); + expect($messages[1]->role()->value)->toBe('user'); + expect($messages[1]->text())->toBe('What is the weather in Manchester?'); + + expect($template->metadata)->not->toBeNull(); + expect($template->metadata->tools)->toHaveCount(2); + expect($template->metadata->tools)->toContain(OpenMeteoWeatherTool::class); +}); diff --git a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php index cdf7296..29e4bb4 100644 --- a/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php +++ b/tests/Unit/Prompts/Factories/LangfusePromptFactoryTest.php @@ -14,9 +14,9 @@ use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\UserMessage; +use Cortex\LLM\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\SystemMessage; use Cortex\Prompts\Contracts\PromptTemplate; -use Cortex\Tasks\Enums\StructuredOutputMode; use Cortex\LLM\Data\Messages\AssistantMessage; use Cortex\LLM\Data\Messages\MessagePlaceholder; use Cortex\Prompts\Templates\ChatPromptTemplate; diff --git a/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php b/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php index 8b1e418..a5615f2 100644 --- a/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php +++ b/tests/Unit/Prompts/Factories/McpPromptFactoryTest.php @@ -114,7 +114,7 @@ expect($prompt->inputSchema)->not()->toBeNull(); expect($prompt->inputSchema->toArray())->toBe([ 'type' => 'object', - '$schema' => 'http://json-schema.org/draft-07/schema#', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', 'title' => 'complex_prompt', 'description' => 'A template prompt with arguments', 'properties' => [ diff --git a/tests/Unit/Prompts/Templates/ChatPromptTemplateTest.php b/tests/Unit/Prompts/Templates/ChatPromptTemplateTest.php index 7a2c753..dbc7108 100644 --- a/tests/Unit/Prompts/Templates/ChatPromptTemplateTest.php +++ b/tests/Unit/Prompts/Templates/ChatPromptTemplateTest.php @@ -4,16 +4,17 @@ namespace Cortex\Tests\Unit\Prompts\Templates; +use Cortex\JsonSchema\Schema; use Cortex\LLM\Data\ChatResult; -use Cortex\LLM\Drivers\OpenAIChat; +use Cortex\Pipeline\RuntimeConfig; use Illuminate\Support\Collection; -use Cortex\JsonSchema\SchemaFactory; use Cortex\Prompts\Data\PromptMetadata; use Cortex\JsonSchema\Types\ObjectSchema; use Cortex\LLM\Data\Messages\UserMessage; use Cortex\OutputParsers\JsonOutputParser; use Cortex\LLM\Data\Messages\SystemMessage; use Cortex\LLM\Data\Messages\AssistantMessage; +use Cortex\LLM\Drivers\OpenAI\Chat\OpenAIChat; use Cortex\LLM\Data\Messages\MessageCollection; use Cortex\LLM\Data\Messages\MessagePlaceholder; use Cortex\Prompts\Templates\ChatPromptTemplate; @@ -73,7 +74,7 @@ new UserMessage('Hello, my name is {name}!'), ]); - $messages = $prompt->handlePipeable('John', function ($formatted) { + $messages = $prompt->handlePipeable('John', new RuntimeConfig(), function ($formatted) { return $formatted; }); @@ -150,7 +151,7 @@ }); test('it throws an exception when a variable is the wrong type when strict is true', function (): void { - $schema = SchemaFactory::object()->properties(SchemaFactory::string('name')->required()); + $schema = Schema::object()->properties(Schema::string('name')->required()); $prompt = new ChatPromptTemplate([ new UserMessage('Hello, my name is {name}!'), ], inputSchema: $schema, strict: true); diff --git a/tests/Unit/Prompts/Templates/TextPromptTemplateTest.php b/tests/Unit/Prompts/Templates/TextPromptTemplateTest.php index 804dd2b..6b0dd28 100644 --- a/tests/Unit/Prompts/Templates/TextPromptTemplateTest.php +++ b/tests/Unit/Prompts/Templates/TextPromptTemplateTest.php @@ -4,8 +4,9 @@ namespace Cortex\Tests\Unit\Prompts\Templates; +use Cortex\JsonSchema\Schema; +use Cortex\Pipeline\RuntimeConfig; use Illuminate\Support\Collection; -use Cortex\JsonSchema\SchemaFactory; use Cortex\Prompts\Templates\TextPromptTemplate; use Cortex\JsonSchema\Exceptions\SchemaException; @@ -63,7 +64,7 @@ test('it can format a prompt template with a single variable that is a string', function (): void { $prompt = new TextPromptTemplate('Hello, my name is {name}!'); - $result = $prompt->handlePipeable('John', function ($formatted) { + $result = $prompt->handlePipeable('John', new RuntimeConfig(), function ($formatted) { return $formatted; }); @@ -71,7 +72,7 @@ }); test('it throws an exception when a variable is the wrong type when strict is true', function (): void { - $schema = SchemaFactory::object()->properties(SchemaFactory::string('name')->required()); + $schema = Schema::object()->properties(Schema::string('name')->required()); $prompt = new TextPromptTemplate('Hello, my name is {name}!', inputSchema: $schema, strict: true); $prompt->format([ diff --git a/tests/Unit/Support/UtilsTest.php b/tests/Unit/Support/UtilsTest.php new file mode 100644 index 0000000..c2b322f --- /dev/null +++ b/tests/Unit/Support/UtilsTest.php @@ -0,0 +1,303 @@ +toBe($expected); + })->with([ + 'openai/gpt-5' => [ + 'input' => 'openai/gpt-5', + 'expected' => true, + ], + 'ollama/gemma3:12b' => [ + 'input' => 'ollama/gemma3:12b', + 'expected' => true, + ], + 'invalid-provider/model' => [ + 'input' => 'invalid-provider/model', + 'expected' => false, + ], + 'no-separator' => [ + 'input' => 'openai', + 'expected' => false, + ], + 'empty-string' => [ + 'input' => '', + 'expected' => false, + ], + ]); + }); + + describe('isPromptShortcut()', function (): void { + test('it can detect a prompt shortcut', function (string $input, bool $expected): void { + expect(Utils::isPromptShortcut($input))->toBe($expected); + })->with([ + 'mcp/driver' => [ + 'input' => 'mcp/driver', + 'expected' => true, + ], + 'blade/template' => [ + 'input' => 'blade/template', + 'expected' => true, + ], + 'langfuse/prompt' => [ + 'input' => 'langfuse/prompt', + 'expected' => true, + ], + 'invalid-factory/driver' => [ + 'input' => 'invalid-factory/driver', + 'expected' => false, + ], + 'no-separator' => [ + 'input' => 'mcp', + 'expected' => false, + ], + 'empty-string' => [ + 'input' => '', + 'expected' => false, + ], + ]); + }); + + describe('llm()', function (): void { + test('can convert string to llm', function (string $input, string $instance, ModelProvider $provider, string $model): void { + expect(Utils::isLLMShortcut($input))->toBeTrue(); + + $llm = Utils::llm($input)->ignoreFeatures(); + + expect($llm)->toBeInstanceOf($instance) + ->and($llm->getModelProvider())->toBe($provider) + ->and($llm->getModel())->toBe($model); + })->with([ + 'openai/gpt-5' => [ + 'input' => 'openai/gpt-5', + 'instance' => OpenAIChat::class, + 'provider' => ModelProvider::OpenAI, + 'model' => 'gpt-5', + ], + 'ollama/gemma3:12b' => [ + 'input' => 'ollama/gemma3:12b', + 'instance' => OpenAIChat::class, + 'provider' => ModelProvider::Ollama, + 'model' => 'gemma3:12b', + ], + 'xai/grok-2-1212' => [ + 'input' => 'xai/grok-2-1212', + 'instance' => OpenAIChat::class, + 'provider' => ModelProvider::XAI, + 'model' => 'grok-2-1212', + ], + 'gemini/gemini-2.5-pro-preview-tts' => [ + 'input' => 'gemini/gemini-2.5-pro-preview-tts', + 'instance' => OpenAIChat::class, + 'provider' => ModelProvider::Gemini, + 'model' => 'gemini-2.5-pro-preview-tts', + ], + 'anthropic/claude-3-7-sonnet-20250219' => [ + 'input' => 'anthropic/claude-3-7-sonnet-20250219', + 'instance' => AnthropicChat::class, + 'provider' => ModelProvider::Anthropic, + 'model' => 'claude-3-7-sonnet-20250219', + ], + ]); + + test('it can convert provider string without model', function (): void { + $llm = Utils::llm('openai')->ignoreFeatures(); + + expect($llm)->toBeInstanceOf(OpenAIChat::class); + }); + + test('it returns LLM instance if already an LLM instance', function (): void { + $llmInstance = Utils::llm('openai')->ignoreFeatures(); + + $result = Utils::llm($llmInstance); + + expect($result)->toBe($llmInstance); + }); + + test('it can handle null provider', function (): void { + $llm = Utils::llm(null); + + expect($llm)->toBeInstanceOf(LLM::class); + }); + }); + + describe('toToolCollection()', function (): void { + test('it can convert Tool instances to collection', function (): void { + $tool = new ClosureTool(fn(): string => 'test'); + + $collection = Utils::toToolCollection([$tool]); + + expect($collection)->toBeInstanceOf(Collection::class) + ->and($collection->count())->toBe(1) + ->and($collection->first())->toBeInstanceOf(Tool::class); + }); + + test('it can convert Closure to ClosureTool', function (): void { + $closure = fn(): string => 'test'; + + $collection = Utils::toToolCollection([$closure]); + + expect($collection)->toBeInstanceOf(Collection::class) + ->and($collection->first())->toBeInstanceOf(ClosureTool::class); + }); + + test('it can convert ObjectSchema to SchemaTool', function (): void { + $schema = Schema::object()->properties( + Schema::string('name')->required(), + ); + + $collection = Utils::toToolCollection([$schema]); + + expect($collection)->toBeInstanceOf(Collection::class) + ->and($collection->first())->toBeInstanceOf(SchemaTool::class); + }); + + test('it throws exception for invalid tool type', function (): void { + expect(fn(): Collection => Utils::toToolCollection([123])) + ->toThrow(GenericException::class, 'Invalid tool'); + }); + + test('it handles empty array', function (): void { + $collection = Utils::toToolCollection([]); + + expect($collection)->toBeInstanceOf(Collection::class) + ->and($collection->isEmpty())->toBeTrue(); + }); + }); + + describe('toMessageCollection()', function (): void { + test('it can convert string to MessageCollection', function (): void { + $collection = Utils::toMessageCollection('Hello world'); + + expect($collection)->toBeInstanceOf(MessageCollection::class) + ->and($collection->count())->toBe(1) + ->and($collection->first())->toBeInstanceOf(UserMessage::class); + }); + + test('it returns MessageCollection if already a MessageCollection', function (): void { + $original = new MessageCollection([new UserMessage('Hello')]); + + $result = Utils::toMessageCollection($original); + + expect($result)->toBe($original); + }); + + test('it can convert single Message to MessageCollection', function (): void { + $message = new UserMessage('Hello'); + + $collection = Utils::toMessageCollection($message); + + expect($collection)->toBeInstanceOf(MessageCollection::class) + ->and($collection->count())->toBe(1) + ->and($collection->first())->toBe($message); + }); + + test('it can convert array of messages to MessageCollection', function (): void { + $messages = [ + new UserMessage('Hello'), + new SystemMessage('You are a helper.'), + ]; + + $collection = Utils::toMessageCollection($messages); + + expect($collection)->toBeInstanceOf(MessageCollection::class) + ->and($collection->count())->toBe(2); + }); + + test('it can convert array with MessagePlaceholder', function (): void { + $messages = [ + new UserMessage('Hello'), + new MessagePlaceholder('user_name'), + ]; + + $collection = Utils::toMessageCollection($messages); + + expect($collection)->toBeInstanceOf(MessageCollection::class) + ->and($collection->count())->toBe(2); + }); + }); + + describe('isUrl()', function (): void { + test('it can detect valid URLs', function (string $url, bool $expected): void { + expect(Utils::isUrl($url))->toBe($expected); + })->with([ + 'http-url' => [ + 'url' => 'http://example.com', + 'expected' => true, + ], + 'https-url' => [ + 'url' => 'https://example.com', + 'expected' => true, + ], + 'url-with-path' => [ + 'url' => 'https://example.com/path', + 'expected' => true, + ], + 'url-with-query' => [ + 'url' => 'https://example.com?foo=bar', + 'expected' => true, + ], + 'not-url' => [ + 'url' => 'not-a-url', + 'expected' => false, + ], + 'empty-string' => [ + 'url' => '', + 'expected' => false, + ], + ]); + }); + + describe('isDataUrl()', function (): void { + test('it can detect data URLs with data: prefix', function (): void { + expect(Utils::isDataUrl('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='))->toBeTrue(); + }); + + test('it returns false for non-data URLs', function (): void { + expect(Utils::isDataUrl('https://example.com'))->toBeFalse(); + expect(Utils::isDataUrl('not-a-data-url'))->toBeFalse(); + }); + + test('it returns true for empty string (valid base64)', function (): void { + // Empty string is technically valid base64 (decodes to empty and encodes back to empty) + expect(Utils::isDataUrl(''))->toBeTrue(); + }); + }); + + describe('resolveMimeType()', function (): void { + test('it throws exception for invalid content', function (): void { + expect(fn(): string => Utils::resolveMimeType('invalid-content')) + ->toThrow(ContentException::class, 'Invalid content.'); + }); + + test('it can resolve mime type from data URL', function (): void { + $dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + $mimeType = Utils::resolveMimeType($dataUrl); + + expect($mimeType)->toBe('image/png'); + }); + }); +}); diff --git a/tests/Unit/Tasks/TaskTest.php b/tests/Unit/Tasks/TaskTest.php deleted file mode 100644 index 2cfdd98..0000000 --- a/tests/Unit/Tasks/TaskTest.php +++ /dev/null @@ -1,388 +0,0 @@ -llm( - 'ollama', - fn(LLMContract $llm): LLMContract => $llm->withModel('mistral-small') - ->withMaxTokens(1000) - ->withTemperature(0.5), - ) - // ->llm('lmstudio') - // ->raw() - ->user('Invent a new holiday and describe its traditions. Max 2 paragraphs.') - ->properties( - new StringSchema('name'), - new StringSchema('description'), - ) - ->build(); - - // dump($task->invoke()); - foreach ($task->stream() as $chunk) { - dump($chunk); - } - - dump($task->memory()->getMessages()->toArray()); - dd($task->usage()->toArray()); -})->skip(); - -test('it can run with a schema output', function (): void { - LLM::fake([ - new ChatGeneration( - message: new AssistantMessage('{"Sentiment": "positive"}'), - index: 0, - createdAt: new DateTimeImmutable(), - finishReason: FinishReason::Stop, - ), - new ChatGeneration( - message: new AssistantMessage('{"Sentiment": "negative"}'), - index: 1, - createdAt: new DateTimeImmutable(), - finishReason: FinishReason::Stop, - ), - new ChatGeneration( - message: new AssistantMessage('{"Sentiment": "neutral"}'), - index: 2, - createdAt: new DateTimeImmutable(), - finishReason: FinishReason::Stop, - ), - ]); - - enum Sentiment: string - { - case Positive = 'positive'; - case Negative = 'negative'; - case Neutral = 'neutral'; - } - - $analyseSentiment = task('sentiment_analysis', TaskType::Structured) - ->user('Analyze the sentiment of this text: {input}') - ->output(Sentiment::class); - - expect($analyseSentiment([ - 'input' => 'This pizza is awesome', - ]))->toBe(Sentiment::Positive); - expect($analyseSentiment([ - 'input' => 'This pizza is terrible', - ]))->toBe(Sentiment::Negative); - expect($analyseSentiment([ - 'input' => 'This pizza is okay', - ]))->toBe(Sentiment::Neutral); -}); - -test('it can build a task using a class output', function (): void { - enum Sex: string - { - case Male = 'male'; - case Female = 'female'; - } - - $user = new class () { - public string $name; - - public string $email; - - public int $age; - - public Sex $sex; - }; - - $generateUser = Cortex::task('data_generator', TaskType::Structured) - ->llm('ollama', 'mistral-small') - ->system('You are a data generator.') - ->user('Generate a user with the following details: {details}') - ->output($user::class) - ->build(); - - $user = $generateUser([ - 'details' => 'young man', - ]); - - dump($user); - dump($generateUser->memory()->getMessages()); - dd($generateUser->usage()); - - expect($user)->toBeInstanceOf($user::class); - expect($user->name)->toBeString(); - expect($user->email)->toBeString(); - expect($user->age)->toBeInt(); - expect($user->sex)->toBeInstanceOf(Sex::class); -})->skip(); - -test('it can build and run a task that uses a tool', function (): void { - LLM::fake([ - ChatGeneration::fromMessage( - message: new AssistantMessage(toolCalls: new ToolCallCollection([ - new ToolCall( - id: '1', - function: new FunctionCall( - name: 'get_weather', - arguments: [ - 'location' => 'Manchester', - ], - ), - ), - ])), - finishReason: FinishReason::ToolCalls, - ), - ChatGeneration::fromMessage( - message: new AssistantMessage('The weather in Manchester is sunny and 23 degrees Celsius.'), - finishReason: FinishReason::Stop, - ), - ]); - - $weatherTool = tool( - 'get_weather', - 'Get the current weather for a given location', - fn(string $location): string => sprintf('The weather in %s is sunny and 23 degrees Celsius.', $location), - ); - - $getWeather = Cortex::task('weather_forecast') - ->system('You are a weather forecaster. Use the tool to get the weather for a given location.') - ->user('What is the weather in {location}?') - ->tools([$weatherTool]) - ->build(); - - $result = $getWeather([ - 'location' => 'Manchester', - ]); - - expect($result)->toBe('The weather in Manchester is sunny and 23 degrees Celsius.'); - expect($getWeather->memory()->getMessages())->toHaveCount(5); - expect($getWeather->usage())->toBeInstanceOf(Usage::class); - - // TODO: Streaming still broken - // foreach ($getWeather->stream([ - // 'location' => 'Manchester', - // ]) as $chunk) { - // dump($chunk); - // } - - // dump($getWeather->memory()->getMessages()->toArray()); - // dd($getWeather->usage()); -}); - -test('it can run a task with multiple tool calls', function (): void { - $multiply = #[Tool(name: 'multiply', description: 'Use when you need to multiply two numbers')] fn(int $x, int $y): int => $x * $y; - $add = #[Tool(name: 'add', description: 'Use when you need to add two numbers')] fn(int $x, int $y): int => $x + $y; - - LLM::fake([ - ChatGeneration::fromMessage( - message: new AssistantMessage(toolCalls: new ToolCallCollection([ - new ToolCall( - id: '1', - function: new FunctionCall( - name: 'multiply', - arguments: [ - 'x' => 3, - 'y' => 12, - ], - ), - ), - new ToolCall( - id: '2', - function: new FunctionCall( - name: 'multiply', - arguments: [ - 'x' => 11, - 'y' => 49, - ], - ), - ), - ])), - finishReason: FinishReason::ToolCalls, - ), - ChatGeneration::fromMessage( - message: new AssistantMessage(toolCalls: new ToolCallCollection([ - new ToolCall( - id: '3', - function: new FunctionCall( - name: 'add', - arguments: [ - 'x' => 36, - 'y' => 539, - ], - ), - ), - ])), - finishReason: FinishReason::ToolCalls, - ), - ChatGeneration::fromMessage( - message: new AssistantMessage('The result is 575.'), - finishReason: FinishReason::Stop, - ), - ]); - - $mathTask = Cortex::task('math') - ->llm('openai', 'gpt-4o-mini') - ->system('You are a math expert. Use the tools to solve the problem.') - ->user('{input}') - ->tools([$multiply, $add]) - ->maxIterations(5) - ->build(); - - $result = $mathTask->invoke([ - 'input' => 'Take the results of 3 * 12 and 11 * 49 and add them together.', - ]); - - expect($result)->toBe('The result is 575.'); - expect($mathTask->memory()->getMessages())->toHaveCount(8); - expect($mathTask->usage())->toBeInstanceOf(Usage::class); - - // dump($result); - - // TODO: Streaming still broken - // foreach ($mathTask->stream([ - // 'input' => 'Take the results of 3 * 12 and 11 * 49 and add them together.', - // ]) as $chunk) { - // dump($chunk); - // } - - // $result = $mathTask->invoke([ - // 'input' => 'Take the results of 3 * 12 and 11 * 49 and add them together.', - // ]); - - // dump($result); - - // dump($mathTask->memory()->getMessages()->toArray()); - // dd($mathTask->usage()); -}); - -test('reAct with tools', function (): void { - $tavily = new TavilySearch(env('TAVILY_API_KEY', '')); - $multiply = #[Tool(name: 'multiply', description: 'Use when you need to multiply two numbers')] fn(int $x, int $y): int => $x * $y; - $subtract = #[Tool(name: 'subtract', description: 'Use when you need to subtract two numbers')] fn(int $x, int $y): int => $x - $y; - - Http::preventStrayRequests(); - - Http::fake([ - 'https://api.tavily.com/search' => Http::sequence() - ->push([ - 'answer' => "Olivia Wilde's boyfriend in 2025 is Dane DiLiegro, as they sparked dating rumors after attending a Lakers game together on January 23, 2025.", - ]) - ->push([ - 'answer' => 'Dane DiLiegro is 35 years old.', - ]), - ]); - - LLM::fake([ - ChatGeneration::fromMessage( - message: new AssistantMessage(toolCalls: new ToolCallCollection([ - new ToolCall( - id: '1', - function: new FunctionCall( - name: $tavily->name(), - arguments: [ - 'query' => 'Olivia Wilde boyfriend 2025', - ], - ), - ), - ])), - finishReason: FinishReason::ToolCalls, - ), - ChatGeneration::fromMessage( - message: new AssistantMessage(toolCalls: new ToolCallCollection([ - new ToolCall( - id: '2', - function: new FunctionCall( - name: $tavily->name(), - arguments: [ - 'query' => 'Dane DiLiegro age', - ], - ), - ), - ])), - finishReason: FinishReason::ToolCalls, - ), - ChatGeneration::fromMessage( - message: new AssistantMessage(toolCalls: new ToolCallCollection([ - new ToolCall( - id: '3', - function: new FunctionCall( - name: 'multiply', - arguments: [ - 'x' => 35, - 'y' => 2, - ], - ), - ), - ])), - finishReason: FinishReason::ToolCalls, - ), - ChatGeneration::fromMessage( - message: new AssistantMessage("Dane DiLiegro's age multiplied by 2 is 70."), - finishReason: FinishReason::Stop, - ), - ]); - - $reAct = task('reAct') - ->llm('openai', 'gpt-4o-mini') - // ->llm('ollama', 'mistral-small') - // ->llm('xai') - ->messages([ - new SystemMessage('You are a helpful assistant that can use tools to answer questions. Think step by step. Current date: ' . now()->format('Y-m-d')), - new UserMessage('{input}'), - ]) - ->tools([$tavily, $multiply, $subtract]) - ->maxIterations(5) - // ->raw() - ->build(); - - $result = $reAct->invoke([ - // 'input' => 'What is the age gap between the current US President and his wife?', - 'input' => "I need to find out who Olivia Wilde's boyfriend is and then multiply his age by 2", - ]); - - // dump($result); - - // $result = $reAct->stream([ - // 'input' => 'I need to find out who Olivia Wilde\'s boyfriend is and then multiply his age by 2', - // // 'input' => 'How old is the UK Prime Ministers wife?', - // ]); - - // $result->each(function ($chunk) { - // dump($chunk); - // // dump($chunk->id . ' - ' . $chunk->contentSoFar); - // }); - - // foreach ($result as $chunk) { - // if ($chunk instanceof ChatStreamResult) { - // foreach ($chunk as $chunk) { - // dump($chunk); - // } - // } else { - // dump($chunk); - // } - // } - - expect($result)->toBe("Dane DiLiegro's age multiplied by 2 is 70."); - - // dump($reAct->memory()->getMessages()->toArray()); - // dd($reAct->usage()); -}); diff --git a/tests/Unit/Tools/ClosureToolTest.php b/tests/Unit/Tools/ClosureToolTest.php index a36e5af..fc6f533 100644 --- a/tests/Unit/Tools/ClosureToolTest.php +++ b/tests/Unit/Tools/ClosureToolTest.php @@ -5,8 +5,10 @@ namespace Cortex\Tests\Unit\Tools; use Cortex\Attributes\Tool; +use Cortex\Pipeline\Context; use Cortex\Tools\ClosureTool; -use Cortex\JsonSchema\Contracts\Schema; +use Cortex\Pipeline\RuntimeConfig; +use Cortex\JsonSchema\Contracts\JsonSchema; use function Cortex\Support\tool; @@ -25,7 +27,7 @@ function ( $tool = new ClosureTool($multiply); - expect($tool->schema())->toBeInstanceOf(Schema::class); + expect($tool->schema())->toBeInstanceOf(JsonSchema::class); expect($tool->name())->toBe('multiply'); expect($tool->description())->toBe('Multiply two numbers'); expect($tool->invoke([ @@ -61,7 +63,7 @@ function ( $tool = new ClosureTool($multiply); - expect($tool->schema())->toBeInstanceOf(Schema::class); + expect($tool->schema())->toBeInstanceOf(JsonSchema::class); expect($tool->name())->toBe('closure'); expect($tool->description())->toBe('Multiply two numbers'); expect($tool->invoke([ @@ -70,15 +72,21 @@ function ( ]))->toBe(4); }); -test('it can create a schema from a Closure with a tool helper function', function (): void { +test('it can create a schema from a Closure with a tool helper function with context', function (): void { $tool = tool( 'get_weather_for_location', 'Get the weather for a location', /** @param string $location The location to get the weather for */ - fn(string $location): string => 'The weather in ' . $location . ' is rainy', + function (string $location, RuntimeConfig $config): string { + if ($config->context->get('unit') === 'metric') { + return 'The weather in ' . $location . ' is 16°C'; + } + + return 'The weather in ' . $location . ' is 61°F'; + }, ); - expect($tool->schema())->toBeInstanceOf(Schema::class); + expect($tool->schema())->toBeInstanceOf(JsonSchema::class); expect($tool->name())->toBe('get_weather_for_location'); expect($tool->description())->toBe('Get the weather for a location'); expect($tool->format())->toBe([ @@ -96,7 +104,19 @@ function ( ], ]); + $config = new RuntimeConfig( + context: new Context([ + 'unit' => 'metric', + ]), + ); + + expect($tool->invoke([ + 'location' => 'Manchester', + ], $config))->toBe('The weather in Manchester is 16°C'); + + $config->context->set('unit', 'imperial'); + expect($tool->invoke([ 'location' => 'Manchester', - ]))->toBe('The weather in Manchester is rainy'); + ], $config))->toBe('The weather in Manchester is 61°F'); }); diff --git a/tests/fixtures/openai/chat-stream-json.txt b/tests/fixtures/openai/chat-stream-json.txt index 19d37f5..6483ba9 100644 --- a/tests/fixtures/openai/chat-stream-json.txt +++ b/tests/fixtures/openai/chat-stream-json.txt @@ -1,31 +1,32 @@ -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":"{"},"index":1,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":"\"setup\""},"index":2,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":":"},"index":3,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" \"Why"},"index":4,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" did"},"index":5,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" the"},"index":6,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" dog"},"index":7,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" sit"},"index":8,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" in"},"index":9,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" the"},"index":10,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" shade"},"index":11,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":"?"},"index":12,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":"\""},"index":13,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":","},"index":14,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" \"punchline\""},"index":15,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":":"},"index":16,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" \"Because"},"index":17,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" he"},"index":18,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" didn't"},"index":19,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" want"},"index":20,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" to"},"index":21,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" be"},"index":22,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" a"},"index":23,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" hot"},"index":24,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":" dog"},"index":25,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":"!"},"index":26,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":"\""},"index":27,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{"content":"}"},"index":28,"finish_reason":null}]} -data: {"id":"chatcmpl-7ze56Y7MVo8Tw2yCj8aXk3f5GyLwM","object":"chat.completion.chunk","created":1679432088,"model":"gpt-4o","choices":[{"delta":{},"index":29,"finish_reason":"stop"}]} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"C2SYaGHRg1BV9D"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"{\""},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"aTTwSKlFIR5hD"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"setup"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"3tGbuzVYDqU"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"\":\""},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ZFRb3WsFCEm"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"Why"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"BmVWKEYaKHqg1"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" did"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"EuITqNi4vXXw"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"7GUkTDVDf59k"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" dog"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6qXYakJFLEDj"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" sit"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"mQ6D0hvtvfAq"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"TVCaGknVJa8Wk"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"jF4tvjGiP60H"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" shade"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"psvR6D6de0"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6rD0hCyMZ5iVSd0"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"\",\""},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6hfKj0qdVgS"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"p"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"F2FekcVB2kIGzE1"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"unch"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ko2R5wGpQhpl"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"line"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Cjk46B79gLqZ"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"\":\""},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"EQOuejCT9MC"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"Because"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"2YMzePt5h"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" it"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"zdoeoDbY9bDB0"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" didn't"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Onlf2IciH"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" want"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ooXpdGl5ruX"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"4qQJjXZQLeKjZ"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" be"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"dx32xk5C2wL9a"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5LbDcVheJfH9sz"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" hot"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5196mIyKVF8S"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" dog"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"27SayS4WmxN5"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"!\""},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"NrQBHG8ZVkUPZ"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"}"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Wr2R1klw6bRuRoC"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"2M11x8yoGT"} +data: {"id":"chatcmpl-CTEuNwB3h0TpVakG6ZwrQ484fJVQG","object":"chat.completion.chunk","created":1761084855,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[],"usage":{"prompt_tokens":61,"completion_tokens":28,"total_tokens":89,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"lL0bAofqmObgAet"} data: [DONE] diff --git a/tests/fixtures/openai/chat-stream-tool-calls.txt b/tests/fixtures/openai/chat-stream-tool-calls.txt new file mode 100644 index 0000000..164ba97 --- /dev/null +++ b/tests/fixtures/openai/chat-stream-tool-calls.txt @@ -0,0 +1,13 @@ +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_3Dc5EAN63U1y4EsRbuteI5zv","type":"function","function":{"name":"multiply","arguments":""}}],"refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Yqu2a7JVc1UP"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6fu"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"x"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"FDJRW"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xfI"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"3"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"nRtGy"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":",\""}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xrO"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"y"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"aJb74"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"sAM"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"4"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ui9qE"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"}"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"lhrhP"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null,"obfuscation":"IJrT"} +data: {"id":"chatcmpl-CR4kIDuXClP1l7GbAfARGQuq69WtE","object":"chat.completion.chunk","created":1760569134,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[],"usage":{"prompt_tokens":52,"completion_tokens":17,"total_tokens":69,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"84vsBvPATro15m6"} +data: [DONE] diff --git a/tests/fixtures/openai/chat-stream.txt b/tests/fixtures/openai/chat-stream.txt index 094eabd..9304f92 100644 --- a/tests/fixtures/openai/chat-stream.txt +++ b/tests/fixtures/openai/chat-stream.txt @@ -1,12 +1,40 @@ -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":"I"},"index":1,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":" am"},"index":2,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":" doing"},"index":3,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":" well,"},"index":4,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":" thank"},"index":5,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":" you"},"index":6,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":" for"},"index":7,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":" asking"},"index":8,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{"content":"!"},"index":9,"finish_reason":null}]} -data: {"id":"chatcmpl-6yo21W6LVo8Tw2yBf7aGf2g17IeIl","object":"chat.completion.chunk","created":1679432086,"model":"gpt-4o","choices":[{"delta":{},"index":10,"finish_reason":"stop"}]} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"zPAdnOBAReAwTU"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"yYKNAsNJMR7"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"FsM6sSasHnu4jTK"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"HZsEupAx3lNvIw"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"’m"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xxCe0OhsVMKj1r"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" just"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"JKHnx4qy7gq"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Hv22r37YQVePyR"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" program"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6z983eCf"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xllefgSGIzC1aHF"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" so"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"1v5ftcx0V7Kg0"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"dSbuF5D6hju0D7"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" don"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"OoK8B9rokAJT"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"’t"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"EizJJMVRoSCPLG"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" have"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"1YRtJdWCzdr"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" feelings"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"04am39M"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"1CLlFxrtFb3hMaP"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" but"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"H2f71w6neDlF"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"dNN6R5CxUlz6GN"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"’m"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"8H74k9Yg6CFeos"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" here"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"CYxR6h1rN7U"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" and"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"kNIfYunt4DR2"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" ready"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"pHBVp7kxHs"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"YHz25S3ETH26e"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" help"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"WO4BBzvZhCQ"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"TgszbCznuZlU"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" with"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"BD0peYel1Pl"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" whatever"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"R1kqC0g"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5RM9rsmB9w9h"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" need"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"pAM6WhDORl4"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"YBmyK7y9t3Wo9Zn"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" How"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"DWx6oqcE4lWT"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"jb8rxoSM8jm2"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"nj5QhLmDy88i89"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" assist"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Nzfa2xf7Q"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"OlYJtlTIcuue"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"lWksBtqfcB"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"OCYJmFVSKpvtoO8"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"pdlAX27TXm"} +data: {"id":"chatcmpl-CR4eoCf5iw0RkoW1tqndwoRsr6fYc","object":"chat.completion.chunk","created":1760568794,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_cbf1785567","choices":[],"usage":{"prompt_tokens":13,"completion_tokens":36,"total_tokens":49,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"N07RhmSYTLP4APQ"} data: [DONE] diff --git a/tests/fixtures/openai/responses-stream-reasoning.txt b/tests/fixtures/openai/responses-stream-reasoning.txt new file mode 100644 index 0000000..0058bb4 --- /dev/null +++ b/tests/fixtures/openai/responses-stream-reasoning.txt @@ -0,0 +1,11 @@ +data: {"type":"response.created","response":{"id":"resp_123","object":"response","created_at":1234567890,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} +data: {"type":"response.reasoning_summary_part.added","item_id":"reasoning_123","output_index":0,"summary_index":0,"part":{"type":"reasoning_summary","text":"","annotations":[]}} +data: {"type":"response.reasoning_summary_text.delta","item_id":"reasoning_123","output_index":0,"summary_index":0,"delta":"Let me think"} +data: {"type":"response.reasoning_summary_text.done","item_id":"reasoning_123","output_index":0,"summary_index":0,"text":"Let me think about this"} +data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_123","type":"message","status":"in_progress","role":"assistant","content":[]}} +data: {"type":"response.output_text.delta","item_id":"msg_123","output_index":0,"content_index":0,"delta":"The answer","sequence_number":1} +data: {"type":"response.output_text.delta","item_id":"msg_123","output_index":0,"content_index":0,"delta":" is 42","sequence_number":2} +data: {"type":"response.output_text.done","item_id":"msg_123","output_index":0,"content_index":0,"text":"The answer is 42","sequence_number":3} +data: {"type":"response.completed","response":{"id":"resp_123","object":"response","created_at":1234567890,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o","output":[{"id":"msg_123","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"The answer is 42","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":5,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":15},"user":null,"metadata":{}}} +data: [DONE] + diff --git a/tests/fixtures/openai/responses-stream-tool-calls.txt b/tests/fixtures/openai/responses-stream-tool-calls.txt new file mode 100644 index 0000000..ef60826 --- /dev/null +++ b/tests/fixtures/openai/responses-stream-tool-calls.txt @@ -0,0 +1,9 @@ +data: {"type":"response.created","response":{"id":"resp_123","object":"response","created_at":1234567890,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} +data: {"type":"response.output_item.added","output_index":0,"item":{"id":"call_123","type":"function_call","status":"in_progress","name":"multiply","arguments":"","call_id":"call_123"}} +data: {"type":"response.function_call_arguments.delta","item_id":"call_123","output_index":0,"delta":"{\"x\":"} +data: {"type":"response.function_call_arguments.delta","item_id":"call_123","output_index":0,"delta":"3"} +data: {"type":"response.function_call_arguments.delta","item_id":"call_123","output_index":0,"delta":",\"y\":4}"} +data: {"type":"response.function_call_arguments.done","item_id":"call_123","output_index":0,"arguments":"{\"x\":3,\"y\":4}"} +data: {"type":"response.completed","response":{"id":"resp_123","object":"response","created_at":1234567890,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o","output":[{"id":"call_123","type":"function_call","status":"completed","name":"multiply","arguments":"{\"x\":3,\"y\":4}","call_id":"call_123"}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":5,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":15},"user":null,"metadata":{}}} +data: [DONE] + diff --git a/tests/fixtures/openai/responses-stream.txt b/tests/fixtures/openai/responses-stream.txt new file mode 100644 index 0000000..0765a50 --- /dev/null +++ b/tests/fixtures/openai/responses-stream.txt @@ -0,0 +1,9 @@ +data: {"type":"response.created","response":{"id":"resp_123","object":"response","created_at":1234567890,"status":"in_progress","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} +data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_123","type":"message","status":"in_progress","role":"assistant","content":[]}} +data: {"type":"response.output_text.delta","item_id":"msg_123","output_index":0,"content_index":0,"delta":"Hello","sequence_number":1} +data: {"type":"response.output_text.delta","item_id":"msg_123","output_index":0,"content_index":0,"delta":" World","sequence_number":2} +data: {"type":"response.output_text.done","item_id":"msg_123","output_index":0,"content_index":0,"text":"Hello World","sequence_number":3} +data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_123","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello World","annotations":[]}]}} +data: {"type":"response.completed","response":{"id":"resp_123","object":"response","created_at":1234567890,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-4o","output":[{"id":"msg_123","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello World","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":false,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12},"user":null,"metadata":{}}} +data: [DONE] + diff --git a/tests/fixtures/prompts/test-complex.blade.php b/tests/fixtures/prompts/test-complex.blade.php new file mode 100644 index 0000000..abb9671 --- /dev/null +++ b/tests/fixtures/prompts/test-complex.blade.php @@ -0,0 +1,20 @@ +required(), + Schema::string('items')->required(), +]); +?> + +@system +You are a helpful assistant. +@endsystem + +@user +Hello {{ $name }}, you have {{ count(explode(',', $items)) }} items: {{ $items }}. +@enduser + diff --git a/tests/fixtures/prompts/test-conditional.blade.php b/tests/fixtures/prompts/test-conditional.blade.php new file mode 100644 index 0000000..fc5d201 --- /dev/null +++ b/tests/fixtures/prompts/test-conditional.blade.php @@ -0,0 +1,24 @@ +required(), + Schema::boolean('formal')->required(), +]); +?> + +@system +You are a helpful assistant. +@endsystem + +@user +@if($formal) +Good day, {{ $name }}. +@else +Hey {{ $name }}! +@endif +@enduser + diff --git a/tests/fixtures/prompts/test-example.blade.php b/tests/fixtures/prompts/test-example.blade.php new file mode 100644 index 0000000..927614d --- /dev/null +++ b/tests/fixtures/prompts/test-example.blade.php @@ -0,0 +1,27 @@ +required(), + Schema::string('language')->required(), +]); + +llm('openai', 'gpt-4', [ + 'temperature' => 0.7, + 'max_tokens' => 1000, +]); + +?> + +@system +You are a helpful coding assistant who writes clean, well-documented code. +@endsystem + +@user +Write a hello world program in {{ $language }} for a person named {{ $name }}. +@enduser + diff --git a/tests/fixtures/prompts/test-prompt.blade.php b/tests/fixtures/prompts/test-prompt.blade.php new file mode 100644 index 0000000..b866d78 --- /dev/null +++ b/tests/fixtures/prompts/test-prompt.blade.php @@ -0,0 +1,31 @@ +required(), +]); + +llm('lmstudio', 'gpt-oss:20b', [ + 'temperature' => 1.5, + 'max_tokens' => 500, +]); + +structuredOutput([ + Schema::string('setup')->required(), + Schema::string('punchline')->required(), +]); +?> + +@system +You are a professional comedian. +@endsystem + +@user +Tell me a joke about {{ $topic }}. +@enduser + diff --git a/tests/fixtures/prompts/test-simple.blade.php b/tests/fixtures/prompts/test-simple.blade.php new file mode 100644 index 0000000..9bc32d7 --- /dev/null +++ b/tests/fixtures/prompts/test-simple.blade.php @@ -0,0 +1,12 @@ +required(), +]); +?> +Hello {{ $query }}! + diff --git a/tests/fixtures/prompts/test-tools.blade.php b/tests/fixtures/prompts/test-tools.blade.php new file mode 100644 index 0000000..fe1f41c --- /dev/null +++ b/tests/fixtures/prompts/test-tools.blade.php @@ -0,0 +1,31 @@ +required(), +]); + +llm('openai', 'gpt-4'); + +tools([ + OpenMeteoWeatherTool::class, + #[Tool(name: 'get_weather', description: 'Get the weather for a given location')] + fn(string $query): string => 'The weather in ' . $query . ' is sunny.', +]); +?> + +@system +You are a helpful assistant with access to tools. +@endsystem + +@user +{{ $query }} +@enduser + diff --git a/workbench/.gitignore b/workbench/.gitignore new file mode 100644 index 0000000..7260321 --- /dev/null +++ b/workbench/.gitignore @@ -0,0 +1,2 @@ +.env +.env.dusk diff --git a/workbench/app/Models/.gitkeep b/workbench/app/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php new file mode 100644 index 0000000..9766c34 --- /dev/null +++ b/workbench/app/Models/User.php @@ -0,0 +1,48 @@ + */ + use HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/workbench/app/Providers/CortexServiceProvider.php b/workbench/app/Providers/CortexServiceProvider.php new file mode 100644 index 0000000..d71beeb --- /dev/null +++ b/workbench/app/Providers/CortexServiceProvider.php @@ -0,0 +1,90 @@ +set( + 'cortex.prompt_factory.blade.path', + package_path('workbench/resources/views/prompts'), + ); + + // Cortex::registerAgent(new Agent( + // name: 'holiday_generator', + // prompt: 'Invent a new holiday and describe its traditions. Max 3 sentences.', + // llm: Cortex::llm('lmstudio/openai/gpt-oss-20b')->withTemperature(1.5), + // output: [ + // Schema::string('name')->required(), + // Schema::string('description')->required(), + // ], + // )); + + // Cortex::registerAgent(new Agent( + // name: 'quote_of_the_day', + // prompt: 'Generate a quote of the day about {topic}.', + // llm: 'lmstudio/openai/gpt-oss-20b', + // output: [ + // Schema::string('quote') + // ->description('Do not include the author in the quote. Just a single sentence.') + // ->required(), + // Schema::string('author')->required(), + // ], + // )); + + // Cortex::registerAgent(new Agent( + // name: 'comedian', + // prompt: Cortex::prompt() + // ->builder() + // ->messages([ + // new SystemMessage('You are a comedian.'), + // new UserMessage('Tell me a joke about {topic}.'), + // ]) + // ->metadata( + // provider: 'lmstudio', + // model: 'gpt-oss:20b', + // parameters: [ + // 'temperature' => 1.5, + // ], + // structuredOutput: Schema::object()->properties( + // Schema::string('setup')->required(), + // Schema::string('punchline')->required(), + // ), + // ), + // )); + + Cortex::registerAgent(new Agent( + name: 'generic', + prompt: 'You are a helpful assistant.', + llm: 'lmstudio/openai/gpt-oss-20b' + )); + + Cortex::registerAgent(new Agent( + name: 'code_generator', + prompt: Cortex::prompt()->factory('blade')->make('example'), + // prompt: 'blade/example', + )); + } +} diff --git a/workbench/bootstrap/app.php b/workbench/bootstrap/app.php new file mode 100644 index 0000000..d06553e --- /dev/null +++ b/workbench/bootstrap/app.php @@ -0,0 +1,19 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + ) + ->withMiddleware(function (Middleware $middleware): void { + // + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); diff --git a/workbench/database/factories/.gitkeep b/workbench/database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/database/factories/UserFactory.php b/workbench/database/factories/UserFactory.php new file mode 100644 index 0000000..dfcab01 --- /dev/null +++ b/workbench/database/factories/UserFactory.php @@ -0,0 +1,54 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = User::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/workbench/database/migrations/.gitkeep b/workbench/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/database/seeders/DatabaseSeeder.php b/workbench/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..12c99d0 --- /dev/null +++ b/workbench/database/seeders/DatabaseSeeder.php @@ -0,0 +1,23 @@ +times(10)->create(); + + UserFactory::new()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/workbench/resources/views/.gitkeep b/workbench/resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/workbench/resources/views/prompts/example.blade.php b/workbench/resources/views/prompts/example.blade.php new file mode 100644 index 0000000..0cc60fa --- /dev/null +++ b/workbench/resources/views/prompts/example.blade.php @@ -0,0 +1,17 @@ + 0.7, + 'max_tokens' => 1000, +]); +?> + +@system +You are a helpful coding assistant who writes clean, well-documented code. +@endsystem + +@user +Write a hello world program in {{ $language }} for a person named {{ $name }}. +@enduser diff --git a/workbench/routes/console.php b/workbench/routes/console.php new file mode 100644 index 0000000..7929725 --- /dev/null +++ b/workbench/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +// })->purpose('Display an inspiring quote'); diff --git a/workbench/routes/web.php b/workbench/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/workbench/routes/web.php @@ -0,0 +1,7 @@ +