├── flow.php ├── main.php ├── nodes.php ├── examples ├── quiz-show-multi-agent │ ├── .env.example │ ├── .gitignore │ ├── main.php │ ├── composer.json │ ├── utils │ │ └── openrouter_api.php │ ├── README.md │ ├── flow.php │ ├── nodes.php │ └── src │ │ └── PocketFlow.php ├── web-search-agent │ ├── .env.example │ ├── .gitignore │ ├── composer.json │ ├── flow.php │ ├── main.php │ ├── utils │ │ ├── openrouter.php │ │ └── brave_search.php │ ├── README.md │ ├── nodes.php │ └── src │ │ └── PocketFlow.php └── text-to-cv-with-frontend │ ├── .env.example │ ├── .gitignore │ ├── main.php │ ├── composer.json │ ├── flow.php │ ├── utils │ ├── pdf_converter.php │ └── llm_api.php │ ├── nodes.php │ ├── api.php │ ├── README.md │ └── src │ └── PocketFlow.php ├── assets └── logo.png ├── .gitignore ├── composer.json ├── LICENSE ├── tests ├── BatchFlowTest.php ├── BatchNodeTest.php ├── AsyncFlowTest.php ├── AsyncBatchFlowTest.php ├── NodeTest.php ├── FlowCompositionTest.php ├── AsyncBatchNodeTest.php └── FlowTest.php ├── README.md ├── src └── PocketFlow.php └── .clinerules /flow.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /main.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nodes.php: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/.env.example: -------------------------------------------------------------------------------- 1 | OPENROUTER_API_KEY="your-api-key" -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .env 3 | composer.lock -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/The-Pocket/PocketFlow-PHP/main/assets/logo.png -------------------------------------------------------------------------------- /examples/web-search-agent/.env.example: -------------------------------------------------------------------------------- 1 | BRAVE_API_KEY=your-brave-search-api-key 2 | OPENROUTER_API_KEY=sk-or-v1-your-key -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | 3 | .env 4 | .env.* 5 | !.env.example 6 | 7 | composer.lock 8 | 9 | .DS_Store 10 | 11 | Thumbs.db -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/.env.example: -------------------------------------------------------------------------------- 1 | OPENROUTER_API_KEY="sk-or-your-key-here" 2 | LLM_NAME="deepseek/deepseek-chat-v3-0324:free" -------------------------------------------------------------------------------- /examples/web-search-agent/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | 3 | .env 4 | .env.* 5 | !.env.example 6 | 7 | composer.lock 8 | 9 | .DS_Store 10 | 11 | Thumbs.db -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | 3 | .env 4 | .env.* 5 | !.env.example 6 | 7 | composer.lock 8 | 9 | .DS_Store 10 | 11 | Thumbs.db -------------------------------------------------------------------------------- /examples/web-search-agent/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weise25/pocketflow-php", 3 | "version": "0.1", 4 | "description": "A PHP port of the minimalist LLM framework PocketFlow.", 5 | "type": "library", 6 | "license": "MIT", 7 | "autoload": { 8 | "psr-4": { 9 | "PocketFlow\\": "src/" 10 | }, 11 | "files": [ 12 | "src/PocketFlow.php" 13 | ] 14 | }, 15 | "require": { 16 | "php": ">=8.3", 17 | "react/async": "^4.0", 18 | "react/promise-timer": "^1.9", 19 | "guzzlehttp/guzzle": "^7.9", 20 | "vlucas/phpdotenv": "^5.6", 21 | "symfony/yaml": "^7.3" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/main.php: -------------------------------------------------------------------------------- 1 | initial_prompt = null; 12 | $shared->cv_plan = null; 13 | $shared->edit_history = []; 14 | $shared->cv_html = null; 15 | $shared->pdf_path = null; 16 | 17 | // Create the flow and run it 18 | $cvFlow = create_cv_crafter_flow(); 19 | $cvFlow->run($shared); 20 | 21 | echo "\nProcess finished. Thank you for using the CV Crafter!\n"; 22 | } 23 | 24 | main(); 25 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weise25/pocketflow-php", 3 | "version": "0.1", 4 | "description": "A PHP port of the minimalist LLM framework PocketFlow.", 5 | "type": "library", 6 | "license": "MIT", 7 | "autoload": { 8 | "psr-4": { 9 | "PocketFlow\\": "src/" 10 | }, 11 | "files": [ 12 | "src/PocketFlow.php" 13 | ] 14 | }, 15 | "autoload-dev": { 16 | "psr-4": { 17 | "PocketFlow\\Tests\\": "tests/" 18 | } 19 | }, 20 | "require": { 21 | "php": ">=8.3", 22 | "react/async": "^4.0", 23 | "react/promise-timer": "^1.9" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^11.1" 27 | } 28 | } -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/main.php: -------------------------------------------------------------------------------- 1 | load(); 9 | 10 | // Interactive query of win Points 11 | $pointsToWin = 0; 12 | while ($pointsToWin < 1 || $pointsToWin > 10) { 13 | $input = readline("How many points to win the game? (1-10): "); 14 | if (is_numeric($input) && $input >= 1 && $input <= 10) { 15 | $pointsToWin = (int)$input; 16 | } else { 17 | echo "Invalid input. Please enter a number between 1 and 10.\n"; 18 | } 19 | } 20 | 21 | // Start the quizshow with the specified number of points 22 | create_quiz_show_flow($pointsToWin); -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weise25/pocketflow-php", 3 | "version": "0.1", 4 | "description": "A PHP port of the minimalist LLM framework PocketFlow.", 5 | "type": "library", 6 | "license": "MIT", 7 | "autoload": { 8 | "psr-4": { 9 | "PocketFlow\\": "src/" 10 | }, 11 | "files": [ 12 | "src/PocketFlow.php" 13 | ] 14 | }, 15 | "require": { 16 | "php": ">=8.3", 17 | "react/async": "^4.0", 18 | "react/promise-timer": "^1.9", 19 | "vlucas/phpdotenv": "^5.6", 20 | "openai-php/client": "^0.14.0", 21 | "guzzlehttp/guzzle": "^7.9" 22 | }, 23 | "config": { 24 | "allow-plugins": { 25 | "php-http/discovery": true 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weise25/pocketflow-php", 3 | "version": "0.1", 4 | "description": "A PHP port of the minimalist LLM framework PocketFlow.", 5 | "type": "library", 6 | "license": "MIT", 7 | "autoload": { 8 | "psr-4": { 9 | "PocketFlow\\": "src/" 10 | }, 11 | "files": [ 12 | "src/PocketFlow.php" 13 | ] 14 | }, 15 | "require": { 16 | "php": ">=8.3", 17 | "react/async": "^4.0", 18 | "react/promise-timer": "^1.9", 19 | "vlucas/phpdotenv": "^5.6", 20 | "dompdf/dompdf": "^3.1", 21 | "openai-php/client": "^0.14.0", 22 | "guzzlehttp/guzzle": "^7.9", 23 | "php-http/guzzle7-adapter": "^1.1", 24 | "symfony/yaml": "^7.3" 25 | }, 26 | "config": { 27 | "allow-plugins": { 28 | "php-http/discovery": true 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/web-search-agent/flow.php: -------------------------------------------------------------------------------- 1 | on('plan_searches')->next($planNode); 18 | $decideNode->on('execute_searches')->next($searchNode); 19 | $decideNode->on('synthesize_report')->next($reportNode); 20 | $decideNode->on('answer_simple')->next($answerNode); 21 | $decideNode->on('error')->next($errorNode); 22 | 23 | // Loop back to the decision node after planning or searching 24 | $planNode->on('continue')->next($decideNode); 25 | $searchNode->on('continue')->next($decideNode); 26 | 27 | return new Flow($decideNode); 28 | } -------------------------------------------------------------------------------- /examples/web-search-agent/main.php: -------------------------------------------------------------------------------- 1 | load(); 12 | 13 | // --- Initialize Shared State --- 14 | $shared = new stdClass(); 15 | $shared->env = $loadedVars; 16 | 17 | // Make shared state globally accessible for utility functions 18 | global $shared; 19 | 20 | // --- Get User Input --- 21 | echo "Please enter your research query: "; 22 | $query = trim(fgets(STDIN)); 23 | $shared->query = $query; 24 | 25 | // --- Create and Run the Flow --- 26 | $agentFlow = create_research_agent_flow(); 27 | $agentFlow->run($shared); 28 | 29 | // --- Display the Final Result --- 30 | echo "\n--- Final Result ---\n"; 31 | if (isset($shared->final_report)) { 32 | echo $shared->final_report; 33 | } elseif (isset($shared->final_answer)) { 34 | echo $shared->final_answer; 35 | } else { 36 | echo "The agent did not produce a final report or answer.\n"; 37 | } 38 | echo "\n"; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Djamal Weise 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/flow.php: -------------------------------------------------------------------------------- 1 | next($createPlan); 19 | $createPlan->next($reviewPlan); 20 | 21 | // Branching based on user feedback 22 | $reviewPlan->on("approved")->next($generateHtml); 23 | $reviewPlan->on("needs_edit")->next($editPlan); 24 | $reviewPlan->next($stopNode); // Explicitly define the default path to stop 25 | 26 | // Loop back after editing 27 | $editPlan->next($reviewPlan); 28 | 29 | // Final sequence 30 | $generateHtml->next($convertToPdf); 31 | 32 | // 3. Create the flow starting with the first node 33 | return new Flow($getInitialPrompt); 34 | } 35 | -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/utils/pdf_converter.php: -------------------------------------------------------------------------------- 1 | set('isHtml5ParserEnabled', true); 18 | $options->set('isRemoteEnabled', true); 19 | 20 | $dompdf = new Dompdf($options); 21 | $dompdf->loadHtml($htmlContent); 22 | $dompdf->setPaper('A4', 'portrait'); 23 | $dompdf->render(); 24 | 25 | $filePath = $outputsDir . '/' . $filename; 26 | file_put_contents($filePath, $dompdf->output()); 27 | 28 | return $filePath; 29 | } 30 | 31 | // To test this utility directly from the command line: 32 | if (basename(__FILE__) === basename($_SERVER['PHP_SELF'])) { 33 | echo "Testing PDF Converter...\n"; 34 | $sampleHtml = '

Hello, World!

This is a test PDF from dompdf.

'; 35 | $outputFile = 'test_cv.pdf'; 36 | try { 37 | $savedPath = convert_html_to_pdf($sampleHtml, $outputFile); 38 | echo "PDF successfully created at: " . $savedPath . "\n"; 39 | } catch (Exception $e) { 40 | echo "Error: " . $e->getMessage() . "\n"; 41 | } 42 | } 43 | 44 | -------------------------------------------------------------------------------- /examples/web-search-agent/utils/openrouter.php: -------------------------------------------------------------------------------- 1 | env['OPENROUTER_API_KEY'] ?? null; 13 | 14 | if (!$apiKey) { 15 | return 'Error: OPENROUTER_API_KEY not found in environment.'; 16 | } 17 | 18 | $client = new Client(); 19 | 20 | try { 21 | $response = $client->post('https://openrouter.ai/api/v1/chat/completions', [ 22 | 'headers' => [ 23 | 'Authorization' => 'Bearer ' . $apiKey, 24 | 'Content-Type' => 'application/json', 25 | 'HTTP-Referer' => 'http://localhost', // Required by OpenRouter 26 | 'X-Title' => 'PocketFlow-PHP', 27 | ], 28 | 'json' => [ 29 | 'model' => $model, 30 | 'messages' => [ 31 | ['role' => 'user', 'content' => $prompt] 32 | ], 33 | ], 34 | ]); 35 | 36 | $body = json_decode((string) $response->getBody(), true); 37 | return $body['choices'][0]['message']['content'] ?? 'Error: Could not extract content from LLM response.'; 38 | } catch (GuzzleException $e) { 39 | error_log("LLM API Error: " . $e->getMessage()); 40 | return "Error communicating with the LLM."; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples/web-search-agent/README.md: -------------------------------------------------------------------------------- 1 | # PocketFlow-PHP Research Agent 2 | 3 | This project is a simple yet powerful research agent built using the PocketFlow-PHP framework. The agent can take a user's query, understand its complexity, perform web searches to gather information, and synthesize a final report based on its findings. 4 | 5 | It is self-contained and does not depend on the parent directory. You can copy this folder anywhere on your system, run `composer install`, and it will work. 6 | 7 | ## Features 8 | 9 | - **Dynamic Task Planning:** The agent analyzes the user's query to decide whether it can answer from its own knowledge or if it needs to perform web research. 10 | - **Strategic Web Search:** For complex queries, the agent creates a multi-step search plan to gather information efficiently. 11 | - **Iterative Research:** The agent executes its search plan, gathering data from the Brave Search API. 12 | - **Report Synthesis:** Once enough information is gathered, the agent synthesizes the search results into a coherent, markdown-formatted report. 13 | - **Resilient:** Includes logic to handle API rate limits gracefully. 14 | 15 | ## Setup 16 | 17 | To get this project running, follow these steps: 18 | 19 | 1. **Install Dependencies:** 20 | If you haven't already, install the required PHP packages using Composer. 21 | ```bash 22 | composer install 23 | ``` 24 | 25 | 2. **Create Environment File:** 26 | Create a `.env` file in the root of the project. This file will hold your secret API keys. You can copy the provided `.env.example` if it exists, or create one from scratch. 27 | 28 | 3. **Add API Keys:** 29 | Open your `.env` file and add your API keys for OpenRouter and Brave Search: 30 | ``` 31 | OPENROUTER_API_KEY="your_openrouter_api_key_here" 32 | BRAVE_API_KEY="your_brave_search_api_key_here" 33 | ``` 34 | 35 | ## Usage 36 | 37 | To run the agent, simply execute the `main.php` script from your terminal: 38 | 39 | ```bash 40 | php main.php 41 | ``` 42 | 43 | The agent will then prompt you to enter your research query. From there, it will show you its decision-making process as it works to answer your query. 44 | 45 | -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/utils/llm_api.php: -------------------------------------------------------------------------------- 1 | load(); 11 | 12 | function call_llm(string $prompt, array $history = []): string 13 | { 14 | $apiKey = $_ENV['OPENROUTER_API_KEY']; 15 | $llmName = $_ENV['LLM_NAME']; 16 | 17 | if (!$apiKey) { 18 | throw new Exception("OPENROUTER_API_KEY is not set in .env file."); 19 | } 20 | 21 | $client = OpenAI::factory() 22 | ->withApiKey($apiKey) 23 | ->withBaseUri('https://openrouter.ai/api/v1') 24 | ->withHttpHeader('HTTP-Referer', 'http://localhost') 25 | ->make(); 26 | 27 | $messages = $history; 28 | $messages[] = ['role' => 'user', 'content' => $prompt]; 29 | 30 | $response = $client->chat()->create([ 31 | 'model' => $llmName, 32 | 'messages' => $messages, 33 | ]); 34 | 35 | return $response->choices[0]->message->content; 36 | } 37 | 38 | function call_llm_stream(string $prompt, array $history = []): iterable 39 | { 40 | $apiKey = $_ENV['OPENROUTER_API_KEY']; 41 | $llmName = $_ENV['LLM_NAME']; 42 | 43 | if (!$apiKey) { 44 | throw new Exception("OPENROUTER_API_KEY is not set in .env file."); 45 | } 46 | 47 | $client = OpenAI::factory() 48 | ->withApiKey($apiKey) 49 | ->withBaseUri('https://openrouter.ai/api/v1') 50 | ->withHttpHeader('HTTP-Referer', 'http://localhost') 51 | ->make(); 52 | 53 | $messages = $history; 54 | $messages[] = ['role' => 'user', 'content' => $prompt]; 55 | 56 | return $client->chat()->createStreamed([ 57 | 'model' => $llmName, 58 | 'messages' => $messages, 59 | ]); 60 | } 61 | 62 | // To test this utility directly from the command line: 63 | if (basename(__FILE__) === basename($_SERVER['PHP_SELF'])) { 64 | try { 65 | echo "Testing LLM API...\n"; 66 | $response = call_llm("What is the capital of France?"); 67 | echo "LLM Response: " . $response . "\n"; 68 | } catch (Exception $e) { 69 | echo "Error: " . $e->getMessage() . "\n"; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/web-search-agent/utils/brave_search.php: -------------------------------------------------------------------------------- 1 | env['BRAVE_API_KEY'] ?? null; 14 | 15 | if (!$apiKey) { 16 | return 'Error: BRAVE_API_KEY not found in environment.'; 17 | } 18 | 19 | $client = new Client(); 20 | $maxRetries = 3; 21 | $retryDelay = 2; // seconds 22 | 23 | for ($attempt = 1; $attempt <= $maxRetries; ++$attempt) { 24 | try { 25 | $response = $client->get('https://api.search.brave.com/res/v1/web/search', [ 26 | 'headers' => [ 27 | 'Accept' => 'application/json', 28 | 'X-Subscription-Token' => $apiKey, 29 | ], 30 | 'query' => [ 31 | 'q' => $query, 32 | ], 33 | ]); 34 | 35 | $body = json_decode((string) $response->getBody(), true); 36 | 37 | $results = []; 38 | if (!empty($body['web']['results'])) { 39 | foreach ($body['web']['results'] as $res) { 40 | $results[] = "[Title: {$res['title']}] [URL: {$res['url']}] [Snippet: {$res['description']}]"; 41 | } 42 | } 43 | return empty($results) ? "No search results found for query: {$query}" : implode("\n", $results); 44 | 45 | } catch (ClientException $e) { 46 | if ($e->getResponse()->getStatusCode() === 429) { 47 | if ($attempt < $maxRetries) { 48 | echo "Rate limit hit. Retrying in {$retryDelay} seconds...\n"; 49 | sleep($retryDelay); 50 | $retryDelay *= 2; // Exponential backoff 51 | } else { 52 | return "Error: Brave Search API rate limit exceeded after multiple retries."; 53 | } 54 | } else { 55 | return "Brave Search API Error: " . $e->getMessage(); 56 | } 57 | } catch (GuzzleException $e) { 58 | return "Brave Search API Error: " . $e->getMessage(); 59 | } 60 | } 61 | 62 | return "Error: Brave Search API request failed after all retries."; 63 | } -------------------------------------------------------------------------------- /tests/BatchFlowTest.php: -------------------------------------------------------------------------------- 1 | input_data = ['a' => 1, 'b' => 2, 'c' => 3]; 22 | $shared->results = []; 23 | 24 | $processItemNode = new class extends Node { 25 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 26 | $key = $this->params['key']; 27 | $shared->results[$key] = $shared->input_data[$key] * 2; 28 | return null; 29 | } 30 | }; 31 | 32 | $subFlow = new Flow($processItemNode); 33 | $batchFlow = new class($subFlow) extends BatchFlow { 34 | public function prep(stdClass $shared): array { 35 | return array_map(fn($k) => ['key' => $k], array_keys($shared->input_data)); 36 | } 37 | }; 38 | 39 | $batchFlow->run($shared); 40 | $this->assertEquals(['a' => 2, 'b' => 4, 'c' => 6], $shared->results); 41 | } 42 | 43 | /** 44 | * Tests that an exception thrown inside a sub-flow correctly propagates up. 45 | */ 46 | public function testErrorHandlingInBatch() 47 | { 48 | $this->expectException(ValueError::class); 49 | $this->expectExceptionMessage("Error processing key: error_key"); 50 | 51 | $shared = new stdClass(); 52 | $shared->input_data = ['ok_key' => 1, 'error_key' => 2]; 53 | 54 | $errorNode = new class extends Node { 55 | public function exec(mixed $p): mixed { 56 | if ($this->params['key'] === 'error_key') { 57 | throw new ValueError("Error processing key: error_key"); 58 | } 59 | return null; 60 | } 61 | }; 62 | 63 | $subFlow = new Flow($errorNode); 64 | $batchFlow = new class($subFlow) extends BatchFlow { 65 | public function prep(stdClass $shared): array { 66 | return array_map(fn($k) => ['key' => $k], array_keys($shared->input_data)); 67 | } 68 | }; 69 | 70 | $batchFlow->run($shared); 71 | } 72 | } -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/utils/openrouter_api.php: -------------------------------------------------------------------------------- 1 | withApiKey($apiKey) 24 | ->withBaseUri('https://openrouter.ai/api/v1') 25 | ->withHttpHeader('HTTP-Referer', 'http://localhost') // Required for OpenRouter 26 | ->withHttpHeader('X-Title', 'PocketFlow-PHP Quiz Show') // Recommended for OpenRouter 27 | ->make(); 28 | 29 | $fullResponse = ''; 30 | // If a callback is provided, we enable streaming. 31 | if (is_callable($streamCallback)) { 32 | $stream = $client->chat()->createStreamed([ 33 | 'model' => $model, 34 | 'messages' => $messages, 35 | ]); 36 | 37 | foreach ($stream as $response) { 38 | $chunk = $response->choices[0]->delta->content; 39 | if ($chunk !== null) { 40 | $fullResponse .= $chunk; 41 | // Call the callback with the new chunk. 42 | $streamCallback($chunk); 43 | } 44 | } 45 | } else { 46 | $response = $client->chat()->create([ 47 | 'model' => $model, 48 | 'messages' => $messages, 49 | ]); 50 | $fullResponse = $response->choices[0]->message->content; 51 | } 52 | return $fullResponse; 53 | 54 | } catch (Exception $e) { 55 | echo "API Error: " . $e->getMessage() . "\n"; 56 | return "I am having trouble connecting to my brain right now."; 57 | } 58 | })(); 59 | } 60 | -------------------------------------------------------------------------------- /tests/BatchNodeTest.php: -------------------------------------------------------------------------------- 1 | input_array ?? [], $this->chunkSize); 21 | } 22 | public function exec(mixed $chunk): int { 23 | return array_sum($chunk); 24 | } 25 | public function post(stdClass $shared, mixed $p, mixed $execResult): ?string { 26 | $shared->chunk_sums = $execResult; 27 | return 'default'; 28 | } 29 | } 30 | 31 | /** 32 | * REDUCE Phase: Aggregates the chunk sums into a final total. 33 | */ 34 | class SumReduceNode extends Node { 35 | public function prep(stdClass $shared): array { 36 | return $shared->chunk_sums ?? []; 37 | } 38 | public function exec(mixed $chunkSums): int { 39 | return array_sum($chunkSums); 40 | } 41 | public function post(stdClass $shared, mixed $p, mixed $execResult): ?string { 42 | $shared->total_sum = $execResult; 43 | return null; 44 | } 45 | } 46 | 47 | class BatchNodeTest extends TestCase 48 | { 49 | private function runMapReducePipeline(array $inputArray, int $chunkSize): stdClass 50 | { 51 | $shared = new stdClass(); 52 | $shared->input_array = $inputArray; 53 | 54 | $mapNode = new ArrayChunkSumNode($chunkSize); 55 | $reduceNode = new SumReduceNode(); 56 | $mapNode->next($reduceNode); 57 | 58 | $pipeline = new Flow($mapNode); 59 | $pipeline->run($shared); 60 | 61 | return $shared; 62 | } 63 | 64 | public function testMapReduceSum() 65 | { 66 | $array = range(0, 99); 67 | $shared = $this->runMapReducePipeline($array, 10); 68 | $this->assertEquals(4950, $shared->total_sum); 69 | } 70 | 71 | public function testUnevenChunks() 72 | { 73 | $array = range(0, 24); 74 | $shared = $this->runMapReducePipeline($array, 10); 75 | $this->assertEquals([45, 145, 110], $shared->chunk_sums); 76 | $this->assertEquals(300, $shared->total_sum); 77 | } 78 | 79 | public function testEmptyArray() 80 | { 81 | $shared = $this->runMapReducePipeline([], 10); 82 | $this->assertEquals(0, $shared->total_sum); 83 | } 84 | } -------------------------------------------------------------------------------- /tests/AsyncFlowTest.php: -------------------------------------------------------------------------------- 1 | execution_order = []; 26 | 27 | // An asynchronous node that simulates fetching data. 28 | $asyncFetcher = new class extends AsyncNode { 29 | public function post_async(stdClass $shared, mixed $p, mixed $e): PromiseInterface { 30 | return async(function() use ($shared) { 31 | await(sleep(0.01)); 32 | $shared->execution_order[] = 'AsyncFetcher'; 33 | $shared->async_data = "Async Data Fetched"; 34 | return 'default'; 35 | })(); 36 | } 37 | }; 38 | // A regular synchronous node that processes the data. 39 | $syncProcessor = new class extends Node { 40 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 41 | $shared->execution_order[] = 'SyncProcessor'; 42 | $shared->final_result = "Processed: " . $shared->async_data; 43 | return null; 44 | } 45 | }; 46 | 47 | $asyncFetcher->next($syncProcessor); 48 | $flow = new AsyncFlow($asyncFetcher); 49 | await($flow->run_async($shared)); 50 | 51 | $this->assertEquals(['AsyncFetcher', 'SyncProcessor'], $shared->execution_order); 52 | $this->assertEquals("Processed: Async Data Fetched", $shared->final_result); 53 | })()); 54 | } 55 | 56 | /** 57 | * Tests that an exception in an AsyncNode correctly propagates up through the AsyncFlow. 58 | */ 59 | public function testAsyncErrorHandlingInFlow() 60 | { 61 | await(async(function() { 62 | $this->expectException(\RuntimeException::class); 63 | $this->expectExceptionMessage("Intentional async failure"); 64 | 65 | $errorNode = new class extends AsyncNode { 66 | public function exec_async(mixed $p): PromiseInterface { 67 | return async(function() { 68 | await(sleep(0.01)); 69 | throw new \RuntimeException("Intentional async failure"); 70 | })(); 71 | } 72 | }; 73 | 74 | $flow = new AsyncFlow($errorNode); 75 | await($flow->run_async(new stdClass())); 76 | })()); 77 | } 78 | } -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/README.md: -------------------------------------------------------------------------------- 1 | # Example: Multi-Agent Quiz Show 2 | 3 | This directory contains a **standalone, fully functional application** built with **PocketFlow-PHP**. It simulates a "Who Wants to be a Millionaire?" style quiz show featuring three distinct AI agents running concurrently. 4 | 5 | This project is self-contained and does not depend on the parent directory. You can copy this folder anywhere on your system, run `composer install`, and it will work. 6 | 7 | ## Overview 8 | 9 | - **Quizmaster:** An AI agent that generates and asks trivia questions, moderates the game, and evaluates the players' answers. 10 | - **Player 1 & Player 2:** Two AI agents that receive questions and compete to answer them correctly based on their assigned LLM models. 11 | - **Communication:** The agents communicate asynchronously using a simple message queue system, allowing them to act independently without blocking each other. 12 | 13 | This example is a powerful showcase of how to orchestrate complex, dynamic interactions between multiple AI agents using the PocketFlow-PHP framework. 14 | 15 | ## Setup & Run 16 | 17 | **Prerequisites:** PHP 8.3+ and [Composer](https://getcomposer.org/) must be installed. 18 | 19 | 1. **Navigate into this directory:** 20 | Make sure your terminal is inside the `quiz-show-multi-agent` folder. 21 | 22 | 2. **Install Dependencies:** 23 | Run Composer to install the required packages for this project (PocketFlow core, OpenAI client, etc.). 24 | ```bash 25 | composer install 26 | ``` 27 | 28 | 3. **Set up API Key:** 29 | This example uses [OpenRouter.ai](https://openrouter.ai/) to access free LLM models. 30 | - Rename the `.env.example` file in this directory to `.env`. 31 | - Paste your OpenRouter API key into the `.env` file. 32 | 33 | 4. **Run the Show!** 34 | Execute the main script to start the quiz show. 35 | ```bash 36 | php main.php 37 | ``` 38 | 39 | You will see the quiz show unfold in your terminal as the agents interact with each other. 40 | 41 | ## Configuration 42 | 43 | ### How to Get an OpenRouter API Key 44 | 45 | 1. Go to **[OpenRouter.ai](https://openrouter.ai/)** and sign up. 46 | 2. Navigate to your account settings/keys page. 47 | 3. Copy your API key and paste it into the `.env` file as the value for `OPENROUTER_API_KEY`. 48 | 49 | ### How to Change the AI Models 50 | 51 | You can easily change the models for each agent. The models are defined in an array at the top of the `flow.php` file. 52 | 53 | **File: `flow.php`** 54 | ```php 55 | // ... 56 | // 2. Define the models for the agents 57 | $models = [ 58 | 'quizmaster' => 'deepseek/deepseek-chat-v3-0324:free', 59 | 'player1' => 'google/gemma-2-27b-it:free', 60 | 'player2' => 'mistralai/mistral-small-3.2-24b-instruct:free', 61 | ]; 62 | // ... 63 | ``` 64 | Simply replace the model strings with any other compatible model available on OpenRouter. 65 | 66 | ## How it Works 67 | 68 | The application logic is split into three main files, following the PocketFlow philosophy: 69 | 70 | - **`main.php`**: The simple entry point that loads the environment and calls the flow creation function. 71 | - **`flow.php`**: Defines the overall game logic. It creates the agents, sets up their communication queues, and orchestrates the concurrent execution of their individual flows. 72 | - **`nodes.php`**: Contains the core logic for each agent (`QuizmasterAgent`, `PlayerAgent`) as `AsyncNode` classes, as well as the `MessageQueue` helper class. 73 | -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/flow.php: -------------------------------------------------------------------------------- 1 | quizmasterQueue = new MessageQueue(); 17 | $shared->player1Queue = new MessageQueue(); 18 | $shared->player2Queue = new MessageQueue(); 19 | $shared->player1_score = 0; 20 | $shared->player2_score = 0; 21 | $shared->question_count = 0; 22 | $shared->game_over = false; 23 | 24 | // 2. Define the models for the agents 25 | $models = [ 26 | 'quizmaster' => 'deepseek/deepseek-chat-v3-0324:free', 27 | 'player1' => 'google/gemma-3-27b-it:free', 28 | 'player2' => 'mistralai/mistral-small-3.2-24b-instruct:free', 29 | ]; 30 | 31 | // 3. Create the agent nodes 32 | $quizmasterNode = new QuizmasterAgent(); 33 | $player1Node = new PlayerAgent(); 34 | $player2Node = new PlayerAgent(); 35 | 36 | // 4. Define the flow paths for each agent 37 | // The 'continue' path makes the agent loop. 38 | $quizmasterNode->on('continue')->next($quizmasterNode); 39 | $player1Node->on('continue')->next($player1Node); 40 | $player2Node->on('continue')->next($player2Node); 41 | 42 | // Explicitly define that 'end_game' terminates the flow without a warning. 43 | // next(null) means: "For this action, there is no successor. End the flow here." 44 | $quizmasterNode->on('end_game')->next(null); 45 | $player1Node->on('end_game')->next(null); 46 | $player2Node->on('end_game')->next(null); 47 | 48 | // 5. Create the flows 49 | $quizmasterFlow = new AsyncFlow($quizmasterNode); 50 | $player1Flow = new AsyncFlow($player1Node); 51 | $player2Flow = new AsyncFlow($player2Node); 52 | 53 | // 6. Set the parameters for each flow 54 | $baseParams = [ 55 | 'points_to_win' => $pointsToWin, 56 | 'shared_state' => $shared, 57 | ]; 58 | $quizmasterFlow->setParams(array_merge($baseParams, [ 59 | 'model' => $models['quizmaster'], 60 | 'player1_model' => $models['player1'], 61 | 'player2_model' => $models['player2'], 62 | ])); 63 | $player1Flow->setParams(array_merge($baseParams, [ 64 | 'model' => $models['player1'], 65 | 'name' => 'Player 1', 66 | 'my_queue' => $shared->player1Queue, 67 | 'personality' => 'Confident and a bit of a show-off' 68 | ])); 69 | $player2Flow->setParams(array_merge($baseParams, [ 70 | 'model' => $models['player2'], 71 | 'name' => 'Player 2', 72 | 'my_queue' => $shared->player2Queue, 73 | 'personality' => 'Humble, thoughtful, and slightly nervous' 74 | ])); 75 | 76 | // 7. Start the game 77 | echo "--- Starting AI Quiz Show! First to {$pointsToWin} points wins! ---\n"; 78 | $shared->quizmasterQueue->put(['type' => 'START_GAME']); 79 | 80 | // 8. Run all flows concurrently 81 | await(\React\Promise\all([ 82 | $quizmasterFlow->run_async($shared), 83 | $player1Flow->run_async($shared), 84 | $player2Flow->run_async($shared), 85 | ])); 86 | 87 | echo "\n--- Quiz Show has finished. ---\n"; 88 | })(); 89 | } -------------------------------------------------------------------------------- /tests/AsyncBatchFlowTest.php: -------------------------------------------------------------------------------- 1 | results = []; 23 | // An array to store the start and end times of each task 24 | $shared->timestamps = []; 25 | 26 | $processNode = new class extends AsyncNode { 27 | public function post_async(stdClass $shared, mixed $p, mixed $e): PromiseInterface { 28 | return async(function() use ($shared) { 29 | $id = $this->params['id']; 30 | 31 | // Record the start time 32 | $shared->timestamps[$id]['start'] = microtime(true); 33 | 34 | await(sleep(0.02)); // Short latency 35 | 36 | // Record the end time 37 | $shared->timestamps[$id]['end'] = microtime(true); 38 | 39 | $shared->results[] = "Processed in parallel: {$id}"; 40 | return null; 41 | })(); 42 | } 43 | }; 44 | 45 | $subFlow = new AsyncFlow($processNode); 46 | $parallelBatchFlow = new class($subFlow) extends AsyncParallelBatchFlow { 47 | public function prep_async(stdClass $shared): PromiseInterface { 48 | return async(fn() => [['id' => 'A'], ['id' => 'B'], ['id' => 'C']])(); 49 | } 50 | }; 51 | 52 | await($parallelBatchFlow->run_async($shared)); 53 | 54 | $this->assertCount(3, $shared->results); 55 | $this->assertCount(3, $shared->timestamps); 56 | 57 | // Logical proof of parallelization: 58 | // The start time of task B must be BEFORE the end time of task A. 59 | // If they were sequential, the start of B would be AFTER the end of A. 60 | $this->assertLessThan( 61 | $shared->timestamps['A']['end'], 62 | $shared->timestamps['B']['start'], 63 | "Task B should have started before Task A finished, proving parallel execution." 64 | ); 65 | $this->assertLessThan( 66 | $shared->timestamps['B']['end'], 67 | $shared->timestamps['C']['start'], 68 | "Task C should have started before Task B finished, proving parallel execution." 69 | ); 70 | })()); 71 | } 72 | 73 | public function testErrorHandlingInParallelBatchFlow() 74 | { 75 | await(async(function() { 76 | $this->expectException(\ValueError::class); 77 | $this->expectExceptionMessage("Async error on B"); 78 | 79 | $shared = new stdClass(); 80 | 81 | $errorNode = new class extends AsyncNode { 82 | public function exec_async(mixed $p): PromiseInterface { 83 | return async(function() { 84 | if ($this->params['id'] === 'B') { 85 | throw new \ValueError("Async error on B"); 86 | } 87 | return null; 88 | })(); 89 | } 90 | }; 91 | 92 | $subFlow = new AsyncFlow($errorNode); 93 | $parallelBatchFlow = new class($subFlow) extends AsyncParallelBatchFlow { 94 | public function prep_async(stdClass $shared): PromiseInterface { 95 | return async(fn() => [['id' => 'A'], ['id' => 'B']])(); 96 | } 97 | }; 98 | 99 | await($parallelBatchFlow->run_async($shared)); 100 | })()); 101 | } 102 | } -------------------------------------------------------------------------------- /tests/NodeTest.php: -------------------------------------------------------------------------------- 1 | exec -> post lifecycle correctly. 15 | */ 16 | public function testNodeExecutesSuccessfully() 17 | { 18 | $shared = new stdClass(); 19 | $shared->result = null; 20 | 21 | $node = new class extends Node { 22 | public function exec(mixed $prepResult): string { 23 | return "success"; 24 | } 25 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string { 26 | $shared->result = $execResult; 27 | return null; 28 | } 29 | }; 30 | 31 | $node->run($shared); 32 | $this->assertEquals("success", $shared->result); 33 | } 34 | 35 | /** 36 | * Tests that the retry mechanism correctly re-executes the exec() method upon failure. 37 | */ 38 | public function testNodeRetriesOnFailureAndSucceeds() 39 | { 40 | $shared = new stdClass(); 41 | $shared->result = null; 42 | $shared->attempts = 0; 43 | 44 | $node = new class(maxRetries: 3) extends Node { 45 | public function exec(mixed $prepResult): string { 46 | // NOTE: This test intentionally mutates state passed from prep() inside exec() 47 | // to isolate and verify the retry mechanism without needing a full Flow. 48 | // This is a violation of the "pure exec" rule for the sake of a focused unit test. 49 | $prepResult->attempts++; 50 | if ($prepResult->attempts < 3) { 51 | throw new \Exception("Failed attempt"); 52 | } 53 | return "success on attempt 3"; 54 | } 55 | public function prep(stdClass $shared): stdClass { 56 | return $shared; 57 | } 58 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string { 59 | $shared->result = $execResult; 60 | return null; 61 | } 62 | }; 63 | 64 | $node->run($shared); 65 | $this->assertEquals(3, $shared->attempts); 66 | $this->assertEquals("success on attempt 3", $shared->result); 67 | } 68 | 69 | /** 70 | * Tests that the execFallback() method is called after all retries have been exhausted. 71 | */ 72 | public function testNodeUsesFallbackAfterAllRetriesFail() 73 | { 74 | $shared = new stdClass(); 75 | $shared->result = null; 76 | 77 | $node = new class(maxRetries: 2) extends Node { 78 | public function exec(mixed $prepResult): string { 79 | throw new \Exception("Always fails"); 80 | } 81 | public function execFallback(mixed $prepResult, Throwable $e): mixed { 82 | return "fallback result"; 83 | } 84 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string { 85 | $shared->result = $execResult; 86 | return null; 87 | } 88 | }; 89 | 90 | $node->run($shared); 91 | $this->assertEquals("fallback result", $shared->result); 92 | } 93 | 94 | /** 95 | * Tests that an exception thrown from within execFallback() propagates up correctly. 96 | */ 97 | public function testNodeThrowsExceptionIfFallbackThrows() 98 | { 99 | $this->expectException(\RuntimeException::class); 100 | $this->expectExceptionMessage("All retries failed and fallback also failed"); 101 | 102 | $node = new class(maxRetries: 2) extends Node { 103 | public function exec(mixed $prepResult): string { 104 | throw new \Exception("Always fails"); 105 | } 106 | public function execFallback(mixed $prepResult, Throwable $e): mixed { 107 | throw new \RuntimeException("All retries failed and fallback also failed", 0, $e); 108 | } 109 | }; 110 | 111 | $node->run(new stdClass()); 112 | } 113 | } -------------------------------------------------------------------------------- /tests/FlowCompositionTest.php: -------------------------------------------------------------------------------- 1 | current = $this->number; 17 | return null; 18 | } 19 | } 20 | class AddNode extends Node { 21 | public function __construct(private int $number) { parent::__construct(); } 22 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 23 | $shared->current += $this->number; 24 | return null; 25 | } 26 | } 27 | class MultiplyNode extends Node { 28 | public function __construct(private int $number) { parent::__construct(); } 29 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 30 | $shared->current *= $this->number; 31 | return null; 32 | } 33 | } 34 | // A node that returns a specific action to test branching from a sub-flow. 35 | class SignalNode extends Node { 36 | public function __construct(private string $signal = "default_signal") { parent::__construct(); } 37 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 38 | $shared->last_signal_emitted = $this->signal; 39 | return $this->signal; 40 | } 41 | } 42 | // A node that stores which path was taken in the main flow. 43 | class PathNode extends Node { 44 | public function __construct(private string $path_id) { parent::__construct(); } 45 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 46 | $shared->path_taken = $this->path_id; 47 | return null; 48 | } 49 | } 50 | 51 | class FlowCompositionTest extends TestCase 52 | { 53 | /** 54 | * Tests that a Flow can be used as a single node within another Flow. 55 | */ 56 | public function testFlowAsNode() 57 | { 58 | $shared = new stdClass(); 59 | $shared->current = 0; 60 | 61 | $startNode = new NumberNode(5); 62 | $startNode->next(new AddNode(10))->next(new MultiplyNode(2)); 63 | $innerFlow = new Flow($startNode); 64 | $outerFlow = new Flow($innerFlow); 65 | 66 | $outerFlow->run($shared); 67 | $this->assertEquals(30, $shared->current); 68 | } 69 | 70 | /** 71 | * Tests that two separate flows can be chained together sequentially. 72 | */ 73 | public function testChainingTwoFlows() 74 | { 75 | $shared = new stdClass(); 76 | $shared->current = 0; 77 | 78 | // Flow 1: 10 + 10 = 20 79 | $flow1_start = new NumberNode(10); 80 | $flow1_start->next(new AddNode(10)); 81 | $flow1 = new Flow($flow1_start); 82 | 83 | // Flow 2: result * 2 84 | $flow2 = new Flow(new MultiplyNode(2)); 85 | 86 | // Chain the flows together. 87 | $flow1->next($flow2); 88 | $mainFlow = new Flow($flow1); 89 | 90 | $mainFlow->run($shared); 91 | $this->assertEquals(40, $shared->current); // (10 + 10) * 2 92 | } 93 | 94 | /** 95 | * Tests that an outer flow can branch based on the action returned by an inner flow. 96 | */ 97 | public function testCompositionWithActionPropagation() 98 | { 99 | $shared = new stdClass(); 100 | $shared->current = 0; 101 | 102 | // 1. Inner flow that ends with a SignalNode returning "inner_done". 103 | $inner_start_node = new NumberNode(100); 104 | $inner_end_node = new SignalNode("inner_done"); 105 | $inner_start_node->next($inner_end_node); 106 | $innerFlow = new Flow($inner_start_node); 107 | 108 | // 2. Target nodes for the outer flow's branches. 109 | $path_a_node = new PathNode("A"); 110 | $path_b_node = new PathNode("B"); 111 | 112 | // 3. Define the outer flow, starting with the inner flow. 113 | $outerFlow = new Flow($innerFlow); 114 | $innerFlow->on("inner_done")->next($path_b_node); 115 | $innerFlow->on("other_action")->next($path_a_node); 116 | 117 | $last_action_outer = $outerFlow->run($shared); 118 | 119 | $this->assertEquals(100, $shared->current); 120 | $this->assertEquals("inner_done", $shared->last_signal_emitted); 121 | $this->assertEquals("B", $shared->path_taken); 122 | $this->assertNull($last_action_outer); 123 | } 124 | } -------------------------------------------------------------------------------- /tests/AsyncBatchNodeTest.php: -------------------------------------------------------------------------------- 1 | items = [1, 2, 3]; 25 | 26 | $node = new class extends AsyncBatchNode { 27 | public function prep_async(stdClass $shared): PromiseInterface { 28 | return async(fn() => $shared->items)(); 29 | } 30 | public function exec_async(mixed $item): PromiseInterface { 31 | return async(function() use ($item) { 32 | await(sleep(0.01)); 33 | return $item * 2; 34 | })(); 35 | } 36 | public function post_async(stdClass $shared, mixed $p, mixed $execResult): PromiseInterface { 37 | return async(function() use ($shared, $execResult) { 38 | $shared->results = $execResult; 39 | return null; 40 | })(); 41 | } 42 | }; 43 | 44 | $start_time = microtime(true); 45 | await($node->run_async($shared)); 46 | $total_time = microtime(true) - $start_time; 47 | 48 | $this->assertEquals([2, 4, 6], $shared->results); 49 | // Total time should be roughly the sum of individual latencies (3 * 0.01s). 50 | $this->assertGreaterThan(0.03, $total_time); 51 | })()); 52 | } 53 | 54 | /** 55 | * Tests that AsyncParallelBatchNode processes items concurrently (at the same time). 56 | */ 57 | public function testAsyncParallelBatchNodeProcessesItemsConcurrently() 58 | { 59 | await(async(function() { 60 | $shared = new stdClass(); 61 | $shared->items = [1, 2, 3]; 62 | 63 | $node = new class extends AsyncParallelBatchNode { 64 | public function prep_async(stdClass $shared): PromiseInterface { 65 | return async(fn() => $shared->items)(); 66 | } 67 | public function exec_async(mixed $item): PromiseInterface { 68 | return async(function() use ($item) { 69 | await(sleep(0.02)); 70 | return $item * 2; 71 | })(); 72 | } 73 | public function post_async(stdClass $shared, mixed $p, mixed $execResult): PromiseInterface { 74 | return async(function() use ($shared, $execResult) { 75 | $shared->results = $execResult; 76 | return null; 77 | })(); 78 | } 79 | }; 80 | 81 | $start_time = microtime(true); 82 | await($node->run_async($shared)); 83 | $total_time = microtime(true) - $start_time; 84 | 85 | sort($shared->results); 86 | $this->assertEquals([2, 4, 6], $shared->results); 87 | // Total time should be slightly more than the longest single task, not the sum. 88 | $this->assertLessThan(0.04, $total_time); 89 | })()); 90 | } 91 | 92 | /** 93 | * Tests that an error in one of the parallel tasks correctly bubbles up. 94 | */ 95 | public function testErrorHandlingInParallelBatch() 96 | { 97 | await(async(function() { 98 | $this->expectException(\ValueError::class); 99 | $this->expectExceptionMessage("Error on item 2"); 100 | 101 | $shared = new stdClass(); 102 | $shared->items = [1, 2, 3]; 103 | 104 | $errorNode = new class extends AsyncParallelBatchNode { 105 | public function prep_async(stdClass $shared): PromiseInterface { 106 | return async(fn() => $shared->items)(); 107 | } 108 | public function exec_async(mixed $item): PromiseInterface { 109 | return async(function() use ($item) { 110 | if ($item === 2) { 111 | throw new \ValueError("Error on item 2"); 112 | } 113 | await(sleep(0.01)); 114 | return $item; 115 | })(); 116 | } 117 | }; 118 | 119 | await($errorNode->run_async($shared)); 120 | })()); 121 | } 122 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PocketFlow-PHP 2 |

3 | PocketFlow-PHP Logo 4 |

5 |

6 | A minimalist LLM framework for PHP, inspired by the 100-line Python original]. 7 |
8 | Build complex Agents, Workflows, RAG systems and more, with a tiny, powerful core. 9 |

10 | 11 |

12 | License: MIT 13 | PHP 8.3+ 14 | Async with ReactPHP 15 |

16 | 17 | --- 18 | 19 | **PocketFlow-PHP** is a port of the amazing 100-line Python LLM framework from [Zachary](https://github.com/The-Pocket/PocketFlow), bringing its core principles to the PHP ecosystem. It's designed for developers (and AI Agents!) who want maximum control and flexibility without the bloat of larger frameworks. It is optimized for **Agentic Coding**, where you design the system and an AI agent writes the code. 20 | 21 | - **Lightweight**: The entire framework core is in a single, well-tested file. 22 | - **Expressive**: Build everything you love from larger frameworks—Multi-Agent systems, RAG pipelines, and complex workflows—using simple, composable building blocks. 23 | - **Agentic-Coding Ready**: The structure is so intuitive that AI agents can assist in building complex LLM applications, following a clear set of rules defined in files like `GEMINI.md`, `.cursorrules`, etc. 24 | 25 | ## How does it work? 26 | 27 | Just like in the original, the core abstraction is a **Graph + Shared Store**. 28 | 29 | 1. **Node**: The smallest unit of work (e.g., call an LLM, read a file). 30 | 2. **Flow**: Connects Nodes into a graph. Transitions are determined by simple string "Actions". 31 | 3. **Shared Store**: A simple `stdClass` object passed through the entire flow, allowing nodes to communicate and share state. 32 | 33 | This simple model is all you need to create powerful design patterns. 34 | 35 | ## Getting Started in 60 Seconds 36 | 37 | Get up and running with your first AI-powered application. 38 | 39 | **Prerequisites:** PHP 8.3+ and [Composer](https://getcomposer.org/) must be installed. 40 | 41 | 1. **Clone the repository:** 42 | ```bash 43 | git clone https://github.com/weise25/PocketFlow-PHP.git 44 | cd PocketFlow-PHP 45 | ``` 46 | 47 | 2. **Install dependencies:** 48 | ```bash 49 | composer install 50 | ``` 51 | 52 | 3. **Set up your environment:** 53 | - Create a `.env` file in the root of the project. 54 | - Add your API keys to the `.env` file (e.g., `OPENROUTER_API_KEY=...`). 55 | 56 | You are now ready to start building with an AI agent! 57 | 58 | ## Agentic Coding with PocketFlow-PHP 59 | 60 | This project is designed to be used with an AI coding assistant like Claude Code, Cursor, Cline, etc. The AI will write the code based on your high-level instructions. 61 | 62 | ### 1. Verify the AI's Context 63 | 64 | Open the project in your AI-powered editor or terminal. To ensure the agent understands the framework's rules, send it a simple test prompt: 65 | 66 | > **Your Prompt:** "Help me explain briefly what PocketFlow-PHP is." 67 | 68 | The agent should respond with a summary based on the project's documentation. This confirms it has the correct context. 69 | 70 | ### 2. Describe Your Goal 71 | 72 | Now, give the agent a high-level description of what you want to build. You design, the agent codes. 73 | 74 | > **Example Prompt for our Quiz Show:** 75 | >"I have an idea for a fun CLI app: a 'Who Wants to be a Millionaire?' style quiz show with AI agents. We need a Quizmaster agent to host the show and ask trivia questions. Then, we need two Player agents to compete against each other by answering the questions. The Quizmaster should evaluate the answers, keep score, and end the game after a few rounds. Please use the OpenRouter API with free models for all three agents." 76 | 77 | ### 3. Let the Agent Work 78 | 79 | The AI agent will now: 80 | 1. Propose a plan. 81 | 2. Ask for your confirmation. 82 | 3. Generate the code in the correct files (`nodes.php`, `flow.php`, `main.php`, and `utils/`). 83 | 84 | Your job is to guide, confirm, and test the final result. 85 | 86 | ### See it in Action 87 | 88 | Check out the completed **[Multi-Agent Quiz Show](https://github.com/weise25/PocketFlow-PHP/tree/main/examples/quiz-show-multi-agent)** in the `/examples` to see a finished project and test it for yourself. 89 | 90 | ## Why This Approach? 91 | 92 | By providing a clear structure and a strict set of rules (e.g., in `GEMINI.md`, `CLAUDE.md`, `.cursorrules`), we minimize common AI errors and allow the agent to focus on what it does best: translating logic into code. This dramatically speeds up development and lets you build complex systems with simple, natural language prompts. 93 | 94 |

95 | Ready to build? Open your AI assistant and start creating. 96 |

97 | -------------------------------------------------------------------------------- /tests/FlowTest.php: -------------------------------------------------------------------------------- 1 | execution_order = []; 20 | 21 | $nodeA = new class extends Node { 22 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 23 | $shared->execution_order[] = 'A'; 24 | return 'default'; 25 | } 26 | }; 27 | $nodeB = new class extends Node { 28 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 29 | $shared->execution_order[] = 'B'; 30 | return null; 31 | } 32 | }; 33 | 34 | $nodeA->next($nodeB); 35 | $flow = new Flow($nodeA); 36 | $flow->run($shared); 37 | 38 | $this->assertEquals(['A', 'B'], $shared->execution_order); 39 | } 40 | 41 | /** 42 | * Tests that a flow with conditional branches follows the correct path based on the returned action. 43 | */ 44 | public function testBranchingFlowSelectsCorrectPath() 45 | { 46 | $shared = new stdClass(); 47 | $shared->execution_order = []; 48 | 49 | $decideNode = new class extends Node { 50 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 51 | $shared->execution_order[] = 'decide'; 52 | return 'path_b'; // Explicitly choose path B 53 | } 54 | }; 55 | $nodeA = new class extends Node { 56 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 57 | $shared->execution_order[] = 'A'; 58 | return null; 59 | } 60 | }; 61 | $nodeB = new class extends Node { 62 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 63 | $shared->execution_order[] = 'B'; 64 | return null; 65 | } 66 | }; 67 | 68 | $decideNode->on('path_a')->next($nodeA); 69 | $decideNode->on('path_b')->next($nodeB); 70 | 71 | $flow = new Flow($decideNode); 72 | $flow->run($shared); 73 | 74 | $this->assertEquals(['decide', 'B'], $shared->execution_order); 75 | $this->assertNotContains('A', $shared->execution_order); 76 | } 77 | 78 | /** 79 | * Tests that a flow correctly terminates when a node returns an action with no defined successor. 80 | */ 81 | public function testFlowEndsWhenActionHasNoSuccessor() 82 | { 83 | $shared = new stdClass(); 84 | $shared->execution_order = []; 85 | 86 | $nodeA = new class extends Node { 87 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 88 | $shared->execution_order[] = 'A'; 89 | return 'unknown_action'; // This action has no successor 90 | } 91 | }; 92 | $nodeB = new class extends Node { 93 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 94 | $shared->execution_order[] = 'B'; 95 | return null; 96 | } 97 | }; 98 | 99 | $nodeA->next($nodeB); // Only for 'default' action 100 | $flow = new Flow($nodeA); 101 | 102 | // Suppress the expected E_USER_WARNING from trigger_error. 103 | @$flow->run($shared); 104 | 105 | // The flow should stop after Node A. 106 | $this->assertEquals(['A'], $shared->execution_order); 107 | } 108 | 109 | /** 110 | * Tests a cyclic flow structure to ensure it loops correctly and terminates on a condition. 111 | */ 112 | public function testCyclicFlowExecutesUntilConditionIsMet() 113 | { 114 | $shared = new stdClass(); 115 | $shared->current_value = 10; 116 | 117 | $checkNode = new class extends Node { 118 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 119 | return $shared->current_value > 0 ? 'is_positive' : 'is_negative_or_zero'; 120 | } 121 | }; 122 | $subtractNode = new class extends Node { 123 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 124 | $shared->current_value -= 3; 125 | return null; 126 | } 127 | }; 128 | $endNode = new class extends Node { 129 | public function post(stdClass $shared, mixed $p, mixed $e): ?string { 130 | $shared->final_signal = "cycle_done"; 131 | return null; 132 | } 133 | }; 134 | 135 | // Flow definition: 136 | // check -> (if positive) -> subtract -> check (loop) 137 | // check -> (if negative or zero) -> end 138 | $checkNode->on('is_positive')->next($subtractNode); 139 | $checkNode->on('is_negative_or_zero')->next($endNode); 140 | $subtractNode->next($checkNode); // The loop connection 141 | 142 | $flow = new Flow($checkNode); 143 | $flow->run($shared); 144 | 145 | // Expected sequence: 10 -> 7 -> 4 -> 1 -> -2 (stops) 146 | $this->assertEquals(-2, $shared->current_value); 147 | $this->assertEquals("cycle_done", $shared->final_signal); 148 | } 149 | } -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/nodes.php: -------------------------------------------------------------------------------- 1 | initial_prompt ?? null; 13 | } 14 | 15 | public function exec(mixed $prompt): ?string { 16 | if (empty($prompt)) { 17 | // This will stop the flow if no prompt is provided, preventing errors. 18 | return null; 19 | } 20 | return $prompt; 21 | } 22 | 23 | public function post(stdClass $shared, mixed $p, mixed $execResult): ?string { 24 | if ($execResult === null) { 25 | return 'stop'; // Stop if exec returned null 26 | } 27 | $shared->initial_prompt = $execResult; 28 | return "default"; 29 | } 30 | } 31 | 32 | class CreatePlanNode extends Node { 33 | public function prep(stdClass $shared): mixed { 34 | return $shared->initial_prompt; 35 | } 36 | 37 | public function exec(mixed $prompt): string { 38 | $system_prompt = "You are a helpful assistant. Based on the user's request, create a structured plan for a CV. The plan should be in YAML format. For example:\nsections:\n - personal_details:\n name: John Doe\n email: john.doe@example.com\n - summary: A brief professional summary.\n - experience:\n - position: Senior Developer\n company: Tech Inc.\n years: 2020-2024\n description: Description of responsibilities.\n - education:\n - degree: BSc Computer Science\n university: University of Example\n years: 2016-2020\n"; 39 | return call_llm($prompt, [['role' => 'system', 'content' => $system_prompt]]); 40 | } 41 | 42 | public function post(stdClass $shared, mixed $p, mixed $execResult): ?string { 43 | $shared->cv_plan = $execResult; 44 | return "default"; 45 | } 46 | } 47 | 48 | // This node now reads the decision from the shared state, set by the API. 49 | class ReviewPlanNode extends Node { 50 | public function prep(stdClass $shared): mixed { 51 | return $shared->user_decision ?? null; 52 | } 53 | 54 | public function exec(mixed $decision): ?string { 55 | if ($decision === 'approve') { 56 | return "approved"; 57 | } 58 | if ($decision === 'edit') { 59 | return "needs_edit"; 60 | } 61 | // If no decision is set, we stop here and wait for the user. 62 | return null; 63 | } 64 | 65 | public function post(stdClass $shared, mixed $p, mixed $execResult): ?string { 66 | return $execResult; 67 | } 68 | } 69 | 70 | // This node gets the edit prompt from the shared state. 71 | class EditPlanNode extends Node { 72 | public function prep(stdClass $shared): mixed { 73 | return [ 74 | 'plan' => $shared->cv_plan ?? null, 75 | 'edit_prompt' => $shared->edit_prompt ?? null 76 | ]; 77 | } 78 | 79 | public function exec(mixed $p): ?string { 80 | if (empty($p['plan']) || empty($p['edit_prompt'])) { 81 | return null; // Not enough info to edit 82 | } 83 | 84 | $system_prompt = "You are a helpful assistant. The user wants to edit a CV plan. Based on the user's edit prompt, update the following YAML plan. Output only the updated YAML plan."; 85 | 86 | return call_llm($p['edit_prompt'], [ 87 | ['role' => 'system', 'content' => $system_prompt], 88 | ['role' => 'user', 'content' => "Here is the current plan to be edited:\n" . $p['plan']] 89 | ]); 90 | } 91 | 92 | public function post(stdClass $shared, mixed $p, mixed $execResult): ?string { 93 | if ($execResult === null) { 94 | return 'stop'; 95 | } 96 | $shared->cv_plan = $execResult; 97 | if (!isset($shared->edit_history)) { 98 | $shared->edit_history = []; 99 | } 100 | $shared->edit_history[] = $p['edit_prompt']; 101 | return "default"; 102 | } 103 | } 104 | 105 | class GenerateHtmlNode extends Node { 106 | public function prep(stdClass $shared): mixed { 107 | return $shared->cv_plan; 108 | } 109 | 110 | public function exec(mixed $p): string { 111 | $prompt = "Based on the following YAML plan, generate a complete, modern, and well-styled HTML document for a CV. Use inline CSS for styling. The HTML should be self-contained and ready to be converted to a PDF. Crucially, the entire CV must fit on a single A4 page. Use compact styling, smaller font sizes (e.g., 10pt-11pt), and potentially a two-column layout to ensure all content is visible on one page without scrolling.\n\n" . $p; 112 | return call_llm($prompt); 113 | } 114 | 115 | public function post(stdClass $shared, mixed $p, mixed $execResult): ?string { 116 | preg_match('//s', $execResult, $matches); 117 | $shared->cv_html = $matches[0] ?? $execResult; 118 | return "default"; 119 | } 120 | } 121 | 122 | class ConvertToPdfNode extends Node { 123 | public function prep(stdClass $shared): mixed { 124 | return $shared->cv_html; 125 | } 126 | 127 | public function exec(mixed $p): string { 128 | error_log("Agent Step: Converting HTML to PDF..."); 129 | $filename = 'cv_' . time() . '.pdf'; 130 | return convert_html_to_pdf($p, $filename); 131 | } 132 | 133 | public function post(stdClass $shared, mixed $p, mixed $execResult): ?string { 134 | $shared->pdf_path = $execResult; 135 | return null; // End of the flow 136 | } 137 | } 138 | 139 | // A dummy node that does nothing, used to explicitly stop a flow path. 140 | class StopNode extends Node { 141 | public function exec(mixed $p): mixed { 142 | // This node does nothing. It's a designated end point. 143 | return null; 144 | } 145 | } -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/api.php: -------------------------------------------------------------------------------- 1 | 0) { 22 | ob_flush(); 23 | } 24 | flush(); 25 | } 26 | 27 | $method = $_SERVER['REQUEST_METHOD']; 28 | 29 | // POST requests are used to set up the state for an action 30 | if ($method === 'POST') { 31 | header('Content-Type: application/json'); 32 | $request_body = json_decode(file_get_contents('php://input'), true); 33 | $action = $request_body['action'] ?? null; 34 | 35 | if (!$action) { 36 | echo json_encode(['success' => false, 'message' => 'No action provided in POST']); 37 | exit(); 38 | } 39 | 40 | switch ($action) { 41 | case 'start': 42 | $_SESSION['shared'] = new stdClass(); // Reset 43 | $_SESSION['shared']->prompt = $request_body['prompt']; 44 | $_SESSION['stream_action'] = 'stream_full_plan'; 45 | break; 46 | case 'submit_edit': 47 | $_SESSION['shared']->edit_prompt = $request_body['edit_prompt']; 48 | $_SESSION['stream_action'] = 'stream_edit'; 49 | break; 50 | case 'approve': 51 | $_SESSION['stream_action'] = 'generate_pdf'; 52 | break; 53 | case 'restart': 54 | session_destroy(); 55 | break; 56 | } 57 | 58 | echo json_encode(['success' => true, 'message' => 'State prepared for streaming.']); 59 | exit(); 60 | } 61 | 62 | // GET requests are used by EventSource to initiate the streaming 63 | if ($method === 'GET') { 64 | header('Content-Type: text/event-stream'); 65 | header('Cache-Control: no-cache'); 66 | header('Connection: keep-alive'); 67 | 68 | $stream_action = $_SESSION['stream_action'] ?? null; 69 | 70 | if (!$stream_action) { 71 | send_sse('error', ['message' => 'No streaming action pending.']); 72 | exit(); 73 | } 74 | 75 | unset($_SESSION['stream_action']); 76 | 77 | try { 78 | $full_response = ''; 79 | $stream_handler = function ($stream) use (&$full_response) { 80 | foreach ($stream as $response) { 81 | $chunk = $response->choices[0]->delta->content; 82 | if ($chunk !== null) { 83 | $full_response .= $chunk; 84 | send_sse('plan_chunk', ['chunk' => $chunk]); 85 | } 86 | } 87 | }; 88 | 89 | switch ($stream_action) { 90 | case 'stream_full_plan': 91 | $prompt = $_SESSION['shared']->prompt; 92 | $system_prompt = <<<'PROMPT' 93 | You are a helpful assistant that thinks step-by-step. Your task is to create a plan for a CV based on the user's request. Structure your response as a single YAML document with two top-level keys: `agent_plan` and `cv`. 94 | 95 | **IMPORTANT RULES:** 96 | 1. **agent_plan**: Use the ReAct framework (Observation, Thought, Action). 97 | 2. **cv**: Create the detailed, structured data for the CV. 98 | 3. **Quoting**: If any text value contains a colon (:), you **MUST** wrap that entire value in double quotes ("). 99 | 100 | Example: 101 | ```yaml 102 | agent_plan: 103 | observation: "User wants a CV for a senior software engineer. Requirement: a modern, compact design." 104 | thought: I will structure the CV with a header, summary, experience, skills, and education. The key is to highlight achievements and use a clean layout. 105 | action: Generate the structured CV plan. 106 | cv: 107 | header: 108 | name: John Doe 109 | # ... rest of the cv data ... 110 | ``` 111 | PROMPT; 112 | $history = [['role' => 'system', 'content' => $system_prompt]]; 113 | $stream_handler(call_llm_stream($prompt, $history)); 114 | break; 115 | 116 | case 'stream_edit': 117 | $prompt = $_SESSION['shared']->edit_prompt; 118 | $edit_system_prompt = "You are a helpful assistant. The user wants to edit a CV plan. Based on the user's edit prompt, you MUST re-generate and output the ENTIRE, complete, and valid YAML document with both the `agent_plan` and `cv` sections, incorporating the requested changes. Do not only output the changed parts or a confirmation message."; 119 | $history = [ 120 | ['role' => 'system', 'content' => $edit_system_prompt], 121 | ['role' => 'user', 'content' => "Here is the current plan to be edited:\n\n" . Yaml::dump($_SESSION['shared']->cv_plan)] 122 | ]; 123 | $stream_handler(call_llm_stream($prompt, $history)); 124 | break; 125 | 126 | case 'generate_pdf': 127 | require_once __DIR__ . '/nodes.php'; 128 | send_sse('thought_chunk', ['chunk' => 'Okay, the plan is approved. I will now generate the final HTML based on the provided structure.']); 129 | sleep(1); 130 | 131 | $generateHtmlNode = new GenerateHtmlNode(); 132 | $cv_yaml = Yaml::dump($_SESSION['shared']->cv_plan['cv']); 133 | $_SESSION['shared']->cv_html = $generateHtmlNode->exec($cv_yaml); 134 | 135 | send_sse('thought_chunk', ['chunk' => '\nConverting the HTML to a PDF document...']); 136 | $convertToPdfNode = new ConvertToPdfNode(); 137 | $_SESSION['shared']->pdf_path = $convertToPdfNode->exec($_SESSION['shared']->cv_html); 138 | sleep(1); 139 | 140 | send_sse('finished', ['pdf_url' => 'outputs/' . basename($_SESSION['shared']->pdf_path)]); 141 | session_destroy(); 142 | exit(); 143 | } 144 | 145 | // Universal YAML parsing logic for streamed responses 146 | // First, try to extract YAML from markdown code blocks 147 | if (preg_match('/```yaml\s*\n(.*?)\n```/s', $full_response, $matches)) { 148 | $yamlToParse = trim($matches[1]); 149 | } else { 150 | // Fallback: remove any markdown code block markers 151 | $yamlToParse = preg_replace('/^```yaml\s*|\s*```$/m', '', $full_response); 152 | $yamlToParse = trim($yamlToParse); 153 | } 154 | 155 | // Debug: Log the raw response for troubleshooting 156 | error_log("Raw LLM Response Length: " . strlen($full_response)); 157 | error_log("YAML to Parse Length: " . strlen($yamlToParse)); 158 | error_log("First 500 chars of YAML: " . substr($yamlToParse, 0, 500)); 159 | 160 | try { 161 | $parsed_yaml = Yaml::parse($yamlToParse); 162 | error_log("YAML parsed successfully"); 163 | } catch (ParseException $e) { 164 | error_log("YAML Parse Error: " . $e->getMessage() . " at line " . $e->getParsedLine()); 165 | 166 | $lines = explode("\n", $yamlToParse); 167 | $errorLine = $e->getParsedLine(); 168 | if ($errorLine > 0 && isset($lines[$errorLine - 1])) { 169 | $line_content = $lines[$errorLine - 1]; 170 | error_log("Problematic line: " . $line_content); 171 | if (preg_match('/^(\s*[^:]+):\s+(.*)$/', $line_content, $matches)) { 172 | $lines[$errorLine - 1] = $matches[1] . ': "' . trim($matches[2]) . '"'; 173 | $fixed_yaml = implode("\n", $lines); 174 | $parsed_yaml = Yaml::parse($fixed_yaml); // Retry parsing 175 | error_log("YAML fixed and parsed successfully"); 176 | } else { 177 | error_log("Could not fix YAML automatically"); 178 | throw $e; // Re-throw if our fix fails 179 | } 180 | } else { 181 | error_log("Error line not available for fixing"); 182 | throw $e; // Re-throw if line is not available 183 | } 184 | } 185 | 186 | // Validate that we have the required structure 187 | if (!isset($parsed_yaml['agent_plan']) || !isset($parsed_yaml['cv'])) { 188 | error_log("Missing required YAML structure. Keys present: " . implode(', ', array_keys($parsed_yaml))); 189 | throw new Exception("Invalid YAML structure: missing 'agent_plan' or 'cv' sections"); 190 | } 191 | 192 | $_SESSION['shared']->cv_plan = $parsed_yaml; 193 | error_log("Sending plan_finished event"); 194 | send_sse('plan_finished', ['plan' => $parsed_yaml]); 195 | 196 | } catch (Exception $e) { 197 | send_sse('error', ['message' => $e->getMessage(), 'trace' => $e->getTraceAsString()]); 198 | } 199 | exit(); 200 | } 201 | -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/README.md: -------------------------------------------------------------------------------- 1 | # Example: Text-to-Resume Agent with Frontend 2 | 3 | This directory contains a **standalone, fully functional web application** built with **PocketFlow-PHP**. It features an AI-powered CV/resume generator with a modern, responsive web interface that transforms natural language descriptions into professional PDF resumes. 4 | 5 | This project is self-contained and does not depend on the parent directory. You can copy this folder anywhere on your system, run `composer install`, and it will work. 6 | 7 | ## Proof of Concept 8 | 9 | **This is a proof of concept that shows how easy you can add a frontend to your PocketFlow-PHP Agents!** 10 | 11 | This example demonstrates that PocketFlow-PHP isn't just for command-line applications - you can seamlessly integrate modern web frontends with your AI workflows. The combination of: 12 | 13 | - **Backend AI Workflows:** Powered by PocketFlow's node-based architecture 14 | - **Modern Web Frontend:** Responsive, real-time interface with streaming capabilities 15 | - **Simple Integration:** Clean API layer connecting frontend and backend 16 | 17 | Shows how quickly you can transform a command-line AI agent into a full-featured web application. The entire frontend is contained in a single HTML file (`/index.html`) that includes all CSS and JavaScript, making it incredibly portable and easy to deploy. 18 | 19 | ## Overview 20 | 21 | - **AI-Powered CV Generation:** Users describe their desired resume in natural language, and the AI creates a structured CV plan using the ReAct framework (Observation, Thought, Action). 22 | - **Interactive Web Interface:** Modern, responsive frontend with real-time streaming, dark/light theme toggle, and step-by-step progress visualization. 23 | - **Live Preview & Editing:** Users can review the AI's plan in real-time and request modifications before final generation. 24 | - **Professional PDF Output:** Converts the structured CV data into a PDF document ready for download. 25 | - **Streaming Architecture:** Uses Server-Sent Events (SSE) for real-time communication between frontend and backend. 26 | 27 | This example demonstrates how to build a complete web application with PocketFlow-PHP, showcasing both the framework's workflow capabilities and its integration with modern web technologies. 28 | 29 | ## Features 30 | 31 | ### Modern Web Interface 32 | - **Responsive Design:** Works perfectly on desktop, tablet, and mobile devices 33 | - **Dark/Light Theme:** Toggle between themes with persistent user preference 34 | - **Real-time Streaming:** Watch the AI think and generate content in real-time 35 | - **Progress Visualization:** Clear step-by-step progress indicators with animations 36 | - **Lucide Icons:** Consistent, professional iconography throughout 37 | 38 | ### AI-Powered Workflow 39 | - **Natural Language Input:** Describe your CV requirements in plain English 40 | - **ReAct Framework:** AI uses Observation → Thought → Action methodology 41 | - **Interactive Editing:** Request changes and see updates in real-time 42 | - **YAML Structure:** Structured data format for consistent CV generation 43 | - **Error Handling:** Robust error recovery and user-friendly messages 44 | 45 | ### Professional Output 46 | - **PDF Generation:** High-quality PDF documents using DomPDF 47 | - **Customizable Design:** AI adapts styling based on user requirements 48 | - **Single-Page Format:** Compact layouts that fit on one page 49 | 50 | ## Setup & Run 51 | 52 | **Prerequisites:** PHP 8.3+ and [Composer](https://getcomposer.org/) must be installed. 53 | 54 | 1. **Navigate into this directory:** 55 | Make sure your terminal is inside the `text-to-resume-agent` folder. 56 | 57 | 2. **Install Dependencies:** 58 | Run Composer to install the required packages for this project (PocketFlow core, OpenAI client, DomPDF, etc.). 59 | ```bash 60 | composer install 61 | ``` 62 | 63 | 3. **Set up API Key:** 64 | This example uses [OpenRouter.ai](https://openrouter.ai/) to access LLM models. 65 | - Rename the `.env.example` file in this directory to `.env`. 66 | - Paste your OpenRouter API key into the `.env` file. 67 | 68 | 4. **Start the Web Server:** 69 | Use PHP's built-in development server to serve the frontend directory. 70 | ```bash 71 | php -S localhost:8000 72 | ``` 73 | 74 | 5. **Open in Browser:** 75 | Navigate to `http://localhost:8000` in your web browser to start using the CV generator. 76 | 77 | ## Configuration 78 | 79 | ### How to Get an OpenRouter API Key 80 | 81 | 1. Go to **[OpenRouter.ai](https://openrouter.ai/)** and sign up. 82 | 2. Navigate to your account settings/keys page. 83 | 3. Copy your API key and paste it into the `.env` file as the value for `OPENROUTER_API_KEY`. 84 | 85 | ### How to Change the AI Model 86 | 87 | You can easily change the LLM model used for CV generation. The model is defined in the `.env` file. 88 | 89 | **File: `.env`** 90 | ```env 91 | OPENROUTER_API_KEY="your-api-key-here" 92 | LLM_NAME="deepseek/deepseek-chat-v3-0324:free" 93 | ``` 94 | 95 | Simply replace the `LLM_NAME` value with any other compatible model available on OpenRouter. Popular free options include: 96 | - `google/gemma-3-27b-it:free` 97 | - `moonshotai/kimi-k2:free` 98 | - `mistralai/mistral-small-3.2-24b-instruct:free` 99 | ## How it Works 100 | 101 | The application follows the PocketFlow philosophy with a clear separation of concerns: 102 | 103 | ### Backend Architecture 104 | 105 | - **`main.php`**: Command-line entry point for testing the workflow independently. 106 | - **`flow.php`**: Defines the CV generation workflow using PocketFlow nodes and transitions. 107 | - **`nodes.php`**: Contains the core logic for each step of the CV generation process. 108 | - **`api.php`**: Web API endpoint that handles HTTP requests and manages streaming responses. 109 | 110 | ### Frontend Architecture 111 | 112 | - **`frontend/index.html`**: Complete web application in a single file containing HTML, CSS, and JavaScript with modern responsive design and real-time streaming capabilities. 113 | 114 | ### Utility Functions 115 | 116 | - **`utils/llm_api.php`**: OpenRouter API integration with streaming support. 117 | - **`utils/pdf_converter.php`**: HTML-to-PDF conversion using DomPDF library. 118 | 119 | ## Workflow Steps 120 | 121 | ### 1. User Input 122 | Users describe their desired CV in natural language, providing details about: 123 | - Professional role and experience level 124 | - Key skills and technologies 125 | - Design preferences 126 | - Industry focus 127 | 128 | ### 2. AI Planning (ReAct Framework) 129 | The AI analyzes the request using structured thinking: 130 | - **Observation:** Understanding the user's requirements 131 | - **Thought:** Planning the CV structure and content 132 | - **Action:** Generating the structured CV plan 133 | 134 | ### 3. Review & Edit 135 | Users can: 136 | - Review the generated plan in real-time 137 | - Request modifications with natural language 138 | - Approve the plan when satisfied 139 | 140 | ### 4. PDF Generation 141 | The system: 142 | - Converts the structured plan to HTML 143 | - Applies professional styling 144 | - Generates a downloadable PDF 145 | 146 | ## Technical Highlights 147 | 148 | ### Real-time Streaming 149 | - **Server-Sent Events (SSE):** Enables real-time communication 150 | - **Chunked Responses:** Stream LLM output as it's generated 151 | - **Live Updates:** Dynamic UI updates during processing 152 | 153 | ### Error Handling 154 | - **YAML Validation:** Automatic fixing of common formatting issues 155 | - **Graceful Degradation:** User-friendly error messages 156 | - **Retry Logic:** Built-in retry mechanisms for API calls 157 | 158 | ### Responsive Design 159 | - **Mobile-First:** Optimized for all screen sizes 160 | - **Touch-Friendly:** Large buttons and intuitive gestures 161 | - **Accessibility:** High contrast ratios and keyboard navigation 162 | 163 | ## Example Usage 164 | 165 | 1. **Describe Your CV:** 166 | ``` 167 | "A modern, single-page CV for a senior software engineer with 10+ years 168 | of experience, focusing on backend development with Go and Python. 169 | Use a clean, professional design with subtle accent colors." 170 | ``` 171 | 172 | 2. **AI Generates Plan:** 173 | The AI creates a structured plan with sections for header, summary, 174 | experience, skills, education, and more. 175 | 176 | 3. **Review & Edit:** 177 | Request changes like "Add a section for volunteer experience" or 178 | "Make the design more colorful." 179 | 180 | 4. **Approve, Generate & Download:** 181 | Approve the plan and let the Agent generate a PDF file ready for download. 182 | 183 | ## Customization 184 | 185 | ### Adding New CV Sections 186 | Modify the CV keywords array in the frontend JavaScript to track additional sections: 187 | ```javascript 188 | const cvKeywords = ['header', 'professional_summary', 'experience', 189 | 'education', 'skills_sections', 'your_new_section']; 190 | ``` 191 | 192 | ### Styling Modifications 193 | Update the CSS variables in the frontend to change colors, fonts, or layouts: 194 | ```css 195 | :root { 196 | --accent-primary: #your-color; 197 | --bg-primary: #your-background; 198 | } 199 | ``` 200 | 201 | ### Custom Prompts 202 | Modify the system prompts in `api.php` to change how the AI generates CVs: 203 | ```php 204 | $system_prompt = "Your custom instructions for CV generation..."; 205 | ``` 206 | 207 | ## Dependencies 208 | 209 | - **PocketFlow-PHP:** Core workflow framework 210 | - **OpenAI PHP Client:** LLM API integration 211 | - **DomPDF:** PDF generation 212 | - **Symfony YAML:** YAML parsing and validation 213 | - **Lucide Icons:** Modern icon library 214 | - **Inter Font:** Professional typography 215 | 216 | 217 | 218 | --- 219 | -------------------------------------------------------------------------------- /examples/web-search-agent/nodes.php: -------------------------------------------------------------------------------- 1 | 'error', 'message' => $response]; 53 | } 54 | 55 | preg_match('/```yaml\s*(.*?)\s*```/s', $response, $matches); 56 | $yamlString = $matches[1] ?? ''; 57 | 58 | try { 59 | $parsed = Yaml::parse($yamlString); 60 | if (!is_array($parsed) || !isset($parsed['action'])) { 61 | return ['action' => 'error', 'message' => 'LLM returned malformed YAML (missing action key).']; 62 | } 63 | return $parsed; 64 | } catch (ParseException $e) { 65 | return ['action' => 'error', 'message' => 'YAML Parse Error: ' . $e->getMessage()]; 66 | } 67 | } 68 | 69 | public function prep(stdClass $shared): array 70 | { 71 | return [ 72 | 'query' => $shared->query, 73 | 'search_history' => $shared->search_history ?? [], 74 | 'search_plan' => $shared->search_plan ?? [], 75 | ]; 76 | } 77 | 78 | public function post(stdClass $shared, mixed $p, mixed $decision): ?string 79 | { 80 | if (empty($decision['action'])) { 81 | $shared->error_message = 'The agent failed to decide on a valid action.'; 82 | return 'error'; 83 | } 84 | 85 | if ($decision['action'] === 'error') { 86 | $shared->error_message = $decision['message'] ?? 'An unknown error occurred in the decision node.'; 87 | } 88 | 89 | echo "Decision: {$decision['action']}\n"; 90 | return $decision['action']; 91 | } 92 | } 93 | 94 | class PlanSearchesNode extends Node 95 | { 96 | public function exec(mixed $query): array 97 | { 98 | $prompt = <<query; 139 | } 140 | 141 | public function post(stdClass $shared, mixed $p, mixed $search_plan): ?string 142 | { 143 | $shared->search_plan = $search_plan; 144 | echo "Search plan created.\n"; 145 | return 'continue'; 146 | } 147 | } 148 | 149 | class ExecuteAllSearchesNode extends BatchNode 150 | { 151 | public function prep(stdClass $shared): array 152 | { 153 | return $shared->search_plan; 154 | } 155 | 156 | public function exec(mixed $search_term): string 157 | { 158 | usleep(500000); // Proactive 0.5-second delay to avoid rate limits 159 | echo "Searching for: {$search_term}\n"; 160 | return call_brave_search($search_term); 161 | } 162 | 163 | public function post(stdClass $shared, mixed $p, mixed $searchResultList): ?string 164 | { 165 | $shared->search_history = $searchResultList; 166 | $shared->search_plan = []; // Clear the plan 167 | echo "All searches complete.\n"; 168 | return 'continue'; 169 | } 170 | } 171 | 172 | class SynthesizeReportNode extends Node 173 | { 174 | public function exec(mixed $prepResult): string 175 | { 176 | $historyString = trim(implode("\n---\n", $prepResult['history'])); 177 | 178 | if (empty($historyString) || str_contains($historyString, "No search results found.")) { 179 | return "I could not find any information to answer the query after searching the web. Please try a different query."; 180 | } 181 | 182 | $prompt = << $shared->query, 208 | 'history' => $shared->search_history, 209 | ]; 210 | } 211 | 212 | public function post(stdClass $shared, mixed $p, mixed $report): ?string 213 | { 214 | $shared->final_report = $report; 215 | echo "Report synthesized.\n"; 216 | return null; 217 | } 218 | } 219 | 220 | class AnswerSimpleNode extends Node 221 | { 222 | public function exec(mixed $query): string 223 | { 224 | $prompt = "Answer the following question directly: {$query}"; 225 | return call_llm($prompt); 226 | } 227 | 228 | public function prep(stdClass $shared): mixed 229 | { 230 | return $shared->query; 231 | } 232 | 233 | public function post(stdClass $shared, mixed $p, mixed $answer): ?string 234 | { 235 | $shared->final_answer = $answer; 236 | echo "Simple answer provided.\n"; 237 | return null; 238 | } 239 | } 240 | 241 | class ErrorNode extends Node 242 | { 243 | public function exec(mixed $p): mixed 244 | { 245 | return null; 246 | } 247 | 248 | public function post(stdClass $shared, mixed $p, mixed $e): ?string 249 | { 250 | echo "\n--- An Error Occurred ---\n"; 251 | echo $shared->error_message . "\n"; 252 | echo "Please check your .env file and API keys.\n"; 253 | return null; 254 | } 255 | } -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/nodes.php: -------------------------------------------------------------------------------- 1 | deferreds)) { $deferred = array_shift($this->deferreds); $deferred->resolve($message); return; } $this->queue[] = $message; } 19 | public function get(): PromiseInterface { if (!empty($this->queue)) { return \React\Promise\resolve(array_shift($this->queue)); } $deferred = new Deferred(); $this->deferreds[] = $deferred; return $deferred->promise(); } 20 | } 21 | 22 | /** 23 | * The Quizmaster Agent: Asks questions, evaluates answers, and hosts the show. 24 | */ 25 | class QuizmasterAgent extends AsyncNode 26 | { 27 | public function prep_async(stdClass $shared): PromiseInterface { 28 | return $shared->quizmasterQueue->get(); 29 | } 30 | 31 | public function exec_async(mixed $message): PromiseInterface { 32 | return async(function() use ($message) { 33 | $model = $this->params['model']; 34 | $shared = $this->params['shared_state']; 35 | $response = new stdClass(); 36 | $response->action = 'ERROR'; 37 | 38 | if ($shared->game_over || !isset($message['type']) || $message['type'] === 'GAME_OVER') { 39 | $response->action = 'END_GAME'; 40 | return $response; 41 | } 42 | 43 | switch ($message['type']) { 44 | case 'START_GAME': 45 | $prompt = "You are a charismatic quiz show host. Welcome the two AI contestants, {$this->params['player1_model']} and {$this->params['player2_model']}, to your show. Keep it exciting and brief."; 46 | echo "Quizmaster: "; 47 | await(call_openrouter_async($model, [['role' => 'user', 'content' => $prompt]], fn($chunk) => print($chunk))); 48 | echo "\n"; 49 | $response->action = 'ASK_QUESTION'; 50 | break; 51 | 52 | case 'ASK_QUESTION': 53 | $shared->question_count++; 54 | echo "\n--- Round " . $shared->question_count . " (Score: P1 {$shared->player1_score} - P2 {$shared->player2_score}) ---\n"; 55 | $prompt = "You are a quizmaster. Ask one challenging but fun trivia question. Just the question, no intro."; 56 | echo "Quizmaster: "; 57 | $question = await(call_openrouter_async($model, [['role' => 'user', 'content' => $prompt]], fn($chunk) => print($chunk))); 58 | echo "\n"; 59 | 60 | $shared->current_question = $question; 61 | $response->action = 'BROADCAST_AND_COLLECT'; 62 | $response->question = $question; 63 | break; 64 | 65 | case 'ANSWERS_RECEIVED': 66 | $p1_name = "Player 1"; 67 | $p2_name = "Player 2"; 68 | $prompt = "You are the judge of a quiz show. The question was: '{$shared->current_question}'. 69 | - {$p1_name}'s answer: '{$message['answers']['Player 1']}' 70 | - {$p2_name}'s answer: '{$message['answers']['Player 2']}' 71 | 72 | Your task is to respond with a JSON object using the following structure, and nothing else. 73 | { 74 | \"commentary\": \"Your entertaining and brief commentary on the answers.\", 75 | \"round_winner\": \"[Player 1|Player 2|Neither]\" 76 | } 77 | 78 | Decision criteria: If both are correct, award the point to the one who was more concise or witty. If it's a true tie, choose 'Neither'."; 79 | 80 | $llmResponse = await(call_openrouter_async($model, [['role' => 'user', 'content' => $prompt]])); 81 | 82 | $jsonString = ''; 83 | if (preg_match('/\{.*?\}/s', $llmResponse, $matches)) { 84 | $jsonString = $matches[0]; 85 | } 86 | 87 | $evaluation = json_decode($jsonString, true); 88 | if (json_last_error() !== JSON_ERROR_NONE) { 89 | echo "Quizmaster (confused): My evaluation card seems to be malfunctioning! We'll call it a draw.\n"; 90 | $evaluation = ['commentary' => 'A technical difficulty!', 'round_winner' => 'Neither']; 91 | } 92 | 93 | echo "Quizmaster: " . ($evaluation['commentary'] ?? 'No commentary.') . "\n"; 94 | 95 | $winner = $evaluation['round_winner'] ?? 'Neither'; 96 | echo "JUDGEMENT: The point for this round goes to... {$winner}!\n"; 97 | 98 | if ($winner === 'Player 1') $shared->player1_score++; 99 | if ($winner === 'Player 2') $shared->player2_score++; 100 | 101 | echo "CURRENT SCORE: [Player 1: {$shared->player1_score}] | [Player 2: {$shared->player2_score}]\n"; 102 | 103 | if ($shared->player1_score >= $this->params['points_to_win'] || $shared->player2_score >= $this->params['points_to_win']) { 104 | $response->action = 'END_GAME'; 105 | } else { 106 | $response->action = 'ASK_QUESTION'; 107 | } 108 | break; 109 | } 110 | return $response; 111 | })(); 112 | } 113 | 114 | public function post_async(stdClass $shared, mixed $p, mixed $execResult): PromiseInterface { 115 | return async(function() use ($shared, $execResult) { 116 | if (!is_object($execResult) || !isset($execResult->action)) return 'end_game'; 117 | 118 | if ($execResult->action === 'END_GAME') { 119 | if (!$shared->game_over) { 120 | $p1_name = "Player 1 ({$this->params['player1_model']})"; 121 | $p2_name = "Player 2 ({$this->params['player2_model']})"; 122 | $winner = $shared->player1_score > $shared->player2_score ? $p1_name : $p2_name; 123 | if ($shared->player1_score === $shared->player2_score) $winner = "Both contestants in a stunning tie"; 124 | 125 | echo "\nQuizmaster: And we have a winner! With a final score of {$shared->player1_score} to {$shared->player2_score}, congratulations to {$winner}! That's all the time we have for today. Thanks for watching!\n"; 126 | $shared->game_over = true; 127 | $shared->player1Queue->put(['type' => 'GAME_OVER']); 128 | $shared->player2Queue->put(['type' => 'GAME_OVER']); 129 | } 130 | return 'end_game'; 131 | } 132 | 133 | switch ($execResult->action) { 134 | case 'BROADCAST_AND_COLLECT': 135 | $shared->player1Queue->put(['type' => 'QUESTION', 'content' => $execResult->question]); 136 | $shared->player2Queue->put(['type' => 'QUESTION', 'content' => $execResult->question]); 137 | 138 | $answers = []; 139 | while(count($answers) < 2 && !$shared->game_over) { 140 | $msg = await($shared->quizmasterQueue->get()); 141 | if ($shared->game_over) break; 142 | if ($msg['type'] === 'PLAYER_ANSWER') { 143 | $answers[$msg['player']] = $msg['answer']; 144 | } 145 | } 146 | if (!$shared->game_over) { 147 | $shared->quizmasterQueue->put(['type' => 'ANSWERS_RECEIVED', 'answers' => $answers]); 148 | } 149 | break; 150 | 151 | case 'ASK_QUESTION': 152 | $shared->quizmasterQueue->put(['type' => 'ASK_QUESTION']); 153 | break; 154 | } 155 | return 'continue'; 156 | })(); 157 | } 158 | } 159 | 160 | /** 161 | * A generic player agent that answers questions. 162 | */ 163 | class PlayerAgent extends AsyncNode 164 | { 165 | public function prep_async(stdClass $shared): PromiseInterface { 166 | return $this->params['my_queue']->get(); 167 | } 168 | 169 | public function exec_async(mixed $message): PromiseInterface { 170 | return async(function() use ($message) { 171 | if ($message['type'] === 'GAME_OVER') return null; 172 | 173 | $model = $this->params['model']; 174 | $playerName = $this->params['name']; 175 | $personality = $this->params['personality']; 176 | 177 | echo "{$playerName} ({$model}): "; 178 | $prompt = "You are an AI contestant in a quiz show. Your personality is '{$personality}'. The quizmaster asked: '{$message['content']}'. Answer the question concisely, but let your personality shine through."; 179 | 180 | $answer = await(call_openrouter_async($model, [['role' => 'user', 'content' => $prompt]], fn($chunk) => print($chunk))); 181 | echo "\n"; 182 | return $answer; 183 | })(); 184 | } 185 | 186 | public function post_async(stdClass $shared, mixed $p, mixed $answer): PromiseInterface { 187 | return async(function() use ($shared, $answer) { 188 | if ($answer === null) return 'end_game'; 189 | 190 | $shared->quizmasterQueue->put(['type' => 'PLAYER_ANSWER', 'player' => $this->params['name'], 'answer' => $answer]); 191 | return 'continue'; 192 | })(); 193 | } 194 | } -------------------------------------------------------------------------------- /examples/web-search-agent/src/PocketFlow.php: -------------------------------------------------------------------------------- 1 | on('action')->next($node) syntax 14 | class ConditionalTransition 15 | { 16 | public function __construct(private BaseNode $source, private string $action) {} 17 | 18 | public function next(?BaseNode $target): ?BaseNode 19 | { 20 | return $this->source->next($target, $this->action); 21 | } 22 | } 23 | 24 | // Base class for all Nodes and Flows 25 | abstract class BaseNode 26 | { 27 | public array $params = []; 28 | protected array $successors = []; 29 | 30 | public function setParams(array $params): void 31 | { 32 | $this->params = $params; 33 | } 34 | 35 | // Allow ?BaseNode to enable explicit flow termination points. 36 | public function next(?BaseNode $node, string $action = 'default'): ?BaseNode 37 | { 38 | if (isset($this->successors[$action])) { 39 | trigger_error("Overwriting successor for action '{$action}'", E_USER_WARNING); 40 | } 41 | $this->successors[$action] = $node; 42 | return $node; 43 | } 44 | 45 | public function on(string $action): ConditionalTransition 46 | { 47 | return new ConditionalTransition($this, $action); 48 | } 49 | 50 | public function getSuccessors(): array 51 | { 52 | return $this->successors; 53 | } 54 | 55 | public function prep(stdClass $shared): mixed { return null; } 56 | public function exec(mixed $prepResult): mixed { return null; } 57 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string { return null; } 58 | 59 | protected function _exec(mixed $prepResult): mixed 60 | { 61 | return $this->exec($prepResult); 62 | } 63 | 64 | protected function _run(stdClass $shared): ?string 65 | { 66 | $prepResult = $this->prep($shared); 67 | $execResult = $this->_exec($prepResult); 68 | return $this->post($shared, $prepResult, $execResult); 69 | } 70 | 71 | public function run(stdClass $shared): ?string 72 | { 73 | if (!empty($this->successors)) { 74 | trigger_error("Node won't run successors. Use a Flow to run the full graph.", E_USER_WARNING); 75 | } 76 | return $this->_run($shared); 77 | } 78 | } 79 | 80 | // A standard node with retry and fallback logic 81 | class Node extends BaseNode 82 | { 83 | protected int $currentRetry = 0; 84 | 85 | public function __construct(public int $maxRetries = 1, public int $wait = 0) {} 86 | 87 | public function execFallback(mixed $prepResult, Throwable $e): mixed 88 | { 89 | throw $e; 90 | } 91 | 92 | protected function _exec(mixed $prepResult): mixed 93 | { 94 | for ($this->currentRetry = 0; $this->currentRetry < $this->maxRetries; $this->currentRetry++) { 95 | try { 96 | return $this->exec($prepResult); 97 | } catch (Throwable $e) { 98 | if ($this->currentRetry === $this->maxRetries - 1) { 99 | return $this->execFallback($prepResult, $e); 100 | } 101 | if ($this->wait > 0) { 102 | sleep($this->wait); 103 | } 104 | } 105 | } 106 | return null; 107 | } 108 | } 109 | 110 | // A node that processes a list of items sequentially 111 | class BatchNode extends Node 112 | { 113 | protected function _exec(mixed $items): mixed 114 | { 115 | $results = []; 116 | foreach ($items ?? [] as $item) { 117 | $results[] = parent::_exec($item); 118 | } 119 | return $results; 120 | } 121 | } 122 | 123 | // Orchestrates a graph of nodes 124 | class Flow extends BaseNode 125 | { 126 | public function __construct(protected ?BaseNode $startNode = null) {} 127 | 128 | public function start(BaseNode $startNode): BaseNode 129 | { 130 | $this->startNode = $startNode; 131 | return $startNode; 132 | } 133 | 134 | protected function getNextNode(?BaseNode $current, ?string $action): ?BaseNode 135 | { 136 | if (!$current) return null; 137 | 138 | $actionKey = $action ?? 'default'; 139 | $successors = $current->getSuccessors(); 140 | 141 | if (!array_key_exists($actionKey, $successors)) { 142 | if (!empty($successors)) { 143 | $availableActions = implode("', '", array_keys($successors)); 144 | trigger_error("Flow ends: Action '{$actionKey}' not found in available actions: '{$availableActions}'", E_USER_WARNING); 145 | } 146 | return null; 147 | } 148 | 149 | return $successors[$actionKey]; 150 | } 151 | 152 | protected function _orchestrate(stdClass $shared, ?array $params = null): ?string 153 | { 154 | $current = $this->startNode; 155 | $p = $params ?? $this->params; 156 | $lastAction = null; 157 | 158 | while ($current) { 159 | $current->setParams($p); 160 | $reflection = new \ReflectionClass($current); 161 | if ($reflection->isSubclassOf(AsyncNode::class) || $reflection->isSubclassOf(AsyncFlow::class)) { 162 | $lastAction = await($current->_run_async($shared)); 163 | } else { 164 | $lastAction = $current->_run($shared); 165 | } 166 | $current = $this->getNextNode($current, $lastAction); 167 | } 168 | return $lastAction; 169 | } 170 | 171 | protected function _run(stdClass $shared): ?string 172 | { 173 | $prepResult = $this->prep($shared); 174 | $orchestrationResult = $this->_orchestrate($shared); 175 | return $this->post($shared, $prepResult, $orchestrationResult); 176 | } 177 | 178 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string 179 | { 180 | return $execResult; 181 | } 182 | } 183 | 184 | // A flow that runs its sub-flow for each item returned by prep() 185 | class BatchFlow extends Flow 186 | { 187 | protected function _run(stdClass $shared): ?string 188 | { 189 | $paramList = $this->prep($shared) ?? []; 190 | foreach ($paramList as $batchParams) { 191 | $this->_orchestrate($shared, array_merge($this->params, $batchParams)); 192 | } 193 | return $this->post($shared, $paramList, null); 194 | } 195 | } 196 | 197 | // --- ASYNC IMPLEMENTATIONS --- 198 | 199 | trait AsyncLogicTrait 200 | { 201 | public int $maxRetries = 1; 202 | public int $wait = 0; 203 | protected int $currentRetry = 0; 204 | 205 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 206 | public function exec_async(mixed $prepResult): PromiseInterface { return async(fn() => null)(); } 207 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => null)(); } 208 | public function exec_fallback_async(mixed $prepResult, Throwable $e): PromiseInterface { return async(function() use ($e) { throw $e; })(); } 209 | 210 | public function _exec_async(mixed $prepResult): PromiseInterface 211 | { 212 | return async(function () use ($prepResult) { 213 | for ($this->currentRetry = 0; $this->currentRetry < $this->maxRetries; $this->currentRetry++) { 214 | try { 215 | return await($this->exec_async($prepResult)); 216 | } catch (Throwable $e) { 217 | if ($this->currentRetry === $this->maxRetries - 1) { 218 | return await($this->exec_fallback_async($prepResult, $e)); 219 | } 220 | if ($this->wait > 0) { 221 | await(async_sleep($this->wait)); 222 | } 223 | } 224 | } 225 | return null; 226 | })(); 227 | } 228 | 229 | public function _run_async(stdClass $shared): PromiseInterface 230 | { 231 | return async(function () use ($shared) { 232 | $prepResult = await($this->prep_async($shared)); 233 | $execResult = await($this->_exec_async($prepResult)); 234 | return await($this->post_async($shared, $prepResult, $execResult)); 235 | })(); 236 | } 237 | 238 | public function run_async(stdClass $shared): PromiseInterface 239 | { 240 | if (!empty($this->successors)) { 241 | trigger_error("Node won't run successors. Use an AsyncFlow to run the full graph.", E_USER_WARNING); 242 | } 243 | return $this->_run_async($shared); 244 | } 245 | 246 | public function run(stdClass $shared): ?string 247 | { 248 | throw new \RuntimeException("Cannot call sync 'run' on an async node. Use 'run_async' instead."); 249 | } 250 | } 251 | 252 | class AsyncNode extends BaseNode 253 | { 254 | use AsyncLogicTrait; 255 | public function __construct(int $maxRetries = 1, int $wait = 0) 256 | { 257 | $this->maxRetries = $maxRetries; 258 | $this->wait = $wait; 259 | } 260 | } 261 | 262 | class AsyncBatchNode extends AsyncNode 263 | { 264 | public function _exec_async(mixed $items): PromiseInterface 265 | { 266 | return async(function () use ($items) { 267 | $results = []; 268 | foreach ($items ?? [] as $item) { 269 | $results[] = await(parent::_exec_async($item)); 270 | } 271 | return $results; 272 | })(); 273 | } 274 | } 275 | 276 | class AsyncParallelBatchNode extends AsyncNode 277 | { 278 | public function _exec_async(mixed $items): PromiseInterface 279 | { 280 | $promises = []; 281 | foreach ($items ?? [] as $item) { 282 | $promises[] = parent::_exec_async($item); 283 | } 284 | return all($promises); 285 | } 286 | } 287 | 288 | class AsyncFlow extends Flow 289 | { 290 | protected function _orchestrate_async(stdClass $shared, ?array $params = null): PromiseInterface 291 | { 292 | return async(function () use ($shared, $params) { 293 | $current = $this->startNode; 294 | $p = $params ?? $this->params; 295 | $lastAction = null; 296 | 297 | while ($current) { 298 | $current->setParams($p); 299 | if ($current instanceof self || $current instanceof AsyncNode) { 300 | $lastAction = await($current->_run_async($shared)); 301 | } else { 302 | $lastAction = $current->_run($shared); 303 | } 304 | $current = $this->getNextNode($current, $lastAction); 305 | } 306 | return $lastAction; 307 | })(); 308 | } 309 | 310 | // Refactor: Re-introduced _run_async for internal orchestration of nested flows. 311 | protected function _run_async(stdClass $shared): PromiseInterface 312 | { 313 | // The "run" logic for a flow is simply to orchestrate it. 314 | return $this->_orchestrate_async($shared, $this->params); 315 | } 316 | 317 | public function run_async(stdClass $shared): PromiseInterface 318 | { 319 | // The public entry point calls the internal run logic. 320 | return $this->_run_async($shared); 321 | } 322 | 323 | public function run(stdClass $shared): ?string 324 | { 325 | throw new \RuntimeException("Cannot call sync 'run' on an AsyncFlow. Use 'run_async' instead."); 326 | } 327 | 328 | protected function _run(stdClass $shared): ?string 329 | { 330 | throw new \RuntimeException("Internal error: _run should not be called on AsyncFlow."); 331 | } 332 | } 333 | 334 | class AsyncBatchFlow extends AsyncFlow 335 | { 336 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 337 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => $execResult)(); } 338 | 339 | public function run_async(stdClass $shared): PromiseInterface 340 | { 341 | return async(function () use ($shared) { 342 | $paramList = await($this->prep_async($shared)) ?? []; 343 | foreach ($paramList as $batchParams) { 344 | await($this->_orchestrate_async($shared, array_merge($this->params, $batchParams))); 345 | } 346 | return await($this->post_async($shared, $paramList, null)); 347 | })(); 348 | } 349 | } 350 | 351 | class AsyncParallelBatchFlow extends AsyncFlow 352 | { 353 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 354 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => $execResult)(); } 355 | 356 | public function run_async(stdClass $shared): PromiseInterface 357 | { 358 | return async(function () use ($shared) { 359 | $paramList = await($this->prep_async($shared)) ?? []; 360 | $promises = []; 361 | foreach ($paramList as $batchParams) { 362 | $promises[] = $this->_orchestrate_async($shared, array_merge($this->params, $batchParams)); 363 | } 364 | await(all($promises)); 365 | return await($this->post_async($shared, $paramList, null)); 366 | })(); 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /examples/quiz-show-multi-agent/src/PocketFlow.php: -------------------------------------------------------------------------------- 1 | on('action')->next($node) syntax 14 | class ConditionalTransition 15 | { 16 | public function __construct(private BaseNode $source, private string $action) {} 17 | 18 | public function next(?BaseNode $target): ?BaseNode 19 | { 20 | return $this->source->next($target, $this->action); 21 | } 22 | } 23 | 24 | // Base class for all Nodes and Flows 25 | abstract class BaseNode 26 | { 27 | public array $params = []; 28 | protected array $successors = []; 29 | 30 | public function setParams(array $params): void 31 | { 32 | $this->params = $params; 33 | } 34 | 35 | // Allow ?BaseNode to enable explicit flow termination points. 36 | public function next(?BaseNode $node, string $action = 'default'): ?BaseNode 37 | { 38 | if (isset($this->successors[$action])) { 39 | trigger_error("Overwriting successor for action '{$action}'", E_USER_WARNING); 40 | } 41 | $this->successors[$action] = $node; 42 | return $node; 43 | } 44 | 45 | public function on(string $action): ConditionalTransition 46 | { 47 | return new ConditionalTransition($this, $action); 48 | } 49 | 50 | public function getSuccessors(): array 51 | { 52 | return $this->successors; 53 | } 54 | 55 | public function prep(stdClass $shared): mixed { return null; } 56 | public function exec(mixed $prepResult): mixed { return null; } 57 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string { return null; } 58 | 59 | protected function _exec(mixed $prepResult): mixed 60 | { 61 | return $this->exec($prepResult); 62 | } 63 | 64 | protected function _run(stdClass $shared): ?string 65 | { 66 | $prepResult = $this->prep($shared); 67 | $execResult = $this->_exec($prepResult); 68 | return $this->post($shared, $prepResult, $execResult); 69 | } 70 | 71 | public function run(stdClass $shared): ?string 72 | { 73 | if (!empty($this->successors)) { 74 | trigger_error("Node won't run successors. Use a Flow to run the full graph.", E_USER_WARNING); 75 | } 76 | return $this->_run($shared); 77 | } 78 | } 79 | 80 | // A standard node with retry and fallback logic 81 | class Node extends BaseNode 82 | { 83 | protected int $currentRetry = 0; 84 | 85 | public function __construct(public int $maxRetries = 1, public int $wait = 0) {} 86 | 87 | public function execFallback(mixed $prepResult, Throwable $e): mixed 88 | { 89 | throw $e; 90 | } 91 | 92 | protected function _exec(mixed $prepResult): mixed 93 | { 94 | for ($this->currentRetry = 0; $this->currentRetry < $this->maxRetries; $this->currentRetry++) { 95 | try { 96 | return $this->exec($prepResult); 97 | } catch (Throwable $e) { 98 | if ($this->currentRetry === $this->maxRetries - 1) { 99 | return $this->execFallback($prepResult, $e); 100 | } 101 | if ($this->wait > 0) { 102 | sleep($this->wait); 103 | } 104 | } 105 | } 106 | return null; 107 | } 108 | } 109 | 110 | // A node that processes a list of items sequentially 111 | class BatchNode extends Node 112 | { 113 | protected function _exec(mixed $items): mixed 114 | { 115 | $results = []; 116 | foreach ($items ?? [] as $item) { 117 | $results[] = parent::_exec($item); 118 | } 119 | return $results; 120 | } 121 | } 122 | 123 | // Orchestrates a graph of nodes 124 | class Flow extends BaseNode 125 | { 126 | public function __construct(protected ?BaseNode $startNode = null) {} 127 | 128 | public function start(BaseNode $startNode): BaseNode 129 | { 130 | $this->startNode = $startNode; 131 | return $startNode; 132 | } 133 | 134 | protected function getNextNode(?BaseNode $current, ?string $action): ?BaseNode 135 | { 136 | if (!$current) return null; 137 | 138 | $actionKey = $action ?? 'default'; 139 | $successors = $current->getSuccessors(); 140 | 141 | if (!array_key_exists($actionKey, $successors)) { 142 | if (!empty($successors)) { 143 | $availableActions = implode("', '", array_keys($successors)); 144 | trigger_error("Flow ends: Action '{$actionKey}' not found in available actions: '{$availableActions}'", E_USER_WARNING); 145 | } 146 | return null; 147 | } 148 | 149 | return $successors[$actionKey]; 150 | } 151 | 152 | protected function _orchestrate(stdClass $shared, ?array $params = null): ?string 153 | { 154 | $current = $this->startNode; 155 | $p = $params ?? $this->params; 156 | $lastAction = null; 157 | 158 | while ($current) { 159 | $current->setParams($p); 160 | $reflection = new \ReflectionClass($current); 161 | if ($reflection->isSubclassOf(AsyncNode::class) || $reflection->isSubclassOf(AsyncFlow::class)) { 162 | $lastAction = await($current->_run_async($shared)); 163 | } else { 164 | $lastAction = $current->_run($shared); 165 | } 166 | $current = $this->getNextNode($current, $lastAction); 167 | } 168 | return $lastAction; 169 | } 170 | 171 | protected function _run(stdClass $shared): ?string 172 | { 173 | $prepResult = $this->prep($shared); 174 | $orchestrationResult = $this->_orchestrate($shared); 175 | return $this->post($shared, $prepResult, $orchestrationResult); 176 | } 177 | 178 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string 179 | { 180 | return $execResult; 181 | } 182 | } 183 | 184 | // A flow that runs its sub-flow for each item returned by prep() 185 | class BatchFlow extends Flow 186 | { 187 | protected function _run(stdClass $shared): ?string 188 | { 189 | $paramList = $this->prep($shared) ?? []; 190 | foreach ($paramList as $batchParams) { 191 | $this->_orchestrate($shared, array_merge($this->params, $batchParams)); 192 | } 193 | return $this->post($shared, $paramList, null); 194 | } 195 | } 196 | 197 | // --- ASYNC IMPLEMENTATIONS --- 198 | 199 | trait AsyncLogicTrait 200 | { 201 | public int $maxRetries = 1; 202 | public int $wait = 0; 203 | protected int $currentRetry = 0; 204 | 205 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 206 | public function exec_async(mixed $prepResult): PromiseInterface { return async(fn() => null)(); } 207 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => null)(); } 208 | public function exec_fallback_async(mixed $prepResult, Throwable $e): PromiseInterface { return async(function() use ($e) { throw $e; })(); } 209 | 210 | public function _exec_async(mixed $prepResult): PromiseInterface 211 | { 212 | return async(function () use ($prepResult) { 213 | for ($this->currentRetry = 0; $this->currentRetry < $this->maxRetries; $this->currentRetry++) { 214 | try { 215 | return await($this->exec_async($prepResult)); 216 | } catch (Throwable $e) { 217 | if ($this->currentRetry === $this->maxRetries - 1) { 218 | return await($this->exec_fallback_async($prepResult, $e)); 219 | } 220 | if ($this->wait > 0) { 221 | await(async_sleep($this->wait)); 222 | } 223 | } 224 | } 225 | return null; 226 | })(); 227 | } 228 | 229 | public function _run_async(stdClass $shared): PromiseInterface 230 | { 231 | return async(function () use ($shared) { 232 | $prepResult = await($this->prep_async($shared)); 233 | $execResult = await($this->_exec_async($prepResult)); 234 | return await($this->post_async($shared, $prepResult, $execResult)); 235 | })(); 236 | } 237 | 238 | public function run_async(stdClass $shared): PromiseInterface 239 | { 240 | if (!empty($this->successors)) { 241 | trigger_error("Node won't run successors. Use an AsyncFlow to run the full graph.", E_USER_WARNING); 242 | } 243 | return $this->_run_async($shared); 244 | } 245 | 246 | public function run(stdClass $shared): ?string 247 | { 248 | throw new \RuntimeException("Cannot call sync 'run' on an async node. Use 'run_async' instead."); 249 | } 250 | } 251 | 252 | class AsyncNode extends BaseNode 253 | { 254 | use AsyncLogicTrait; 255 | public function __construct(int $maxRetries = 1, int $wait = 0) 256 | { 257 | $this->maxRetries = $maxRetries; 258 | $this->wait = $wait; 259 | } 260 | } 261 | 262 | class AsyncBatchNode extends AsyncNode 263 | { 264 | public function _exec_async(mixed $items): PromiseInterface 265 | { 266 | return async(function () use ($items) { 267 | $results = []; 268 | foreach ($items ?? [] as $item) { 269 | $results[] = await(parent::_exec_async($item)); 270 | } 271 | return $results; 272 | })(); 273 | } 274 | } 275 | 276 | class AsyncParallelBatchNode extends AsyncNode 277 | { 278 | public function _exec_async(mixed $items): PromiseInterface 279 | { 280 | $promises = []; 281 | foreach ($items ?? [] as $item) { 282 | $promises[] = parent::_exec_async($item); 283 | } 284 | return all($promises); 285 | } 286 | } 287 | 288 | class AsyncFlow extends Flow 289 | { 290 | protected function _orchestrate_async(stdClass $shared, ?array $params = null): PromiseInterface 291 | { 292 | return async(function () use ($shared, $params) { 293 | $current = $this->startNode; 294 | $p = $params ?? $this->params; 295 | $lastAction = null; 296 | 297 | while ($current) { 298 | $current->setParams($p); 299 | if ($current instanceof self || $current instanceof AsyncNode) { 300 | $lastAction = await($current->_run_async($shared)); 301 | } else { 302 | $lastAction = $current->_run($shared); 303 | } 304 | $current = $this->getNextNode($current, $lastAction); 305 | } 306 | return $lastAction; 307 | })(); 308 | } 309 | 310 | // Refactor: Re-introduced _run_async for internal orchestration of nested flows. 311 | protected function _run_async(stdClass $shared): PromiseInterface 312 | { 313 | // The "run" logic for a flow is simply to orchestrate it. 314 | return $this->_orchestrate_async($shared, $this->params); 315 | } 316 | 317 | public function run_async(stdClass $shared): PromiseInterface 318 | { 319 | // The public entry point calls the internal run logic. 320 | return $this->_run_async($shared); 321 | } 322 | 323 | public function run(stdClass $shared): ?string 324 | { 325 | throw new \RuntimeException("Cannot call sync 'run' on an AsyncFlow. Use 'run_async' instead."); 326 | } 327 | 328 | protected function _run(stdClass $shared): ?string 329 | { 330 | throw new \RuntimeException("Internal error: _run should not be called on AsyncFlow."); 331 | } 332 | } 333 | 334 | class AsyncBatchFlow extends AsyncFlow 335 | { 336 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 337 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => $execResult)(); } 338 | 339 | public function run_async(stdClass $shared): PromiseInterface 340 | { 341 | return async(function () use ($shared) { 342 | $paramList = await($this->prep_async($shared)) ?? []; 343 | foreach ($paramList as $batchParams) { 344 | await($this->_orchestrate_async($shared, array_merge($this->params, $batchParams))); 345 | } 346 | return await($this->post_async($shared, $paramList, null)); 347 | })(); 348 | } 349 | } 350 | 351 | class AsyncParallelBatchFlow extends AsyncFlow 352 | { 353 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 354 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => $execResult)(); } 355 | 356 | public function run_async(stdClass $shared): PromiseInterface 357 | { 358 | return async(function () use ($shared) { 359 | $paramList = await($this->prep_async($shared)) ?? []; 360 | $promises = []; 361 | foreach ($paramList as $batchParams) { 362 | $promises[] = $this->_orchestrate_async($shared, array_merge($this->params, $batchParams)); 363 | } 364 | await(all($promises)); 365 | return await($this->post_async($shared, $paramList, null)); 366 | })(); 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /examples/text-to-cv-with-frontend/src/PocketFlow.php: -------------------------------------------------------------------------------- 1 | on('action')->next($node) syntax 14 | class ConditionalTransition 15 | { 16 | public function __construct(private BaseNode $source, private string $action) {} 17 | 18 | public function next(?BaseNode $target): ?BaseNode 19 | { 20 | return $this->source->next($target, $this->action); 21 | } 22 | } 23 | 24 | // Base class for all Nodes and Flows 25 | abstract class BaseNode 26 | { 27 | public array $params = []; 28 | protected array $successors = []; 29 | 30 | public function setParams(array $params): void 31 | { 32 | $this->params = $params; 33 | } 34 | 35 | // Allow ?BaseNode to enable explicit flow termination points. 36 | public function next(?BaseNode $node, string $action = 'default'): ?BaseNode 37 | { 38 | if (isset($this->successors[$action])) { 39 | trigger_error("Overwriting successor for action '{$action}'", E_USER_WARNING); 40 | } 41 | $this->successors[$action] = $node; 42 | return $node; 43 | } 44 | 45 | public function on(string $action): ConditionalTransition 46 | { 47 | return new ConditionalTransition($this, $action); 48 | } 49 | 50 | public function getSuccessors(): array 51 | { 52 | return $this->successors; 53 | } 54 | 55 | public function prep(stdClass $shared): mixed { return null; } 56 | public function exec(mixed $prepResult): mixed { return null; } 57 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string { return null; } 58 | 59 | protected function _exec(mixed $prepResult): mixed 60 | { 61 | return $this->exec($prepResult); 62 | } 63 | 64 | protected function _run(stdClass $shared): ?string 65 | { 66 | $prepResult = $this->prep($shared); 67 | $execResult = $this->_exec($prepResult); 68 | return $this->post($shared, $prepResult, $execResult); 69 | } 70 | 71 | public function run(stdClass $shared): ?string 72 | { 73 | if (!empty($this->successors)) { 74 | trigger_error("Node won't run successors. Use a Flow to run the full graph.", E_USER_WARNING); 75 | } 76 | return $this->_run($shared); 77 | } 78 | } 79 | 80 | // A standard node with retry and fallback logic 81 | class Node extends BaseNode 82 | { 83 | protected int $currentRetry = 0; 84 | 85 | public function __construct(public int $maxRetries = 1, public int $wait = 0) {} 86 | 87 | public function execFallback(mixed $prepResult, Throwable $e): mixed 88 | { 89 | throw $e; 90 | } 91 | 92 | protected function _exec(mixed $prepResult): mixed 93 | { 94 | for ($this->currentRetry = 0; $this->currentRetry < $this->maxRetries; $this->currentRetry++) { 95 | try { 96 | return $this->exec($prepResult); 97 | } catch (Throwable $e) { 98 | if ($this->currentRetry === $this->maxRetries - 1) { 99 | return $this->execFallback($prepResult, $e); 100 | } 101 | if ($this->wait > 0) { 102 | sleep($this->wait); 103 | } 104 | } 105 | } 106 | return null; 107 | } 108 | } 109 | 110 | // A node that processes a list of items sequentially 111 | class BatchNode extends Node 112 | { 113 | protected function _exec(mixed $items): mixed 114 | { 115 | $results = []; 116 | foreach ($items ?? [] as $item) { 117 | $results[] = parent::_exec($item); 118 | } 119 | return $results; 120 | } 121 | } 122 | 123 | // Orchestrates a graph of nodes 124 | class Flow extends BaseNode 125 | { 126 | public function __construct(protected ?BaseNode $startNode = null) {} 127 | 128 | public function start(BaseNode $startNode): BaseNode 129 | { 130 | $this->startNode = $startNode; 131 | return $startNode; 132 | } 133 | 134 | protected function getNextNode(?BaseNode $current, ?string $action): ?BaseNode 135 | { 136 | if (!$current) return null; 137 | 138 | $actionKey = $action ?? 'default'; 139 | $successors = $current->getSuccessors(); 140 | 141 | if (!array_key_exists($actionKey, $successors)) { 142 | if (!empty($successors)) { 143 | $availableActions = implode("', '", array_keys($successors)); 144 | trigger_error("Flow ends: Action '{$actionKey}' not found in available actions: '{$availableActions}'", E_USER_WARNING); 145 | } 146 | return null; 147 | } 148 | 149 | return $successors[$actionKey]; 150 | } 151 | 152 | protected function _orchestrate(stdClass $shared, ?array $params = null): ?string 153 | { 154 | $current = $this->startNode; 155 | $p = $params ?? $this->params; 156 | $lastAction = null; 157 | 158 | while ($current) { 159 | $current->setParams($p); 160 | $reflection = new \ReflectionClass($current); 161 | if ($reflection->isSubclassOf(AsyncNode::class) || $reflection->isSubclassOf(AsyncFlow::class)) { 162 | $lastAction = await($current->_run_async($shared)); 163 | } else { 164 | $lastAction = $current->_run($shared); 165 | } 166 | $current = $this->getNextNode($current, $lastAction); 167 | } 168 | return $lastAction; 169 | } 170 | 171 | protected function _run(stdClass $shared): ?string 172 | { 173 | $prepResult = $this->prep($shared); 174 | $orchestrationResult = $this->_orchestrate($shared); 175 | return $this->post($shared, $prepResult, $orchestrationResult); 176 | } 177 | 178 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string 179 | { 180 | return $execResult; 181 | } 182 | } 183 | 184 | // A flow that runs its sub-flow for each item returned by prep() 185 | class BatchFlow extends Flow 186 | { 187 | protected function _run(stdClass $shared): ?string 188 | { 189 | $paramList = $this->prep($shared) ?? []; 190 | foreach ($paramList as $batchParams) { 191 | $this->_orchestrate($shared, array_merge($this->params, $batchParams)); 192 | } 193 | return $this->post($shared, $paramList, null); 194 | } 195 | } 196 | 197 | // --- ASYNC IMPLEMENTATIONS --- 198 | 199 | trait AsyncLogicTrait 200 | { 201 | public int $maxRetries = 1; 202 | public int $wait = 0; 203 | protected int $currentRetry = 0; 204 | 205 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 206 | public function exec_async(mixed $prepResult): PromiseInterface { return async(fn() => null)(); } 207 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => null)(); } 208 | public function exec_fallback_async(mixed $prepResult, Throwable $e): PromiseInterface { return async(function() use ($e) { throw $e; })(); } 209 | 210 | public function _exec_async(mixed $prepResult): PromiseInterface 211 | { 212 | return async(function () use ($prepResult) { 213 | for ($this->currentRetry = 0; $this->currentRetry < $this->maxRetries; $this->currentRetry++) { 214 | try { 215 | return await($this->exec_async($prepResult)); 216 | } catch (Throwable $e) { 217 | if ($this->currentRetry === $this->maxRetries - 1) { 218 | return await($this->exec_fallback_async($prepResult, $e)); 219 | } 220 | if ($this->wait > 0) { 221 | await(async_sleep($this->wait)); 222 | } 223 | } 224 | } 225 | return null; 226 | })(); 227 | } 228 | 229 | public function _run_async(stdClass $shared): PromiseInterface 230 | { 231 | return async(function () use ($shared) { 232 | $prepResult = await($this->prep_async($shared)); 233 | $execResult = await($this->_exec_async($prepResult)); 234 | return await($this->post_async($shared, $prepResult, $execResult)); 235 | })(); 236 | } 237 | 238 | public function run_async(stdClass $shared): PromiseInterface 239 | { 240 | if (!empty($this->successors)) { 241 | trigger_error("Node won't run successors. Use an AsyncFlow to run the full graph.", E_USER_WARNING); 242 | } 243 | return $this->_run_async($shared); 244 | } 245 | 246 | public function run(stdClass $shared): ?string 247 | { 248 | throw new \RuntimeException("Cannot call sync 'run' on an async node. Use 'run_async' instead."); 249 | } 250 | } 251 | 252 | class AsyncNode extends BaseNode 253 | { 254 | use AsyncLogicTrait; 255 | public function __construct(int $maxRetries = 1, int $wait = 0) 256 | { 257 | $this->maxRetries = $maxRetries; 258 | $this->wait = $wait; 259 | } 260 | } 261 | 262 | class AsyncBatchNode extends AsyncNode 263 | { 264 | public function _exec_async(mixed $items): PromiseInterface 265 | { 266 | return async(function () use ($items) { 267 | $results = []; 268 | foreach ($items ?? [] as $item) { 269 | $results[] = await(parent::_exec_async($item)); 270 | } 271 | return $results; 272 | })(); 273 | } 274 | } 275 | 276 | class AsyncParallelBatchNode extends AsyncNode 277 | { 278 | public function _exec_async(mixed $items): PromiseInterface 279 | { 280 | $promises = []; 281 | foreach ($items ?? [] as $item) { 282 | $promises[] = parent::_exec_async($item); 283 | } 284 | return all($promises); 285 | } 286 | } 287 | 288 | class AsyncFlow extends Flow 289 | { 290 | protected function _orchestrate_async(stdClass $shared, ?array $params = null): PromiseInterface 291 | { 292 | return async(function () use ($shared, $params) { 293 | $current = $this->startNode; 294 | $p = $params ?? $this->params; 295 | $lastAction = null; 296 | 297 | while ($current) { 298 | $current->setParams($p); 299 | if ($current instanceof self || $current instanceof AsyncNode) { 300 | $lastAction = await($current->_run_async($shared)); 301 | } else { 302 | $lastAction = $current->_run($shared); 303 | } 304 | $current = $this->getNextNode($current, $lastAction); 305 | } 306 | return $lastAction; 307 | })(); 308 | } 309 | 310 | // Refactor: Re-introduced _run_async for internal orchestration of nested flows. 311 | protected function _run_async(stdClass $shared): PromiseInterface 312 | { 313 | // The "run" logic for a flow is simply to orchestrate it. 314 | return $this->_orchestrate_async($shared, $this->params); 315 | } 316 | 317 | public function run_async(stdClass $shared): PromiseInterface 318 | { 319 | // The public entry point calls the internal run logic. 320 | return $this->_run_async($shared); 321 | } 322 | 323 | public function run(stdClass $shared): ?string 324 | { 325 | throw new \RuntimeException("Cannot call sync 'run' on an AsyncFlow. Use 'run_async' instead."); 326 | } 327 | 328 | protected function _run(stdClass $shared): ?string 329 | { 330 | throw new \RuntimeException("Internal error: _run should not be called on AsyncFlow."); 331 | } 332 | } 333 | 334 | class AsyncBatchFlow extends AsyncFlow 335 | { 336 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 337 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => $execResult)(); } 338 | 339 | public function run_async(stdClass $shared): PromiseInterface 340 | { 341 | return async(function () use ($shared) { 342 | $paramList = await($this->prep_async($shared)) ?? []; 343 | foreach ($paramList as $batchParams) { 344 | await($this->_orchestrate_async($shared, array_merge($this->params, $batchParams))); 345 | } 346 | return await($this->post_async($shared, $paramList, null)); 347 | })(); 348 | } 349 | } 350 | 351 | class AsyncParallelBatchFlow extends AsyncFlow 352 | { 353 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 354 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => $execResult)(); } 355 | 356 | public function run_async(stdClass $shared): PromiseInterface 357 | { 358 | return async(function () use ($shared) { 359 | $paramList = await($this->prep_async($shared)) ?? []; 360 | $promises = []; 361 | foreach ($paramList as $batchParams) { 362 | $promises[] = $this->_orchestrate_async($shared, array_merge($this->params, $batchParams)); 363 | } 364 | await(all($promises)); 365 | return await($this->post_async($shared, $paramList, null)); 366 | })(); 367 | } 368 | } 369 | -------------------------------------------------------------------------------- /src/PocketFlow.php: -------------------------------------------------------------------------------- 1 | on('action')->next($node) syntax 18 | class ConditionalTransition 19 | { 20 | public function __construct(private BaseNode $source, private string $action) {} 21 | 22 | public function next(?BaseNode $target): ?BaseNode 23 | { 24 | return $this->source->next($target, $this->action); 25 | } 26 | } 27 | 28 | // Base class for all Nodes and Flows 29 | abstract class BaseNode 30 | { 31 | public array $params = []; 32 | protected array $successors = []; 33 | 34 | public function setParams(array $params): void 35 | { 36 | $this->params = $params; 37 | } 38 | 39 | public function next(?BaseNode $node, string $action = 'default'): ?BaseNode 40 | { 41 | if (isset($this->successors[$action])) { 42 | trigger_error("Overwriting successor for action '{$action}'", E_USER_WARNING); 43 | } 44 | $this->successors[$action] = $node; 45 | return $node; 46 | } 47 | 48 | public function on(string $action): ConditionalTransition 49 | { 50 | return new ConditionalTransition($this, $action); 51 | } 52 | 53 | public function getSuccessors(): array 54 | { 55 | return $this->successors; 56 | } 57 | 58 | public function prep(stdClass $shared): mixed { return null; } 59 | public function exec(mixed $prepResult): mixed { return null; } 60 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string { return null; } 61 | 62 | protected function _exec(mixed $prepResult): mixed 63 | { 64 | return $this->exec($prepResult); 65 | } 66 | 67 | protected function _run(stdClass $shared): ?string 68 | { 69 | $prepResult = $this->prep($shared); 70 | $execResult = $this->_exec($prepResult); 71 | return $this->post($shared, $prepResult, $execResult); 72 | } 73 | 74 | public function run(stdClass $shared): ?string 75 | { 76 | if (!empty($this->successors)) { 77 | trigger_error("Node won't run successors. Use a Flow to run the full graph.", E_USER_WARNING); 78 | } 79 | return $this->_run($shared); 80 | } 81 | } 82 | 83 | // A standard node with retry and fallback logic 84 | class Node extends BaseNode 85 | { 86 | public function __construct(public int $maxRetries = 1, public int $wait = 0) {} 87 | 88 | public function execFallback(mixed $prepResult, Throwable $e): mixed 89 | { 90 | throw $e; 91 | } 92 | 93 | protected function _exec(mixed $prepResult): mixed 94 | { 95 | for ($retryCount = 0; $retryCount < $this->maxRetries; $retryCount++) { 96 | try { 97 | return $this->exec($prepResult); 98 | } catch (Throwable $e) { 99 | if ($retryCount === $this->maxRetries - 1) { 100 | return $this->execFallback($prepResult, $e); 101 | } 102 | if ($this->wait > 0) { 103 | sleep($this->wait); 104 | } 105 | } 106 | } 107 | return null; 108 | } 109 | } 110 | 111 | // A node that processes a list of items sequentially 112 | class BatchNode extends Node 113 | { 114 | protected function _exec(mixed $items): mixed 115 | { 116 | $results = []; 117 | foreach ($items ?? [] as $item) { 118 | $results[] = parent::_exec($item); 119 | } 120 | return $results; 121 | } 122 | } 123 | 124 | // Orchestrates a graph of nodes 125 | class Flow extends BaseNode 126 | { 127 | public function __construct(protected ?BaseNode $startNode = null) {} 128 | 129 | public function start(BaseNode $startNode): BaseNode 130 | { 131 | $this->startNode = $startNode; 132 | return $startNode; 133 | } 134 | 135 | protected function getNextNode(?BaseNode $current, ?string $action): ?BaseNode 136 | { 137 | if (!$current) return null; 138 | $actionKey = $action ?? 'default'; 139 | $successors = $current->getSuccessors(); 140 | if (!array_key_exists($actionKey, $successors)) { 141 | if (!empty($successors)) { 142 | $availableActions = implode("', '", array_keys($successors)); 143 | trigger_error("Flow ends: Action '{$actionKey}' not found in available actions: '{$availableActions}'", E_USER_WARNING); 144 | } 145 | return null; 146 | } 147 | return $successors[$actionKey]; 148 | } 149 | 150 | protected function _orchestrate(stdClass $shared, ?array $params = null): ?string 151 | { 152 | $current = $this->startNode; 153 | $p = $params ?? $this->params; 154 | $lastAction = null; 155 | 156 | while ($current) { 157 | $current->setParams($p); 158 | if ($current instanceof AsyncRunnable) { 159 | $lastAction = await($current->_run_async($shared)); 160 | } else { 161 | $lastAction = $current->_run($shared); 162 | } 163 | $current = $this->getNextNode($current, $lastAction); 164 | } 165 | return $lastAction; 166 | } 167 | 168 | protected function _run(stdClass $shared): ?string 169 | { 170 | $prepResult = $this->prep($shared); 171 | $orchestrationResult = $this->_orchestrate($shared); 172 | return $this->post($shared, $prepResult, $orchestrationResult); 173 | } 174 | 175 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string 176 | { 177 | return $execResult; 178 | } 179 | } 180 | 181 | // A flow that runs its sub-flow for each item returned by prep() 182 | class BatchFlow extends Flow 183 | { 184 | protected function _run(stdClass $shared): ?string 185 | { 186 | $paramList = $this->prep($shared) ?? []; 187 | foreach ($paramList as $batchParams) { 188 | $this->_orchestrate($shared, array_merge($this->params, $batchParams)); 189 | } 190 | return $this->post($shared, $paramList, null); 191 | } 192 | } 193 | 194 | // --- ASYNC IMPLEMENTATIONS --- 195 | 196 | trait AsyncLogicTrait 197 | { 198 | public int $maxRetries = 1; 199 | public int $wait = 0; 200 | 201 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 202 | public function exec_async(mixed $prepResult): PromiseInterface { return async(fn() => null)(); } 203 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => null)(); } 204 | public function exec_fallback_async(mixed $prepResult, Throwable $e): PromiseInterface { return async(function() use ($e) { throw $e; })(); } 205 | 206 | public function _exec_async(mixed $prepResult): PromiseInterface 207 | { 208 | return async(function () use ($prepResult) { 209 | for ($retryCount = 0; $retryCount < $this->maxRetries; $retryCount++) { 210 | try { 211 | return await($this->exec_async($prepResult)); 212 | } catch (Throwable $e) { 213 | if ($retryCount === $this->maxRetries - 1) { 214 | return await($this->exec_fallback_async($prepResult, $e)); 215 | } 216 | if ($this->wait > 0) { 217 | await(async_sleep($this->wait)); 218 | } 219 | } 220 | } 221 | return null; 222 | })(); 223 | } 224 | 225 | public function _run_async(stdClass $shared): PromiseInterface 226 | { 227 | return async(function () use ($shared) { 228 | $prepResult = await($this->prep_async($shared)); 229 | $execResult = await($this->_exec_async($prepResult)); 230 | return await($this->post_async($shared, $prepResult, $execResult)); 231 | })(); 232 | } 233 | 234 | public function run_async(stdClass $shared): PromiseInterface 235 | { 236 | if (!empty($this->successors)) { 237 | trigger_error("Node won't run successors. Use an AsyncFlow to run the full graph.", E_USER_WARNING); 238 | } 239 | return $this->_run_async($shared); 240 | } 241 | 242 | public function run(stdClass $shared): ?string 243 | { 244 | throw new \RuntimeException("Cannot call sync 'run' on an async node. Use 'run_async' instead."); 245 | } 246 | } 247 | 248 | class AsyncNode extends BaseNode implements AsyncRunnable 249 | { 250 | use AsyncLogicTrait; 251 | public function __construct(int $maxRetries = 1, int $wait = 0) 252 | { 253 | $this->maxRetries = $maxRetries; 254 | $this->wait = $wait; 255 | } 256 | } 257 | 258 | class AsyncBatchNode extends AsyncNode 259 | { 260 | public function _exec_async(mixed $items): PromiseInterface 261 | { 262 | return async(function () use ($items) { 263 | $results = []; 264 | foreach ($items ?? [] as $item) { 265 | $results[] = await(parent::_exec_async($item)); 266 | } 267 | return $results; 268 | })(); 269 | } 270 | } 271 | 272 | class AsyncParallelBatchNode extends AsyncNode 273 | { 274 | public function _exec_async(mixed $items): PromiseInterface 275 | { 276 | return async(function () use ($items) { 277 | $promises = []; 278 | foreach ($items ?? [] as $item) { 279 | $promise = parent::_exec_async($item); 280 | // Manually implement "settle" logic 281 | $promises[] = $promise->then( 282 | fn($value) => ['state' => 'fulfilled', 'value' => $value], 283 | fn($reason) => ['state' => 'rejected', 'reason' => $reason] 284 | ); 285 | } 286 | $results = await(all($promises)); 287 | 288 | $finalResults = []; 289 | foreach ($results as $result) { 290 | if ($result['state'] === 'rejected') { 291 | throw $result['reason']; 292 | } 293 | $finalResults[] = $result['value']; 294 | } 295 | return $finalResults; 296 | })(); 297 | } 298 | } 299 | 300 | class AsyncFlow extends Flow implements AsyncRunnable 301 | { 302 | public function _orchestrate_async(stdClass $shared, ?array $params = null): PromiseInterface 303 | { 304 | return async(function () use ($shared, $params) { 305 | $current = $this->startNode; 306 | $p = $params ?? $this->params; 307 | $lastAction = null; 308 | 309 | while ($current) { 310 | $current->setParams($p); 311 | if ($current instanceof AsyncRunnable) { 312 | $lastAction = await($current->_run_async($shared)); 313 | } else { 314 | $lastAction = $current->_run($shared); 315 | } 316 | $current = $this->getNextNode($current, $lastAction); 317 | } 318 | return $lastAction; 319 | })(); 320 | } 321 | 322 | public function _run_async(stdClass $shared): PromiseInterface 323 | { 324 | return $this->_orchestrate_async($shared, $this->params); 325 | } 326 | 327 | public function run_async(stdClass $shared): PromiseInterface 328 | { 329 | return $this->_run_async($shared); 330 | } 331 | 332 | public function run(stdClass $shared): ?string 333 | { 334 | throw new \RuntimeException("Cannot call sync 'run' on an AsyncFlow. Use 'run_async' instead."); 335 | } 336 | 337 | protected function _run(stdClass $shared): ?string 338 | { 339 | throw new \RuntimeException("Internal error: _run should not be called on AsyncFlow."); 340 | } 341 | } 342 | 343 | class AsyncBatchFlow extends AsyncFlow 344 | { 345 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 346 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => $execResult)(); } 347 | 348 | public function run_async(stdClass $shared): PromiseInterface 349 | { 350 | return async(function () use ($shared) { 351 | $paramList = await($this->prep_async($shared)) ?? []; 352 | foreach ($paramList as $batchParams) { 353 | await($this->_orchestrate_async($shared, array_merge($this->params, $batchParams))); 354 | } 355 | return await($this->post_async($shared, $paramList, null)); 356 | })(); 357 | } 358 | } 359 | 360 | class AsyncParallelBatchFlow extends AsyncFlow 361 | { 362 | public function prep_async(stdClass $shared): PromiseInterface { return async(fn() => null)(); } 363 | public function post_async(stdClass $shared, mixed $prepResult, mixed $execResult): PromiseInterface { return async(fn() => $execResult)(); } 364 | 365 | public function run_async(stdClass $shared): PromiseInterface 366 | { 367 | return async(function () use ($shared) { 368 | $paramList = await($this->prep_async($shared)) ?? []; 369 | $promises = []; 370 | foreach ($paramList as $batchParams) { 371 | $promise = $this->_orchestrate_async($shared, array_merge($this->params, $batchParams)); 372 | // Manually implement "settle" logic 373 | $promises[] = $promise->then( 374 | fn($value) => ['state' => 'fulfilled', 'value' => $value], 375 | fn($reason) => ['state' => 'rejected', 'reason' => $reason] 376 | ); 377 | } 378 | $results = await(all($promises)); 379 | foreach ($results as $result) { 380 | if ($result['state'] === 'rejected') { 381 | throw $result['reason']; 382 | } 383 | } 384 | return await($this->post_async($shared, $paramList, null)); 385 | })(); 386 | } 387 | } -------------------------------------------------------------------------------- /.clinerules: -------------------------------------------------------------------------------- 1 | # Agentic Coding with PocketFlow-PHP 2 | 3 | > **Attention AI Agent:** This is your primary instruction manual. Follow these rules strictly to generate correct, robust, and bug-free PHP code for the PocketFlow-PHP framework. Failure to adhere to these rules will result in errors. 4 | 5 | ## The Golden Rules 6 | 7 | 1. **THE CORE FRAMEWORK IS READ-ONLY.** The file `src/PocketFlow.php` is the engine. You are **strictly forbidden** from ever editing it. 8 | 9 | 2. **USE THE THREE MAIN FILES.** All your application code **must** be written in these three files in the project root: 10 | - `nodes.php`: For all `Node`, `BatchNode`, and `AsyncNode` class definitions. 11 | - `flow.php`: For functions that create and wire up `Flows`. 12 | - `main.php`: The single entry point that calls a function from `flow.php`. 13 | 14 | 3. **UTILITIES GO IN `utils/`.** Any function that communicates with the outside world (e.g., calling an LLM API, a database, or a web search) **must** be placed in a new file inside the `utils/` directory. 15 | 16 | 4. **STATE MANAGEMENT IS CRITICAL.** 17 | - **DO** use the `$shared` object (`stdClass`) to manage all **mutable application state** (e.g., results, counters, lists of items). 18 | - **DO NOT** use `static` properties or variables inside Node classes to manage state. This will fail in loops. 19 | - **DO NOT** use `$this->params` to store mutable state. It is for **immutable configuration** only. 20 | 21 | 5. **STRICT TYPE COMPATIBILITY & SCOPE.** 22 | - The `post()` method of a `Node` **must** have the return type `?string`. If it does not decide the next action, it **must** end with `return null;`. 23 | - All `_async` methods (`prep_async`, `exec_async`, `post_async`) **must** have the return type `React\Promise\PromiseInterface`. 24 | - To return a promise, **always** use the pattern `return async(function() { ... })();`. Do not forget the final `()`. 25 | - **DO NOT** use `use ($this)` in closures. To access `$this->params` inside a `post_async` or `exec_async` closure, read the required values into local variables *before* the closure and pass them in with `use()`. 26 | 27 | 6. **ALWAYS IMPORT CLASSES WITH `use`.** 28 | - Any file that references a class (e.g., `AsyncNode`, `Flow`, `PromiseInterface`) **must** include a `use` statement for that class at the top of the file. 29 | 30 | 7. **DEFINE NODE CONNECTIONS *BEFORE* CREATING THE FLOW.** 31 | - The `Flow` class does not have a public method to retrieve its start node. Therefore, you **must** configure all node connections (`->next()`, `->on()->next()`) *before* passing the start node to the `Flow` constructor. This is especially important for creating loops. 32 | - **Correct Pattern:** 33 | ```php 34 | $myStartNode = new MyNode(); 35 | $nextNode = new NextNode(); 36 | $myStartNode->on('continue')->next($myStartNode); // Define loop on the node itself 37 | $myStartNode->on('finish')->next($nextNode); 38 | $flow = new Flow($myStartNode); // Then create the flow with the fully wired node 39 | ``` 40 | 41 | 8. **USE PHP 8.3 FEATURES WISELY.** 42 | - **DO** use constructor property promotion for cleaner `Node` classes. 43 | - **DO** use the `match` expression inside `post()` for clean, readable action routing. 44 | - **DO NOT** use features like Fibers directly. Rely on the `async()` and `await()` functions provided by `react/async`. 45 | 46 | 9. **ENABLE STRICT TYPES.** 47 | - To prevent common type-related errors, **every new PHP file** you create (`nodes.php`, `flow.php`, `main.php`, and all utility files) **must** start with the following declaration on the very first line: 48 | ```php 49 | "Build a system that summarizes news articles from a list of URLs." 63 | 64 | 2. **AI: Propose a Plan.** Based on the documentation, the AI proposes a plan using PocketFlow concepts. 65 | > "I will create a `BatchFlow` to process each URL. The sub-flow will have two nodes: `FetchArticleNode` to download the content, and `SummarizeNode` to call an LLM. The final summaries will be stored in `$shared->summaries`." 66 | 67 | 3. **Human: Approve the Plan.** The human confirms the logic. 68 | 69 | 4. **AI: Generate the Code.** The AI writes the code for `utils/`, `nodes.php`, `flow.php`, and `main.php`, strictly following the Golden Rules and the Mandatory Project Structure. 70 | 71 | 5. **Human & AI: Test and Iterate.** The human runs `php main.php`. If there are errors, the AI debugs them by reviewing the Golden Rules. 72 | 73 | This collaborative process, guided by this documentation, is the fastest and most reliable way to build LLM applications with PocketFlow-PHP. 74 | 75 | ## Mandatory Project Structure 76 | 77 | Your final project **must** adhere to this structure. Do **not** create additional top-level directories or files, unless stated otherwise by the user, e.g. when asked to add a frontend. 78 | 79 | / 80 | ├── main.php # The application's single entry point. 81 | ├── nodes.php # All Node class definitions. 82 | ├── flow.php # All Flow creation functions. 83 | ├── utils/ # Directory for all helper functions (API calls, etc.). 84 | │ └── ... # e.g., llm_api.php, web_search.php 85 | ├── composer.json # Managed by Composer. Do not edit manually. 86 | └── src/ 87 | └── PocketFlow.php # The core framework. READ-ONLY. 88 | 89 | --- 90 | 91 | ### 1: Introduction to PocketFlow-PHP 92 | 93 | # PocketFlow-PHP 94 | 95 | 96 | A minimalist LLM framework for PHP, written in 368 lines of PHP. 97 | 98 | Build complex Agents, Workflows, and RAG systems with a tiny, powerful core. 99 | 100 | 101 | --- 102 | 103 | **PocketFlow-PHP** is a port of the minimalist Python LLM framework, bringing its core principles to the PHP ecosystem. It's designed for developers who want maximum control and flexibility without the bloat of larger frameworks. It is optimized for **Agentic Coding**, where humans design the system and an AI agent writes the code. 104 | 105 | - **Lightweight**: The entire framework core is in a single, well-tested file. 106 | - **Expressive**: Build everything you love from larger frameworks—Multi-Agent systems, RAG pipelines, and complex workflows—using simple, composable building blocks. 107 | 108 | ## How does it work? 109 | 110 | The core abstraction is a **Graph + Shared Store**. 111 | 112 | 1. **Node**: The smallest unit of work (e.g., call an LLM, read a file). 113 | 2. **Flow**: Connects Nodes into a graph. Transitions are determined by simple string "Actions". 114 | 3. **Shared Store**: A simple `stdClass` object passed through the entire flow, allowing nodes to communicate and share state. 115 | 116 | This simple model is all you need to create powerful design patterns: 117 | 118 | ## Getting Started in 60 Seconds 119 | 120 | Get up and running with your first AI-powered application. 121 | 122 | **Prerequisites:** PHP 8.3+ and [Composer](https://getcomposer.org/) must be installed. 123 | 124 | 1. **Clone the repository:** 125 | ```bash 126 | git clone https://github.com/weise25/PocketFlow-PHP.git 127 | cd PocketFlow-PHP 128 | ``` 129 | 130 | 2. **Install dependencies:** 131 | ```bash 132 | composer install 133 | ``` 134 | 135 | 3. **Set up your environment:** 136 | - Create a `.env` file in the root of the project. 137 | - Add your API keys to the `.env` file (e.g., `OPENROUTER_API_KEY=...`). 138 | 139 | ### Chapter 2: Core Abstraction 140 | 141 | # Core Abstraction 142 | 143 | This chapter explains the fundamental building blocks of **PocketFlow-PHP**. Understanding these concepts is essential for building any application with the framework. 144 | 145 | We model the LLM workflow as a **Graph + Shared Store**. 146 | 147 | - [Node] – The smallest unit of work. 148 | - [Flow] – The orchestrator that connects Nodes. 149 | - [Communication] – How Nodes and Flows share data. 150 | - [Batch] – Tools for handling multiple items efficiently. 151 | - [(Advanced) Async] – For non-blocking, I/O-bound tasks. 152 | - [(Advanced) Parallel] – For running multiple async tasks concurrently. 153 | 154 | 155 | --- 156 | 157 | ### 2.1: Node 158 | 159 | # Node 160 | 161 | A **`Node`** is the smallest building block in PocketFlow. It represents a single, concrete step in a workflow. Each Node follows a simple, three-step lifecycle: `prep() -> exec() -> post()`. 162 | 163 | 164 | ### 1. The Lifecycle 165 | 166 | 1. **`prep(stdClass $shared): mixed`** 167 | * **Purpose:** To read data from the `$shared` store and prepare it for the main task. 168 | * **Example:** Fetch a user ID from `$shared->user->id`. 169 | * **Returns:** The data needed by the `exec()` method. 170 | 171 | 2. **`exec(mixed $prepResult): mixed`** 172 | * **Purpose:** To perform the core computation. This is where you call LLMs, APIs, or run business logic. 173 | * **Rule:** This method **must not** access the `$shared` store directly. It should be a "pure" function that only works with the data it receives from `prep()`. 174 | * **Returns:** The result of the computation. 175 | 176 | 3. **`post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string`** 177 | * **Purpose:** To write the results from `exec()` back into the `$shared` store and to decide what happens next. 178 | * **Returns:** An **action string**. This string determines which path the `Flow` will take. Returning `null` triggers the default path. 179 | * > **AI Agent Rule:** The return type of this method **must** be `?string`. If you don't need to return a specific action, you **must** `return null;`. 180 | 181 | ### 2. Fault Tolerance 182 | 183 | You can make your Nodes resilient to failure using constructor arguments and a fallback method. 184 | 185 | **Retries:** 186 | Define the number of retries and the wait time in the constructor. 187 | 188 | ```php 189 | // nodes.php 190 | use PocketFlow\Node; 191 | 192 | class ApiCallNode extends Node 193 | { 194 | public function __construct() 195 | { 196 | // Try exec() up to 3 times, wait 5 seconds between failures. 197 | parent::__construct(maxRetries: 3, wait: 5); 198 | } 199 | 200 | public function exec(mixed $prepResult): string 201 | { 202 | // This might throw an exception if the API is down. 203 | return call_external_api(); 204 | } 205 | } 206 | ``` 207 | 208 | **Graceful Fallback:** 209 | If all retries fail, the `execFallback()` method is called. Instead of crashing, you can return a safe default value. 210 | 211 | ```php 212 | // nodes.php 213 | use PocketFlow\Node; 214 | 215 | class ApiCallNode extends Node 216 | { 217 | // ... constructor and exec() method ... 218 | 219 | public function execFallback(mixed $prepResult, \Throwable $e): string 220 | { 221 | // Log the error and return a safe default. 222 | error_log("API call failed after all retries: " . $e->getMessage()); 223 | return "Default value due to API failure."; 224 | } 225 | } 226 | ``` 227 | 228 | ### 3. Example: A Complete Node 229 | 230 | ```php 231 | // nodes.php 232 | use PocketFlow\Node; 233 | 234 | class SummarizeArticleNode extends Node 235 | { 236 | public function __construct() 237 | { 238 | parent::__construct(maxRetries: 2, wait: 3); 239 | } 240 | 241 | public function prep(stdClass $shared): ?string 242 | { 243 | return $shared->article_text ?? null; 244 | } 245 | 246 | public function exec(mixed $articleText): string 247 | { 248 | if (empty($articleText)) { 249 | return "No text provided."; 250 | } 251 | $prompt = "Summarize this article: " . $articleText; 252 | return call_llm($prompt); // This might fail. 253 | } 254 | 255 | public function execFallback(mixed $prepResult, \Throwable $e): string 256 | { 257 | return "Could not generate summary due to an error."; 258 | } 259 | 260 | public function post(stdClass $shared, mixed $prepResult, mixed $execResult): ?string 261 | { 262 | $shared->summary = $execResult; 263 | return null; // Triggers the "default" action. 264 | } 265 | } 266 | ``` 267 | 268 | --- 269 | ## 2.2: Flow 270 | 271 | A **`Flow`** is the orchestrator. It connects `Nodes` together to form a process graph. The path through the graph is determined by the **action strings** returned by each Node's `post()` method. 272 | 273 | ### 1. Action-Based Transitions 274 | 275 | You define the connections between nodes using a simple, fluent API. 276 | 277 | **Default Transition:** 278 | If a Node's `post()` method returns `null` (or the string `"default"`), the default path is taken. 279 | 280 | ```php 281 | // flow.php 282 | $nodeA = new NodeA(); 283 | $nodeB = new NodeB(); 284 | 285 | // If NodeA returns null, execute NodeB next. 286 | $nodeA->next($nodeB); 287 | 288 | $flow = new Flow($nodeA); 289 | ``` 290 | 291 | **Conditional Transitions:** 292 | You can create branches in your logic based on specific action strings. 293 | 294 | ```php 295 | // flow.php 296 | $reviewNode = new ReviewNode(); 297 | $approveNode = new ApproveNode(); 298 | $rejectNode = new RejectNode(); 299 | 300 | // If reviewNode returns "approved", go to approveNode. 301 | $reviewNode->on('approved')->next($approveNode); 302 | 303 | // If reviewNode returns "rejected", go to rejectNode. 304 | $reviewNode->on('rejected')->next($rejectNode); 305 | 306 | $flow = new Flow($reviewNode); 307 | ``` 308 | 309 | ### 2. Nested Flows (Composition) 310 | 311 | A `Flow` itself behaves like a `Node`. This allows you to build complex workflows by composing smaller, reusable flows. 312 | 313 | When a `Flow` is used as a node, the action returned by the **last node** of that inner flow is used to determine the next step in the outer flow. 314 | 315 | **Example:** 316 | 317 | ```php 318 | // flow.php 319 | use PocketFlow\Flow; 320 | 321 | // --- Sub-Flow 1: User Validation --- 322 | $checkUser = new CheckUserNode(); 323 | $checkPermissions = new CheckPermissionsNode(); 324 | $checkUser->next($checkPermissions); 325 | $validationFlow = new Flow($checkUser); // This flow might return "validation_failed" 326 | 327 | // --- Sub-Flow 2: Data Processing --- 328 | $loadData = new LoadDataNode(); 329 | $processData = new ProcessDataNode(); 330 | $loadData->next($processData); 331 | $processingFlow = new Flow($loadData); // This flow might return "processing_complete" 332 | 333 | // --- Main Flow: Composing the sub-flows --- 334 | $endNode = new EndNode(); 335 | $errorNode = new ErrorNode(); 336 | 337 | // Wire the main flow 338 | $validationFlow->next($processingFlow); // Default transition 339 | $validationFlow->on('validation_failed')->next($errorNode); 340 | $processingFlow->on('processing_complete')->next($endNode); 341 | 342 | $mainFlow = new Flow($validationFlow); 343 | $mainFlow->run($shared); 344 | ``` 345 | 346 | This creates a clear, modular structure: 347 | ```mermaid 348 | graph TD 349 | subgraph Main Flow 350 | A(validationFlow) --> B(processingFlow); 351 | A --> C(errorNode); 352 | B --> D(endNode); 353 | end 354 | ``` 355 | --- 356 | 357 | # 2.3: Communication 358 | 359 | Nodes and Flows need to share data. PocketFlow provides two mechanisms for this, each with a distinct purpose. 360 | 361 | ### 1. The Shared Store (`$shared`) 362 | 363 | This is the **primary** method of communication and state management. 364 | 365 | - **What it is:** A single `stdClass` object that is passed by reference to every Node in a Flow. 366 | - **Purpose:** To hold all **mutable application state**. This includes initial inputs, intermediate results, final outputs, counters, flags, etc. 367 | - **When to use:** For almost everything. It acts as the central "memory" or "database" for your workflow. 368 | - **How to use:** 369 | - Read from it in `prep()`. 370 | - Write to it in `post()`. 371 | 372 | > **AI Agent Golden Rule:** The `$shared` store is the single source of truth for your application's state. Any data that changes or needs to be passed between nodes **must** live in `$shared`. 373 | 374 | **Example:** 375 | 376 | ```php 377 | // main.php 378 | $shared = new stdClass(); 379 | $shared->user_id = 123; 380 | $shared->user_data = null; // To be filled by a node 381 | $shared->final_report = null; // To be filled by another node 382 | 383 | $flow->run($shared); 384 | 385 | // After the flow runs, $shared will be populated. 386 | echo $shared->final_report; 387 | ``` 388 | 389 | ### 2. Params (`$params`) 390 | 391 | This is a **secondary** mechanism for configuration. 392 | 393 | - **What it is:** An `array` that is passed by value to each Node. 394 | - **Purpose:** To hold **immutable configuration** that does not change during the flow's execution. 395 | - **When to use:** For things like model names, API endpoints, or identifiers in a `BatchFlow`. 396 | - **How to use:** 397 | - Set once on the top-level `Flow` using `$flow->setParams([...])`. 398 | - Access within any Node via `$this->params['my_key']`. 399 | 400 | > **AI Agent Warning:** Do not use `$params` to store state that needs to change (like counters or lists of results). The `$params` array is reset for each node in a loop, so any changes you make will be lost. Use `$shared` for that. 401 | 402 | **Example:** 403 | 404 | ```php 405 | // flow.php 406 | $node = new ApiCallNode(); 407 | $flow = new Flow($node); 408 | 409 | // Set the API endpoint once for the entire flow. 410 | $flow->setParams(['api_endpoint' => 'https://api.example.com/v1']); 411 | $flow->run($shared); 412 | 413 | // nodes.php 414 | class ApiCallNode extends Node 415 | { 416 | public function exec(mixed $prepResult): string 417 | { 418 | $endpoint = $this->params['api_endpoint']; 419 | // ... make a call to the endpoint ... 420 | return "Data from API"; 421 | } 422 | } 423 | ``` 424 | --- 425 | 426 | ### 2.4: Batch 427 | 428 | # Batch Processing 429 | 430 | PocketFlow provides two powerful classes for processing multiple items: `BatchNode` and `BatchFlow`. 431 | 432 | ### 1. `BatchNode` 433 | 434 | Use `BatchNode` when you have a list of **data items** and want to perform the **same operation** on each one. This is the "Map" part of a Map-Reduce pattern. 435 | 436 | **How it works:** 437 | - `prep(stdClass $shared)`: Must return an `array` of items to be processed. 438 | - `exec(mixed $item)`: Is called **sequentially** for each `$item` in the array from `prep()`. 439 | - `post(stdClass $shared, ..., array $execResultList)`: Receives an `array` containing the results of all `exec()` calls. 440 | 441 | **Example: Summarize Text Chunks** 442 | 443 | ```php 444 | // nodes.php 445 | use PocketFlow\BatchNode; 446 | 447 | class SummarizeChunksNode extends BatchNode 448 | { 449 | public function prep(stdClass $shared): array 450 | { 451 | // Assume $shared->text_chunks is an array of strings. 452 | return $shared->text_chunks; 453 | } 454 | 455 | public function exec(mixed $chunk): string 456 | { 457 | // This method is called for each chunk. 458 | return call_llm("Summarize this chunk: " . $chunk); 459 | } 460 | 461 | public function post(stdClass $shared, mixed $p, mixed $summaries): ?string 462 | { 463 | // $summaries is an array of all the individual summaries. 464 | $shared->combined_summary = implode("\n", $summaries); 465 | return null; 466 | } 467 | } 468 | ``` 469 | 470 | ### 2. `BatchFlow` 471 | 472 | Use `BatchFlow` when you want to run an entire **sub-flow** multiple times, each time with a different **configuration**. 473 | 474 | **How it works:** 475 | - `prep(stdClass $shared)`: Must return an `array` of **parameter arrays**. 476 | - The sub-flow is executed once for each parameter array. 477 | - Inside the sub-flow's nodes, the parameters are available via `$this->params`. 478 | 479 | **Example: Process Multiple Files** 480 | 481 | ```php 482 | // flow.php 483 | use PocketFlow\Flow; 484 | use PocketFlow\BatchFlow; 485 | 486 | function create_file_processing_flow(): Flow 487 | { 488 | // The sub-flow that processes a single file. 489 | $loadFileNode = new LoadFileNode(); 490 | $analyzeFileNode = new AnalyzeFileNode(); 491 | $loadFileNode->next($analyzeFileNode); 492 | $singleFileFlow = new Flow($loadFileNode); 493 | 494 | // The BatchFlow that orchestrates the process. 495 | $allFilesFlow = new ProcessAllFilesBatchFlow($singleFileFlow); 496 | return $allFilesFlow; 497 | } 498 | 499 | // nodes.php 500 | use PocketFlow\Node; 501 | use PocketFlow\BatchFlow; 502 | 503 | class ProcessAllFilesBatchFlow extends BatchFlow 504 | { 505 | public function prep(stdClass $shared): array 506 | { 507 | // Returns an array of parameter sets. 508 | // e.g., [['filename' => 'a.txt'], ['filename' => 'b.txt']] 509 | return array_map(fn($f) => ['filename' => $f], $shared->filenames); 510 | } 511 | } 512 | 513 | class LoadFileNode extends Node 514 | { 515 | public function exec(mixed $p): string 516 | { 517 | // Accesses the filename from the params set by the BatchFlow. 518 | $filename = $this->params['filename']; 519 | echo "Loading file: {$filename}\n"; 520 | return file_get_contents($filename); 521 | } 522 | 523 | public function post(stdClass $shared, mixed $p, mixed $content): ?string 524 | { 525 | $shared->current_content = $content; 526 | return null; 527 | } 528 | } 529 | 530 | class AnalyzeFileNode extends Node { /* ... analyzes $shared->current_content ... */ } 531 | ``` 532 | --- 533 | 534 | ### Seite 2.5: (Advanced) Async 535 | # (Advanced) Async 536 | 537 | Use `AsyncNode` and `AsyncFlow` for tasks that are **I/O-bound**, such as API calls, database queries, or reading large files. This allows your application to perform other work while waiting for these operations to complete, making it much more efficient. All async operations are powered by **ReactPHP**. 538 | 539 | ### 1. `AsyncNode` 540 | 541 | An `AsyncNode` is similar to a regular `Node`, but its lifecycle methods are asynchronous. 542 | 543 | **Rules for `AsyncNode`:** 544 | 1. Implement the `_async` versions of the lifecycle methods: `prep_async()`, `exec_async()`, `post_async()`. 545 | 2. Each of these methods **must** return a `React\Promise\PromiseInterface`. 546 | 3. The easiest way to return a promise is to wrap your logic in `return async(function() { ... })();`. 547 | 4. Inside an `async` block, you can use `await()` to wait for other promises to resolve. 548 | 549 | **Example: Asynchronous API Call** 550 | 551 | ```php 552 | // nodes.php 553 | use PocketFlow\AsyncNode; 554 | use React\Promise\PromiseInterface; 555 | use function React\Async\async; 556 | use function React\Async\await; 557 | 558 | class FetchUserDataNode extends AsyncNode 559 | { 560 | public function prep_async(stdClass $shared): PromiseInterface 561 | { 562 | return async(fn() => $shared->user_id)(); 563 | } 564 | 565 | public function exec_async(mixed $userId): PromiseInterface 566 | { 567 | // Assume call_user_api_async() returns a Promise. 568 | return call_user_api_async($userId); 569 | } 570 | 571 | public function post_async(stdClass $shared, mixed $p, mixed $userData): PromiseInterface 572 | { 573 | return async(function() use ($shared, $userData) { 574 | $shared->user_data = $userData; 575 | return null; 576 | })(); 577 | } 578 | } 579 | ``` 580 | 581 | ### 2. `AsyncFlow` 582 | 583 | An `AsyncFlow` is used to orchestrate flows that contain at least one `AsyncNode`. It can also correctly orchestrate regular synchronous `Nodes`. 584 | 585 | ### 3. Running an Async Workflow 586 | 587 | The top-level entry point of any application that uses async features **must** be wrapped in an `async` block. 588 | 589 | **Example:** 590 | 591 | ```php 592 | // main.php 593 | require_once __DIR__ . '/vendor/autoload.php'; 594 | require_once __DIR__ . '/flow.php'; 595 | use function React\Async\async; 596 | use function React\Async\await; 597 | 598 | // Wrap the entire application logic in async(). 599 | async(function() { 600 | $shared = new stdClass(); 601 | $shared->user_id = 123; 602 | 603 | $flow = create_user_data_flow(); // This flow returns an AsyncFlow 604 | 605 | // Use await() to run the flow and wait for it to complete. 606 | await($flow->run_async($shared)); 607 | 608 | print_r($shared->user_data); 609 | })(); 610 | ``` 611 | --- 612 | 613 | ### 2.6: (Advanced) Parallel 614 | 615 | # (Advanced) Parallel Execution 616 | 617 | Parallel execution in PocketFlow-PHP means running multiple **asynchronous** tasks **concurrently**. This is extremely useful for speeding up I/O-bound workflows where tasks don't depend on each other. 618 | 619 | > **Note:** This is **concurrency**, not true parallelism. PHP's single-threaded nature means CPU-bound tasks won't run faster. However, for waiting on multiple API calls or database queries at once, the performance gain is significant. 620 | 621 | ### 1. `AsyncParallelBatchNode` 622 | 623 | This is the parallel version of `AsyncBatchNode`. It calls `exec_async()` for **all items at the same time** and waits for all of them to complete. 624 | 625 | **Use Case:** Fetching data for multiple users from an API simultaneously. 626 | 627 | **Example: Parallel API Calls** 628 | 629 | ```php 630 | // nodes.php 631 | use PocketFlow\AsyncParallelBatchNode; 632 | use React\Promise\PromiseInterface; 633 | use function React\Async\async; 634 | 635 | class FetchAllUsersNode extends AsyncParallelBatchNode 636 | { 637 | public function prep_async(stdClass $shared): PromiseInterface 638 | { 639 | // Returns an array of user IDs. 640 | return async(fn() => $shared->user_ids)(); 641 | } 642 | 643 | public function exec_async(mixed $userId): PromiseInterface 644 | { 645 | // This is called concurrently for all user IDs. 646 | // Assume call_user_api_async() returns a Promise. 647 | return call_user_api_async($userId); 648 | } 649 | 650 | public function post_async(stdClass $shared, mixed $p, mixed $usersData): PromiseInterface 651 | { 652 | return async(function() use ($shared, $usersData) { 653 | // $usersData is an array of results. The order is preserved. 654 | $shared->users = $usersData; 655 | return null; 656 | })(); 657 | } 658 | } 659 | ``` 660 | 661 | ### 2. `AsyncParallelBatchFlow` 662 | 663 | This is the parallel version of `AsyncBatchFlow`. It runs the entire sub-flow for **all parameter sets concurrently**. 664 | 665 | **Use Case:** Processing multiple files, where each file requires a multi-step async workflow (e.g., download, analyze, upload). 666 | 667 | **Example: Parallel File Processing** 668 | 669 | ```php 670 | // flow.php 671 | use PocketFlow\AsyncFlow; 672 | use PocketFlow\AsyncParallelBatchFlow; 673 | 674 | function create_parallel_file_flow(): AsyncFlow 675 | { 676 | // The sub-flow for a single file. 677 | $downloadNode = new DownloadFileAsyncNode(); 678 | $analyzeNode = new AnalyzeFileAsyncNode(); 679 | $downloadNode->next($analyzeNode); 680 | $singleFileFlow = new AsyncFlow($downloadNode); 681 | 682 | // The parallel batch flow. 683 | $parallelFlow = new ProcessAllFilesParallelBatchFlow($singleFileFlow); 684 | return $parallelFlow; 685 | } 686 | 687 | // nodes.php 688 | use PocketFlow\AsyncParallelBatchFlow; 689 | use React\Promise\PromiseInterface; 690 | use function React\Async\async; 691 | 692 | class ProcessAllFilesParallelBatchFlow extends AsyncParallelBatchFlow 693 | { 694 | public function prep_async(stdClass $shared): PromiseInterface 695 | { 696 | // Returns an array of parameter sets. 697 | return async(fn() => array_map(fn($f) => ['url' => $f], $shared->file_urls))(); 698 | } 699 | } 700 | 701 | // DownloadFileAsyncNode and AnalyzeFileAsyncNode are defined as regular AsyncNodes. 702 | // They will access the URL via $this->params['url']. 703 | ``` 704 | 705 | > **Warning:** Be mindful of API rate limits when using parallel execution. A large number of concurrent requests can quickly exhaust your quota. 706 | 707 | # Chapter 3: Design Pattern 708 | 709 | # Design Patterns 710 | 711 | Design patterns are reusable blueprints for solving common problems in LLM application development. PocketFlow-PHP's simple core abstractions (`Node`, `Flow`, `Shared Store`) are all you need to implement these powerful patterns. 712 | 713 | This chapter provides practical, copy-paste-ready examples for each pattern. 714 | 715 | - [Agent] – For creating autonomous, decision-making entities. 716 | - [Workflow] – For breaking down complex tasks into a sequence of simple steps. 717 | - [RAG (Retrieval-Augmented Generation)] – For providing external knowledge to an LLM. 718 | - [Map-Reduce] – For processing large amounts of data efficiently. 719 | - [Structured Output] – For forcing an LLM to return clean, predictable data formats. 720 | - [(Advanced) Multi-Agent Systems] – For orchestrating collaboration between multiple specialized agents. 721 | --- 722 | 723 | ### Seite 3.1: Agent 724 | 725 | # Agent 726 | 727 | An **Agent** is a `Node` that can make autonomous decisions. Instead of following a fixed path, it analyzes the current state in the `$shared` store and returns a specific **action string** to direct the `Flow` down different branches, often looping back to itself to create a cycle of reasoning and action. 728 | 729 | ### The Agentic Loop 730 | 731 | The core of an agent is a loop: 732 | 1. **Think:** The Agent `Node` analyzes the state (`$shared`) and the goal. 733 | 2. **Decide:** It calls an LLM to choose the next `action` from a predefined set of tools. 734 | 3. **Act:** The `Flow` routes to a `Tool Node` based on the chosen action. 735 | 4. **Observe:** The `Tool Node` executes its task (e.g., a web search) and updates the `$shared` store with the result. 736 | 5. The `Flow` loops back to the Agent `Node` to start the cycle again with new information. 737 | 738 | ```mermaid 739 | graph TD 740 | A[Agent Node (Think & Decide)] -- action: 'search' --> B[Search Tool Node (Act)]; 741 | A -- action: 'answer' --> C[Answer Node (Final Act)]; 742 | B -- result --> D(Update Shared Store); 743 | D --> A; 744 | ``` 745 | 746 | ### Example: A Simple Research Agent 747 | 748 | This agent can decide whether to search the web for more information or to answer a question based on the context it has already gathered. 749 | 750 | **`flow.php`** 751 | ```php 752 | on('search')->next($searchNode); 764 | $decideNode->on('answer')->next($answerNode); 765 | $searchNode->next($decideNode); // Loop back to the decision node after acting 766 | 767 | return new Flow($decideNode); 768 | } 769 | ``` 770 | 771 | **`nodes.php`** 772 | ```php 773 | search_history) ? "Nothing yet." : implode("\n", $shared->search_history); 785 | return [$shared->query, $context]; 786 | } 787 | 788 | public function exec(mixed $prepResult): array 789 | { 790 | [$query, $context] = $prepResult; 791 | $prompt = <<current_search_term = $decision['search_term']; 816 | } 817 | return $decision['action']; 818 | } 819 | } 820 | 821 | class SearchWebNode extends Node 822 | { 823 | public function prep(stdClass $shared): string 824 | { 825 | return $shared->current_search_term; 826 | } 827 | 828 | public function exec(mixed $searchTerm): string 829 | { 830 | echo "Agent is searching for: {$searchTerm}\n"; 831 | return web_search($searchTerm); // Assume web_search() is defined 832 | } 833 | 834 | public function post(stdClass $shared, mixed $p, mixed $searchResult): ?string 835 | { 836 | $shared->search_history[] = $searchResult; 837 | return null; // Triggers default transition back to DecideActionNode 838 | } 839 | } 840 | 841 | class AnswerNode extends Node 842 | { 843 | public function exec(mixed $p): string 844 | { 845 | // In a real app, this would also use prep() to get context 846 | return "This is the final answer based on the research."; 847 | } 848 | 849 | public function post(stdClass $shared, mixed $p, mixed $answer): ?string 850 | { 851 | $shared->final_answer = $answer; 852 | return null; // End of flow 853 | } 854 | } 855 | ``` 856 | --- 857 | 858 | ### Chapter 3.2 - 3.6 (Workflow, RAG, etc.) 859 | 860 | # Workflow 861 | 862 | A **Workflow** is the simplest design pattern. It involves chaining a sequence of `Nodes` together to break a complex task into manageable steps. Each node performs one part of the job and passes its result to the next via the `$shared` store. 863 | 864 | ### Example: Article Writing Workflow 865 | 866 | This workflow generates an article by breaking the process into three distinct steps: outlining, drafting, and reviewing. 867 | 868 | **`flow.php`** 869 | ```php 870 | next($draftNode)->next($reviewNode); 882 | 883 | return new Flow($outlineNode); 884 | } 885 | ``` 886 | 887 | **`nodes.php`** 888 | ```php 889 | topic; } 894 | public function exec(mixed $topic): string { return "Outline for: {$topic}"; /* call_llm(...) */ } 895 | public function post(stdClass $shared, mixed $p, mixed $outline): ?string { 896 | $shared->outline = $outline; 897 | return null; 898 | } 899 | } 900 | 901 | class DraftArticleNode extends Node { 902 | public function prep(stdClass $shared): string { return $shared->outline; } 903 | public function exec(mixed $outline): string { return "Draft based on: {$outline}"; /* call_llm(...) */ } 904 | public function post(stdClass $shared, mixed $p, mixed $draft): ?string { 905 | $shared->draft = $draft; 906 | return null; 907 | } 908 | } 909 | 910 | class ReviewArticleNode extends Node { 911 | public function prep(stdClass $shared): string { return $shared->draft; } 912 | public function exec(mixed $draft): string { return "Reviewed draft: {$draft}"; /* call_llm(...) */ } 913 | public function post(stdClass $shared, mixed $p, mixed $final): ?string { 914 | $shared->final_article = $final; 915 | return null; 916 | } 917 | } 918 | ``` 919 | 920 | # RAG (Retrieval-Augmented Generation) 921 | 922 | RAG allows an LLM to answer questions using information from your own documents. It's a two-stage process. 923 | 924 | ### Stage 1: Offline Indexing (A Workflow) 925 | 926 | This flow reads documents, splits them into chunks, creates vector embeddings for each chunk, and stores them in a vector database. 927 | 928 | **`flow.php` (Offline Indexing)** 929 | ```php 930 | next($embedNode)->next($storeNode); 940 | return new Flow($chunkNode); 941 | } 942 | ``` 943 | 944 | **`nodes.php` (Offline Indexing)** 945 | ```php 946 | file_paths; } 953 | public function exec(mixed $path): array { return sentence_chunk(file_get_contents($path)); } 954 | public function post(stdClass $shared, mixed $p, mixed $chunkLists): ?string { 955 | $shared->all_chunks = array_merge(...$chunkLists); 956 | return null; 957 | } 958 | } 959 | 960 | // 2. Embedding (using BatchNode) 961 | class EmbedChunksNode extends BatchNode { 962 | public function prep(stdClass $shared): array { return $shared->all_chunks; } 963 | public function exec(mixed $chunk): array { return get_embedding($chunk); } // Assume get_embedding() exists 964 | public function post(stdClass $shared, mixed $p, mixed $embeddings): ?string { 965 | $shared->embeddings = $embeddings; 966 | return null; 967 | } 968 | } 969 | 970 | // 3. Storing 971 | class StoreEmbeddingsNode extends Node { 972 | public function exec(mixed $p): mixed { 973 | /* ... code to store embeddings in a vector DB ... */ 974 | return null; // Must return a value compatible with 'mixed' 975 | } 976 | } 977 | ``` 978 | 979 | ### Stage 2: Online Retrieval & Answering (A Workflow) 980 | 981 | This flow takes a user's question, finds the most relevant chunks from the vector database, and passes them to an LLM as context to generate an answer. 982 | 983 | **`flow.php` (Online Answering)** 984 | ```php 985 | next($retrieveDocs)->next($generateAnswer); 995 | return new Flow($embedQuery); 996 | } 997 | ``` 998 | 999 | # Map-Reduce 1000 | 1001 | The Map-Reduce pattern is perfect for processing large datasets that can be broken down into smaller, independent parts. 1002 | 1003 | - **Map Phase:** A `BatchNode` processes each part of the data individually. 1004 | - **Reduce Phase:** A regular `Node` takes the list of results from the map phase and combines them into a single, final output. 1005 | 1006 | ### Example: Summarizing Multiple Documents 1007 | 1008 | **`flow.php`** 1009 | ```php 1010 | next($reduceNode); 1019 | return new Flow($mapNode); 1020 | } 1021 | ``` 1022 | 1023 | **`nodes.php`** 1024 | ```php 1025 | documents is an associative array ['filename' => 'content', ...] 1035 | return $shared->documents; 1036 | } 1037 | 1038 | public function exec(mixed $content): string 1039 | { 1040 | return call_llm("Summarize this document: " . $content); 1041 | } 1042 | 1043 | public function post(stdClass $shared, mixed $p, mixed $summaries): ?string 1044 | { 1045 | $shared->individual_summaries = $summaries; 1046 | return null; 1047 | } 1048 | } 1049 | 1050 | // REDUCE: Combine all summaries into one 1051 | class ReduceSummariesNode extends Node 1052 | { 1053 | public function prep(stdClass $shared): array 1054 | { 1055 | return $shared->individual_summaries; 1056 | } 1057 | 1058 | public function exec(mixed $summaries): string 1059 | { 1060 | $combinedText = implode("\n---\n", $summaries); 1061 | return call_llm("Create a final summary from these smaller summaries: " . $combinedText); 1062 | } 1063 | 1064 | public function post(stdClass $shared, mixed $p, mixed $finalSummary): ?string 1065 | { 1066 | $shared->final_summary = $finalSummary; 1067 | return null; 1068 | } 1069 | } 1070 | ``` 1071 | 1072 | # Structured Output 1073 | 1074 | Often, you need an LLM to return data in a specific format like JSON or an array. The most reliable way to achieve this is through **prompt engineering**. 1075 | 1076 | ### The YAML Technique 1077 | 1078 | YAML is often easier for LLMs to generate correctly than JSON because it is less strict about quotes and commas. 1079 | 1080 | **The Strategy:** 1081 | 1. In your prompt, provide a clear example of the YAML structure you expect. 1082 | 2. Wrap the example in ```yaml code blocks. 1083 | 3. In your `exec()` method, parse the YAML string from the LLM's response. 1084 | 1085 | ### Example: Extracting Information from Text 1086 | 1087 | ```php 1088 | // nodes.php 1089 | use PocketFlow\Node; 1090 | use Symfony\Component\Yaml\Yaml; // `composer require symfony/yaml` 1091 | 1092 | class ExtractUserInfoNode extends Node 1093 | { 1094 | public function prep(stdClass $shared): string 1095 | { 1096 | return $shared->raw_text; // e.g., "John Doe is 30 years old and lives in New York." 1097 | } 1098 | 1099 | public function exec(mixed $text): array 1100 | { 1101 | $prompt = <<user_info = $userInfo; 1128 | return null; 1129 | } 1130 | } 1131 | ``` 1132 | 1133 | # (Advanced) Multi-Agent Systems 1134 | 1135 | A Multi-Agent System involves two or more autonomous `Agents` collaborating to solve a problem. In PocketFlow-PHP, this is achieved by running multiple `AsyncFlows` concurrently, with agents communicating via shared **message queues**. 1136 | 1137 | ### The Core Pattern 1138 | 1139 | 1. **One Flow per Agent:** Each agent's logic is contained in its own `AsyncNode`, which runs in a loop inside its own `AsyncFlow`. 1140 | 2. **Message Queues:** A simple, promise-based queue class is used for communication. Queues are stored in the `$shared` object. 1141 | 3. **Concurrent Execution:** All agent flows are started simultaneously using `React\Promise\all()`. 1142 | 4. **State Management:** 1143 | - **Immutable Config:** Agent-specific configuration (like model name or personality prompt) is passed via `$params`. 1144 | - **Mutable State:** Shared game state (like scores or history) is managed in the `$shared` object. 1145 | 1146 | ### Example: The Taboo Game 1147 | 1148 | This example features a "Hinter" agent and a "Guesser" agent playing the word game Taboo. 1149 | 1150 | **`flow.php`** 1151 | ```php 1152 | hinterQueue = new MessageQueue(); 1160 | $shared->guesserQueue = new MessageQueue(); 1161 | $shared->past_guesses = []; 1162 | $shared->guess_count = 0; 1163 | 1164 | // 2. Create and configure agent nodes and flows 1165 | $hinterNode = new HinterAgent(); 1166 | $guesserNode = new GuesserAgent(); 1167 | 1168 | $hinterNode->on('continue')->next($hinterNode); // Loop 1169 | $guesserNode->on('continue')->next($guesserNode); // Loop 1170 | 1171 | $hinterFlow = new AsyncFlow($hinterNode); 1172 | $guesserFlow = new AsyncFlow($guesserNode); 1173 | 1174 | // ... set params for each flow ... 1175 | 1176 | // 3. Start the game and run flows concurrently 1177 | $shared->hinterQueue->put("START"); 1178 | await(\React\Promise\all([ 1179 | $hinterFlow->run_async($shared), 1180 | $guesserFlow->run_async($shared) 1181 | ])); 1182 | })(); 1183 | } 1184 | ``` 1185 | 1186 | **`nodes.php`** 1187 | ```php 1188 | hinterQueue->get(); // Wait for a message 1196 | } 1197 | public function exec_async(mixed $message): PromiseInterface { 1198 | // ... generate a hint based on the message ... 1199 | } 1200 | public function post_async(stdClass $shared, mixed $p, mixed $hint): PromiseInterface { 1201 | return async(function() use ($shared, $hint) { 1202 | $shared->guesserQueue->put($hint); // Send hint to the other agent 1203 | return "continue"; 1204 | })(); 1205 | } 1206 | } 1207 | 1208 | class GuesserAgent extends AsyncNode { 1209 | public function prep_async(stdClass $shared): PromiseInterface { 1210 | return $shared->guesserQueue->get(); // Wait for a hint 1211 | } 1212 | // ... exec and post methods to make a guess and send it back ... 1213 | } 1214 | ``` 1215 | 1216 | --- 1217 | 1218 | ### 4: Utility Function 1219 | 1220 | # Utility Functions 1221 | 1222 | PocketFlow-PHP intentionally does not include built-in wrappers for external APIs. This "bring your own" philosophy gives you maximum flexibility and avoids vendor lock-in. 1223 | 1224 | This chapter provides simple, copy-paste-ready snippets for common utility functions. Place these functions in new files inside your `utils/` directory (e.g., `utils/llm_api.php`). 1225 | 1226 | - [LLM Wrapper] 1227 | - [Viz and Debug] 1228 | - [Web Search] 1229 | - [Text Chunking] 1230 | - [Embedding] 1231 | - [Vector Databases] 1232 | - [Text to Speech] 1233 | 1234 | --- 1235 | 1236 | ### 4.1 - 4.7 (Utility Function Examples Pages) 1237 | 1238 | # LLM Wrapper 1239 | 1240 | A centralized function to handle all your LLM API calls is a best practice. 1241 | 1242 | ### Recommended Client: `openai-php/client` 1243 | 1244 | This client is compatible with any OpenAI-compliant API, including OpenRouter, DeepSeek, and many local models. 1245 | 1246 | **`utils/llm_api.php`** 1247 | ```php 1248 | withApiKey(getenv('OPENROUTER_API_KEY')) 1259 | ->withBaseUri('https://openrouter.ai/api/v1') 1260 | ->withHttpHeader('HTTP-Referer', 'http://localhost') // Required by OpenRouter 1261 | ->withHttpHeader('X-Title', 'PocketFlow-PHP') 1262 | ->make(); 1263 | } 1264 | 1265 | function call_llm_async(string $model, array $messages): PromiseInterface 1266 | { 1267 | return async(function () use ($model, $messages) { 1268 | try { 1269 | $client = get_openrouter_client(); 1270 | $response = $client->chat()->create([ 1271 | 'model' => $model, 1272 | 'messages' => $messages, 1273 | ]); 1274 | return $response->choices[0]->message->content; 1275 | } catch (\Exception $e) { 1276 | error_log("API Error: " . $e->getMessage()); 1277 | return "Error communicating with the LLM."; 1278 | } 1279 | })(); 1280 | } 1281 | ``` 1282 | 1283 | # Visualization and Debugging 1284 | 1285 | Simple helpers can make debugging complex flows much easier. 1286 | 1287 | ### Mermaid Graph Generator 1288 | 1289 | This function traverses a `Flow` and generates a Mermaid syntax string, which you can paste into any Mermaid renderer to visualize your graph. 1290 | 1291 | **`utils/visualize.php`** 1292 | ```php 1293 | enqueue($startNode); 1303 | $nodes[$startNode] = "N" . ($nodes->count() + 1); 1304 | 1305 | while (!$queue->isEmpty()) { 1306 | $current = $queue->dequeue(); 1307 | $currentId = $nodes[$current]; 1308 | $className = (new \ReflectionClass($current))->getShortName(); 1309 | $dot .= " {$currentId}[\"{$className}\"]\n"; 1310 | 1311 | foreach ($current->getSuccessors() as $action => $successor) { 1312 | if (!$successor) continue; 1313 | if (!$nodes->contains($successor)) { 1314 | $nodes[$successor] = "N" . ($nodes->count() + 1); 1315 | $queue->enqueue($successor); 1316 | } 1317 | $successorId = $nodes[$successor]; 1318 | $dot .= " {$currentId} -- \"{$action}\" --> {$successorId}\n"; 1319 | } 1320 | } 1321 | return $dot; 1322 | } 1323 | ``` 1324 | 1325 | # Web Search 1326 | 1327 | A simple wrapper for a web search API. The DuckDuckGo API is free and requires no API key, making it great for testing. 1328 | 1329 | **`utils/web_search.php`** 1330 | ```php 1331 | ['header' => "User-Agent: PocketFlow-PHP/1.0\r\n"]]; 1336 | $context = stream_context_create($options); 1337 | $response = @file_get_contents($url, false, $context); 1338 | 1339 | if ($response === false) { 1340 | return "Error fetching search results."; 1341 | } 1342 | 1343 | $data = json_decode($response, true); 1344 | return $data['AbstractText'] ?? 'No results found.'; 1345 | } 1346 | ``` 1347 | 1348 | # Text Chunking 1349 | 1350 | Breaking large texts into smaller, manageable chunks is a core task in RAG systems. 1351 | 1352 | **`utils/chunking.php`** 1353 | ```php 1354 | embeddings()->create([ 1388 | 'model' => $model, 1389 | 'input' => $text, 1390 | ]); 1391 | return $response->embeddings[0]->embedding; 1392 | })(); 1393 | } 1394 | ``` 1395 | # Vector Databases 1396 | 1397 | Storing and retrieving embeddings requires a vector database. Here is a very simple in-memory example for prototyping. For production, use a dedicated service like Pinecone, Qdrant, or Redis. 1398 | 1399 | **`utils/memory_vector_store.php`** 1400 | ```php 1401 | vectors[$id] = $vector; 1410 | $this->metadata[$id] = $meta; 1411 | } 1412 | 1413 | public function search(array $queryVector, int $topK = 1): array 1414 | { 1415 | $distances = []; 1416 | foreach ($this->vectors as $id => $vector) { 1417 | // Cosine similarity calculation 1418 | $dotProduct = 0; 1419 | $normA = 0; 1420 | $normB = 0; 1421 | for ($i = 0; $i < count($vector); $i++) { 1422 | $dotProduct += $queryVector[$i] * $vector[$i]; 1423 | $normA += $queryVector[$i] ** 2; 1424 | $normB += $vector[$i] ** 2; 1425 | } 1426 | $similarity = $dotProduct / (sqrt($normA) * sqrt($normB)); 1427 | $distances[$id] = $similarity; 1428 | } 1429 | 1430 | arsort($distances); // Sort by similarity, descending 1431 | 1432 | $results = []; 1433 | foreach (array_slice($distances, 0, $topK, true) as $id => $score) { 1434 | $results[] = [ 1435 | 'id' => $id, 1436 | 'score' => $score, 1437 | 'metadata' => $this->metadata[$id] 1438 | ]; 1439 | } 1440 | return $results; 1441 | } 1442 | } 1443 | ``` 1444 | # Text to Speech (TTS) 1445 | 1446 | Generate audio from text using a TTS service like ElevenLabs. 1447 | 1448 | **`utils/tts.php`** 1449 | ```php 1450 | post("https://api.elevenlabs.io/v1/text-to-speech/{$voiceId}", [ 1461 | 'headers' => [ 1462 | 'Accept' => 'audio/mpeg', 1463 | 'Content-Type' => 'application/json', 1464 | 'xi-api-key' => $apiKey, 1465 | ], 1466 | 'json' => [ 1467 | 'text' => $text, 1468 | 'model_id' => 'eleven_monolingual_v1', 1469 | ], 1470 | 'sink' => $outputFilePath // Stream response directly to a file 1471 | ]); 1472 | echo "Audio saved to {$outputFilePath}\n"; 1473 | } catch (\Exception $e) { 1474 | echo "Error generating audio: " . $e->getMessage() . "\n"; 1475 | } 1476 | } 1477 | ``` 1478 | --------------------------------------------------------------------------------