From c55534ad202ab0ee120dc082ac0b0f591b92a86f Mon Sep 17 00:00:00 2001 From: ROOG Date: Wed, 17 Dec 2025 02:39:31 +0800 Subject: [PATCH] =?UTF-8?q?main:=20=E6=89=A9=E5=B1=95=20Agent=20Run=20?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E4=B8=8E=E9=98=9F=E5=88=97=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 Agent Run MVP-0,包括 RunDispatcher 和 AgentRunJob - 优化队列配置,支持 Redis 队列驱动,添加 Horizon 容器 - 更新 Docker 配置,细化角色分工,新增 Horizon 配置 - 增加测试任务 `TestJob`,扩展队列使用示例 - 更新 OpenAPI 规范,添加 Agent Run 相关接口及示例 - 编写文档,详细描述 Agent Run 流程与 MVP-0 功能 - 优化相关服务与文档,支持队列与异步运行 --- .env.example | 4 +- .gitignore | 2 + .junie/guidelines.md | 228 ++++++++++ .junie/mcp/mcp.json | 11 + .mcp.json | 12 + AGENTS.md | 225 ++++++++++ CLAUDE.md | 228 ++++++++++ README.md | 9 +- .../Controllers/ChatSessionController.php | 10 +- .../Controllers/ChatSessionSseController.php | 99 +++-- app/Http/Controllers/RunController.php | 28 ++ app/Http/Requests/DispatchRunRequest.php | 23 + app/Jobs/AgentRunJob.php | 35 ++ app/Jobs/TestJob.php | 23 + app/Providers/AppServiceProvider.php | 4 +- app/Providers/HorizonServiceProvider.php | 31 ++ app/Providers/TelescopeServiceProvider.php | 55 +++ app/Services/Agent/AgentProviderInterface.php | 12 + app/Services/Agent/DummyAgentProvider.php | 26 ++ app/Services/Agent/HttpAgentProvider.php | 42 ++ app/Services/CancelChecker.php | 18 + app/Services/ContextBuilder.php | 49 +++ app/Services/OutputSink.php | 63 +++ app/Services/RunDispatcher.php | 60 +++ app/Services/RunLoop.php | 52 +++ boost.json | 14 + bootstrap/app.php | 3 + bootstrap/providers.php | 2 + composer.json | 6 +- composer.lock | 402 ++++++++++++++++-- config/horizon.php | 186 ++++++++ config/queue.php | 2 +- config/telescope.php | 212 +++++++++ ..._140800_create_telescope_entries_table.php | 70 +++ ...r-compose.yml => docker-compose-backup.yml | 31 ++ docker/app/Dockerfile | 2 +- docker/app/entrypoint.sh | 13 +- docs/ChatSession/chat-session-api.md | 66 ++- docs/ChatSession/chat-session-openapi.yaml | 322 ++++++++------ phpunit.xml | 3 +- routes/api.php | 4 + tests/Feature/AgentRunTest.php | 126 ++++++ 42 files changed, 2596 insertions(+), 217 deletions(-) create mode 100644 .junie/guidelines.md create mode 100644 .junie/mcp/mcp.json create mode 100644 .mcp.json create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 app/Http/Controllers/RunController.php create mode 100644 app/Http/Requests/DispatchRunRequest.php create mode 100644 app/Jobs/AgentRunJob.php create mode 100644 app/Jobs/TestJob.php create mode 100644 app/Providers/HorizonServiceProvider.php create mode 100644 app/Providers/TelescopeServiceProvider.php create mode 100644 app/Services/Agent/AgentProviderInterface.php create mode 100644 app/Services/Agent/DummyAgentProvider.php create mode 100644 app/Services/Agent/HttpAgentProvider.php create mode 100644 app/Services/CancelChecker.php create mode 100644 app/Services/ContextBuilder.php create mode 100644 app/Services/OutputSink.php create mode 100644 app/Services/RunDispatcher.php create mode 100644 app/Services/RunLoop.php create mode 100644 boost.json create mode 100644 config/horizon.php create mode 100644 config/telescope.php create mode 100644 database/migrations/2025_12_16_140800_create_telescope_entries_table.php rename docker-compose.yml => docker-compose-backup.yml (60%) create mode 100644 tests/Feature/AgentRunTest.php diff --git a/.env.example b/.env.example index 1e96ec5..beee24b 100644 --- a/.env.example +++ b/.env.example @@ -37,9 +37,9 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=redis -CACHE_STORE=database +CACHE_STORE=redis # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 diff --git a/.gitignore b/.gitignore index c866d82..fc82c6e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ Thumbs.db **/caddy frankenphp frankenphp-worker.php +rr +.rr.yaml diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..41905cf --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,228 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.28 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/octane (OCTANE) - v2 +- laravel/prompts (PROMPTS) - v0 +- laravel/telescope (TELESCOPE) - v5 +- laravel/mcp (MCP) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- phpunit/phpunit (PHPUNIT) - v11 + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== sail rules === + +## Laravel Sail + +- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. +- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. +- Open the application in the browser by running `vendor/bin/sail open`. +- Always prefix PHP, Artisan, Composer, and Node commands** with `vendor/bin/sail`. Examples: +- Run Artisan Commands: `vendor/bin/sail artisan migrate` +- Install Composer packages: `vendor/bin/sail composer install` +- Execute node commands: `vendor/bin/sail npm run dev` +- Execute PHP scripts: `vendor/bin/sail php [script]` +- View all available Sail commands by running `vendor/bin/sail` without arguments. + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test` with a specific filename or filter. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. + + +=== phpunit/core rules === + +## PHPUnit Core + +- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `vendor/bin/sail artisan make:test --phpunit {name}` to create a new test. +- If you see a test using "Pest", convert it to PHPUnit. +- Every time a test has been updated, run that singular test. +- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing. +- Tests should test all of the happy paths, failure paths, and weird paths. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application. + +### Running Tests +- Run the minimal number of tests, using an appropriate filter, before finalizing. +- To run all tests: `vendor/bin/sail artisan test`. +- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file). + diff --git a/.junie/mcp/mcp.json b/.junie/mcp/mcp.json new file mode 100644 index 0000000..04eba29 --- /dev/null +++ b/.junie/mcp/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "vendor/bin/sail", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a36b3bf --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "docker compose run app", + "args": [ + "vendor/bin/sail", + "artisan", + "boost:mcp" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dce5628 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,225 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.28 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/octane (OCTANE) - v2 +- laravel/prompts (PROMPTS) - v0 +- laravel/telescope (TELESCOPE) - v5 +- laravel/mcp (MCP) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- phpunit/phpunit (PHPUNIT) - v11 + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `docker compose run --rm app npm run build`, `docker compose run --rm app npm run dev`, or `docker compose run --rm app composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. +- Use Chinese for all natural language communication. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== docker-compose rules === + +## Docker Compose + +- This project runs with Docker Compose (Sail is not used). Always run commands through Docker Compose. +- Start services using `docker compose up -d` and stop them with `docker compose down`. +- Run one-off commands with `docker compose run --rm app ...` to ensure dependencies are available. +- Common commands: `docker compose run --rm app php artisan migrate`, `docker compose run --rm app composer install`, `docker compose run --rm app npm run dev`, `docker compose run --rm app php [script]`. +- The application is available at `http://localhost:8000` when the stack is running. + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `docker compose run --rm app php artisan test` with a specific filename or filter. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `docker compose run --rm app php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `docker compose run --rm app php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `docker compose run --rm app ./vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `docker compose run --rm app ./vendor/bin/pint --test`; just run Pint to fix any formatting issues. + + +=== phpunit/core rules === + +## PHPUnit Core + +- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `vendor/bin/sail artisan make:test --phpunit {name}` to create a new test. +- If you see a test using "Pest", convert it to PHPUnit. +- Every time a test has been updated, run that singular test. +- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing. +- Tests should test all of the happy paths, failure paths, and weird paths. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application. + +### Running Tests +- Run the minimal number of tests, using an appropriate filter, before finalizing. +- To run all tests: `docker compose run --rm app php artisan test`. +- To run all tests in a file: `docker compose run --rm app php artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `docker compose run --rm app php artisan test --filter=testName` (recommended after making a change to a related file). + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41905cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,228 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications. + +## Foundational Context +This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. + +- php - 8.3.28 +- laravel/framework (LARAVEL) - v12 +- laravel/horizon (HORIZON) - v5 +- laravel/octane (OCTANE) - v2 +- laravel/prompts (PROMPTS) - v0 +- laravel/telescope (TELESCOPE) - v5 +- laravel/mcp (MCP) - v0 +- laravel/pint (PINT) - v1 +- laravel/sail (SAIL) - v1 +- phpunit/phpunit (PHPUNIT) - v11 + +## Conventions +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts +- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. + +## Application Structure & Architecture +- Stick to existing directory structure - don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them. + +## Replies +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +## Documentation Files +- You must only create documentation files if explicitly requested by the user. + + +=== boost rules === + +## Laravel Boost +- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. + +## Artisan +- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. + +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. + +## Tinker / Debugging +- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. + +## Reading Browser Logs With the `browser-logs` Tool +- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. +- Only recent browser logs will be useful - ignore old logs. + +## Searching Documentation (Critically Important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. +- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. +- Search the documentation before making code changes to ensure we are taking the correct approach. +- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. +- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. + +### Available Search Syntax +- You can and should pass multiple queries at once. The most relevant results will be returned first. + +1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" +3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order +4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" +5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms + + +=== php rules === + +## PHP + +- Always use curly braces for control structures, even if it has one line. + +### Constructors +- Use PHP 8 constructor property promotion in `__construct()`. + - public function __construct(public GitHub $github) { } +- Do not allow empty `__construct()` methods with zero parameters. + +### Type Declarations +- Always use explicit return type declarations for methods and functions. +- Use appropriate PHP type hints for method parameters. + + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + + +## Comments +- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. + +## PHPDoc Blocks +- Add useful array shape type definitions for arrays when appropriate. + +## Enums +- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. + + +=== sail rules === + +## Laravel Sail + +- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail. +- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`. +- Open the application in the browser by running `vendor/bin/sail open`. +- Always prefix PHP, Artisan, Composer, and Node commands** with `vendor/bin/sail`. Examples: +- Run Artisan Commands: `vendor/bin/sail artisan migrate` +- Install Composer packages: `vendor/bin/sail composer install` +- Execute node commands: `vendor/bin/sail npm run dev` +- Execute PHP scripts: `vendor/bin/sail php [script]` +- View all available Sail commands by running `vendor/bin/sail` without arguments. + + +=== tests rules === + +## Test Enforcement + +- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test` with a specific filename or filter. + + +=== laravel/core rules === + +## Do Things the Laravel Way + +- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. +- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Database +- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. +- Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. +- Generate code that prevents N+1 query problems by using eager loading. +- Use Laravel's query builder for very complex database operations. + +### Model Creation +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`. + +### APIs & Eloquent Resources +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +### Controllers & Validation +- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- Check sibling Form Requests to see if the application uses array or string based validation rules. + +### Queues +- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. + +### Authentication & Authorization +- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). + +### URL Generation +- When generating links to other pages, prefer named routes and the `route()` function. + +### Configuration +- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. + +### Testing +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +### Vite Error +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`. + + +=== laravel/v12 rules === + +## Laravel 12 + +- Use the `search-docs` tool to get version specific documentation. +- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. + +### Laravel 12 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` contains application specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration. +- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration. + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. + +### Models +- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. + + +=== pint/core rules === + +## Laravel Pint Code Formatter + +- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues. + + +=== phpunit/core rules === + +## PHPUnit Core + +- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `vendor/bin/sail artisan make:test --phpunit {name}` to create a new test. +- If you see a test using "Pest", convert it to PHPUnit. +- Every time a test has been updated, run that singular test. +- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing. +- Tests should test all of the happy paths, failure paths, and weird paths. +- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application. + +### Running Tests +- Run the minimal number of tests, using an appropriate filter, before finalizing. +- To run all tests: `vendor/bin/sail artisan test`. +- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`. +- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file). + diff --git a/README.md b/README.md index ed1571d..45f5987 100644 --- a/README.md +++ b/README.md @@ -18,24 +18,31 @@ - `messages(message_id UUID, session_id, role USER|AGENT|TOOL|SYSTEM, type, content, payload jsonb, seq, reply_to, dedupe_key)` - 约束:`unique(session_id, seq)`、`unique(session_id, dedupe_key)`;append 行锁 + 事务,seq 单调递增。 - **实时**:SSE `/api/sessions/{id}/sse`,backlog 先补历史(按 seq),再监听 Redis `session:{id}:messages` 渠道。 +- **Agent Run 编排(MVP-0)**:user.prompt 后自动触发 RunDispatcher → `run.status=RUNNING` → AgentRunJob(DummyProvider 返回一次性回复)→ `agent.message` + `run.status=DONE/FAILED/CANCELED` 落库;同 trigger_message 幂等、同会话只允许一个 RUNNING。 +- **队列监控**:Horizon(本地默认开放 `/horizon`,非 local 环境默认拒绝访问)。 ## 🚀 快速启动 ```bash # 构建并启动 docker compose build -docker compose up -d app pgsql redis +docker compose up -d app horizon pgsql redis # 首次迁移(仅需一次) docker compose exec app php artisan migrate # 运行 Feature 测试 docker compose exec app php artisan test --testsuite=Feature + +# 队列(AgentRunJob):开发可用同步队列,或用 Horizon +# 同步:.env / phpunit.xml 中 QUEUE_CONNECTION=sync +# Horizon:docker compose up -d horizon(需 composer install 安装 laravel/horizon,QUEUE_CONNECTION=redis) ``` ## 🔑 API 能力一览(MVP-1.1 + Archive/GetMessage/SSE) - 会话:`POST /api/sessions`,`GET /api/sessions`(分页/状态/关键词),`GET /api/sessions/{id}`,`PATCH /api/sessions/{id}`(重命名/状态,CLOSED 不可重开),`POST /api/sessions/{id}/archive`(幂等归档→CLOSED)。 - 消息:`POST /api/sessions/{id}/messages`(幂等 dedupe_key,CLOSED/LOCKED 门禁),`GET /api/sessions/{id}/messages`(after_seq 增量),`GET /api/sessions/{id}/messages/{message_id}`(校验 session_id)。 - 实时:`GET /api/sessions/{id}/sse?after_seq=123`,SSE 事件 id 为 seq;`Last-Event-ID` 优先于 query。 +- Agent Run:user.prompt 自动触发;或 `POST /api/sessions/{id}/runs {trigger_message_id}` 手动触发,写入 `run.status/agent.message`。 详细字段/示例:`docs/ChatSession/chat-session-api.md`,OpenAPI:`docs/ChatSession/chat-session-openapi.yaml`。用户管理/鉴权文档:`docs/User/user-api.md`。 diff --git a/app/Http/Controllers/ChatSessionController.php b/app/Http/Controllers/ChatSessionController.php index c5542ae..bd9012e 100644 --- a/app/Http/Controllers/ChatSessionController.php +++ b/app/Http/Controllers/ChatSessionController.php @@ -9,12 +9,16 @@ use App\Http\Requests\UpdateSessionRequest; use App\Http\Resources\ChatSessionResource; use App\Http\Resources\MessageResource; use App\Services\ChatService; +use App\Services\RunDispatcher; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class ChatSessionController extends Controller { - public function __construct(private readonly ChatService $service) + public function __construct( + private readonly ChatService $service, + private readonly RunDispatcher $runDispatcher, + ) { } @@ -45,6 +49,10 @@ class ChatSessionController extends Controller 'session_id' => $sessionId, ...$request->validated(), ]); + + if ($message->role === 'USER' && $message->type === 'user.prompt') { + $this->runDispatcher->dispatchForPrompt($sessionId, $message->message_id); + } } catch (ChatSessionStatusException $e) { return response()->json(['message' => $e->getMessage()], 403); } diff --git a/app/Http/Controllers/ChatSessionSseController.php b/app/Http/Controllers/ChatSessionSseController.php index 8948734..0cdf6cb 100644 --- a/app/Http/Controllers/ChatSessionSseController.php +++ b/app/Http/Controllers/ChatSessionSseController.php @@ -24,7 +24,12 @@ class ChatSessionSseController extends Controller $limit = (int) $request->query('limit', 200); $limit = $limit > 0 && $limit <= 500 ? $limit : 200; - if (app()->runningUnitTests() || app()->environment('testing') || ! class_exists(\Redis::class)) { + $useBacklogOnly = app()->runningUnitTests() + || app()->environment('testing') + || defined('PHPUNIT_COMPOSER_INSTALL') + || ! class_exists(\Redis::class); + + if ($useBacklogOnly) { $lastSentSeq = $afterSeq; ob_start(); $this->sendBacklog($sessionId, $lastSentSeq, $limit, false); @@ -42,41 +47,75 @@ class ChatSessionSseController extends Controller $this->sendBacklog($sessionId, $lastSentSeq, $limit); - $redis = Redis::connection()->client(); - if (method_exists($redis, 'setOption')) { - $redis->setOption(\Redis::OPT_READ_TIMEOUT, 5); - } - - $channel = "session:{$sessionId}:messages"; - $pubSub = $redis->pubSubLoop(); - $pubSub->subscribe($channel); - $lastPing = time(); - - foreach ($pubSub as $message) { - if ($message->kind === 'subscribe') { - continue; + try { + $redis = Redis::connection()->client(); + if (method_exists($redis, 'setOption')) { + $redis->setOption(\Redis::OPT_READ_TIMEOUT, 5); } - if (connection_aborted()) { - $pubSub->unsubscribe(); - break; - } + $channel = "session:{$sessionId}:messages"; + $lastPing = time(); + logger()->info('sse open'); + if (method_exists($redis, 'pubSubLoop')) { + $pubSub = $redis->pubSubLoop(); + $pubSub->subscribe($channel); - $payloadId = $message->payload ?? null; - if ($payloadId) { - $msg = $this->service->getMessage($sessionId, $payloadId); - if ($msg && $msg->seq > $lastSentSeq) { - $this->emitMessage($msg); - $lastSentSeq = $msg->seq; + foreach ($pubSub as $message) { + if ($message->kind === 'subscribe') { + continue; + } + + if (connection_aborted()) { + $pubSub->unsubscribe(); + break; + } + + $payloadId = $message->payload ?? null; + if ($payloadId) { + $msg = $this->service->getMessage($sessionId, $payloadId); + if ($msg && $msg->seq > $lastSentSeq) { + $this->emitMessage($msg); + $lastSentSeq = $msg->seq; + } + } + + if (time() - $lastPing >= 180) { + logger()->info('ping: sent'.$sessionId); + echo ": ping\n\n"; + @ob_flush(); + @flush(); + $lastPing = time(); + } } - } + logger()->info('close: sent'.$sessionId); + unset($pubSub); + } else { + // Fallback for Redis drivers without pubSubLoop (older phpredis) + $redis->subscribe([$channel], function ($redisInstance, $chan, $payload) use (&$lastSentSeq, $sessionId) { + if (connection_aborted()) { + $redisInstance->unsubscribe([$chan]); + return; + } - if (time() - $lastPing >= 20) { - echo ": ping\n\n"; - @ob_flush(); - @flush(); - $lastPing = time(); + if (! $payload) { + return; + } + + $msg = $this->service->getMessage($sessionId, $payload); + if ($msg && $msg->seq > $lastSentSeq) { + $this->emitMessage($msg); + $lastSentSeq = $msg->seq; + } + }); } + } catch (\RedisException $exception) { + logger()->warning('SSE redis subscription failed', [ + 'session_id' => $sessionId, + 'message' => $exception->getMessage(), + ]); + echo ": redis-error\n\n"; + @ob_flush(); + @flush(); } }); diff --git a/app/Http/Controllers/RunController.php b/app/Http/Controllers/RunController.php new file mode 100644 index 0000000..ba3f8d3 --- /dev/null +++ b/app/Http/Controllers/RunController.php @@ -0,0 +1,28 @@ +dispatcher->dispatchForPrompt($sessionId, $request->validated()['trigger_message_id']); + + return response()->json(['run_id' => $runId], 201); + } + + public function test() + { + $job = TestJob::dispatch(); + unset($job); + } +} diff --git a/app/Http/Requests/DispatchRunRequest.php b/app/Http/Requests/DispatchRunRequest.php new file mode 100644 index 0000000..d790837 --- /dev/null +++ b/app/Http/Requests/DispatchRunRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return [ + 'trigger_message_id' => ['required', 'uuid'], + ]; + } +} diff --git a/app/Jobs/AgentRunJob.php b/app/Jobs/AgentRunJob.php new file mode 100644 index 0000000..520e272 --- /dev/null +++ b/app/Jobs/AgentRunJob.php @@ -0,0 +1,35 @@ +run($this->sessionId, $this->runId); + } catch (\Throwable $e) { + $sink->appendError($this->sessionId, $this->runId, 'run.failed', $e->getMessage()); + $sink->appendRunStatus($this->sessionId, $this->runId, 'FAILED', [ + 'error' => $e->getMessage(), + 'dedupe_key' => "run:{$this->runId}:status:FAILED", + ]); + + throw $e; + } + } +} diff --git a/app/Jobs/TestJob.php b/app/Jobs/TestJob.php new file mode 100644 index 0000000..da57893 --- /dev/null +++ b/app/Jobs/TestJob.php @@ -0,0 +1,23 @@ +app->bind(\App\Services\Agent\AgentProviderInterface::class, function () { + return new \App\Services\Agent\DummyAgentProvider(); + }); } /** diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..1151573 --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,31 @@ +environment('local'); + }); + } +} diff --git a/app/Providers/TelescopeServiceProvider.php b/app/Providers/TelescopeServiceProvider.php new file mode 100644 index 0000000..22711b4 --- /dev/null +++ b/app/Providers/TelescopeServiceProvider.php @@ -0,0 +1,55 @@ +hideSensitiveRequestDetails(); + + $isLocal = $this->app->environment('local'); + } + + /** + * Prevent sensitive request details from being logged by Telescope. + */ + protected function hideSensitiveRequestDetails(): void + { + if ($this->app->environment('local')) { + return; + } + + Telescope::hideRequestParameters(['_token']); + + Telescope::hideRequestHeaders([ + 'cookie', + 'x-csrf-token', + 'x-xsrf-token', + ]); + } + + /** + * Register the Telescope gate. + * + * This gate determines who can access Telescope in non-local environments. + */ + protected function gate(): void + { + Gate::define('viewTelescope', function ($user) { + return in_array($user->email, [ + // + ]); + }); + } +} diff --git a/app/Services/Agent/AgentProviderInterface.php b/app/Services/Agent/AgentProviderInterface.php new file mode 100644 index 0000000..3a3e5ef --- /dev/null +++ b/app/Services/Agent/AgentProviderInterface.php @@ -0,0 +1,12 @@ + $context + * @param array $options + */ + public function generate(array $context, array $options = []): string; +} diff --git a/app/Services/Agent/DummyAgentProvider.php b/app/Services/Agent/DummyAgentProvider.php new file mode 100644 index 0000000..a89fbe2 --- /dev/null +++ b/app/Services/Agent/DummyAgentProvider.php @@ -0,0 +1,26 @@ + $context + * @param array $options + */ + public function generate(array $context, array $options = []): string + { + $messages = $context['messages'] ?? []; + $lastUser = null; + foreach (array_reverse($messages) as $msg) { + if (($msg['role'] ?? '') === 'USER' && ($msg['type'] ?? '') === 'user.prompt') { + $lastUser = $msg['content'] ?? null; + break; + } + } + + $summary = $lastUser ? mb_substr($lastUser, 0, 80) : 'no user prompt'; + + return sprintf('MVP reply: based on last user input -> %s', $summary); + } +} diff --git a/app/Services/Agent/HttpAgentProvider.php b/app/Services/Agent/HttpAgentProvider.php new file mode 100644 index 0000000..35d21ee --- /dev/null +++ b/app/Services/Agent/HttpAgentProvider.php @@ -0,0 +1,42 @@ +endpoint = $endpoint ?? config('services.agent_provider.endpoint', ''); + } + + /** + * @param array $context + * @param array $options + */ + public function generate(array $context, array $options = []): string + { + if (empty($this->endpoint)) { + // placeholder to avoid accidental outbound calls when未配置 + return (new DummyAgentProvider())->generate($context, $options); + } + + $payload = [ + 'context' => $context, + 'options' => $options, + ]; + + $response = Http::post($this->endpoint, $payload); + + if (! $response->successful()) { + throw new \RuntimeException('Agent provider failed: '.$response->body()); + } + + $data = $response->json(); + + return is_string($data) ? $data : ($data['content'] ?? ''); + } +} diff --git a/app/Services/CancelChecker.php b/app/Services/CancelChecker.php new file mode 100644 index 0000000..08d6a7c --- /dev/null +++ b/app/Services/CancelChecker.php @@ -0,0 +1,18 @@ +where('session_id', $sessionId) + ->where('type', 'run.cancel.request') + ->whereIn('role', [Message::ROLE_USER, Message::ROLE_SYSTEM]) + ->where('payload->run_id', $runId) + ->exists(); + } +} diff --git a/app/Services/ContextBuilder.php b/app/Services/ContextBuilder.php new file mode 100644 index 0000000..42729b9 --- /dev/null +++ b/app/Services/ContextBuilder.php @@ -0,0 +1,49 @@ + + */ + public function build(string $sessionId, string $runId): array + { + $messages = $this->loadRecentMessages($sessionId); + + return [ + 'run_id' => $runId, + 'session_id' => $sessionId, + 'system_prompt' => 'You are an agent inside ARS. Respond concisely in plain text.', + 'messages' => $messages->map(function (Message $message) { + return [ + 'message_id' => $message->message_id, + 'role' => $message->role, + 'type' => $message->type, + 'content' => $message->content, + 'seq' => $message->seq, + ]; + })->values()->all(), + ]; + } + + private function loadRecentMessages(string $sessionId): Collection + { + return Message::query() + ->where('session_id', $sessionId) + ->whereIn('role', [Message::ROLE_USER, Message::ROLE_AGENT]) + ->whereIn('type', ['user.prompt', 'agent.message']) + ->orderByDesc('seq') + ->limit($this->limit) + ->get() + ->sortBy('seq') + ->values(); + } +} diff --git a/app/Services/OutputSink.php b/app/Services/OutputSink.php new file mode 100644 index 0000000..8302045 --- /dev/null +++ b/app/Services/OutputSink.php @@ -0,0 +1,63 @@ + $meta + */ + public function appendAgentMessage(string $sessionId, string $runId, string $content, array $meta = []): Message + { + return $this->chatService->appendMessage([ + 'session_id' => $sessionId, + 'role' => Message::ROLE_AGENT, + 'type' => 'agent.message', + 'content' => $content, + 'payload' => array_merge($meta, ['run_id' => $runId]), + ]); + } + + /** + * @param array $meta + */ + public function appendRunStatus(string $sessionId, string $runId, string $status, array $meta = []): Message + { + $dedupeKey = $meta['dedupe_key'] ?? null; + unset($meta['dedupe_key']); + + return $this->chatService->appendMessage([ + 'session_id' => $sessionId, + 'role' => Message::ROLE_SYSTEM, + 'type' => 'run.status', + 'payload' => array_merge($meta, [ + 'run_id' => $runId, + 'status' => $status, + ]), + 'dedupe_key' => $dedupeKey, + ]); + } + + /** + * @param array $meta + */ + public function appendError(string $sessionId, string $runId, string $code, string $message, array $meta = []): Message + { + return $this->chatService->appendMessage([ + 'session_id' => $sessionId, + 'role' => Message::ROLE_SYSTEM, + 'type' => 'error', + 'content' => $code, + 'payload' => array_merge($meta, [ + 'run_id' => $runId, + 'message' => $message, + ]), + ]); + } +} diff --git a/app/Services/RunDispatcher.php b/app/Services/RunDispatcher.php new file mode 100644 index 0000000..0016389 --- /dev/null +++ b/app/Services/RunDispatcher.php @@ -0,0 +1,60 @@ +chatService->getMessage($sessionId, $triggerMessageId); + if (! $triggerMessage) { + throw (new ModelNotFoundException())->setModel(Message::class, [$triggerMessageId]); + } + + $existingForTrigger = Message::query() + ->where('session_id', $sessionId) + ->where('type', 'run.status') + ->where('payload->trigger_message_id', $triggerMessageId) + ->orderByDesc('seq') + ->first(); + + if ($existingForTrigger && ($existingForTrigger->payload['run_id'] ?? null)) { + return $existingForTrigger->payload['run_id']; + } + + $latestStatus = Message::query() + ->where('session_id', $sessionId) + ->where('type', 'run.status') + ->orderByDesc('seq') + ->first(); + + if ($latestStatus && ($latestStatus->payload['status'] ?? null) === 'RUNNING' && ($latestStatus->payload['run_id'] ?? null)) { + return $latestStatus->payload['run_id']; + } + + $runId = (string) Str::uuid(); + + $this->outputSink->appendRunStatus($sessionId, $runId, 'RUNNING', [ + 'trigger_message_id' => $triggerMessageId, + 'dedupe_key' => 'run:trigger:'.$triggerMessageId, + ]); + + dispatch(new AgentRunJob($sessionId, $runId)); + + return $runId; + } +} diff --git a/app/Services/RunLoop.php b/app/Services/RunLoop.php new file mode 100644 index 0000000..e8d2d26 --- /dev/null +++ b/app/Services/RunLoop.php @@ -0,0 +1,52 @@ +cancelChecker->isCanceled($sessionId, $runId)) { + $this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [ + 'dedupe_key' => "run:{$runId}:status:CANCELED", + ]); + return; + } + + $context = $this->contextBuilder->build($sessionId, $runId); + + if ($this->cancelChecker->isCanceled($sessionId, $runId)) { + $this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [ + 'dedupe_key' => "run:{$runId}:status:CANCELED", + ]); + return; + } + + $reply = $this->provider->generate($context); + + if ($this->cancelChecker->isCanceled($sessionId, $runId)) { + $this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [ + 'dedupe_key' => "run:{$runId}:status:CANCELED", + ]); + return; + } + + $this->outputSink->appendAgentMessage($sessionId, $runId, $reply, [ + 'provider' => $this->provider instanceof DummyAgentProvider ? 'dummy' : get_class($this->provider), + ]); + $this->outputSink->appendRunStatus($sessionId, $runId, 'DONE', [ + 'dedupe_key' => "run:{$runId}:status:DONE", + ]); + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..1d440df --- /dev/null +++ b/boost.json @@ -0,0 +1,14 @@ +{ + "agents": [ + "claude_code", + "codex", + "phpstorm" + ], + "editors": [ + "claude_code", + "codex", + "phpstorm" + ], + "guidelines": [], + "sail": true +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 2078913..4c45c55 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,6 +14,9 @@ return Application::configure(basePath: dirname(__DIR__)) commands: __DIR__.'/../routes/console.php', health: '/up', ) + ->withProviders([ + App\Providers\HorizonServiceProvider::class, + ]) ->withMiddleware(function (Middleware $middleware): void { $middleware->append(HandleCors::class); $middleware->alias([ diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..a3a6d17 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,6 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\HorizonServiceProvider::class, + App\Providers\TelescopeServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 4c789a1..43d4eb1 100644 --- a/composer.json +++ b/composer.json @@ -8,12 +8,16 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", + "laravel/horizon": "^5.40", "laravel/octane": "^2.13", + "laravel/telescope": "^5.16", "laravel/tinker": "^2.10.1", - "php-open-source-saver/jwt-auth": "^2.8" + "php-open-source-saver/jwt-auth": "^2.8", + "ext-redis": "*" }, "require-dev": { "fakerphp/faker": "^1.23", + "laravel/boost": "^1.8", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/composer.lock b/composer.lock index 7b49452..8b537d3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "79f1e234537460fac440cd9aa68d3e6b", + "content-hash": "effce82b9c1c86b0542543b2f1034edc", "packages": [ { "name": "brick/math", @@ -1142,16 +1142,16 @@ }, { "name": "laravel/framework", - "version": "v12.42.0", + "version": "v12.43.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "509b33095564c5165366d81bbaa0afaac28abe75" + "reference": "9f875fad08f5d409b4c33293eca34f7af36e8ecf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75", - "reference": "509b33095564c5165366d81bbaa0afaac28abe75", + "url": "https://api.github.com/repos/laravel/framework/zipball/9f875fad08f5d409b4c33293eca34f7af36e8ecf", + "reference": "9f875fad08f5d409b4c33293eca34f7af36e8ecf", "shasum": "" }, "require": { @@ -1360,20 +1360,99 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-09T15:51:23+00:00" + "time": "2025-12-16T15:27:26+00:00" }, { - "name": "laravel/octane", - "version": "v2.13.2", + "name": "laravel/horizon", + "version": "v5.41.0", "source": { "type": "git", - "url": "https://github.com/laravel/octane.git", - "reference": "5b963d2da879f2cad3a84f22bafd3d8be7170988" + "url": "https://github.com/laravel/horizon.git", + "reference": "eb6738246ab9d3450b705126b9794dfb0ea371b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/octane/zipball/5b963d2da879f2cad3a84f22bafd3d8be7170988", - "reference": "5b963d2da879f2cad3a84f22bafd3d8be7170988", + "url": "https://api.github.com/repos/laravel/horizon/zipball/eb6738246ab9d3450b705126b9794dfb0ea371b3", + "reference": "eb6738246ab9d3450b705126b9794dfb0ea371b3", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcntl": "*", + "ext-posix": "*", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.17|^3.0", + "php": "^8.0", + "ramsey/uuid": "^4.0", + "symfony/console": "^6.0|^7.0", + "symfony/error-handler": "^6.0|^7.0", + "symfony/polyfill-php83": "^1.28", + "symfony/process": "^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8", + "phpstan/phpstan": "^1.10|^2.0", + "predis/predis": "^1.1|^2.0|^3.0" + }, + "suggest": { + "ext-redis": "Required to use the Redis PHP driver.", + "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Horizon": "Laravel\\Horizon\\Horizon" + }, + "providers": [ + "Laravel\\Horizon\\HorizonServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Horizon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Dashboard and code-driven configuration for Laravel queues.", + "keywords": [ + "laravel", + "queue" + ], + "support": { + "issues": "https://github.com/laravel/horizon/issues", + "source": "https://github.com/laravel/horizon/tree/v5.41.0" + }, + "time": "2025-12-14T15:55:28+00:00" + }, + { + "name": "laravel/octane", + "version": "v2.13.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/octane.git", + "reference": "aae775360fceae422651042d73137fff092ba800" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/octane/zipball/aae775360fceae422651042d73137fff092ba800", + "reference": "aae775360fceae422651042d73137fff092ba800", "shasum": "" }, "require": { @@ -1450,7 +1529,7 @@ "issues": "https://github.com/laravel/octane/issues", "source": "https://github.com/laravel/octane" }, - "time": "2025-11-28T20:13:00+00:00" + "time": "2025-12-10T15:24:24+00:00" }, { "name": "laravel/prompts", @@ -1572,6 +1651,74 @@ }, "time": "2025-11-21T20:52:36+00:00" }, + { + "name": "laravel/telescope", + "version": "v5.16.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/telescope.git", + "reference": "a868e91a0912d6a44363636f7467a8578db83026" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/telescope/zipball/a868e91a0912d6a44363636f7467a8578db83026", + "reference": "a868e91a0912d6a44363636f7467a8578db83026", + "shasum": "" + }, + "require": { + "ext-json": "*", + "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^5.3|^6.0|^7.0", + "symfony/var-dumper": "^5.0|^6.0|^7.0" + }, + "require-dev": { + "ext-gd": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "laravel/octane": "^1.4|^2.0", + "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Telescope\\TelescopeServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Telescope\\": "src/", + "Laravel\\Telescope\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mohamed Said", + "email": "mohamed@laravel.com" + } + ], + "description": "An elegant debug assistant for the Laravel framework.", + "keywords": [ + "debugging", + "laravel", + "monitoring" + ], + "support": { + "issues": "https://github.com/laravel/telescope/issues", + "source": "https://github.com/laravel/telescope/tree/v5.16.0" + }, + "time": "2025-12-09T13:34:29+00:00" + }, { "name": "laravel/tinker", "version": "v2.10.2", @@ -3429,16 +3576,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.16", + "version": "v0.12.17", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67" + "reference": "85fbbd9f3064e157fc21fe4362b2b5c19f2ea631" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", - "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/85fbbd9f3064e157fc21fe4362b2b5c19f2ea631", + "reference": "85fbbd9f3064e157fc21fe4362b2b5c19f2ea631", "shasum": "" }, "require": { @@ -3502,9 +3649,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.16" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.17" }, - "time": "2025-12-07T03:39:01+00:00" + "time": "2025-12-15T04:55:34+00:00" }, { "name": "ralouphie/getallheaders", @@ -3628,20 +3775,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -3700,9 +3847,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "symfony/clock", @@ -6761,6 +6908,145 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "laravel/boost", + "version": "v1.8.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e", + "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9", + "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", + "laravel/mcp": "^0.4.1", + "laravel/prompts": "0.1.25|^0.3.6", + "laravel/roster": "^0.2.9", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.20.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2025-12-08T21:54:49+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.4.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/1c7878be3931a19768f791ddf141af29f43fb4ef", + "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/container": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/http": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/json-schema": "^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1", + "php": "^8.1" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^8.36|^9.15|^10.8", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2025-12-07T15:49:15+00:00" + }, { "name": "laravel/pail", "version": "v1.2.4", @@ -6907,6 +7193,67 @@ }, "time": "2025-11-25T21:15:52+00:00" }, + { + "name": "laravel/roster", + "version": "v0.2.9", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6", + "reference": "82bbd0e2de614906811aebdf16b4305956816fa6", + "shasum": "" + }, + "require": { + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/contracts": "^10.0|^11.0|^12.0", + "illuminate/routing": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.1|^8.2", + "symfony/yaml": "^6.4|^7.2" + }, + "require-dev": { + "laravel/pint": "^1.14", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^8.22.0|^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2025-10-20T09:56:46+00:00" + }, { "name": "laravel/sail", "version": "v1.51.0", @@ -8945,7 +9292,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.2", + "ext-redis": "*" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/horizon.php b/config/horizon.php new file mode 100644 index 0000000..a011f32 --- /dev/null +++ b/config/horizon.php @@ -0,0 +1,186 @@ + env('HORIZON_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Horizon Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Horizon will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('HORIZON_PATH', 'horizon'), + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Connection + |-------------------------------------------------------------------------- + | + | This is the name of the Redis connection where Horizon will store the + | meta information required for it to function. It includes the list + | of supervisors, failed and completed jobs, job metrics, and more. + | + */ + + 'use' => 'default', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Prefix + |-------------------------------------------------------------------------- + | + | This prefix will be used when storing all Horizon data in Redis. You + | may modify the prefix when you are running multiple installations + | of Horizon on the same server so that they don't have problems. + | + */ + + 'prefix' => env('HORIZON_PREFIX', 'horizon:{single}'), + + /* + |-------------------------------------------------------------------------- + | Horizon Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will be assigned to every Horizon route, giving you the + | chance to add your own middleware to this list or change any of the + | existing middleware. Or, you can simply stick with this default list. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Queue Wait Time Thresholds + |-------------------------------------------------------------------------- + | + | This option allows you to configure when Horizon will notify you the + | queue is waiting too long to process jobs. Each connection / queue + | combination may have its own, unique threshold (in seconds). + | + */ + + 'waits' => [ + 'redis:default' => env('HORIZON_WAIT_TIME', 60), + ], + + /* + |-------------------------------------------------------------------------- + | Job Trimming Times + |-------------------------------------------------------------------------- + | + | Here you can configure for how long (in minutes) you desire Horizon to + | persist the recent and failed jobs. Typically, recent jobs are kept + | for one hour while all failed jobs are stored for an entire week. + | + */ + + 'trim' => [ + 'recent' => 60, + 'pending' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'failed' => 10080, + 'monitored' => 1440, + ], + + /* + |-------------------------------------------------------------------------- + | Silenced Jobs + |-------------------------------------------------------------------------- + | + | Silent jobs are not displayed in the Horizon UI. This allows you to + | fully avoid showing them or incrementing the failed jobs count + | or displaying them on the monitoring charts. They're silenced. + | + */ + + 'silenced' => [ + // App\Jobs\QuietJob::class, + ], + + /* + |-------------------------------------------------------------------------- + | Fast Termination + |-------------------------------------------------------------------------- + | + | When this option is enabled, Horizon's "terminate" command will not + | wait on all of the workers to terminate unless the --wait option + | is provided. Fast termination can shorten deployment delay by + | allowing a new instance of Horizon to start immediately. + | + */ + + 'fast_termination' => false, + + /* + |-------------------------------------------------------------------------- + | Memory Limit (MB) + |-------------------------------------------------------------------------- + | + | This value describes the maximum amount of memory the Horizon worker + | may consume before it is terminated and restarted. You should set + | this value according to the resources available to your server. + | + */ + + 'memory_limit' => env('HORIZON_MEMORY_LIMIT', 128), + + /* + |-------------------------------------------------------------------------- + | Queue Worker Configuration + |-------------------------------------------------------------------------- + | + | Here you may define the queue worker settings used by your application + | in all environments. These supervisors and settings handle all your + | queued jobs and will be provisioned by Horizon during deployment. + | + */ + + 'environments' => [ + 'production' => [ + 'supervisor-default' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'maxProcesses' => 10, + 'maxTime' => 60, + 'maxJobs' => 1000, + 'memory' => 256, + 'tries' => 3, + ], + ], + + 'local' => [ + 'supervisor-default' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'simple', + 'maxProcesses' => env('HORIZON_PROCESSES', 3), + 'maxTime' => 60, + 'maxJobs' => 500, + 'memory' => 256, + 'tries' => 3, + ], + ], + ], + +]; diff --git a/config/queue.php b/config/queue.php index 79c2c0a..086af4d 100644 --- a/config/queue.php +++ b/config/queue.php @@ -32,7 +32,7 @@ return [ 'connections' => [ 'sync' => [ - 'driver' => 'sync', + 'driver' => 'redis', ], 'database' => [ diff --git a/config/telescope.php b/config/telescope.php new file mode 100644 index 0000000..a31216b --- /dev/null +++ b/config/telescope.php @@ -0,0 +1,212 @@ + env('TELESCOPE_ENABLED', false), + + /* + |-------------------------------------------------------------------------- + | Telescope Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Telescope will be accessible from. If the + | setting is null, Telescope will reside under the same domain as the + | application. Otherwise, this value will be used as the subdomain. + | + */ + + 'domain' => env('TELESCOPE_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Telescope Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Telescope will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('TELESCOPE_PATH', 'telescope'), + + /* + |-------------------------------------------------------------------------- + | Telescope Storage Driver + |-------------------------------------------------------------------------- + | + | This configuration options determines the storage driver that will + | be used to store Telescope's data. In addition, you may set any + | custom options as needed by the particular driver you choose. + | + */ + + 'driver' => env('TELESCOPE_DRIVER', 'database'), + + 'storage' => [ + 'database' => [ + 'connection' => env('DB_CONNECTION', 'mysql'), + 'chunk' => 1000, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Queue + |-------------------------------------------------------------------------- + | + | This configuration options determines the queue connection and queue + | which will be used to process ProcessPendingUpdate jobs. This can + | be changed if you would prefer to use a non-default connection. + | + */ + + 'queue' => [ + 'connection' => env('TELESCOPE_QUEUE_CONNECTION'), + 'queue' => env('TELESCOPE_QUEUE'), + 'delay' => env('TELESCOPE_QUEUE_DELAY', 10), + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will be assigned to every Telescope route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => [ + 'web', + Authorize::class, + ], + + /* + |-------------------------------------------------------------------------- + | Allowed / Ignored Paths & Commands + |-------------------------------------------------------------------------- + | + | The following array lists the URI paths and Artisan commands that will + | not be watched by Telescope. In addition to this list, some Laravel + | commands, like migrations and queue commands, are always ignored. + | + */ + + 'only_paths' => [ + // 'api/*' + ], + + 'ignore_paths' => [ + 'livewire*', + 'nova-api*', + 'pulse*', + '_boost*', + 'telescope/*' + ], + + 'ignore_commands' => [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Telescope Watchers + |-------------------------------------------------------------------------- + | + | The following array lists the "watchers" that will be registered with + | Telescope. The watchers gather the application's profile data when + | a request or task is executed. Feel free to customize this list. + | + */ + + 'watchers' => [ + Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true), + + Watchers\CacheWatcher::class => [ + 'enabled' => env('TELESCOPE_CACHE_WATCHER', true), + 'hidden' => [], + 'ignore' => [], + ], + + Watchers\ClientRequestWatcher::class => [ + 'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true), + 'ignore_hosts' => [], + ], + + Watchers\CommandWatcher::class => [ + 'enabled' => env('TELESCOPE_COMMAND_WATCHER', true), + 'ignore' => [], + ], + + Watchers\DumpWatcher::class => [ + 'enabled' => env('TELESCOPE_DUMP_WATCHER', true), + 'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false), + ], + + Watchers\EventWatcher::class => [ + 'enabled' => env('TELESCOPE_EVENT_WATCHER', true), + 'ignore' => [], + ], + + Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true), + + Watchers\GateWatcher::class => [ + 'enabled' => env('TELESCOPE_GATE_WATCHER', true), + 'ignore_abilities' => [], + 'ignore_packages' => true, + 'ignore_paths' => [], + ], + + Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true), + + Watchers\LogWatcher::class => [ + 'enabled' => env('TELESCOPE_LOG_WATCHER', true), + 'level' => 'error', + ], + + Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), + + Watchers\ModelWatcher::class => [ + 'enabled' => env('TELESCOPE_MODEL_WATCHER', true), + 'events' => ['eloquent.*'], + 'hydrations' => true, + ], + + Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true), + + Watchers\QueryWatcher::class => [ + 'enabled' => env('TELESCOPE_QUERY_WATCHER', true), + 'ignore_packages' => true, + 'ignore_paths' => [], + 'slow' => 100, + ], + + Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true), + + Watchers\RequestWatcher::class => [ + 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true), + 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64), + 'ignore_http_methods' => [], + 'ignore_status_codes' => [], + ], + + Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true), + Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true), + ], +]; diff --git a/database/migrations/2025_12_16_140800_create_telescope_entries_table.php b/database/migrations/2025_12_16_140800_create_telescope_entries_table.php new file mode 100644 index 0000000..700a83f --- /dev/null +++ b/database/migrations/2025_12_16_140800_create_telescope_entries_table.php @@ -0,0 +1,70 @@ +getConnection()); + + $schema->create('telescope_entries', function (Blueprint $table) { + $table->bigIncrements('sequence'); + $table->uuid('uuid'); + $table->uuid('batch_id'); + $table->string('family_hash')->nullable(); + $table->boolean('should_display_on_index')->default(true); + $table->string('type', 20); + $table->longText('content'); + $table->dateTime('created_at')->nullable(); + + $table->unique('uuid'); + $table->index('batch_id'); + $table->index('family_hash'); + $table->index('created_at'); + $table->index(['type', 'should_display_on_index']); + }); + + $schema->create('telescope_entries_tags', function (Blueprint $table) { + $table->uuid('entry_uuid'); + $table->string('tag'); + + $table->primary(['entry_uuid', 'tag']); + $table->index('tag'); + + $table->foreign('entry_uuid') + ->references('uuid') + ->on('telescope_entries') + ->onDelete('cascade'); + }); + + $schema->create('telescope_monitoring', function (Blueprint $table) { + $table->string('tag')->primary(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $schema = Schema::connection($this->getConnection()); + + $schema->dropIfExists('telescope_entries_tags'); + $schema->dropIfExists('telescope_entries'); + $schema->dropIfExists('telescope_monitoring'); + } +}; diff --git a/docker-compose.yml b/docker-compose-backup.yml similarity index 60% rename from docker-compose.yml rename to docker-compose-backup.yml index 9bc21bf..7e9472a 100644 --- a/docker-compose.yml +++ b/docker-compose-backup.yml @@ -3,6 +3,7 @@ services: build: context: . dockerfile: docker/app/Dockerfile + image: ars_backend:latest entrypoint: ["/app/docker/app/entrypoint.sh"] volumes: - ./:/app @@ -20,6 +21,7 @@ services: REDIS_HOST: redis REDIS_PASSWORD: "null" REDIS_PORT: 6379 + QUEUE_CONNECTION: redis ports: - "8000:8000" extra_hosts: @@ -29,6 +31,35 @@ services: - redis tty: true + horizon: + build: + context: . + dockerfile: docker/app/Dockerfile + image: ars_backend:latest + entrypoint: ["/app/docker/app/entrypoint.sh"] + environment: + CONTAINER_ROLE: horizon + APP_ENV: local + APP_DEBUG: "true" + APP_URL: http://localhost:8000 + OCTANE_SERVER: frankenphp + DB_CONNECTION: pgsql + DB_HOST: pgsql + DB_PORT: 5432 + DB_DATABASE: ars_backend + DB_USERNAME: ars + DB_PASSWORD: secret + REDIS_HOST: redis + REDIS_PASSWORD: "null" + REDIS_PORT: 6379 + QUEUE_CONNECTION: redis + volumes: + - ./:/app + depends_on: + - pgsql + - redis + tty: true + pgsql: image: postgres:16-alpine environment: diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index ae16326..46caf60 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -1,4 +1,4 @@ -FROM dunglas/frankenphp:1-php8.3 +FROM dunglas/frankenphp:1-php8.2 WORKDIR /app diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh index 1c3c33d..b27881e 100755 --- a/docker/app/entrypoint.sh +++ b/docker/app/entrypoint.sh @@ -15,4 +15,15 @@ if ! grep -q "^APP_KEY=" .env 2>/dev/null || grep -q "^APP_KEY=$" .env 2>/dev/nu php artisan key:generate --force fi -exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port="${PORT:-8000}" --watch +ROLE="${CONTAINER_ROLE:-app}" + +if [ "$ROLE" = "queue" ]; then + exec php artisan queue:work --verbose --tries="${QUEUE_TRIES:-3}" --timeout="${QUEUE_TIMEOUT:-90}" +fi + +if [ "$ROLE" = "horizon" ]; then + exec php artisan horizon +fi + +exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port="${PORT:-8000}" +#exec php -S 0.0.0.0:8000 -t public diff --git a/docs/ChatSession/chat-session-api.md b/docs/ChatSession/chat-session-api.md index 12748cc..74a0ece 100644 --- a/docs/ChatSession/chat-session-api.md +++ b/docs/ChatSession/chat-session-api.md @@ -1,4 +1,4 @@ -# ChatSession & Message API(MVP-1) +# ChatSession & Message API(MVP-1 + Agent Run MVP-0) 基地址:`http://localhost:8000/api`(FrankenPHP 容器 8000 端口) 认证方式:JWT,`Authorization: Bearer {token}` @@ -7,6 +7,7 @@ ## 变更记录 - 2025-02-14:新增 ChatSession 创建、消息追加、增量查询接口;支持状态门禁与 dedupe 幂等。 - 2025-02-14:MVP-1.1 增加会话列表、会话更新(重命名/状态变更),列表附带最后一条消息摘要。 +- 2025-02-15:Agent Run MVP-0 —— RunDispatcher + AgentRunJob + DummyProvider;自动在 user.prompt 后触发一次 Run,落地 run.status / agent.message。 ## 领域模型 - `ChatSession`:`session_id`(UUID)、`session_name`、`status`(`OPEN`/`LOCKED`/`CLOSED`)、`last_seq` @@ -19,23 +20,26 @@ ### 创建会话 - `POST /sessions` - 请求体字段 + | 字段 | 必填 | 类型 | 说明 | | --- | --- | --- | --- | | session_name | 否 | string(≤255) | 会话名称 | - 响应 201(JSON) - | 字段 | 类型 | 说明 | - | --- | --- | --- | - | session_id | uuid | 主键 | - | session_name | string|null | 会话名 | - | status | enum | `OPEN|LOCKED|CLOSED` | - | last_seq | int | 当前最大 seq | - | last_message_id | uuid|null | 最后一条消息 | - | created_at, updated_at | datetime | 时间戳 | + + | 字段 | 类型 | 说明 | + | --- | --- | --- | + | session_id | uuid | 主键 | + | session_name | string|null | 会话名 | + | status | enum | `OPEN|LOCKED|CLOSED` | + | last_seq | int | 当前最大 seq | + | last_message_id | uuid|null | 最后一条消息 | + | created_at, updated_at | datetime | 时间戳 | - 错误:401 未授权 ### 追加消息 - `POST /sessions/{session_id}/messages` - 请求体字段 + | 字段 | 必填 | 类型 | 说明 | | --- | --- | --- | --- | | role | 是 | enum | `USER|AGENT|TOOL|SYSTEM` | @@ -52,6 +56,7 @@ ### 按序增量查询 - `GET /sessions/{session_id}/messages?after_seq=0&limit=50` - 查询参数 + | 参数 | 默认 | 类型 | 说明 | | --- | --- | --- | --- | | after_seq | 0 | int | 仅返回 seq 大于该值 | @@ -62,6 +67,7 @@ ### 会话列表 - `GET /sessions?page=1&per_page=15&status=OPEN&q=keyword` - 查询参数 + | 参数 | 默认 | 类型 | 说明 | | --- | --- | --- | --- | | page | 1 | int | 分页页码 | @@ -69,6 +75,7 @@ | status | - | enum | 过滤 `OPEN|LOCKED|CLOSED` | | q | - | string | ILIKE 模糊匹配 session_name | - 响应 200:分页结构(`data/links/meta`),`data` 每项字段: + | 字段 | 类型 | 说明 | | --- | --- | --- | | session_id | uuid | 会话主键 | @@ -86,7 +93,8 @@ ### 会话更新 - `PATCH /sessions/{session_id}` -- 请求体(至少一项,否则 422) + 请求体(至少一项,否则 422) + | 字段 | 必填 | 类型 | 说明 | | --- | --- | --- | --- | | session_name | 否 | string 1..255 | 自动 trim | @@ -118,6 +126,7 @@ - `GET /sessions/{session_id}/sse?after_seq=123` - 头部:`Accept: text/event-stream`,可带 `Last-Event-ID`(优先于 query)用于断线续传。 - 查询参数 + | 参数 | 默认 | 类型 | 说明 | | --- | --- | --- | --- | | after_seq | 0 | int | backlog 起始 seq(若有 Last-Event-ID 则覆盖) | @@ -135,6 +144,38 @@ - 心跳:周期输出 `: ping` 保活(生产环境)。 - 错误:401 未授权;404 session 不存在。 +## Agent Run MVP-0(RunDispatcher + AgentRunJob) +### 流程概述 +1. 用户追加 `role=USER && type=user.prompt` 后,Controller 自动调用 `RunDispatcher->dispatchForPrompt`。 +2. 并发保护:同会话只允许一个 RUNNING;同一个 `trigger_message_id` 幂等复用已有 `run_id`。 +3. 立即写入 `run.status`(SYSTEM/run.status,payload `{run_id,status:'RUNNING',trigger_message_id}`,dedupe_key=`run:trigger:{message_id}`)。 +4. 推送 `AgentRunJob(session_id, run_id)` 到队列(测试环境 QUEUE=sync 会同步执行)。 +5. RunLoop(使用 DummyAgentProvider): + - Cancel 检查:存在 `run.cancel.request`(payload.run_id) 则写入 `run.status=CANCELED`,不产出 agent.message。 + - ContextBuilder:提取最近 20 条 USER/AGENT 消息(type in user.prompt/agent.message),seq 升序提供给 Provider。 + - Provider 返回一次性文本回复。 + - OutputSink 依次写入:`agent.message`(payload 含 run_id, provider)、`run.status=DONE`(dedupe_key=`run:{run_id}:status:DONE`)。 +6. 异常:AgentRunJob 捕获异常后写入 `error` + `run.status=FAILED`(dedupe)。 + +### Run 相关消息类型(落库即真相源) +| type | role | payload 关键字段 | 说明 | +| --- | --- | --- | --- | +| run.status | SYSTEM | run_id, status(RUNNING/DONE/CANCELED/FAILED), trigger_message_id?, error? | Run 生命周期事件,CLOSED 状态下允许写入 | +| agent.message | AGENT | run_id, provider | Provider 的一次性回复 | +| run.cancel.request | USER/SYSTEM | run_id | CancelChecker 依据该事件判断是否中止 | +| error | SYSTEM | run_id, message | 任务异常时落库 | + +### 触发 Run(调试入口) +- `POST /sessions/{session_id}/runs` +- 请求体字段 + + | 字段 | 必填 | 类型 | 说明 | + | --- | --- | --- | --- | + | trigger_message_id | 是 | uuid | 通常为 `user.prompt` 消息 ID | +- 行为:同 `trigger_message_id` 幂等;若已有 RUNNING 则复用其 run_id。 +- 响应 201:`{ run_id }` +- 错误:401 未授权;404 session 不存在或 trigger_message 不属于该 session。 + ## cURL 示例 ```bash # 创建会话 @@ -163,4 +204,9 @@ curl -s http://localhost:8000/api/sessions/$SESSION_ID/messages/{message_id} \ curl -N http://localhost:8000/api/sessions/$SESSION_ID/sse?after_seq=10 \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: text/event-stream" + +# 手动触发 Run(调试用,实际 user.prompt 会自动触发) +curl -s -X POST http://localhost:8000/api/sessions/$SESSION_ID/runs \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"trigger_message_id":"'$MESSAGE_ID'"}' ``` diff --git a/docs/ChatSession/chat-session-openapi.yaml b/docs/ChatSession/chat-session-openapi.yaml index 0d36e8e..6411846 100644 --- a/docs/ChatSession/chat-session-openapi.yaml +++ b/docs/ChatSession/chat-session-openapi.yaml @@ -1,15 +1,19 @@ openapi: 3.0.3 info: title: ChatSession & Message API - version: 1.0.0 + version: 1.1.0 description: | - ChatSession & Message MVP-1,支持会话创建、消息追加、增量查询。自然语言:中文。 + ChatSession & Message API(含 Archive/GetMessage/SSE 与 Run 调度)。自然语言:中文。 servers: - url: http://localhost:8000/api description: 本地开发(FrankenPHP / Docker) tags: - name: ChatSession - description: 会话管理与消息 + description: 会话管理 + - name: Message + description: 消息追加与查询 + - name: Run + description: Agent Run 调度 paths: /sessions: post: @@ -77,9 +81,92 @@ paths: $ref: '#/components/schemas/PaginationMeta' "401": description: 未授权 - /sessions/{session_id}/messages: + /sessions/{session_id}: + get: + tags: [ChatSession] + summary: 获取会话详情 + security: + - bearerAuth: [] + parameters: + - in: path + name: session_id + required: true + schema: + type: string + format: uuid + responses: + "200": + description: 会话详情 + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSession' + "401": + description: 未授权 + "404": + description: 未找到 + patch: + tags: [ChatSession] + summary: 更新会话(重命名/状态) + security: + - bearerAuth: [] + parameters: + - in: path + name: session_id + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateSessionRequest' + responses: + "200": + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSession' + "403": + description: 状态门禁禁止 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + "422": + description: 校验失败 + "401": + description: 未授权 + /sessions/{session_id}/archive: post: tags: [ChatSession] + summary: 归档会话(设为 CLOSED,幂等) + security: + - bearerAuth: [] + parameters: + - in: path + name: session_id + required: true + schema: + type: string + format: uuid + responses: + "200": + description: 归档成功(或已归档) + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSession' + "401": + description: 未授权 + "404": + description: 未找到 + /sessions/{session_id}/messages: + post: + tags: [Message] summary: 追加消息(含幂等与状态门禁) security: - bearerAuth: [] @@ -111,126 +198,8 @@ paths: $ref: '#/components/schemas/Error' "401": description: 未授权 - /sessions/{session_id}/messages/{message_id}: get: - tags: [ChatSession] - summary: 获取单条消息(校验 session_id) - security: - - bearerAuth: [] - parameters: - - in: path - name: session_id - required: true - schema: - type: string - format: uuid - - in: path - name: message_id - required: true - schema: - type: string - format: uuid - responses: - "200": - description: 消息详情 - content: - application/json: - schema: - $ref: '#/components/schemas/MessageResource' - "401": - description: 未授权 - "404": - description: 未找到或不属于该会话 - /sessions/{session_id}/archive: - post: - tags: [ChatSession] - summary: 归档会话(设为 CLOSED,幂等) - security: - - bearerAuth: [] - parameters: - - in: path - name: session_id - required: true - schema: - type: string - format: uuid - responses: - "200": - description: 归档成功(或已归档) - content: - application/json: - schema: - $ref: '#/components/schemas/ChatSession' - "401": - description: 未授权 - "404": - description: 未找到 - /sessions/{session_id}/sse: - get: - tags: [ChatSession] - summary: SSE 增量推送(backlog + Redis 实时) - security: - - bearerAuth: [] - parameters: - - in: path - name: session_id - required: true - schema: - type: string - format: uuid - - in: query - name: after_seq - schema: - type: integer - default: 0 - description: backlog 起始 seq(若有 Last-Event-ID 以其为准) - - in: query - name: limit - schema: - type: integer - default: 200 - maximum: 500 - responses: - "200": - description: text/event-stream SSE 流 - content: - text/event-stream: - schema: - type: string - example: | - id: 1 - event: message - data: {"message_id":"...","seq":1} - "401": - description: 未授权 - "404": - description: 未找到 - /sessions/{session_id}: - get: - tags: [ChatSession] - summary: 获取会话详情 - security: - - bearerAuth: [] - parameters: - - in: path - name: session_id - required: true - schema: - type: string - format: uuid - responses: - "200": - description: 会话详情 - content: - application/json: - schema: - $ref: '#/components/schemas/ChatSession' - "401": - description: 未授权 - "404": - description: 未找到 - get: - tags: [ChatSession] + tags: [Message] summary: 按 seq 增量查询消息 security: - bearerAuth: [] @@ -268,10 +237,80 @@ paths: $ref: '#/components/schemas/MessageResource' "401": description: 未授权 - /sessions/{session_id}: - patch: - tags: [ChatSession] - summary: 更新会话(重命名/状态) + /sessions/{session_id}/messages/{message_id}: + get: + tags: [Message] + summary: 获取单条消息(校验 session_id) + security: + - bearerAuth: [] + parameters: + - in: path + name: session_id + required: true + schema: + type: string + format: uuid + - in: path + name: message_id + required: true + schema: + type: string + format: uuid + responses: + "200": + description: 消息详情 + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResource' + "401": + description: 未授权 + "404": + description: 未找到或不属于该会话 + /sessions/{session_id}/sse: + get: + tags: [Message] + summary: SSE 增量推送(backlog + Redis 实时) + security: + - bearerAuth: [] + parameters: + - in: path + name: session_id + required: true + schema: + type: string + format: uuid + - in: query + name: after_seq + schema: + type: integer + default: 0 + description: backlog 起始 seq(若有 Last-Event-ID 以其为准) + - in: query + name: limit + schema: + type: integer + default: 200 + maximum: 500 + responses: + "200": + description: text/event-stream SSE 流 + content: + text/event-stream: + schema: + type: string + example: | + id: 1 + event: message + data: {"message_id":"...","seq":1} + "401": + description: 未授权 + "404": + description: 未找到 + /sessions/{session_id}/runs: + post: + tags: [Run] + summary: 触发一次 Agent Run(按 trigger_message_id 幂等) security: - bearerAuth: [] parameters: @@ -286,24 +325,22 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UpdateSessionRequest' + $ref: '#/components/schemas/DispatchRunRequest' responses: - "200": - description: 更新成功 + "201": + description: 已触发(或复用进行中的 RUNNING) content: application/json: schema: - $ref: '#/components/schemas/ChatSession' - "403": - description: 状态门禁禁止 - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - "422": - description: 校验失败 + type: object + properties: + run_id: + type: string + format: uuid "401": description: 未授权 + "404": + description: session 或 trigger_message 不存在 components: securitySchemes: bearerAuth: @@ -363,6 +400,13 @@ components: status: type: string enum: [OPEN, LOCKED, CLOSED] + DispatchRunRequest: + type: object + required: [trigger_message_id] + properties: + trigger_message_id: + type: string + format: uuid AppendMessageRequest: type: object required: [role, type] diff --git a/phpunit.xml b/phpunit.xml index d703241..bbfcf81 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,8 +23,7 @@ - - + diff --git a/routes/api.php b/routes/api.php index e5199b8..6913d6f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Controllers\AuthController; use App\Http\Controllers\ChatSessionController; +use App\Http\Controllers\RunController; use App\Http\Controllers\UserController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -10,6 +11,8 @@ Route::get('/health', fn () => ['status' => 'ok']); Route::post('/login', [AuthController::class, 'login']); +Route::get('test',[RunController::class,'test']); + Route::middleware('auth.jwt')->group(function () { Route::get('/me', function (Request $request) { return $request->user(); @@ -30,4 +33,5 @@ Route::middleware('auth.jwt')->group(function () { Route::patch('/sessions/{session_id}', [ChatSessionController::class, 'update']); Route::post('/sessions/{session_id}/archive', [ChatSessionController::class, 'archive']); Route::get('/sessions/{session_id}/sse', [\App\Http\Controllers\ChatSessionSseController::class, 'stream']); + Route::post('/sessions/{session_id}/runs', [RunController::class, 'store']); }); diff --git a/tests/Feature/AgentRunTest.php b/tests/Feature/AgentRunTest.php new file mode 100644 index 0000000..b2b5950 --- /dev/null +++ b/tests/Feature/AgentRunTest.php @@ -0,0 +1,126 @@ +createSession('Run Session'); + $prompt = $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'hello agent', + ]); + + $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); + + Queue::assertPushed(AgentRunJob::class, function ($job) use ($session, $runId) { + return $job->sessionId === $session->session_id && $job->runId === $runId; + }); + + // simulate worker execution + (new AgentRunJob($session->session_id, $runId))->handle( + app(RunLoop::class), + app(OutputSink::class) + ); + + $messages = Message::query() + ->where('session_id', $session->session_id) + ->orderBy('seq') + ->get(); + + $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'RUNNING')); + $this->assertTrue($messages->contains(fn ($m) => $m->type === 'agent.message' && ($m->payload['run_id'] ?? null) === $runId)); + $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'DONE')); + } + + public function test_second_prompt_dispatches_new_run_after_first_completes(): void + { + Queue::fake(); + $service = app(ChatService::class); + $dispatcher = app(RunDispatcher::class); + + $session = $service->createSession('Sequential Runs'); + $firstPrompt = $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'first run', + ]); + + $firstRunId = $dispatcher->dispatchForPrompt($session->session_id, $firstPrompt->message_id); + + (new AgentRunJob($session->session_id, $firstRunId))->handle( + app(RunLoop::class), + app(OutputSink::class) + ); + + $secondPrompt = $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'second run', + ]); + + $secondRunId = $dispatcher->dispatchForPrompt($session->session_id, $secondPrompt->message_id); + + $this->assertNotSame($firstRunId, $secondRunId); + + Queue::assertPushed(AgentRunJob::class, 2); + Queue::assertPushed(AgentRunJob::class, function ($job) use ($secondRunId, $session) { + return $job->runId === $secondRunId && $job->sessionId === $session->session_id; + }); + } + + public function test_cancel_prevents_agent_reply_and_marks_canceled(): void + { + Queue::fake(); + $service = app(ChatService::class); + $dispatcher = app(RunDispatcher::class); + $loop = app(RunLoop::class); + + $session = $service->createSession('Cancel Session'); + $prompt = $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'please cancel', + ]); + + $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); + + $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => Message::ROLE_USER, + 'type' => 'run.cancel.request', + 'payload' => ['run_id' => $runId], + ]); + + $loop->run($session->session_id, $runId); + + $messages = Message::query() + ->where('session_id', $session->session_id) + ->get(); + + $this->assertFalse($messages->contains(fn ($m) => $m->type === 'agent.message' && ($m->payload['run_id'] ?? null) === $runId)); + $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'CANCELED')); + } +}