Compare commits

...

16 Commits

Author SHA1 Message Date
e956df9daa main: 增强工具功能与消息处理
- 添加 `FileReadTool`,支持文件内容读取与安全验证
- 引入 `hasToolMessages` 逻辑,优化工具历史上下文处理
- 修改工具选项逻辑,支持禁用工具时的动态调整
- 增加消息序列化逻辑,优化 Redis 序列管理与数据同步
- 扩展测试覆盖,验证序列化与工具调用场景
- 增强 Docker Compose 脚本,支持应用重置与日志清理
- 调整工具调用超时设置,提升运行时用户体验
2025-12-24 00:55:54 +08:00
71226c255b 添加新的工具功能和测试覆盖:
- 注册 `LsTool` 和 `BashTool` 工具,支持目录操作和命令执行
- 增强工具调用逻辑,添加日志记录以提升调试能力
- 增加 `ToolRegistry` 和 `RunLoop` 的增量累积与排序优化
- 完善单元测试覆盖新工具的执行与行为验证
2025-12-23 17:26:27 +08:00
78875ec3eb main: 增强工具调用与上下文日志记录
- 添加 `disable_tools` 选项,支持达到调用上限后禁用工具
- 增加工具调用与结果的日志记录,提升调试信息
- 优化上下文构建,记录已加载的消息信息
- 完善流式消息推送逻辑,支持 `message.delta` 类型
2025-12-22 19:10:44 +08:00
663e15395b main: 增强 Agent Run 逻辑与消息处理
- 添加流式文本推送,支持 `message.delta` 消息类型
- 优化 Run 主流程,增加工具调用与流式数据发布逻辑
- 更新 `phpunit.xml` 环境变量,支持 Agent 配置项
- 扩展文档,完善工具调用与消息类型说明
2025-12-22 17:51:56 +08:00
59d4831f00 main: 增强工具调用与消息流程
- 支持 tool.call 和 tool.result 消息类型处理
- 引入 Tool 调度与执行逻辑,支持超时与结果截断
- 增加 ToolRegistry 和 ToolExecutor 管理工具定义与执行
- 更新上下文构建与消息映射逻辑,适配工具闭环处理
- 扩展配置与环境变量,支持 Tool 调用相关选项
- 增强单元测试覆盖工具调用与执行情景
- 更新文档和 OpenAPI,新增工具相关说明与模型定义
2025-12-22 12:36:59 +08:00
dcbd0338e6 main: 优化消息处理逻辑与 Redis 发布
- 调整消息解析流程,支持 JSON 解码与模型实例化
- 增加 `appendMessage` 方法的保存控制参数
- 修复因保存控制导致的重复发布问题
- 优化 Redis 发布逻辑,支持消息内容推送
- 更新注释与待优化标记,提升代码可读性
2025-12-19 12:53:53 +08:00
8c4ad80dab main: 引入 AgentProvider 流式事件与 OpenAI 兼容适配
- 增加流式事件流支持,Provider 输出 `message.delta` 等事件
- 实现 OpenAI 兼容适配器,包括 RequestBuilder、ApiClient 等模块
- 更新 Agent Run 逻辑,支持流式增量写入与模型完成状态管理
- 扩展配置项 `agent.openai.*`,支持模型、密钥等配置
- 优化文档,完善流式事件与消息类型说明
- 增加单元测试,覆盖 Provider 和 OpenAI 适配相关逻辑
- 更新环境变量与配置示例,支持新功能
2025-12-19 02:35:37 +08:00
56523c1f0a main: 修改 DummyAgentProvider 返回逻辑
- 更新返回内容,改为包含上下文和当前回复条目
- 增强消息格式,支持更多调试信息输出
2025-12-18 18:48:12 +08:00
977c8ee272 main: 更新 README,增强架构描述与配置说明
- 添加 `messages.payload` 表达式索引,优化查询性能
- 增强 SSE 功能,支持 seq gap 补偿与心跳保活机制
- 更新 Agent Run 逻辑,支持 HttpProvider 幂等去重与取消机制
- 增加 Agent Provider 配置说明,完善环境变量文档
2025-12-18 18:17:44 +08:00
6d934f4e34 main: 增强 Agent Run 调度可靠性与幂等性
- 默认切换 AgentProvider 为 HttpAgentProvider,增强网络请求的容错和重试机制
- 优化 Run 逻辑,支持多场景去重与并发保护
- 添加 Redis 发布失败的日志记录以提升问题排查效率
- 扩展 OpenAPI 规范,新增 Error 和 Run 状态相关模型
- 增强测试覆盖,验证调度策略和重复请求的幂等性
- 增加数据库索引以优化查询性能
- 更新所有相关文档和配置文件
2025-12-18 17:41:42 +08:00
2ad101c297 更新 DatabaseSeeder:调整默认用户信息 2025-12-18 09:10:50 +08:00
fa00da5966 调整 Docker 环境与依赖配置:
- 替换基础镜像为 `php:8.4.15-cli-alpine3.23`,重构依赖安装流程
- 切换包管理工具为 `apk`,添加必要系统库及扩展
- 更新 Composer 脚本及依赖映射
- 优化命令与环境变量配置,增强一致性与兼容性
2025-12-17 17:13:37 +08:00
ced95c02cb main: 扩展 Agent Run 调度与队列功能
- 增加 Agent Run MVP-0,包括 RunDispatcher 和 AgentRunJob
- 优化队列配置,支持 Redis 队列驱动,添加 Horizon 容器
- 更新 Docker 配置,细化角色分工,新增 Horizon 配置
- 增加测试任务 `TestJob`,扩展队列使用示例
- 更新 OpenAPI 规范,添加 Agent Run 相关接口及示例
- 编写文档,详细描述 Agent Run 流程与 MVP-0 功能
- 优化相关服务与文档,支持队列与异步运行
2025-12-17 02:39:45 +08:00
c55534ad20 main: 扩展 Agent Run 调度与队列功能
- 增加 Agent Run MVP-0,包括 RunDispatcher 和 AgentRunJob
- 优化队列配置,支持 Redis 队列驱动,添加 Horizon 容器
- 更新 Docker 配置,细化角色分工,新增 Horizon 配置
- 增加测试任务 `TestJob`,扩展队列使用示例
- 更新 OpenAPI 规范,添加 Agent Run 相关接口及示例
- 编写文档,详细描述 Agent Run 流程与 MVP-0 功能
- 优化相关服务与文档,支持队列与异步运行
2025-12-17 02:39:31 +08:00
dafa8f6b06 main: 调整 README,描述项目架构与快速启动
- 重新组织 README,详细说明愿景与运行目标
- 增加系统架构概览,包括存储、鉴权与实时功能介绍
- 提供快速启动指南,包括服务构建和迁移步骤
- 列出 API 功能,并说明状态与门禁规则
- 补充开发验证示例与后续演进方向
2025-12-14 22:01:59 +08:00
318571a6d9 main: 增强会话功能,支持归档与消息检索
- 添加会话归档接口及相关服务逻辑,并确保幂等性
- 实现单条消息获取接口,校验消息所属会话
- 增加 SSE 增量推送与实时消息订阅功能
- 提供相关的测试用例覆盖新功能
- 更新接口文档,完善 OpenAPI 规范,新增多项示例
2025-12-14 21:58:05 +08:00
84 changed files with 7815 additions and 207 deletions

11
.ai/mcp/mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "vendor/bin/sail",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"enabledPlugins": {
"php-lsp@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"laravel-boost@claude-plugins-official": true
}
}

View File

@@ -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
@@ -67,3 +67,36 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Agent Provider HTTP为空则走 Dummy/OpenAI
AGENT_PROVIDER_ENDPOINT=true
AGENT_PROVIDER_TIMEOUT=30 # HTTP 请求超时(秒)
AGENT_PROVIDER_CONNECT_TIMEOUT=5 # 连接超时(秒)
AGENT_PROVIDER_RETRY_TIMES=1 # 建立流前重试次数(仅连接失败/429/5xx 且未产出事件时)
AGENT_PROVIDER_RETRY_BACKOFF_MS=500 # 重试退避毫秒(指数退避)
# OpenAI-compatible Chat Completions填充后启用否则回退 Dummy
AGENT_OPENAI_BASE_URL=https://open.bigmodel.cn/api/paas/v4/
AGENT_OPENAI_API_KEY=
AGENT_OPENAI_ORGANIZATION= # 可选
AGENT_OPENAI_PROJECT= # 可选
AGENT_OPENAI_MODEL=gpt-4o-mini
AGENT_OPENAI_TEMPERATURE=0.7
AGENT_OPENAI_TOP_P=1.0
AGENT_OPENAI_INCLUDE_USAGE=false
# AgentRunJob 队列执行策略
AGENT_RUN_JOB_TRIES=1 # 队列重试次数
AGENT_RUN_JOB_BACKOFF=3 # 重试退避秒数
AGENT_RUN_JOB_TIMEOUT=600 # Job 超时时间(秒)
# Tool 子 Run 调度与超时
AGENT_TOOL_MAX_CALLS_PER_RUN=99 # 单个父 Run 允许的工具调用次数
AGENT_TOOL_WAIT_TIMEOUT_MS=30000 # 等待 tool.result 的超时时间(毫秒)
AGENT_TOOL_WAIT_POLL_MS=200 # 等待工具结果轮询间隔(毫秒)
AGENT_TOOL_TIMEOUT_SECONDS=15 # 单个工具执行超时(秒,超出记为 TIMEOUT
AGENT_TOOL_RESULT_MAX_BYTES=4096 # 工具结果最大保存字节数(截断后仍会写入)
AGENT_TOOL_CHOICE=auto # OpenAI tool_choice 选项auto/required 等)
AGENT_TOOL_JOB_TRIES=1 # ToolRunJob 重试次数
AGENT_TOOL_JOB_BACKOFF=3 # ToolRunJob 重试退避秒数
AGENT_TOOL_JOB_TIMEOUT=120 # ToolRunJob 超时时间(秒)

2
.gitignore vendored
View File

@@ -26,3 +26,5 @@ Thumbs.db
**/caddy
frankenphp
frankenphp-worker.php
rr
.rr.yaml

228
.junie/guidelines.md Normal file
View File

@@ -0,0 +1,228 @@
<laravel-boost-guidelines>
=== 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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).
</laravel-boost-guidelines>

11
.junie/mcp/mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "vendor/bin/sail",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

15
.mcp.json Normal file
View File

@@ -0,0 +1,15 @@
{
"mcpServers": {
"laravel-boost": {
"command": "docker",
"args": [
"compose",
"exec",
"app",
"php",
"artisan",
"boost:mcp"
]
}
}
}

1
.qoderignore Normal file
View File

@@ -0,0 +1 @@
Specify files or folders to ignore during indexing. Use commas to separate entries. Glob patterns like *.logmy-security/ are supported.

225
AGENTS.md Executable file
View File

@@ -0,0 +1,225 @@
<laravel-boost-guidelines>
=== 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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 exec --rm app php artisan test`.
- To run all tests in a file: `docker compose exec --rm app php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `docker compose exec --rm app php artisan test --filter=testName` (recommended after making a change to a related file).
</laravel-boost-guidelines>

679
CLAUDE.md Executable file
View File

@@ -0,0 +1,679 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
自然语言使用中文。
## 项目概述
这是一个基于 Laravel 12 + Octane + Docker 的 Agent Runtime Server (ARS),用于提供可部署的 Agent 运行时服务。核心特性包括:
- 兼容多种 Agent 模型
- Web 终端实时交互,支持断线重连
- 后台任务持续执行,重连后可续传会话
## 技术栈
- **PHP**: 8.2+
- **Laravel Framework**: 12.x
- **Laravel Octane**: 2.x (FrankenPHP)
- **Laravel Horizon**: 5.x (队列监控)
- **Laravel Telescope**: 5.x (调试工具)
- **数据库**: PostgreSQL 16
- **缓存/队列**: Redis 7
- **认证**: JWT (php-open-source-saver/jwt-auth)
- **容器化**: Docker Compose
## 核心架构
### 数据模型
#### ChatSession (会话)
- `session_id` (UUID, 主键): 会话唯一标识
- `session_name`: 会话名称
- `status`: 会话状态 (OPEN/LOCKED/CLOSED)
- `last_seq`: 最后消息序号
- `last_message_id`: 最后消息ID
#### Message (消息)
- `message_id` (UUID, 主键): 消息唯一标识
- `session_id`: 所属会话ID
- `role`: 消息角色 (USER/AGENT/TOOL/SYSTEM)
- `type`: 消息类型 (user.prompt/agent.message/message.delta/tool.call/tool.result/run.status/error等)
- `content`: 消息内容 (text)
- `payload`: 附加数据 (jsonb),包含 run_id、tool_call_id、error_type 等元数据
- `seq`: 会话内序号 (单调递增)
- `reply_to`: 回复的消息ID
- `dedupe_key`: 幂等去重键
- **约束**: `unique(session_id, seq)``unique(session_id, dedupe_key)`
#### 消息类型完整列表
- `user.prompt` (USER): 用户提示
- `agent.message` (AGENT): Agent 完整回复
- `message.delta` (AGENT): 流式文本增量
- `tool.call` (AGENT): 工具调用请求
- `tool.result` (TOOL): 工具执行结果
- `run.status` (SYSTEM): Run 状态RUNNING/DONE/FAILED/CANCELED
- `error` (SYSTEM): 错误信息
- `run.cancel.request` (USER): 取消请求
### 会话状态与门禁规则
- **OPEN**: 正常追加所有消息
- **LOCKED**: 拒绝 `role=USER && type=user.prompt`
- **CLOSED**: 拒绝追加,例外允许 `role=SYSTEM && type in [run.status, error]`
- **状态变更规则**: CLOSED 状态的会话不能重新打开
### Agent Run 执行流程
1. 用户发送 `user.prompt` 消息后自动触发,或通过 API 手动触发
2. `RunDispatcher` 检查幂等性(基于 `trigger_message_id`
3. 检查会话是否已有 RUNNING 状态的 run单会话单任务限制
4. 创建 `run.status=RUNNING` 消息并派发 `AgentRunJob`
5. `RunLoop` 执行 Agent 调用流程:
- `ContextBuilder` 构建上下文(加载最近 20 条相关消息)
- `AgentProviderInterface::stream()` 流式调用 Agent
- 消费 `Generator<ProviderEvent>` 流:
- `MessageDelta`: 流式文本,写入 `message.delta` 消息
- `ToolCall`: 工具调用,累积后写入 `tool.call` 并分发 `ToolRunJob`
- `Done`: 流结束,写入最终 `agent.message` + `DONE` 状态
- `Error`: 错误,写入 `error` + `FAILED` 状态
- `CancelChecker` 定期检查取消信号
- 工具调用完成后,等待 `tool.result`,继续下一轮 Provider 调用
- `OutputSink` 统一写入消息,保证幂等性
6. 完成后写入 `run.status=DONE/FAILED/CANCELED`
**Provider 选择逻辑**(在 `AppServiceProvider` 中绑定):
- `HttpAgentProvider` 会检查 `AGENT_OPENAI_API_KEY` 环境变量
- 若配置了 OpenAI Key则使用 `OpenAiChatCompletionsAdapter`
- 否则回退到 `DummyAgentProvider`(返回模拟响应)
### 工具系统架构
项目支持 Agent 调用工具Tools采用子 Run 模式:
- **Tool** (`app/Services/Tool/Tool.php`): 工具接口,定义 name、description、parameters、execute 方法
- **ToolRegistry** (`app/Services/Tool/ToolRegistry.php`): 管理已注册工具,生成 OpenAI 兼容工具声明
- **ToolExecutor** (`app/Services/Tool/ToolExecutor.php`): 执行工具,处理超时和结果截断
- **ToolRunDispatcher** (`app/Services/Tool/ToolRunDispatcher.php`): 为每个工具调用创建子 run 并投递 `ToolRunJob`
- **ToolRunJob** (`app/Jobs/ToolRunJob.php`): 队列任务,执行工具并写入 `tool.result` 消息
工具调用流程:
1. Agent 返回 ToolCall 事件
2. RunLoop 累积工具调用,写入 `tool.call` 消息
3. ToolRunDispatcher 为每个工具创建子 run`run.status=RUNNING`
4. ToolRunJob 执行工具,写入 `tool.result` 消息
5. RunLoop 轮询等待所有 `tool.result`(支持超时)
6. 收集工具结果后,继续下一轮 Provider 调用
配置项(`config/agent.php`
- `agent.tools.max_calls_per_run`: 单 run 最多工具调用次数(默认 1
- `agent.tools.wait_timeout_ms`: 等待工具结果超时(默认 15000ms
- `agent.tools.wait_poll_interval_ms`: 轮询间隔(默认 200ms
- `agent.tools.timeout_seconds`: 工具执行超时(默认 15s
- `agent.tools.result_max_bytes`: 结果最大字节数(默认 4096
### 实时消息推送 (SSE)
- **端点**: `GET /api/sessions/{id}/sse?after_seq=123`
- **机制**:
1. 先从数据库补发历史消息seq > after_seq
2. 订阅 Redis 频道 `session:{id}:messages` 监听新消息
3. 支持 `Last-Event-ID` 自动续传
4. 检测 seq gap 自动回补
5. 15 秒心跳保活
- **事件格式**: SSE event id 为消息 seq
### 服务层架构
- **ChatService** (`app/Services/ChatService.php`): 会话和消息的核心业务逻辑
- 使用行锁 (`lockForUpdate`) + 事务保证消息 seq 单调递增
- 通过 `dedupe_key` 实现幂等性
- 消息追加后发布 Redis 事件用于 SSE 推送
- 提供 `appendMessage()``listMessagesBySeq()``updateSession()` 等方法
- **RunDispatcher** (`app/Services/RunDispatcher.php`): Agent Run 调度器
- 检查 trigger_message_id 幂等性
- 确保同会话只有一个 RUNNING 状态的 run
- **RunLoop** (`app/Services/RunLoop.php`): Agent 执行循环
- 协调 ContextBuilder、AgentProvider、OutputSink、CancelChecker、ToolRunDispatcher
- 处理工具调用上限(`max_calls_per_run`
- 达到上限后强制 `tool_choice=none` 防止再次触发
- **OutputSink** (`app/Services/OutputSink.php`): 统一的消息写入接口
- `appendAgentMessage()`: 写入 agent 回复
- `appendAgentDelta()`: 写入流式文本增量
- `appendRunStatus()`: 写入 run 状态
- `appendError()`: 写入错误信息
- `appendToolCall()`: 写入工具调用
- `appendToolResult()`: 写入工具结果
- **ContextBuilder** (`app/Services/ContextBuilder.php`): 构建 Agent 上下文
- 加载最近 20 条相关消息USER/AGENT/TOOL 角色)
- 按 seq 排序并转换为 AgentContext
- **CancelChecker** (`app/Services/CancelChecker.php`): 检查 run 是否被取消
- 查询 `type='run.cancel.request'` 消息
## 常用开发命令
### Docker 容器操作
```bash
# 构建并启动所有服务
docker compose build
docker compose up -d app horizon pgsql redis
# 停止服务
docker compose stop
# 查看日志
docker compose logs -f app
docker compose logs -f horizon
# 进入容器 shell
docker compose exec app bash
```
### 数据库操作
```bash
# 运行迁移
docker compose exec app php artisan migrate
# 回滚迁移
docker compose exec app php artisan migrate:rollback
# 刷新数据库(危险:删除所有表并重新迁移)
docker compose exec app php artisan migrate:fresh
# 运行 seeder
docker compose exec app php artisan db:seed
```
### 测试
```bash
# 运行所有测试
docker compose exec app php artisan test
# 运行特定测试套件
docker compose exec app php artisan test --testsuite=Feature
docker compose exec app php artisan test --testsuite=Unit
# 运行特定测试文件
docker compose exec app php artisan test tests/Feature/ChatSessionTest.php
# 运行特定测试方法(使用 filter
docker compose exec app php artisan test --filter=testCreateSession
docker compose exec app php artisan test --filter=testAppendMessageWithDedupe
# 显示测试覆盖率
docker compose exec app php artisan test --coverage
```
### 代码质量
```bash
# 运行 Laravel Pint 格式化代码
docker compose exec app vendor/bin/pint
# 只检查格式问题(不修改)
docker compose exec app vendor/bin/pint --test
# 格式化脏文件git 变更的文件)
docker compose exec app vendor/bin/pint --dirty
```
### 本地开发(不使用 Docker
如果你想在本地直接运行(需要 PHP 8.2+、PostgreSQL、Redis
```bash
# 安装依赖
composer install
# 启动 Octane 开发服务器
php artisan octane:start --host=0.0.0.0 --port=8000
# 启动队列 worker
php artisan queue:work
# 或启动 Horizon
php artisan horizon
# 查看实时日志
php artisan pail
```
### 队列与任务
```bash
# 查看 Horizon 仪表板
# 访问 http://localhost:8000/horizon
# 手动运行队列 worker开发调试用
docker compose exec app php artisan queue:work
# 查看失败的任务
docker compose exec app php artisan queue:failed
# 重试失败的任务
docker compose exec app php artisan queue:retry all
```
### 开发调试
```bash
# 启动 Tinker REPL
docker compose exec app php artisan tinker
# 查看路由列表
docker compose exec app php artisan route:list
# 清除缓存
docker compose exec app php artisan cache:clear
docker compose exec app php artisan config:clear
docker compose exec app php artisan route:clear
# 查看实时日志(使用 Laravel Pail
docker compose exec app php artisan pail
```
### Artisan 生成器
```bash
# 生成 Model推荐带选项一次性生成相关文件
docker compose exec app php artisan make:model ChatSession -mfs
# -m: migration, -f: factory, -s: seeder
# 生成 Controller
docker compose exec app php artisan make:controller ChatSessionController
# 生成 Form Request用于验证
docker compose exec app php artisan make:request AppendMessageRequest
# 生成 ResourceAPI 响应格式化)
docker compose exec app php artisan make:resource ChatSessionResource
# 生成 Job
docker compose exec app php artisan make:job AgentRunJob
# 生成 Service 类
docker compose exec app php artisan make:class Services/ChatService
# 生成测试
docker compose exec app php artisan make:test ChatSessionTest --phpunit
docker compose exec app php artisan make:test ChatServiceTest --unit --phpunit
```
## API 路由结构
所有 API 路由均在 `/api/*` 下,除登录和健康检查外均需 JWT 认证:
- `POST /api/login`: 用户登录
- `GET /api/health`: 健康检查
- `GET /api/me`: 获取当前用户信息
### 用户管理
- `GET /api/users`: 用户列表
- `POST /api/users`: 创建用户
- `PUT /api/users/{user}`: 更新用户
- `POST /api/users/{user}/deactivate`: 停用用户
- `POST /api/users/{user}/activate`: 激活用户
### 会话管理
- `POST /api/sessions`: 创建会话
- `GET /api/sessions`: 会话列表(支持分页、状态过滤、关键词搜索)
- `GET /api/sessions/{session_id}`: 获取会话详情
- `PATCH /api/sessions/{session_id}`: 更新会话(重命名、状态变更)
- `POST /api/sessions/{session_id}/archive`: 归档会话(幂等,设为 CLOSED
### 消息管理
- `POST /api/sessions/{session_id}/messages`: 追加消息(支持幂等 dedupe_key
- `GET /api/sessions/{session_id}/messages`: 获取消息列表(支持 after_seq 增量拉取)
- `GET /api/sessions/{session_id}/messages/{message_id}`: 获取单条消息
### 实时与 Agent Run
- `GET /api/sessions/{session_id}/sse`: SSE 实时消息流
- `POST /api/sessions/{session_id}/runs`: 手动触发 Agent Run
## 项目结构
```
app/
├── Enums/ # 枚举类ChatSessionStatus 等)
├── Exceptions/ # 自定义异常
├── Http/
│ ├── Controllers/ # API 控制器
│ │ ├── ChatSessionController.php # 会话和消息 API
│ │ ├── ChatSessionSseController.php # SSE 实时推送
│ │ ├── RunController.php # Agent Run 手动触发
│ │ ├── AuthController.php # 用户认证
│ │ └── UserController.php # 用户管理
│ ├── Requests/ # Form Request 验证
│ └── Resources/ # API 响应格式化
├── Jobs/ # 队列任务
│ ├── AgentRunJob.php # Agent Run 队列任务
│ └── ToolRunJob.php # 工具执行队列任务
├── Models/ # Eloquent 模型
│ ├── ChatSession.php
│ ├── Message.php
│ └── User.php
├── Providers/ # 服务提供者
│ └── AppServiceProvider.php # 绑定 AgentProviderInterface
└── Services/ # 业务逻辑服务
├── Agent/ # Agent Provider 实现
│ ├── OpenAi/ # OpenAI 适配器
│ │ ├── OpenAiChatCompletionsAdapter.php
│ │ ├── ChatCompletionsRequestBuilder.php
│ │ ├── OpenAiApiClient.php
│ │ ├── OpenAiStreamParser.php
│ │ └── OpenAiEventNormalizer.php
│ ├── AgentProviderInterface.php
│ ├── AgentContext.php
│ ├── ProviderEvent.php
│ ├── ProviderEventType.php
│ ├── ProviderException.php
│ ├── HttpAgentProvider.php
│ └── DummyAgentProvider.php
├── Tool/ # 工具系统
│ ├── Tool.php # 工具接口
│ ├── ToolRegistry.php # 工具注册表
│ ├── ToolExecutor.php # 工具执行器
│ ├── ToolRunDispatcher.php # 工具 Run 分发器
│ ├── ToolCall.php # 工具调用对象
│ ├── ToolResult.php # 工具结果对象
│ └── Tools/ # 具体工具实现
│ └── GetTimeTool.php # 获取时间工具(示例)
├── ChatService.php # 会话和消息核心服务
├── RunDispatcher.php # Run 调度器
├── RunLoop.php # Run 执行循环
├── ContextBuilder.php # 上下文构建器
├── OutputSink.php # 消息写入器
└── CancelChecker.php # 取消检查器
database/
├── migrations/
│ └── 2025_02_14_000003_create_chat_tables.php # 核心表结构
└── factories/ # 测试数据工厂
tests/
├── Feature/
│ ├── ChatSessionTest.php # 会话和消息测试
│ └── AgentRunTest.php # Agent Run 流程测试
└── Unit/
└── OpenAiAdapterTest.php # OpenAI 适配器单元测试
config/
├── agent.php # Agent Provider 和工具配置
├── auth.php # JWT 认证配置
├── queue.php # 队列配置
└── horizon.php # Horizon 队列监控配置
bootstrap/
├── app.php # Laravel 12 应用引导(中间件、路由、异常)
└── providers.php # 服务提供者注册
```
**关键设计原则**
- 所有 Agent Provider 实现 `AgentProviderInterface::stream()` 接口
- 使用 `Generator` 模式流式返回 `ProviderEvent`
- 统一通过 `OutputSink` 写入消息,保证事务性和幂等性
- 工具系统采用子 Run 模式,每个工具调用创建独立 run
- 所有异步操作通过队列AgentRunJob、ToolRunJob执行
## 开发注意事项
### Laravel 12 新特性
- 无 `app/Console/Kernel.php``app/Http/Kernel.php`
- 中间件、路由、异常处理在 `bootstrap/app.php` 配置
- 服务提供者在 `bootstrap/providers.php` 注册
- Commands 自动注册(无需手动注册)
- JWT 中间件别名在 `bootstrap/app.php` 中配置为 `auth.jwt`
### 数据库操作规范
- **消息追加**:必须使用 `ChatService::appendMessage()`,不要直接操作 Message 模型
- 原因:需要行锁 + 事务保证 seq 单调递增,并发布 Redis 事件
- 所有 `OutputSink` 方法最终都调用 `ChatService::appendMessage()`
- **会话状态变更**:必须通过 `ChatService::updateSession()`
- 会自动校验 CLOSED 状态不可重新打开
- **所有涉及 seq 递增的操作**:必须在事务 + 行锁中完成
### Provider 与 Event Stream 开发
- 实现自定义 Provider 时必须实现 `AgentProviderInterface::stream()` 接口
- 使用 `Generator` 模式 yield `ProviderEvent` 对象
- 事件类型:
- `ProviderEvent::messageDelta($content)`: 流式文本增量
- `ProviderEvent::toolCall($toolCallId, $name, $arguments)`: 工具调用
- `ProviderEvent::done($finishReason)`: 流结束
- `ProviderEvent::error($errorCode, $message, $retryable)`: 错误
- 错误处理:抛出 `ProviderException` 包含 errorCode、retryable、httpStatus
- `RunLoop` 会自动处理重试、取消检查、工具调用分发
### 工具开发规范
- 创建新工具:继承 `Tool` 抽象类,实现 `name()``description()``parameters()``execute()` 方法
- 注册工具:在 `AppServiceProvider` 中调用 `ToolRegistry::register($tool)`
- 工具执行:
- `execute()` 方法接收 `array $args`,返回字符串结果
- 超时控制:通过 `AGENT_TOOL_TIMEOUT_SECONDS` 配置
- 结果截断:超过 `AGENT_TOOL_RESULT_MAX_BYTES` 会自动截断并标记
- 工具参数:使用 JSON Schema 格式定义,会自动传递给 OpenAI API
### 测试规范
- **框架**:所有测试使用 PHPUnit非 Pest
- **Feature 测试**:必须测试完整的 HTTP 请求流程
- 使用 `RefreshDatabase` trait 在测试间刷新数据库
- 使用 `Queue::fake()` 模拟队列
- 使用 `Redis::shouldReceive()` 模拟 Redis 发布
- **测试数据**:使用 Factory 创建模型数据
- **运行测试**:修改代码后必须运行相关测试确保通过
```bash
# 运行所有测试
docker compose exec app php artisan test
# 运行特定测试方法
docker compose exec app php artisan test --filter=testAppendMessageWithDedupe
```
### 队列配置
- **开发环境**:可使用同步队列,`.env` 中设置 `QUEUE_CONNECTION=sync`
- 优点:调试方便,错误堆栈清晰
- 缺点:阻塞 HTTP 请求
- **生产环境**:使用 Redis 队列 + Horizon 监控
- `AgentRunJob``ToolRunJob` 在队列中异步执行
- Horizon 仪表板http://localhost:8000/horizon
- **Job 配置**:通过 `config/agent.php` 控制重试次数、退避时间、超时
### 幂等性设计
- **dedupe_key 机制**:所有可能重复调用的操作都使用 `dedupe_key`
- 基于 UNIQUE 约束 `unique(session_id, dedupe_key)` 自动去重
- 重复请求返回已有消息(相同 message_id 和 seq
- **Run 幂等**`RunDispatcher` 通过 `trigger_message_id` 的 dedupe_key 确保不会为同一 prompt 重复创建 run
- **SSE 续传**:通过 `Last-Event-ID` / `after_seq` 支持断线续传
- **消息幂等模式**
- `run:{runId}:agent:message` - Agent 最终回复
- `run:{runId}:agent:delta:{index}` - 流式增量
- `run:{runId}:status:{status}` - Run 状态
- `run:{runId}:tool:call:{toolCallId}` - 工具调用
- `run:{runId}:tool:result:{toolCallId}` - 工具结果
### 性能优化建议
- **上下文加载**`ContextBuilder` 只加载最近 20 条消息,可通过配置调整
- **消息分页**`listMessagesBySeq()` 使用 `after_seq` + `limit` 增量拉取
- **索引优化**`(session_id, seq)``(session_id, dedupe_key)` 复合索引加速查询
- **Redis 发布**:消息追加后异步发布,使用 `DB::afterCommit()` 保证顺序
- **SSE 优化**backlog 限制 200 条,心跳 15 秒gap 检测自动回补
## 环境变量关键配置
```bash
# Octane 服务器
OCTANE_SERVER=frankenphp
# 数据库PostgreSQL
DB_CONNECTION=pgsql
DB_HOST=pgsql
DB_PORT=5432
DB_DATABASE=ars_backend
DB_USERNAME=ars
DB_PASSWORD=secret
# Redis
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PORT=6379
CACHE_STORE=redis
# 队列
QUEUE_CONNECTION=redis # 或 sync开发用
# JWT 认证
JWT_SECRET=<生成的密钥>
AUTH_GUARD=api
# CORS
CORS_ALLOWED_ORIGINS=http://localhost:5173
# OpenAI 兼容 API 配置
AGENT_OPENAI_BASE_URL=https://api.openai.com/v1 # 支持任何 OpenAI 兼容端点
AGENT_OPENAI_API_KEY= # 为空时使用 DummyProvider
AGENT_OPENAI_ORGANIZATION= # 可选
AGENT_OPENAI_PROJECT= # 可选
AGENT_OPENAI_MODEL=gpt-4o-mini
AGENT_OPENAI_TEMPERATURE=0.7
AGENT_OPENAI_TOP_P=1.0
AGENT_OPENAI_INCLUDE_USAGE=false
# Agent Provider HTTP 配置(重试机制)
AGENT_PROVIDER_TIMEOUT=30 # HTTP 请求超时(秒)
AGENT_PROVIDER_CONNECT_TIMEOUT=5 # 连接超时(秒)
AGENT_PROVIDER_RETRY_TIMES=1 # 建立流前重试次数(仅连接失败/429/5xx 且未产出事件时)
AGENT_PROVIDER_RETRY_BACKOFF_MS=500 # 重试退避毫秒(指数退避)
# Agent Run Job 配置
AGENT_RUN_JOB_TRIES=1 # 队列重试次数
AGENT_RUN_JOB_BACKOFF=3 # 重试退避秒数
AGENT_RUN_JOB_TIMEOUT=600 # Job 超时时间(秒)
# 工具系统配置
AGENT_TOOL_MAX_CALLS_PER_RUN=1 # 单个父 Run 允许的工具调用次数
AGENT_TOOL_WAIT_TIMEOUT_MS=15000 # 等待 tool.result 的超时时间(毫秒)
AGENT_TOOL_WAIT_POLL_MS=200 # 等待工具结果轮询间隔(毫秒)
AGENT_TOOL_TIMEOUT_SECONDS=15 # 单个工具执行超时(秒,超出记为 TIMEOUT
AGENT_TOOL_RESULT_MAX_BYTES=4096 # 工具结果最大保存字节数(截断后仍会写入)
AGENT_TOOL_CHOICE=auto # OpenAI tool_choice 选项auto/required 等)
AGENT_TOOL_JOB_TRIES=1 # ToolRunJob 重试次数
AGENT_TOOL_JOB_BACKOFF=3 # ToolRunJob 重试退避秒数
AGENT_TOOL_JOB_TIMEOUT=120 # ToolRunJob 超时时间(秒)
```
## 初始化新环境
```bash
# 1. 复制环境配置
cp .env.example .env
# 2. 生成应用密钥
docker compose exec app php artisan key:generate
# 3. 生成 JWT 密钥
docker compose exec app php artisan jwt:secret
# 4. 运行迁移
docker compose exec app php artisan migrate
# 5. (可选)创建测试用户
docker compose exec app php artisan db:seed
```
## 相关文档
- API 详细文档:`docs/ChatSession/chat-session-api.md`
- OpenAPI 规范:`docs/ChatSession/chat-session-openapi.yaml`
- 用户管理文档:`docs/User/user-api.md`
## 常见问题排查
### 队列任务不执行
- 检查 Horizon 是否运行:`docker compose ps horizon`
- 查看 Horizon 日志:`docker compose logs -f horizon`
- 检查 Redis 连接:
```bash
docker compose exec app php artisan tinker
> Redis::ping() # 应返回 "PONG"
```
- 查看失败的任务:`docker compose exec app php artisan queue:failed`
- 重试失败任务:`docker compose exec app php artisan queue:retry all`
### SSE 连接断开
- 检查 Nginx/代理是否支持 SSE需要禁用缓冲
- 确认客户端正确处理 `Last-Event-ID` 续传
- 查看 Redis 发布日志
- 测试环境下 SSE 会自动回退到仅返回 backlog无实时推送
### Agent Run 失败
- 查看 `messages` 表中 `type=error` 的消息:
```sql
SELECT message_id, session_id, content, payload
FROM messages
WHERE type = 'error'
ORDER BY created_at DESC
LIMIT 10;
```
- 检查 `payload.error_type``payload.provider``payload.retryable``payload.details`
- 检查 Provider 配置:
```bash
docker compose exec app php artisan config:show agent
```
- 查看实时日志:
```bash
docker compose exec app php artisan pail
# 或查看容器日志
docker compose logs -f app
```
- 测试 OpenAI API Key
```bash
docker compose exec app php artisan tinker
> $provider = app(App\Services\Agent\AgentProviderInterface::class);
> $context = new App\Services\Agent\AgentContext('test', []);
> foreach ($provider->stream($context) as $event) { dump($event); }
```
### 工具调用问题
- 检查工具是否注册:
```bash
docker compose exec app php artisan tinker
> $registry = app(App\Services\Tool\ToolRegistry::class);
> dump($registry->openAiToolsSpec());
```
- 查看 tool.call 和 tool.result 消息:
```sql
SELECT message_id, type, content, payload
FROM messages
WHERE session_id = 'xxx' AND type IN ('tool.call', 'tool.result')
ORDER BY seq;
```
- 检查工具调用上限:配置 `AGENT_TOOL_MAX_CALLS_PER_RUN`
- 工具执行超时:检查 `payload.status` 是否为 `TIMEOUT`
### 数据库迁移问题
- 确保 PostgreSQL 已启动:`docker compose ps pgsql`
- 查看迁移状态:`docker compose exec app php artisan migrate:status`
- 检查数据库连接:
```bash
docker compose exec app php artisan tinker
> DB::connection()->getPdo()
```
- 查看数据库日志:`docker compose logs -f pgsql`
### 消息 seq 不连续或重复
- 检查是否有并发追加消息(应使用行锁 + 事务)
- 确认所有消息追加都通过 `ChatService::appendMessage()`
- 查看 unique 约束冲突日志
### 调试技巧
- **实时日志**`docker compose exec app php artisan pail`
- **Telescope**:访问 http://localhost:8000/telescope 查看请求、查询、队列
- **Tinker REPL**`docker compose exec app php artisan tinker` 交互式调试
- **查看配置**`php artisan config:show agent`
- **查看路由**`php artisan route:list`
- **数据库查询日志**:在 `.env` 中设置 `DB_LOG_QUERIES=true`

153
README.md Normal file → Executable file
View File

@@ -1,80 +1,99 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# Agent Runtime Server (ARS) · Laravel + Octane + Docker
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
> 自建可部署的 Agent Runtime Server。愿景兼容大部分 Agent 模型、随时在 Web 终端输入指令/查看日志、断开后任务继续执行,重新连入能续上会话。
>
> “输入 prompt 就能离开工位,路上/手机继续 approve 和观察”。
## Local Development (Docker + FrankenPHP Octane)
## 🎯 愿景与思路(摘录)
- 兼容多数 Agent 模型,提供可观测的运行日志。
- Web 终端可随时输入/查看;断线不中断,重连可续。
- 面向开源生态,避免被单一厂商闭锁;先做最小实现,再逐步拆分组件。
Everything runs in containers; no PHP/Composer is required on the host.
## 🏗️ 当前架构概览
- **运行**Docker ComposeFrankenPHP + Laravel Octane。
- **存储**PostgreSQL主存储Redis可选用于 SSE 推送messages.payload 提供表达式索引以加速 run/cancel 查询。
- **鉴权**JWT无状态API 路由均在 `/api/*`
- **会话/消息模型**
- `chat_sessions(session_id UUID, session_name, status OPEN|LOCKED|CLOSED, last_seq, last_message_id, timestamps)`
- `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` 渠道;发现 seq gap 会回补并提供心跳。
- **Agent Run 编排MVP-0**user.prompt 后自动触发 RunDispatcher → `run.status=RUNNING` → AgentRunJobHttpProvider未配置 endpoint 时回退 DummyProvider`agent.message` + `run.status=DONE/FAILED/CANCELED` 落库;同 trigger_message 幂等、同会话只允许一个 RUNNING终态/消息均去重,取消可保证不写 agent.message。
- **队列监控**Horizon本地默认开放 `/horizon`,非 local 环境默认拒绝访问)。
1. Build the FrankenPHP image (installs PHP extensions, Composer, git): `docker compose build`
2. Start the stack (app + PostgreSQL + Redis): `docker compose up -d app pgsql redis`
3. Tail app logs: `docker compose logs -f app`
4. Run framework commands: `docker compose exec app php artisan migrate`
5. Stop and clean up: `docker compose down` (add `-v` to drop databases/Redis data)
## 🚀 快速启动
```bash
# 构建并启动
docker compose build
docker compose up -d app horizon pgsql redis
Notes:
- `docker/app/entrypoint.sh` will run `composer install`, copy `.env` if missing, generate `APP_KEY`, and boot Octane with `--watch`.
- Config sets `OCTANE_SERVER=frankenphp` by default (see `.env` and `config/octane.php`).
- PostgreSQL service: host `pgsql`, port `5432`, database `ars_backend`, user `ars`, password `secret` (see `.env.example`).
- Redis service: host `redis`, port `6379`, default no password, client `phpredis`.
- API 默认使用 JWT`AUTH_GUARD=api`,会话驱动为 `array`(无状态)。首次运行如需重置密钥:`docker compose run --rm --entrypoint=php app artisan jwt:secret --force`
- API 说明文档:`docs/user/user-api.md`中文OpenAPI/Swagger 规范:`docs/user/user-openapi.yaml`(可导入 Swagger UI / Postman
- ChatSession 接口文档:`docs/ChatSession/chat-session-api.md`OpenAPI`docs/ChatSession/chat-session-openapi.yaml`
- CORS通过全局中间件开启允许域名由环境变量 `CORS_ALLOWED_ORIGINS` 配置(默认 `http://localhost:5173`,多域名用逗号分隔)。
- 项目沟通与自然语言默认使用中文。
# 首次迁移(仅需一次)
docker compose exec app php artisan migrate
## About Laravel
# 运行 Feature 测试
docker compose exec app php artisan test --testsuite=Feature
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
# 队列AgentRunJob开发可用同步队列或用 Horizon
# 同步:.env / phpunit.xml 中 QUEUE_CONNECTION=sync
# Horizondocker compose up -d horizon需 composer install 安装 laravel/horizonQUEUE_CONNECTION=redis
```
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
### Agent Provider 配置(可选)
`config/agent.php` 读取以下环境变量(默认值已内置),用于控制 HTTP 调用、OpenAI 直连以及队列重试:
- `AGENT_PROVIDER_ENDPOINT`:自定义 HTTP Provider 入口(为空时回退 Dummy 或 OpenAI 适配器)
- `AGENT_PROVIDER_TIMEOUT`(默认 30Provider HTTP 请求超时时间(秒)
- `AGENT_PROVIDER_CONNECT_TIMEOUT`(默认 5Provider 连接超时时间(秒)
- `AGENT_PROVIDER_RETRY_TIMES`(默认 1建立流前的重试次数仅连接失败/429/5xx 且尚未产出事件时重试)
- `AGENT_PROVIDER_RETRY_BACKOFF_MS`(默认 500重试退避毫秒指数退避
- `AGENT_OPENAI_BASE_URL`(默认 https://api.openai.com/v1OpenAI-compatible Chat Completions 基础地址
- `AGENT_OPENAI_API_KEY`OpenAI API Key为空则使用 DummyProvider
- `AGENT_OPENAI_ORGANIZATION`OpenAI Organization header可选
- `AGENT_OPENAI_PROJECT`OpenAI Project header可选
- `AGENT_OPENAI_MODEL`(默认 gpt-4o-mini模型名称
- `AGENT_OPENAI_TEMPERATURE`(默认 0.7):采样温度
- `AGENT_OPENAI_TOP_P`(默认 1.0Top-p 采样
- `AGENT_OPENAI_INCLUDE_USAGE`(默认 false是否请求流式返回 usage 统计
- `AGENT_RUN_JOB_TRIES`(默认 1AgentRunJob 队列重试次数
- `AGENT_RUN_JOB_BACKOFF`(默认 3AgentRunJob 重试退避秒数
- `AGENT_RUN_JOB_TIMEOUT`(默认 360AgentRunJob 超时时间(秒)
- 工具调用(子 Run 模式):
- `AGENT_TOOL_MAX_CALLS_PER_RUN`(默认 1单个父 Run 允许的工具调用次数上限(超过直接失败)
- `AGENT_TOOL_WAIT_TIMEOUT_MS`(默认 15000等待子 Run 写入 `tool.result` 的超时时间(毫秒)
- `AGENT_TOOL_WAIT_POLL_MS`(默认 200等待工具结果时的轮询间隔毫秒
- `AGENT_TOOL_TIMEOUT_SECONDS`(默认 15单个工具执行的预期超时时间超过会记为 TIMEOUT
- `AGENT_TOOL_RESULT_MAX_BYTES`(默认 4096工具结果最大保存字节数超出会截断
- `AGENT_TOOL_CHOICE`(默认 autoOpenAI tool_choice 传参策略
- `AGENT_TOOL_JOB_TRIES/AGENT_TOOL_JOB_BACKOFF/AGENT_TOOL_JOB_TIMEOUT`ToolRunJob 队列重试/退避/超时设置
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## 🔑 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_keyCLOSED/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` 优先于 querygap 会回补,定期心跳保活。
- Agent Runuser.prompt 自动触发;或 `POST /api/sessions/{id}/runs {trigger_message_id}` 手动触发,写入 `run.status/agent.message`,幂等去重,失败会写 `error`(含 retryable/http_status/provider/latency_ms
## Learning Laravel
详细字段/示例:`docs/ChatSession/chat-session-api.md`OpenAPI`docs/ChatSession/chat-session-openapi.yaml`。用户管理/鉴权文档:`docs/User/user-api.md`
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
## 🔒 状态与门禁规则
- `OPEN`:正常追加。
- `LOCKED`:拒绝 `role=USER && type=user.prompt`
- `CLOSED`:拒绝追加,例外 `role=SYSTEM && type in [run.status, error]`
- 幂等:同一 session + dedupe_key 返回已有消息(同 message_id/seqrun.status 与 agent.message 均通过 dedupe_key 防重复。
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
## 🔌 开发/验证
- 迁移:`docker compose exec app php artisan migrate`
- 测试:`docker compose exec app php artisan test --testsuite=Feature`
- cURL 示例(需 Bearer TOKEN
```bash
# 归档
curl -X POST http://localhost:8000/api/sessions/{sid}/archive -H "Authorization: Bearer $TOKEN"
# 单条消息
curl http://localhost:8000/api/sessions/{sid}/messages/{mid} -H "Authorization: Bearer $TOKEN"
# SSE断线续传可带 Last-Event-ID
curl -N http://localhost:8000/api/sessions/{sid}/sse?after_seq=0 \
-H "Authorization: Bearer $TOKEN" -H "Accept: text/event-stream"
```
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
### Premium Partners
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
## 📌 后续演进(规划)
- Agent Loop/Tools/Policy Engine/Context Store 解耦与插件化。
- 更丰富的前端控制台:日志流、工具审批、移动端友好。
- 兼容多家模型/工具 SDK保持开放生态。

View File

@@ -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);
}
@@ -70,6 +78,17 @@ class ChatSessionController extends Controller
return MessageResource::collection($messages)->response();
}
public function showMessage(string $sessionId, string $messageId): JsonResponse
{
$message = $this->service->getMessage($sessionId, $messageId);
if (! $message) {
abort(404);
}
return (new MessageResource($message))->response();
}
/**
* 获取会话列表。
*
@@ -109,4 +128,18 @@ class ChatSessionController extends Controller
return (new ChatSessionResource($session))->response();
}
public function show(string $sessionId): JsonResponse
{
$session = $this->service->getSessionWithLastMessage($sessionId);
return (new ChatSessionResource($session))->response();
}
public function archive(string $sessionId): JsonResponse
{
$session = $this->service->archiveSession($sessionId);
return (new ChatSessionResource($session))->response();
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\MessageResource;
use App\Models\Message;
use App\Services\ChatService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ChatSessionSseController extends Controller
{
public function __construct(private readonly ChatService $service)
{
}
public function stream(Request $request, string $sessionId): Response|StreamedResponse
{
set_time_limit(360);
$this->service->getSession($sessionId); // ensure exists
$lastEventId = $request->header('Last-Event-ID');
$afterSeq = is_numeric($lastEventId) ? (int) $lastEventId : (int) $request->query('after_seq', 0);
$limit = (int) $request->query('limit', 200);
$limit = $limit > 0 && $limit <= 500 ? $limit : 200;
$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);
$content = ob_get_clean() ?: '';
return response($content, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
$response = new StreamedResponse(function () use ($sessionId, $afterSeq, $limit) {
$lastSentSeq = $afterSeq;
$lastHeartbeat = microtime(true);
$this->sendBacklog($sessionId, $lastSentSeq, $limit);
$this->sendHeartbeat($lastHeartbeat);
try {
$redis = Redis::connection()->client();
if (method_exists($redis, 'setOption')) {
$redis->setOption(\Redis::OPT_READ_TIMEOUT, 360);
}
$channel = "session:{$sessionId}:messages";
logger('sse open');
// Fallback for Redis drivers without pubSubLoop (older phpredis)
$redis->subscribe([$channel], function ($redisInstance, $chan, $payload) use (&$lastSentSeq, $sessionId, $limit, &$lastHeartbeat) {
if (connection_aborted()) {
logger('sse aborted');
$redisInstance->unsubscribe([$chan]);
return;
}
$this->sendHeartbeat($lastHeartbeat);
if (! $payload) {
return;
}
$msg = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
$msg = new Message($msg);
// message.delta 不持久化seq=0直接推送
if ($msg && $msg->type === 'message.delta') {
$this->emitMessage($msg, true);
return;
}
if ($msg && $msg->seq > $lastSentSeq) {
if ($msg->seq > $lastSentSeq + 1) {
$this->sendBacklog($sessionId, $lastSentSeq, $limit);
}
if ($msg->seq > $lastSentSeq) {
$this->emitMessage($msg);
$lastSentSeq = $msg->seq;
}
}
});
} catch (\RedisException $exception) {
logger()->warning('SSE redis subscription failed', [
'session_id' => $sessionId,
'message' => $exception->getMessage(),
'code' => $exception->getCode(),
]);
echo ": redis-error\n\n";
@ob_flush();
@flush();
}
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('X-Accel-Buffering', 'no');
return $response;
}
private function sendBacklog(string $sessionId, int &$lastSentSeq, int $limit, bool $flush = true): void
{
$backlog = $this->service->listMessagesBySeq($sessionId, $lastSentSeq, $limit);
foreach ($backlog as $message) {
$this->emitMessage($message, $flush);
$lastSentSeq = $message->seq;
}
}
private function emitMessage($message, bool $flush = true): void
{
$payload = (new MessageResource($message))->resolve();
echo 'id: '.$message->seq."\n";
echo "event: message\n";
echo 'data: '.json_encode($payload, JSON_UNESCAPED_UNICODE)."\n\n";
if ($flush) {
@ob_flush();
@flush();
}
}
private function sendHeartbeat(float &$lastHeartbeat, int $intervalSeconds = 15): void
{
if ((microtime(true) - $lastHeartbeat) < $intervalSeconds) {
return;
}
echo ": ping\n\n";
@ob_flush();
@flush();
$lastHeartbeat = microtime(true);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\DispatchRunRequest;
use App\Jobs\TestJob;
use App\Services\RunDispatcher;
use Illuminate\Http\JsonResponse;
class RunController extends Controller
{
public function __construct(private readonly RunDispatcher $dispatcher)
{
}
public function store(string $sessionId, DispatchRunRequest $request): JsonResponse
{
$runId = $this->dispatcher->dispatchForPrompt($sessionId, $request->validated()['trigger_message_id']);
return response()->json(['run_id' => $runId], 201);
}
public function test()
{
$job = TestJob::dispatch();
unset($job);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DispatchRunRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'trigger_message_id' => ['required', 'uuid'],
];
}
}

77
app/Jobs/AgentRunJob.php Normal file
View File

@@ -0,0 +1,77 @@
<?php
namespace App\Jobs;
use App\Models\Message;
use App\Services\Agent\ProviderException;
use App\Services\CancelChecker;
use App\Services\OutputSink;
use App\Services\RunLoop;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class AgentRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries;
public int $timeout;
public int $backoff;
public function __construct(public string $sessionId, public string $runId)
{
$this->tries = (int) config('agent.job.tries', 1);
$this->timeout = (int) config('agent.job.timeout_seconds', 120);
$this->backoff = (int) config('agent.job.backoff_seconds', 5);
}
public function handle(RunLoop $loop, OutputSink $sink, CancelChecker $cancelChecker): void
{
try {
logger("Running run {$this->runId} for session {$this->sessionId}");
$loop->run($this->sessionId, $this->runId);
} catch (\Throwable $e) {
logger("Running error {$this->runId} for session {$this->sessionId}");
logger("error message:",[$e->getMessage(),$e->getTraceAsString()]);
$errorCode = $e instanceof ProviderException ? $e->errorCode : 'run.failed';
$dedupeKey = $e instanceof ProviderException
? "run:{$this->runId}:error:provider"
: "run:{$this->runId}:error:job";
$sink->appendError($this->sessionId, $this->runId, $errorCode, $e->getMessage(), [], $dedupeKey);
$sink->appendRunStatus($this->sessionId, $this->runId, 'FAILED', [
'error' => $e->getMessage(),
'dedupe_key' => "run:{$this->runId}:status:FAILED",
]);
throw $e;
} finally {
$latestStatus = Message::query()
->where('session_id', $this->sessionId)
->where('type', 'run.status')
->whereRaw("payload->>'run_id' = ?", [$this->runId])
->orderByDesc('seq')
->first();
$status = $latestStatus ? ($latestStatus->payload['status'] ?? null) : null;
if ($status === 'RUNNING' || ! $status) {
if ($cancelChecker->isCanceled($this->sessionId, $this->runId)) {
$sink->appendRunStatus($this->sessionId, $this->runId, 'CANCELED', [
'dedupe_key' => "run:{$this->runId}:status:CANCELED",
]);
} else {
$sink->appendRunStatus($this->sessionId, $this->runId, 'FAILED', [
'error' => 'JOB_ENDED_UNEXPECTEDLY',
'dedupe_key' => "run:{$this->runId}:status:FAILED",
]);
}
}
}
}
}

23
app/Jobs/TestJob.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class TestJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct()
{
}
public function handle(): void
{
logger('TestJob');
}
}

72
app/Jobs/ToolRunJob.php Normal file
View File

@@ -0,0 +1,72 @@
<?php
namespace App\Jobs;
use App\Services\CancelChecker;
use App\Services\OutputSink;
use App\Services\Tool\ToolCall;
use App\Services\Tool\ToolExecutor;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ToolRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries;
public int $timeout;
public int $backoff;
/**
* @param array<string, mixed> $toolCall
*/
public function __construct(public string $sessionId, public array $toolCall)
{
$this->tries = (int) config('agent.tools.job.tries', 1);
$this->timeout = (int) config('agent.tools.job.timeout_seconds', 60);
$this->backoff = (int) config('agent.tools.job.backoff_seconds', 3);
}
public function handle(ToolExecutor $executor, OutputSink $sink, CancelChecker $cancelChecker): void
{
$call = ToolCall::fromArray($this->toolCall);
logger('ToolRunJob call:', $call->toArray());
if ($cancelChecker->isCanceled($this->sessionId, $call->parentRunId)) {
$sink->appendRunStatus($this->sessionId, $call->runId, 'CANCELED', [
'dedupe_key' => "run:{$call->runId}:status:CANCELED",
'parent_run_id' => $call->parentRunId,
]);
return;
}
try {
$result = $executor->execute($call);
$sink->appendToolResult($this->sessionId, $result);
$status = $result->status === 'SUCCESS' ? 'DONE' : 'FAILED';
$sink->appendRunStatus($this->sessionId, $call->runId, $status, [
'parent_run_id' => $call->parentRunId,
'tool_call_id' => $call->toolCallId,
'dedupe_key' => "run:{$call->runId}:status:{$status}",
'error' => $result->error,
]);
} catch (\Throwable $exception) {
$sink->appendRunStatus($this->sessionId, $call->runId, 'FAILED', [
'parent_run_id' => $call->parentRunId,
'tool_call_id' => $call->toolCallId,
'dedupe_key' => "run:{$call->runId}:status:FAILED",
'error' => $exception->getMessage(),
]);
throw $exception;
}
}
}

View File

@@ -11,7 +11,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->bind(\App\Services\Agent\AgentProviderInterface::class, function () {
return $this->app->make(\App\Services\Agent\HttpAgentProvider::class);
});
}
/**

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Laravel\Horizon\Horizon;
class HorizonServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
if (! class_exists(Horizon::class)) {
return;
}
Horizon::auth(function ($request) {
return app()->environment('local');
});
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
Telescope::night();
$this->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 true;
});
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Services\Agent;
final class AgentContext
{
/**
* @param array<int, array{message_id: string, role: string, type: string, content: ?string, payload: ?array, seq: int}> $messages
*/
public function __construct(
public string $runId,
public string $sessionId,
public string $systemPrompt,
public array $messages,
) {
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Services\Agent;
interface AgentPlatformAdapterInterface
{
/**
* @param array<string, mixed> $options
* @return \Generator<int, \App\Services\Agent\ProviderEvent>
*/
public function stream(AgentContext $context, array $options = []): \Generator;
public function name(): string;
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Services\Agent;
interface AgentProviderInterface
{
/**
* @param array<string, mixed> $options
* @return \Generator<int, \App\Services\Agent\ProviderEvent>
*/
public function stream(AgentContext $context, array $options = []): \Generator;
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Services\Agent;
class DummyAgentProvider implements AgentProviderInterface
{
/**
* @param array<string, mixed> $options
*/
public function stream(AgentContext $context, array $options = []): \Generator
{
$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';
$content = sprintf(
"Dummy-Agent: 我的当前回复的条目为 -> %s \n 我的上下文是: %s",
$summary,
json_encode($context->messages)
);
yield ProviderEvent::messageDelta($content);
yield ProviderEvent::done('dummy');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Services\Agent;
use App\Services\Agent\OpenAi\OpenAiChatCompletionsAdapter;
class HttpAgentProvider implements AgentProviderInterface
{
private readonly bool $enabled;
public function __construct(private readonly OpenAiChatCompletionsAdapter $adapter)
{
$baseUrl = (string) config('agent.openai.base_url', '');
$apiKey = (string) config('agent.openai.api_key', '');
$this->enabled = trim($baseUrl) !== '' && trim($apiKey) !== '';
}
/**
* @param array<string, mixed> $options
*/
public function stream(AgentContext $context, array $options = []): \Generator
{
if (! $this->enabled) {
return (new DummyAgentProvider())->stream($context, $options);
}
return $this->adapter->stream($context, $options);
}
}

View File

@@ -0,0 +1,243 @@
<?php
namespace App\Services\Agent\OpenAi;
use App\Models\Message;
use App\Services\Agent\AgentContext;
use App\Services\Tool\ToolRegistry;
class ChatCompletionsRequestBuilder
{
public function __construct(
private readonly ToolRegistry $toolRegistry,
private ?string $model = null,
private ?float $temperature = null,
private ?float $topP = null,
private ?bool $includeUsage = null,
private ?string $toolChoice = null,
) {
$this->model = $this->model ?? (string) config('agent.openai.model', 'gpt-4o-mini');
$this->temperature = $this->temperature ?? (float) config('agent.openai.temperature', 0.7);
$this->topP = $this->topP ?? (float) config('agent.openai.top_p', 1.0);
$this->includeUsage = $this->includeUsage ?? (bool) config('agent.openai.include_usage', false);
$this->toolChoice = $this->toolChoice ?? (string) config('agent.tools.tool_choice', 'auto');
}
/**
* Builds an OpenAI-compatible Chat Completions payload from AgentContext.
*
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
public function build(AgentContext $context, array $options = []): array
{
$payload = [
'model' => (string) ($options['model'] ?? $this->model),
'messages' => $this->buildMessages($context),
'stream' => true,
];
if (array_key_exists('temperature', $options)) {
$payload['temperature'] = (float) $options['temperature'];
} else {
$payload['temperature'] = (float) $this->temperature;
}
if (array_key_exists('top_p', $options)) {
$payload['top_p'] = (float) $options['top_p'];
} else {
$payload['top_p'] = (float) $this->topP;
}
if (array_key_exists('max_tokens', $options)) {
$payload['max_tokens'] = (int) $options['max_tokens'];
}
if (array_key_exists('stop', $options)) {
$payload['stop'] = $options['stop'];
}
if (array_key_exists('stream_options', $options)) {
$payload['stream_options'] = $options['stream_options'];
} elseif ($this->includeUsage) {
$payload['stream_options'] = ['include_usage' => true];
}
if (array_key_exists('response_format', $options)) {
$payload['response_format'] = $options['response_format'];
}
$toolsSpec = $this->toolRegistry->openAiToolsSpec();
$hasToolMessages = $this->hasToolMessages($context);
// 支持 disable_tools 选项,用于在达到工具调用上限后禁用工具
$disableTools = $options['disable_tools'] ?? false;
if (! empty($toolsSpec)) {
if ($disableTools) {
$payload['tool_choice'] = 'none';
if ($hasToolMessages) {
// 历史包含工具消息时仍需携带 tools 定义以满足接口校验
$payload['tools'] = $toolsSpec;
}
} else {
$payload['tools'] = $toolsSpec;
$payload['tool_choice'] = $options['tool_choice'] ?? $this->toolChoice ?? 'auto';
}
}
return $payload;
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildMessages(AgentContext $context): array
{
$messages = [];
if ($context->systemPrompt !== '') {
$messages[] = [
'role' => 'system',
'content' => $context->systemPrompt,
];
}
foreach ($context->messages as $message) {
$role = $this->mapRole((string) ($message['role'] ?? ''));
$content = $message['content'] ?? null;
$type = (string) ($message['type'] ?? '');
$payload = $message['payload'] ?? null;
if (! $role || ! is_string($content) || $content === '') {
$content = null;
}
if ($type === 'tool.call' && is_array($payload)) {
$toolCall = $this->normalizeToolCallPayload($payload, $content);
if ($toolCall) {
$messages[] = $toolCall;
logger('openai adapter: added tool.call', [
'tool_call_id' => $payload['tool_call_id'] ?? null,
'name' => $payload['name'] ?? null,
]);
}
continue;
}
if ($type === 'tool.result' && is_array($payload)) {
$toolResult = $this->normalizeToolResultPayload($payload, $content);
if ($toolResult) {
$messages[] = $toolResult;
logger('openai adapter: added tool.result', [
'tool_call_id' => $payload['tool_call_id'] ?? null,
'name' => $payload['name'] ?? null,
'content_length' => strlen($toolResult['content'] ?? ''),
]);
} else {
logger('openai adapter: tool.result normalized to null', [
'payload' => $payload,
'content' => $content,
]);
}
continue;
}
if ($content !== null) {
$messages[] = [
'role' => $role,
'content' => $content,
];
}
}
logger('openai adapter: built messages', [
'total_messages' => count($messages),
'message_roles' => array_column($messages, 'role'),
]);
return $messages;
}
private function mapRole(string $role): ?string
{
return match ($role) {
Message::ROLE_USER => 'user',
Message::ROLE_AGENT => 'assistant',
Message::ROLE_SYSTEM => 'system',
Message::ROLE_TOOL => 'tool',
default => null,
};
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>|null
*/
private function normalizeToolCallPayload(array $payload, ?string $content): ?array
{
$toolCallId = $payload['tool_call_id'] ?? null;
$name = $payload['name'] ?? null;
$arguments = $payload['arguments'] ?? null;
if (! is_string($toolCallId) || ! is_string($name) || $toolCallId === '' || $name === '') {
return null;
}
$argumentsString = is_string($arguments) ? $arguments : json_encode($arguments, JSON_UNESCAPED_UNICODE);
return [
'role' => 'assistant',
'content' => $content,
'tool_calls' => [
[
'id' => $toolCallId,
'type' => 'function',
'function' => [
'name' => $name,
'arguments' => $argumentsString ?? '',
],
],
],
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>|null
*/
private function normalizeToolResultPayload(array $payload, ?string $content): ?array
{
$toolCallId = $payload['tool_call_id'] ?? null;
$name = $payload['name'] ?? null;
if (! is_string($toolCallId) || $toolCallId === '') {
return null;
}
$resultContent = $content ?? ($payload['output'] ?? null);
if (! is_string($resultContent)) {
$resultContent = json_encode($payload['output'] ?? $payload, JSON_UNESCAPED_UNICODE);
}
return [
'role' => 'tool',
'tool_call_id' => $toolCallId,
'name' => is_string($name) ? $name : null,
'content' => $resultContent,
];
}
private function hasToolMessages(AgentContext $context): bool
{
foreach ($context->messages as $message) {
$type = (string) ($message['type'] ?? '');
if ($type === 'tool.call' || $type === 'tool.result') {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services\Agent\OpenAi;
use App\Services\Agent\ProviderException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Psr\Http\Message\ResponseInterface;
class OpenAiApiClient
{
public function __construct(
private ?string $baseUrl = null,
private ?string $apiKey = null,
private ?string $organization = null,
private ?string $project = null,
private ?int $timeoutSeconds = null,
private ?int $connectTimeoutSeconds = null,
) {
$this->baseUrl = $this->baseUrl ?? (string) config('agent.openai.base_url', '');
$this->apiKey = $this->apiKey ?? (string) config('agent.openai.api_key', '');
$this->organization = $this->organization ?? (string) config('agent.openai.organization', '');
$this->project = $this->project ?? (string) config('agent.openai.project', '');
$this->timeoutSeconds = $this->timeoutSeconds ?? (int) config('agent.provider.timeout_seconds', 30);
$this->connectTimeoutSeconds = $this->connectTimeoutSeconds ?? (int) config('agent.provider.connect_timeout_seconds', 5);
}
/**
* Opens a streaming response for the Chat Completions endpoint.
*
* @param array<string, mixed> $payload
*/
public function openStream(array $payload): ResponseInterface
{
$baseUrl = trim((string) $this->baseUrl);
$apiKey = trim((string) $this->apiKey);
if ($baseUrl === '' || $apiKey === '') {
throw new ProviderException('CONFIG_MISSING', 'Agent provider configuration missing', false);
}
$endpoint = rtrim($baseUrl, '/').'/chat/completions';
$headers = [
'Authorization' => 'Bearer '.$apiKey,
'Accept' => 'text/event-stream',
];
if (trim((string) $this->organization) !== '') {
$headers['OpenAI-Organization'] = (string) $this->organization;
}
if (trim((string) $this->project) !== '') {
$headers['OpenAI-Project'] = (string) $this->project;
}
try {
$response = Http::withHeaders($headers)
->connectTimeout($this->connectTimeoutSeconds)
->timeout($this->timeoutSeconds)
->withOptions(['stream' => true])
->post($endpoint, $payload);
} catch (ConnectionException $exception) {
throw new ProviderException(
'CONNECTION_FAILED',
'Agent provider connection failed',
true,
null,
$exception->getMessage()
);
}
$status = $response->status();
if ($status < 200 || $status >= 300) {
$retryable = $status === 429 || $status >= 500;
throw new ProviderException(
'HTTP_ERROR',
'Agent provider failed',
$retryable,
$status,
$response->body()
);
}
return $response->toPsrResponse();
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Services\Agent\OpenAi;
use App\Services\Agent\AgentContext;
use App\Services\Agent\AgentPlatformAdapterInterface;
use App\Services\Agent\ProviderEvent;
use App\Services\Agent\ProviderEventType;
use App\Services\Agent\ProviderException;
class OpenAiChatCompletionsAdapter implements AgentPlatformAdapterInterface
{
public function __construct(
private readonly ChatCompletionsRequestBuilder $requestBuilder,
private readonly OpenAiApiClient $apiClient,
private readonly OpenAiStreamParser $streamParser,
private readonly OpenAiEventNormalizer $eventNormalizer,
private ?int $retryTimes = null,
private ?int $retryBackoffMs = null,
) {
$this->retryTimes = $this->retryTimes ?? (int) config('agent.provider.retry_times', 1);
$this->retryBackoffMs = $this->retryBackoffMs ?? (int) config('agent.provider.retry_backoff_ms', 500);
}
/**
* Streams OpenAI-compatible chat completions and yields normalized events.
*
* @param array<string, mixed> $options
* @return \Generator<int, ProviderEvent>
*/
public function stream(AgentContext $context, array $options = []): \Generator
{
$payload = $this->requestBuilder->build($context, $options);
$attempts = $this->retryTimes + 1;
$attempt = 1;
$backoffMs = $this->retryBackoffMs;
$hasYielded = false;
$shouldStop = $options['should_stop'] ?? null;
while (true) {
try {
$response = $this->apiClient->openStream($payload);
$stream = $response->getBody();
try {
foreach ($this->streamParser->parse($stream, is_callable($shouldStop) ? $shouldStop : null) as $chunk) {
$events = $this->eventNormalizer->normalize($chunk);
foreach ($events as $event) {
$hasYielded = true;
yield $event;
if ($event->type === ProviderEventType::Done || $event->type === ProviderEventType::Error) {
return;
}
}
}
} finally {
$stream->close();
}
if (! $hasYielded) {
if (is_callable($shouldStop) && $shouldStop()) {
return;
}
yield ProviderEvent::error('EMPTY_STREAM', 'Agent provider returned empty stream');
}
return;
} catch (ProviderException $exception) {
if (! $hasYielded && is_callable($shouldStop) && $shouldStop()) {
return;
}
if (! $hasYielded && $exception->retryable && $attempt < $attempts) {
usleep($backoffMs * 1000);
$attempt++;
$backoffMs *= 2;
continue;
}
yield ProviderEvent::error($exception->errorCode, $exception->getMessage(), [
'retryable' => $exception->retryable,
'http_status' => $exception->httpStatus,
'raw_message' => $exception->rawMessage,
]);
return;
} catch (\Throwable $exception) {
if (! $hasYielded && is_callable($shouldStop) && $shouldStop()) {
return;
}
yield ProviderEvent::error('UNKNOWN_ERROR', $exception->getMessage());
return;
}
}
}
public function name(): string
{
return 'openai.chat.completions';
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\Agent\OpenAi;
use App\Services\Agent\ProviderEvent;
class OpenAiEventNormalizer
{
/**
* Normalizes a single SSE payload into ProviderEvent values.
*
* @return array<int, ProviderEvent>
*/
public function normalize(string $payload): array
{
if (trim($payload) === '[DONE]') {
return [ProviderEvent::done('done')];
}
$decoded = json_decode($payload, true);
if (! is_array($decoded)) {
return [ProviderEvent::error('INVALID_JSON', 'Agent provider returned invalid JSON', [
'raw' => $payload,
])];
}
$events = [];
$choices = $decoded['choices'] ?? [];
$firstChoice = is_array($choices) ? ($choices[0] ?? null) : null;
$delta = is_array($firstChoice) ? ($firstChoice['delta'] ?? null) : null;
if (is_array($delta)) {
$content = $delta['content'] ?? null;
if (is_string($content) && $content !== '') {
$events[] = ProviderEvent::messageDelta($content);
}
if (isset($delta['tool_calls']) && is_array($delta['tool_calls'])) {
$toolCalls = [];
foreach ($delta['tool_calls'] as $toolCall) {
if (! is_array($toolCall)) {
continue;
}
$toolCalls[] = [
'id' => $toolCall['id'] ?? null,
'name' => $toolCall['function']['name'] ?? null,
'arguments' => $toolCall['function']['arguments'] ?? '',
'index' => $toolCall['index'] ?? null,
];
}
if (! empty($toolCalls)) {
$events[] = ProviderEvent::toolDelta(['tool_calls' => $toolCalls]);
}
}
}
if (is_array($firstChoice) && array_key_exists('finish_reason', $firstChoice) && $firstChoice['finish_reason'] !== null) {
$events[] = ProviderEvent::done((string) $firstChoice['finish_reason']);
}
if (isset($decoded['usage']) && is_array($decoded['usage'])) {
$events[] = ProviderEvent::usage($decoded['usage']);
}
return $events;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Services\Agent\OpenAi;
use Psr\Http\Message\StreamInterface;
class OpenAiStreamParser
{
public function __construct(private readonly int $chunkSize = 1024)
{
}
/**
* Parses SSE data lines into payload strings.
*
* @return \Generator<int, string>
*/
public function parse(StreamInterface $stream, ?callable $shouldStop = null): \Generator
{
$buffer = '';
$eventData = '';
while (! $stream->eof()) {
if ($shouldStop && $shouldStop()) {
break;
}
$chunk = $stream->read($this->chunkSize);
if ($chunk === '') {
usleep(10000);
continue;
}
$buffer .= $chunk;
while (($pos = strpos($buffer, "\n")) !== false) {
$line = substr($buffer, 0, $pos);
$buffer = substr($buffer, $pos + 1);
$line = rtrim($line, "\r");
if ($line === '') {
if ($eventData !== '') {
yield $eventData;
$eventData = '';
}
continue;
}
if (str_starts_with($line, 'data:')) {
$data = ltrim(substr($line, 5));
if ($eventData !== '') {
$eventData .= "\n";
}
$eventData .= $data;
}
}
}
if ($buffer !== '') {
$line = rtrim($buffer, "\r");
if (str_starts_with($line, 'data:')) {
$data = ltrim(substr($line, 5));
if ($eventData !== '') {
$eventData .= "\n";
}
$eventData .= $data;
}
}
if ($eventData !== '') {
yield $eventData;
}
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services\Agent;
final class ProviderEvent
{
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public ProviderEventType $type,
public array $payload = [],
) {
}
public static function messageDelta(string $text): self
{
return new self(ProviderEventType::MessageDelta, ['text' => $text]);
}
/**
* @param array<string, mixed> $payload
*/
public static function toolCall(array $payload): self
{
return new self(ProviderEventType::ToolCall, $payload);
}
/**
* @param array<string, mixed> $payload
*/
public static function toolDelta(array $payload): self
{
return new self(ProviderEventType::ToolDelta, $payload);
}
/**
* @param array<string, mixed> $usage
*/
public static function usage(array $usage): self
{
return new self(ProviderEventType::Usage, $usage);
}
public static function done(?string $reason = null): self
{
return new self(ProviderEventType::Done, ['reason' => $reason]);
}
/**
* @param array<string, mixed> $meta
*/
public static function error(string $code, string $message, array $meta = []): self
{
return new self(ProviderEventType::Error, array_merge([
'code' => $code,
'message' => $message,
], $meta));
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Services\Agent;
enum ProviderEventType: string
{
case MessageDelta = 'message.delta';
case ToolCall = 'tool.call';
case ToolDelta = 'tool.delta';
case Usage = 'usage';
case Done = 'done';
case Error = 'error';
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Services\Agent;
class ProviderException extends \RuntimeException
{
public function __construct(
public readonly string $errorCode,
string $message,
public readonly bool $retryable = false,
public readonly ?int $httpStatus = null,
public readonly ?string $rawMessage = null,
) {
parent::__construct($message);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Services;
use App\Models\Message;
class CancelChecker
{
public function isCanceled(string $sessionId, string $runId): bool
{
return Message::query()
->where('session_id', $sessionId)
->where('type', 'run.cancel.request')
->whereIn('role', [Message::ROLE_USER, Message::ROLE_SYSTEM])
->whereRaw("payload->>'run_id' = ?", [$runId])
->exists();
}
}

View File

@@ -11,10 +11,14 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\QueryException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class ChatService
{
public function __construct(private readonly MessageSequence $messageSequence)
{
}
/**
* 创建一个新聊天会话。
@@ -55,11 +59,16 @@ class ChatService
* - payload: 附加信息(可选)
* - reply_to: 被回复的消息 ID可选
* - dedupe_key: 消息去重键(可选)
* @param bool|null $wasDeduped 是否发生了去重(可选,按引用返回)
* @return Message 返回成功追加的消息实例。如果存在去重键并已存在重复消息,则返回现有的消息。
*/
public function appendMessage(array $dto): Message
public function appendMessage(array $dto, ?bool &$wasDeduped = null, bool $save = true): Message
{
return DB::transaction(function () use ($dto) {
$messageRef = null;
$isNew = false;
$wasDeduped = false;
DB::transaction(function () use ($dto, &$messageRef, &$isNew, &$wasDeduped, $save) {
/** @var ChatSession $session */
$session = ChatSession::query()
->whereKey($dto['session_id'])
@@ -76,40 +85,61 @@ class ChatService
->first();
if ($existing) {
return $existing;
$messageRef = $existing;
$wasDeduped = true;
return;
}
}
$newSeq = $session->last_seq + 1;
$attempts = 0;
while (true) {
$attempts++;
$newSeq = $this->messageSequence->nextForSession($session);
$message = new Message([
'message_id' => (string) Str::uuid(),
'session_id' => $session->session_id,
'role' => $dto['role'],
'type' => $dto['type'],
'content' => $dto['content'] ?? null,
'payload' => $dto['payload'] ?? null,
'reply_to' => $dto['reply_to'] ?? null,
'dedupe_key' => $dedupeKey,
'seq' => $newSeq,
'created_at' => now(),
]);
$message = new Message([
'message_id' => (string) Str::uuid(),
'session_id' => $session->session_id,
'role' => $dto['role'],
'type' => $dto['type'],
'content' => $dto['content'] ?? null,
'payload' => $dto['payload'] ?? null,
'reply_to' => $dto['reply_to'] ?? null,
'dedupe_key' => $dedupeKey,
'seq' => $newSeq,
'created_at' => now(),
]);
try {
$message->save();
} catch (QueryException $e) {
if ($this->isUniqueConstraint($e) && $dedupeKey) {
$existing = Message::query()
->where('session_id', $session->session_id)
->where('dedupe_key', $dedupeKey)
->first();
if ($existing) {
return $existing;
try {
if ($save) {
$message->save();
}
}
$isNew = true;
break;
} catch (QueryException $e) {
if ($this->isUniqueConstraint($e) && $dedupeKey) {
$existing = Message::query()
->where('session_id', $session->session_id)
->where('dedupe_key', $dedupeKey)
->first();
throw $e;
if ($existing) {
$messageRef = $existing;
$wasDeduped = true;
return;
}
}
if ($this->isUniqueConstraint($e) && $this->isSeqUniqueConstraint($e) && $attempts < 3) {
$maxPersistedSeq = (int) (Message::query()
->where('session_id', $session->session_id)
->max('seq') ?? 0);
$this->messageSequence->syncToAtLeast($session->session_id, max($session->last_seq, $maxPersistedSeq));
continue;
}
throw $e;
}
}
$session->update([
@@ -118,8 +148,17 @@ class ChatService
'updated_at' => now(),
]);
return $message;
$messageRef = $message;
if ($isNew && $save) {
DB::afterCommit(fn () => $this->publishMessageAppended($message));
} else {
$this->publishMessageAppended($message);
}
});
/** @var Message $messageRef */
return $messageRef;
}
/**
@@ -142,6 +181,42 @@ class ChatService
->get();
}
public function getSessionWithLastMessage(string $sessionId): ChatSession
{
/** @var ChatSession $session */
$session = $this->baseSessionQuery()
->where('chat_sessions.session_id', $sessionId)
->firstOrFail();
return $session;
}
public function archiveSession(string $sessionId): ChatSession
{
/** @var ChatSession $session */
$session = ChatSession::query()->whereKey($sessionId)->firstOrFail();
if ($session->status !== ChatSessionStatus::CLOSED) {
$session->update([
'status' => ChatSessionStatus::CLOSED,
'updated_at' => now(),
]);
}
return $this->getSessionWithLastMessage($sessionId);
}
public function getMessage(string $sessionId, string $messageId): ?Message
{
$message = Message::query()->where('message_id', $messageId)->first();
if (! $message || $message->session_id !== $sessionId) {
return null;
}
return $message;
}
/**
* 获取会话列表
*
@@ -225,6 +300,28 @@ class ChatService
return $session;
}
private function publishMessageAppended(Message $message): void
{
$root = Redis::getFacadeRoot();
$isMocked = $root instanceof \Mockery\MockInterface;
if (! class_exists(\Redis::class) && ! $isMocked) {
return;
}
$channel = "session:{$message->session_id}:messages";
try {
//todo::优化这里。
Redis::publish($channel, json_encode($message->toArray(), JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_IGNORE));
} catch (\Throwable $e) {
logger()->warning('Redis publish failed', [
'session_id' => $message->session_id,
'message_id' => $message->message_id,
'error' => $e->getMessage(),
]);
}
}
private function ensureCanAppend(ChatSession $session, string $role, string $type): void
{
if ($session->status === ChatSessionStatus::CLOSED) {
@@ -247,4 +344,11 @@ class ChatService
return $sqlState === '23505';
}
private function isSeqUniqueConstraint(QueryException $e): bool
{
$details = $e->errorInfo[2] ?? $e->getMessage();
return is_string($details) && str_contains($details, 'messages_session_id_seq_unique');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Services;
use App\Models\Message;
use App\Services\Agent\AgentContext;
use Illuminate\Support\Collection;
class ContextBuilder
{
public function __construct(private readonly int $limit = 20)
{
}
public function build(string $sessionId, string $runId): AgentContext
{
$messages = $this->loadRecentMessages($sessionId);
logger('context builder loaded messages', [
'session_id' => $sessionId,
'run_id' => $runId,
'message_count' => $messages->count(),
'message_types' => $messages->pluck('type', 'seq')->toArray(),
]);
return new AgentContext(
$runId,
$sessionId,
'You are an agent inside ARS. Respond concisely in markdown format. Use the following conversation context.',
$messages->map(function (Message $message) {
return [
'message_id' => $message->message_id,
'role' => $message->role,
'type' => $message->type,
'content' => $message->content,
'payload' => $message->payload,
'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, Message::ROLE_TOOL])
->whereIn('type', ['user.prompt', 'agent.message', 'tool.call', 'tool.result'])
->orderByDesc('seq')
->limit($this->limit)
->get()
->sortBy('seq')
->values();
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Services;
use App\Models\ChatSession;
use App\Models\Message;
use Illuminate\Support\Facades\Redis;
class MessageSequence
{
public function nextForSession(ChatSession $session): int
{
$key = $this->redisKey($session->session_id);
try {
$current = Redis::get($key);
if ($current === null) {
$seed = $this->seedFromDatabase($session);
Redis::setnx($key, $seed);
} elseif ((int) $current < $session->last_seq) {
Redis::set($key, (string) $session->last_seq);
}
return (int) Redis::incr($key);
} catch (\Throwable) {
return $session->last_seq + 1;
}
}
public function syncToAtLeast(string $sessionId, int $seq): void
{
$key = $this->redisKey($sessionId);
try {
$current = Redis::get($key);
if ($current === null || (int) $current < $seq) {
Redis::set($key, (string) $seq);
}
} catch (\Throwable) {
return;
}
}
private function seedFromDatabase(ChatSession $session): int
{
$maxPersistedSeq = (int) (Message::query()
->where('session_id', $session->session_id)
->max('seq') ?? 0);
return max($session->last_seq, $maxPersistedSeq);
}
private function redisKey(string $sessionId): string
{
return "chat_session:{$sessionId}:seq";
}
}

176
app/Services/OutputSink.php Normal file
View File

@@ -0,0 +1,176 @@
<?php
namespace App\Services;
use App\Models\Message;
use App\Services\MessageSequence;
use App\Services\Tool\ToolCall;
use App\Services\Tool\ToolResult;
class OutputSink
{
public function __construct(
private readonly ChatService $chatService,
private readonly MessageSequence $messageSequence,
)
{
}
/**
* @param array<string, mixed> $meta
*/
public function appendAgentMessage(string $sessionId, string $runId, string $content, array $meta = [], ?string $dedupeKey = null): Message
{
$dedupeKey = $dedupeKey ?? "run:{$runId}:agent:message";
return $this->chatService->appendMessage([
'session_id' => $sessionId,
'role' => Message::ROLE_AGENT,
'type' => 'agent.message',
'content' => $content,
'payload' => array_merge($meta, ['run_id' => $runId]),
'dedupe_key' => $dedupeKey,
]);
}
/**
* 追加 Agent 流式文本增量(仅用于 SSE 推送,不落库)。
*
* message.delta 消息只用于实时流式推送,不需要持久化到数据库。
* 最终的完整回复会通过 appendAgentMessage() 落库。
*
* @param array<string, mixed> $meta
*/
public function appendAgentDelta(string $sessionId, string $runId, string $content, int $deltaIndex, array $meta = []): void
{
$session = $this->chatService->getSession($sessionId);
// 1. 创建临时 Message 对象(不保存到数据库)
$message = new Message([
'message_id' => (string) \Illuminate\Support\Str::uuid(),
'session_id' => $session->session_id,
'role' => Message::ROLE_AGENT,
'type' => 'message.delta',
'content' => $content,
'payload' => array_merge($meta, [
'run_id' => $runId,
'delta_index' => $deltaIndex,
]),
'dedupe_key' => "run:{$runId}:agent:delta:{$deltaIndex}",
'seq' => $this->messageSequence->nextForSession($session),
'created_at' => now(),
]);
// 2. 仅发布 Redis 事件,供 SSE 实时推送
$this->publishDeltaMessage($message);
}
/**
* 发布 delta 消息到 Redis仅用于 SSE 推送)。
*
* 此方法不保存消息到数据库,只发布事件供 SSE 客户端接收。
*/
private function publishDeltaMessage(Message $message): void
{
$root = \Illuminate\Support\Facades\Redis::getFacadeRoot();
$isMocked = $root instanceof \Mockery\MockInterface;
// 如果 Redis 不可用(测试环境),直接返回
if (!class_exists(\Redis::class) && !$isMocked) {
return;
}
$channel = "session:{$message->session_id}:messages";
try {
\Illuminate\Support\Facades\Redis::publish(
$channel,
json_encode($message->toArray(), JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE)
);
} catch (\Throwable $e) {
logger()->warning('Redis publish failed for delta message', [
'session_id' => $message->session_id,
'run_id' => $message->payload['run_id'] ?? null,
'delta_index' => $message->payload['delta_index'] ?? null,
'error' => $e->getMessage(),
]);
}
}
/**
* @param array<string, mixed> $meta
*/
public function appendRunStatus(string $sessionId, string $runId, string $status, array $meta = [], ?bool &$wasDeduped = null): Message
{
$dedupeKey = $meta['dedupe_key'] ?? null;
unset($meta['dedupe_key']);
return $this->chatService->appendMessage([
'session_id' => $sessionId,
'role' => Message::ROLE_SYSTEM,
'type' => 'run.status',
'content' => null,
'payload' => array_merge($meta, [
'run_id' => $runId,
'status' => $status,
]),
'dedupe_key' => $dedupeKey,
], $wasDeduped);
}
/**
* @param array<string, mixed> $meta
*/
public function appendError(string $sessionId, string $runId, string $code, string $message, array $meta = [], ?string $dedupeKey = null): 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,
]),
'dedupe_key' => $dedupeKey,
]);
}
public function appendToolCall(string $sessionId, ToolCall $toolCall): Message
{
return $this->chatService->appendMessage([
'session_id' => $sessionId,
'role' => Message::ROLE_AGENT,
'type' => 'tool.call',
'content' => $toolCall->rawArguments ?: json_encode($toolCall->arguments, JSON_UNESCAPED_UNICODE),
'payload' => [
'run_id' => $toolCall->parentRunId,
'tool_run_id' => $toolCall->runId,
'tool_call_id' => $toolCall->toolCallId,
'name' => $toolCall->name,
'arguments' => $toolCall->arguments,
],
'dedupe_key' => "run:{$toolCall->parentRunId}:tool_call:{$toolCall->toolCallId}",
]);
}
public function appendToolResult(string $sessionId, ToolResult $result): Message
{
return $this->chatService->appendMessage([
'session_id' => $sessionId,
'role' => Message::ROLE_TOOL,
'type' => 'tool.result',
'content' => $result->output,
'payload' => [
'run_id' => $result->runId,
'parent_run_id' => $result->parentRunId,
'tool_call_id' => $result->toolCallId,
'name' => $result->name,
'status' => $result->status,
'error' => $result->error,
'truncated' => $result->truncated,
],
'dedupe_key' => "run:{$result->runId}:tool_result",
]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Services;
use App\Jobs\AgentRunJob;
use App\Models\ChatSession;
use App\Models\Message;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class RunDispatcher
{
public function __construct(
private readonly ChatService $chatService,
private readonly OutputSink $outputSink,
) {
}
/**
* @throws ModelNotFoundException
*/
public function dispatchForPrompt(string $sessionId, string $triggerMessageId): string
{
$triggerMessage = $this->chatService->getMessage($sessionId, $triggerMessageId);
if (! $triggerMessage) {
throw (new ModelNotFoundException())->setModel(Message::class, [$triggerMessageId]);
}
$shouldDispatch = false;
$runId = DB::transaction(function () use ($sessionId, $triggerMessageId, &$shouldDispatch) {
ChatSession::query()
->whereKey($sessionId)
->lockForUpdate()
->firstOrFail();
$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)) {
logger('existing run found', ['sessionId' => $sessionId, 'runId' => $latestStatus->payload['run_id']]);
return $latestStatus->payload['run_id'];
}
$candidateRunId = (string) Str::uuid();
$wasDeduped = null;
$statusMessage = $this->outputSink->appendRunStatus($sessionId, $candidateRunId, 'RUNNING', [
'trigger_message_id' => $triggerMessageId,
'dedupe_key' => 'run:trigger:'.$triggerMessageId,
], $wasDeduped);
$finalRunId = $statusMessage->payload['run_id'] ?? $candidateRunId;
if ($wasDeduped) {
logger('existing run found', ['sessionId' => $sessionId, 'runId' => $finalRunId]);
return $finalRunId;
}
$shouldDispatch = true;
return $finalRunId;
});
if ($shouldDispatch) {
logger('dispatching run', ['sessionId' => $sessionId, 'runId' => $runId]);
dispatch(new AgentRunJob($sessionId, $runId));
}
return $runId;
}
}

876
app/Services/RunLoop.php Normal file
View File

@@ -0,0 +1,876 @@
<?php
namespace App\Services;
use App\Services\Agent\AgentProviderInterface;
use App\Services\Agent\AgentContext;
use App\Services\Agent\DummyAgentProvider;
use App\Services\Agent\ProviderEventType;
use App\Services\Agent\ProviderException;
use App\Models\Message;
use App\Services\Tool\ToolCall;
use App\Services\Tool\ToolRunDispatcher;
/**
* Agent Run 主循环:
* - 构建上下文,消费 Provider 事件流Streaming
* - 处理取消、错误、增量输出、终态写回
*/
class RunLoop
{
private const TERMINAL_STATUSES = ['DONE', 'FAILED', 'CANCELED'];
private readonly int $maxToolCalls;
private readonly int $toolWaitTimeoutMs;
private readonly int $toolPollIntervalMs;
public function __construct(
private readonly ContextBuilder $contextBuilder,
private readonly AgentProviderInterface $provider,
private readonly OutputSink $outputSink,
private readonly CancelChecker $cancelChecker,
private readonly ToolRunDispatcher $toolRunDispatcher,
) {
$this->maxToolCalls = (int) config('agent.tools.max_calls_per_run', 10);
$this->toolWaitTimeoutMs = (int) config('agent.tools.wait_timeout_ms', 15000);
$this->toolPollIntervalMs = (int) config('agent.tools.wait_poll_interval_ms', 200);
}
/**
* 运行单次 Agent Run run_id 幂等)。
*
* 主流程:
* 1. 检查 run 是否已终止
* 2. 进入主循环,持续调用 Provider 直到完成或失败
* 3. 每轮迭代可能触发工具调用,工具完成后继续下一轮
* 4. 没有工具调用时,写入最终回复并标记 DONE
*/
public function run(string $sessionId, string $runId): void
{
// 1. 幂等性检查:避免重复执行已完成的 run
if ($this->isRunTerminal($sessionId, $runId)) {
return;
}
$providerName = $this->resolveProviderName();
$toolCallCount = 0;
// 2. 主循环:持续调用 Provider 直到完成或失败
while (true) {
// 2.1 检查用户是否取消
if ($this->checkAndHandleCancel($sessionId, $runId)) {
return;
}
// 2.2 执行一轮 Provider 调用
$iterationResult = $this->executeProviderIteration(
$sessionId,
$runId,
$providerName,
$toolCallCount
);
logger('agent provider iteration', [$iterationResult]);
// 2.3 处理失败或取消
if ($iterationResult['should_exit']) {
return;
}
// 2.4 如果有工具调用,处理工具执行流程
if ($iterationResult['has_tool_calls']) {
$shouldExit = $this->handleToolCalls(
$sessionId,
$runId,
$providerName,
$iterationResult,
$toolCallCount
);
if ($shouldExit) {
return;
}
// 更新工具调用计数,继续下一轮 Provider 调用
$toolCallCount = $iterationResult['updated_tool_count'];
continue;
}
// 2.5 没有工具调用,完成 run
$this->completeRun(
$sessionId,
$runId,
$providerName,
$iterationResult['stream_state'],
$iterationResult['latency_ms']
);
return;
}
}
/**
* 检查并处理取消状态。
*
* @return bool 是否已处理取消true 表示已取消并写入状态)
*/
private function checkAndHandleCancel(string $sessionId, string $runId): bool
{
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return true;
}
return false;
}
/**
* 执行一轮 Provider 调用迭代。
*
* 包括:
* - 构建上下文
* - 准备 Provider 选项(工具调用限制、取消回调等)
* - 调用 Provider 流式接口
* - 记录日志
*
* @return array{
* stream_state: array,
* has_tool_calls: bool,
* updated_tool_count: int,
* should_exit: bool,
* latency_ms: int
* }
*/
private function executeProviderIteration(
string $sessionId,
string $runId,
string $providerName,
int $currentToolCallCount
): array {
// 1. 构建上下文和 Provider 选项
$context = $this->contextBuilder->build($sessionId, $runId);
$providerOptions = $this->buildProviderOptions($sessionId, $runId, $currentToolCallCount);
// 2. 记录调用日志
$this->logProviderRequest($sessionId, $runId, $providerName, $context, $providerOptions, $currentToolCallCount);
// 3. 调用 Provider 并消费事件流
$startedAt = microtime(true);
$streamState = $this->consumeProviderStream(
$sessionId,
$runId,
$context,
$providerName,
$startedAt,
$providerOptions
);
$latencyMs = $this->latencyMs($startedAt);
// 4. 检查流式调用是否失败或取消
if ($streamState['canceled'] || $streamState['failed']) {
return [
'stream_state' => $streamState,
'has_tool_calls' => false,
'updated_tool_count' => $currentToolCallCount,
'should_exit' => true,
'latency_ms' => $latencyMs,
];
}
// 5. 检查是否有工具调用
$hasToolCalls = !empty($streamState['tool_calls']);
$updatedToolCount = $currentToolCallCount + count($streamState['tool_calls']);
return [
'stream_state' => $streamState,
'has_tool_calls' => $hasToolCalls,
'updated_tool_count' => $updatedToolCount,
'should_exit' => false,
'latency_ms' => $latencyMs,
];
}
/**
* 构建 Provider 调用选项。
*
* 包括:
* - 取消检查回调
* - 工具调用限制控制
*/
private function buildProviderOptions(string $sessionId, string $runId, int $toolCallCount): array
{
$options = [
'should_stop' => fn () => $this->isCanceled($sessionId, $runId),
];
// 达到工具调用上限后,禁用工具列表,避免再次触发 TOOL_CALL_LIMIT 错误
if ($toolCallCount >= $this->maxToolCalls) {
$options['disable_tools'] = true;
}
return $options;
}
/**
* 记录 Provider 请求日志。
*/
private function logProviderRequest(
string $sessionId,
string $runId,
string $providerName,
AgentContext $context,
array $providerOptions,
int $iteration
): void {
// 日志选项(移除不可序列化的回调)
$logOptions = $providerOptions;
unset($logOptions['should_stop']);
logger('agent provider context', [
'sessionId' => $sessionId,
'runId' => $runId,
'provider' => $providerName,
'context' => $context,
'provider_options' => $logOptions,
]);
logger('agent provider request', [
'sessionId' => $sessionId,
'runId' => $runId,
'provider' => $providerName,
'iteration' => $iteration,
]);
}
/**
* 处理工具调用流程。
*
* 流程:
* 1. 检查工具调用数量是否超限
* 2. 分发工具子 Run
* 3. 等待工具执行结果
*
* @return bool 是否应该退出主循环(超限、失败或取消时返回 true
*/
private function handleToolCalls(
string $sessionId,
string $runId,
string $providerName,
array $iterationResult,
int $originalToolCallCount
): bool {
$streamState = $iterationResult['stream_state'];
$latencyMs = $iterationResult['latency_ms'];
$updatedToolCount = $iterationResult['updated_tool_count'];
logger('agent tool calls', [$streamState, $latencyMs, $updatedToolCount]);
// 1. 检查工具调用数量是否超限
if ($updatedToolCount > $this->maxToolCalls) {
$this->appendProviderFailure(
$sessionId,
$runId,
'TOOL_CALL_LIMIT',
'Tool call limit reached for this run',
$providerName,
$latencyMs,
[],
'TOOL_CALL_LIMIT'
);
return true; // 退出主循环
}
// 2. 分发工具子 Run
$toolCalls = $this->dispatchToolRuns($sessionId, $runId, $streamState['tool_calls']);
// 3. 等待所有工具执行完成
$waitState = $this->awaitToolResults($sessionId, $runId, $toolCalls, $providerName);
// 4. 检查等待过程中是否失败或取消
if ($waitState['failed'] || $waitState['canceled']) {
return true; // 退出主循环
}
// 工具结果已写回上下文,继续下一轮 Agent 调用
return false;
}
/**
* 完成 Run 并写入最终状态。
*
* 流程:
* 1. 验证流式响应的有效性
* 2. 写入最终 agent.message
* 3. 再次检查取消状态
* 4. 写入 run.status = DONE
*/
private function completeRun(
string $sessionId,
string $runId,
string $providerName,
array $streamState,
int $latencyMs
): void {
// 1. 记录响应日志
logger('agent provider response', [
'sessionId' => $sessionId,
'runId' => $runId,
'provider' => $providerName,
'latency_ms' => $latencyMs,
]);
// 2. 再次检查取消状态(在写入最终消息前)
if ($this->checkAndHandleCancel($sessionId, $runId)) {
return;
}
// 3. 验证流式响应的有效性
if (!$this->validateStreamResponse($sessionId, $runId, $providerName, $streamState, $latencyMs)) {
return;
}
// 4. 写入最终 agent.message
$this->outputSink->appendAgentMessage($sessionId, $runId, $streamState['reply'], [
'provider' => $providerName,
'done_reason' => $streamState['done_reason'],
], "run:{$runId}:agent:message");
// 5. 最后一次检查取消状态(在写入 DONE 前)
if ($this->checkAndHandleCancel($sessionId, $runId)) {
return;
}
// 6. 写入 run.status = DONE
$this->outputSink->appendRunStatus($sessionId, $runId, 'DONE', [
'dedupe_key' => "run:{$runId}:status:DONE",
]);
}
/**
* 验证流式响应的有效性。
*
* 检查:
* - 是否收到任何事件(避免空流)
* - 流是否正常结束(有 done_reason
*
* @return bool 是否有效true 表示有效false 表示无效并已写入错误)
*/
private function validateStreamResponse(
string $sessionId,
string $runId,
string $providerName,
array $streamState,
int $latencyMs
): bool {
// 1. 检查是否收到任何事件
if (!$streamState['received_event']) {
$this->appendProviderFailure(
$sessionId,
$runId,
'EMPTY_STREAM',
'Agent provider returned no events',
$providerName,
$latencyMs,
[],
'EMPTY_STREAM'
);
return false;
}
// 2. 检查流是否正常结束
if ($streamState['done_reason'] === null) {
$this->appendProviderFailure(
$sessionId,
$runId,
'STREAM_INCOMPLETE',
'Agent provider stream ended unexpectedly',
$providerName,
$latencyMs,
[],
'STREAM_INCOMPLETE'
);
return false;
}
return true;
}
/**
* 判断指定 run 是否已到终态,避免重复执行。
*/
private function isRunTerminal(string $sessionId, string $runId): bool
{
$latestStatus = Message::query()
->where('session_id', $sessionId)
->where('type', 'run.status')
->whereRaw("payload->>'run_id' = ?", [$runId])
->orderByDesc('seq')
->first();
$status = $latestStatus ? ($latestStatus->payload['status'] ?? null) : null;
return in_array($status, self::TERMINAL_STATUSES, true);
}
/**
* 取消时写入终态 CANCELED幂等
*/
private function appendCanceled(string $sessionId, string $runId): void
{
$this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
'dedupe_key' => "run:{$runId}:status:CANCELED",
]);
}
/**
* 消费 Provider Streaming 事件流:
* - message.delta落增量并累计最终回复
* - done记录结束理由
* - error/异常:写入 error + FAILED
* - cancel即时中断并写 CANCELED
* - tool.delta/tool.call收集工具调用信息后续驱动子 Run
*
* @return array{
* reply: string,
* done_reason: ?string,
* received_event: bool,
* failed: bool,
* canceled: bool,
* tool_calls: array<int, array<string, mixed>>
* }
*/
private function consumeProviderStream(
string $sessionId,
string $runId,
AgentContext $context,
string $providerName,
float $startedAt,
array $providerOptions = []
): array {
$reply = '';
$deltaIndex = 0;
$doneReason = null;
$receivedEvent = false;
$toolCallBuffer = [];
$toolCallOrder = [];
try {
$providerOptions = array_merge([
'should_stop' => fn () => $this->isCanceled($sessionId, $runId),
], $providerOptions);
foreach ($this->provider->stream($context, $providerOptions) as $event) {
$receivedEvent = true;
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
return $this->streamState($reply, $doneReason, $receivedEvent, false, true, $toolCalls);
}
// 文本增量:持续写 message.delta 并拼接最终回复
if ($event->type === ProviderEventType::MessageDelta) {
$text = (string) ($event->payload['text'] ?? '');
if ($text !== '') {
$reply .= $text;
$deltaIndex++;
$this->outputSink->appendAgentDelta($sessionId, $runId, $text, $deltaIndex, [
'provider' => $providerName,
]);
}
continue;
}
if ($event->type === ProviderEventType::ToolDelta || $event->type === ProviderEventType::ToolCall) {
$toolCalls = $event->payload['tool_calls'] ?? [];
if (is_array($toolCalls)) {
$this->accumulateToolCalls($toolCallBuffer, $toolCallOrder, $toolCalls);
}
continue;
}
// 流结束
if ($event->type === ProviderEventType::Done) {
$doneReason = $event->payload['reason'] ?? null;
break;
}
// Provider 内部错误事件
if ($event->type === ProviderEventType::Error) {
$latencyMs = $this->latencyMs($startedAt);
$code = (string) ($event->payload['code'] ?? 'PROVIDER_ERROR');
$message = (string) ($event->payload['message'] ?? 'Agent provider error');
$this->appendProviderFailure(
$sessionId,
$runId,
$code,
$message,
$providerName,
$latencyMs,
[
'retryable' => $event->payload['retryable'] ?? null,
'http_status' => $event->payload['http_status'] ?? null,
'raw_message' => $event->payload['raw_message'] ?? null,
]
);
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
return $this->streamState($reply, $doneReason, $receivedEvent, true, false, $toolCalls);
}
}
} catch (ProviderException $exception) {
$latencyMs = $this->latencyMs($startedAt);
$this->appendProviderFailure(
$sessionId,
$runId,
$exception->errorCode,
$exception->getMessage(),
$providerName,
$latencyMs,
[
'retryable' => $exception->retryable,
'http_status' => $exception->httpStatus,
'raw_message' => $exception->rawMessage,
]
);
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
return $this->streamState($reply, $doneReason, $receivedEvent, true, false, $toolCalls);
}
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
return $this->streamState($reply, $doneReason, $receivedEvent, false, false, $toolCalls);
}
/**
* 统一落库 Provider 错误与 FAILED 终态。
*
* @param array<string, mixed> $meta
*/
private function appendProviderFailure(
string $sessionId,
string $runId,
string $code,
string $message,
string $providerName,
int $latencyMs,
array $meta = [],
?string $statusError = null
): void {
$this->outputSink->appendError($sessionId, $runId, $code, $message, array_merge($meta, [
'provider' => $providerName,
'latency_ms' => $latencyMs,
]), "run:{$runId}:error:provider");
$this->outputSink->appendRunStatus($sessionId, $runId, 'FAILED', [
'error' => $statusError ?? $message,
'dedupe_key' => "run:{$runId}:status:FAILED",
]);
}
/**
* 封装流式状态返回,便于上层判断。
*
* @return array{
* reply: string,
* done_reason: ?string,
* received_event: bool,
* failed: bool,
* canceled: bool,
* tool_calls: array<int, array<string, mixed>>
* }
*/
private function streamState(
string $reply,
?string $doneReason,
bool $receivedEvent,
bool $failed,
bool $canceled,
array $toolCalls
): array {
return [
'reply' => $reply,
'done_reason' => $doneReason,
'received_event' => $receivedEvent,
'failed' => $failed,
'canceled' => $canceled,
'tool_calls' => $toolCalls,
];
}
/**
* 工具增量收集:同一个 tool call 通过 index 关联,多次分片返回时拼接参数与名称。
*
* OpenAI 流式 API tool call 的第一个 chunk 包含 id、name、index
* 后续 chunks 只包含 arguments 增量和 index id
* 因此必须使用 index 作为 buffer key 来正确累积。
*
* @param array<int, array<string, mixed>> $buffer index key 的缓冲区
* @param array<int, int> $order 记录 index 出现顺序
* @param array<int, array<string, mixed>> $toolCalls
*/
private function accumulateToolCalls(array &$buffer, array &$order, array $toolCalls): void
{
foreach ($toolCalls as $call) {
// 使用 index 作为主键OpenAI 流式 API 的标准做法)
$index = is_int($call['index'] ?? null) ? (int) $call['index'] : 0;
if (! isset($buffer[$index])) {
$buffer[$index] = [
'id' => $call['id'] ?? null,
'name' => $call['name'] ?? null,
'arguments' => '',
'index' => $index,
];
$order[$index] = count($order);
}
// 更新 id第一个 chunk 才有)
if (isset($call['id']) && is_string($call['id']) && $call['id'] !== '') {
$buffer[$index]['id'] = $call['id'];
}
// 更新 name第一个 chunk 才有)
if (isset($call['name']) && is_string($call['name']) && $call['name'] !== '') {
$buffer[$index]['name'] = $call['name'];
}
// 累积 arguments
$arguments = $call['arguments'] ?? '';
if (is_string($arguments) && $arguments !== '') {
$buffer[$index]['arguments'] .= $arguments;
}
}
}
/**
* 将缓存的 tool.call 增量整理为最终列表(按 index 排序)。
*
* @param array<int, array<string, mixed>> $buffer index key 的缓冲区
* @param array<int, int> $order 记录 index 出现顺序
* @return array<int, array<string, mixed>>
*/
private function finalizeToolCalls(array $buffer, array $order, ?string $doneReason): array
{
if (empty($buffer)) {
return [];
}
// 按 index 排序
ksort($buffer);
return array_values(array_map(function (array $call) use ($doneReason) {
return [
'id' => (string) ($call['id'] ?? ''),
'name' => (string) ($call['name'] ?? ''),
'arguments' => (string) ($call['arguments'] ?? ''),
'finish_reason' => $doneReason,
];
}, $buffer));
}
/**
* Tool 调用落库并触发子 Run。
*
* @param array<int, array<string, mixed>> $toolCalls
* @return array<int, ToolCall>
*/
private function dispatchToolRuns(string $sessionId, string $parentRunId, array $toolCalls): array
{
$dispatched = [];
foreach ($toolCalls as $call) {
$toolCallId = (string) ($call['id'] ?? '');
$name = (string) ($call['name'] ?? '');
$rawArguments = (string) ($call['arguments'] ?? '');
if ($toolCallId === '' || $name === '') {
continue;
}
$arguments = $this->decodeToolArguments($rawArguments);
$toolRunId = $this->generateToolRunId($parentRunId, $toolCallId);
$toolCall = new ToolCall($toolRunId, $parentRunId, $toolCallId, $name, $arguments, $rawArguments);
$this->outputSink->appendToolCall($sessionId, $toolCall);
$this->toolRunDispatcher->dispatch($sessionId, $toolCall);
$dispatched[] = $toolCall;
}
return $dispatched;
}
/**
* 等待工具子 Run 写入 tool.result超时/失败会直接结束父 Run。
*
* @param array<int, ToolCall> $toolCalls
* @return array{failed: bool, canceled: bool}
*/
private function awaitToolResults(string $sessionId, string $parentRunId, array $toolCalls, string $providerName): array
{
$start = microtime(true);
$expectedIds = array_map(fn (ToolCall $call) => $call->toolCallId, $toolCalls);
$expectedRuns = array_map(fn (ToolCall $call) => $call->runId, $toolCalls);
while (true) {
if ($this->isCanceled($sessionId, $parentRunId)) {
$this->appendCanceled($sessionId, $parentRunId);
return ['failed' => false, 'canceled' => true];
}
$results = $this->findToolResults($sessionId, $parentRunId);
$statuses = $this->findToolRunStatuses($sessionId, $parentRunId);
foreach ($expectedRuns as $runId) {
$status = $statuses[$runId] ?? null;
if ($status === 'FAILED') {
$this->appendProviderFailure(
$sessionId,
$parentRunId,
'TOOL_RUN_FAILED',
"Tool run {$runId} failed",
$providerName,
$this->latencyMs($start),
[],
'TOOL_RUN_FAILED'
);
return ['failed' => true, 'canceled' => false];
}
if ($status === 'CANCELED') {
$this->appendCanceled($sessionId, $parentRunId);
return ['failed' => false, 'canceled' => true];
}
}
$readyIds = array_intersect($expectedIds, array_keys($results));
if (count($readyIds) === count($expectedIds)) {
return ['failed' => false, 'canceled' => false];
}
if ($this->latencyMs($start) >= $this->toolWaitTimeoutMs) {
$this->appendProviderFailure(
$sessionId,
$parentRunId,
'TOOL_RESULT_TIMEOUT',
'Tool result wait timeout',
$providerName,
$this->latencyMs($start),
[],
'TOOL_RESULT_TIMEOUT'
);
return ['failed' => true, 'canceled' => false];
}
usleep($this->toolPollIntervalMs * 1000);
}
}
/**
* @return array<string, \App\Models\Message>
*/
private function findToolResults(string $sessionId, string $parentRunId): array
{
$messages = Message::query()
->where('session_id', $sessionId)
->where('type', 'tool.result')
->whereRaw("payload->>'parent_run_id' = ?", [$parentRunId])
->orderBy('seq')
->get();
$byToolCall = [];
foreach ($messages as $message) {
$toolCallId = $message->payload['tool_call_id'] ?? null;
if (is_string($toolCallId) && $toolCallId !== '') {
$byToolCall[$toolCallId] = $message;
}
}
return $byToolCall;
}
/**
* @return array<string, string>
*/
private function findToolRunStatuses(string $sessionId, string $parentRunId): array
{
$messages = Message::query()
->where('session_id', $sessionId)
->where('type', 'run.status')
->whereRaw("payload->>'parent_run_id' = ?", [$parentRunId])
->orderBy('seq')
->get();
$statuses = [];
foreach ($messages as $message) {
$runId = $message->payload['run_id'] ?? null;
$status = $message->payload['status'] ?? null;
if (is_string($runId) && is_string($status)) {
$statuses[$runId] = $status;
}
}
return $statuses;
}
private function generateToolRunId(string $parentRunId, string $toolCallId): string
{
return substr(hash('sha256', $parentRunId.'|'.$toolCallId), 0, 32);
}
/**
* @return array<string, mixed>
*/
private function decodeToolArguments(string $rawArguments): array
{
$decoded = json_decode($rawArguments, true);
return is_array($decoded) ? $decoded : [];
}
/**
* 计算耗时(毫秒)。
*/
private function latencyMs(float $startedAt): int
{
return (int) ((microtime(true) - $startedAt) * 1000);
}
/**
* 统一取消判断,便于 mock。
*/
private function isCanceled(string $sessionId, string $runId): bool
{
return $this->cancelChecker->isCanceled($sessionId, $runId);
}
/**
* 返回 Provider 名称Dummy 使用短名)。
*/
private function resolveProviderName(): string
{
if ($this->provider instanceof DummyAgentProvider) {
return 'dummy';
}
return str_replace("\0", '', get_class($this->provider));
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services\Tool;
interface Tool
{
public function name(): string;
public function description(): string;
/**
* OpenAI function 参数 JSON Schema。
*
* @return array<string, mixed>
*/
public function parameters(): array;
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>|string
*/
public function execute(array $arguments): array|string;
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Services\Tool;
/**
* Tool 调用请求 DTO携带父子 Run 关联信息与参数。
*/
final class ToolCall
{
/**
* @param array<string, mixed> $arguments
*/
public function __construct(
public string $runId,
public string $parentRunId,
public string $toolCallId,
public string $name,
public array $arguments,
public string $rawArguments = '',
) {
}
/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
return new self(
(string) $payload['run_id'],
(string) $payload['parent_run_id'],
(string) $payload['tool_call_id'],
(string) $payload['name'],
(array) ($payload['arguments'] ?? []),
(string) ($payload['raw_arguments'] ?? '')
);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'run_id' => $this->runId,
'parent_run_id' => $this->parentRunId,
'tool_call_id' => $this->toolCallId,
'name' => $this->name,
'arguments' => $this->arguments,
'raw_arguments' => $this->rawArguments,
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Services\Tool;
use Illuminate\Support\Str;
/**
* Tool 执行调度器,负责超时/结果截断等基础防护。
*/
class ToolExecutor
{
public function __construct(
private readonly ToolRegistry $registry,
private ?int $timeoutSeconds = null,
private ?int $maxResultBytes = null,
) {
$this->timeoutSeconds = $this->timeoutSeconds ?? (int) config('agent.tools.timeout_seconds', 15);
$this->maxResultBytes = $this->maxResultBytes ?? (int) config('agent.tools.result_max_bytes', 4096);
}
public function execute(ToolCall $call): ToolResult
{
$tool = $this->registry->get($call->name);
if (! $tool) {
return new ToolResult(
$call->runId,
$call->parentRunId,
$call->toolCallId,
$call->name,
'FAILED',
'',
'TOOL_NOT_FOUND'
);
}
$started = microtime(true);
try {
$result = $tool->execute($call->arguments);
$output = is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE);
} catch (\Throwable $exception) {
return new ToolResult(
$call->runId,
$call->parentRunId,
$call->toolCallId,
$call->name,
'FAILED',
'',
Str::limit($exception->getMessage(), 200)
);
}
$duration = microtime(true) - $started;
$truncated = false;
if ($this->maxResultBytes > 0 && strlen($output) > $this->maxResultBytes) {
$output = substr($output, 0, $this->maxResultBytes);
$truncated = true;
}
if ($this->timeoutSeconds > 0 && $duration > $this->timeoutSeconds) {
return new ToolResult(
$call->runId,
$call->parentRunId,
$call->toolCallId,
$call->name,
'TIMEOUT',
$output,
'TOOL_TIMEOUT',
$truncated
);
}
return new ToolResult(
$call->runId,
$call->parentRunId,
$call->toolCallId,
$call->name,
'SUCCESS',
$output,
null,
$truncated
);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Services\Tool;
use App\Services\Tool\Tools\BashTool;
use App\Services\Tool\Tools\GetTimeTool;
use App\Services\Tool\Tools\LsTool;
use App\Services\Tool\Tools\FileReadTool;
/**
* Tool 注册表:管理已注册工具与 OpenAI 兼容的声明。
*/
class ToolRegistry
{
/**
* @var array<string, Tool>
*/
private array $tools;
/**
* @param array<int, Tool>|null $tools
*/
public function __construct(?array $tools = null)
{
$tools = $tools ?? [
new GetTimeTool(),
new LsTool(),
new BashTool(),
new FileReadTool(),
];
$this->tools = [];
foreach ($tools as $tool) {
$this->tools[$tool->name()] = $tool;
}
}
public function get(string $name): ?Tool
{
return $this->tools[$name] ?? null;
}
/**
* @return array<int, Tool>
*/
public function all(): array
{
return array_values($this->tools);
}
/**
* 返回 OpenAI-compatible tools 描述。
*
* @return array<int, array<string, mixed>>
*/
public function openAiToolsSpec(): array
{
return array_map(function (Tool $tool) {
return [
'type' => 'function',
'function' => [
'name' => $tool->name(),
'description' => $tool->description(),
'parameters' => $tool->parameters(),
],
];
}, $this->all());
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Tool;
/**
* Tool 执行结果 DTO记录结果文本与状态。
*/
final class ToolResult
{
public function __construct(
public string $runId,
public string $parentRunId,
public string $toolCallId,
public string $name,
public string $status,
public string $output,
public ?string $error = null,
public bool $truncated = false,
) {
}
/**
* 便于在测试/日志中以数组格式使用。
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'run_id' => $this->runId,
'parent_run_id' => $this->parentRunId,
'tool_call_id' => $this->toolCallId,
'name' => $this->name,
'status' => $this->status,
'output' => $this->output,
'error' => $this->error,
'truncated' => $this->truncated,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Services\Tool;
use App\Jobs\ToolRunJob;
use App\Services\OutputSink;
use Illuminate\Support\Facades\DB;
/**
* Run 调度:为单个 tool_call 创建 Run 并投递队列,幂等。
*/
class ToolRunDispatcher
{
public function __construct(private readonly OutputSink $outputSink)
{
}
public function dispatch(string $sessionId, ToolCall $toolCall): string
{
$shouldDispatch = false;
DB::transaction(function () use ($sessionId, $toolCall, &$shouldDispatch): void {
$wasDeduped = null;
$this->outputSink->appendRunStatus($sessionId, $toolCall->runId, 'RUNNING', [
'parent_run_id' => $toolCall->parentRunId,
'tool_call_id' => $toolCall->toolCallId,
'dedupe_key' => "run:{$toolCall->runId}:status:RUNNING",
], $wasDeduped);
$shouldDispatch = ! $wasDeduped;
});
if ($shouldDispatch) {
ToolRunJob::dispatchSync($sessionId, $toolCall->toArray());
}
return $toolCall->runId;
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Services\Tool\Tools;
use App\Services\Tool\Tool;
class BashTool implements Tool
{
public function name(): string
{
return 'bash';
}
public function description(): string
{
return 'Execute bash commands';
}
/**
* @inheritDoc
*/
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'directory' => [
'type' => 'string',
'description' => 'The directory to list',
'default' => '.',
],
'file' => [
'type' => 'string',
'description' => 'The file to read',
],
'command' => [
'type' => 'string',
'description' => 'The command to execute',
],
'user_confirmation_message' => [
'type' => 'string',
'description' => ' A message describing the purpose of this command, shown to the user for approval before execution ',
]
],
'required' => [],
];
}
/**
* @inheritDoc
*/
public function execute(array $arguments): array|string
{
return [
'output' => shell_exec($arguments['command']),
];
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Services\Tool\Tools;
use App\Services\Tool\Tool;
use InvalidArgumentException;
class FileReadTool implements Tool
{
public function name(): string
{
return 'file_read';
}
public function description(): string
{
return '读取文件内容,支持指定行范围、编码和大文件分段读取。';
}
/**
* @return array<string, mixed>
*/
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'path' => [
'type' => 'string',
'description' => '要读取的文件路径(相对或绝对路径)。',
],
'start_line' => [
'type' => 'integer',
'description' => '起始行号从1开始默认从第一行开始。',
'minimum' => 1,
'default' => 1,
],
'end_line' => [
'type' => 'integer',
'description' => '结束行号(包含),默认读取到文件末尾。',
'minimum' => 1,
],
'max_size' => [
'type' => 'integer',
'description' => '最大读取字节数1-10MB默认1MB防止读取过大文件。',
'minimum' => 1,
'maximum' => 10485760,
'default' => 1048576,
],
'encoding' => [
'type' => 'string',
'description' => '文件编码默认UTF-8。',
'enum' => ['UTF-8', 'GBK', 'GB2312', 'ISO-8859-1'],
'default' => 'UTF-8',
],
],
'required' => ['path'],
];
}
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>
*/
public function execute(array $arguments): array
{
$path = $arguments['path'] ?? '';
// 验证路径
if (empty($path)) {
throw new InvalidArgumentException('文件路径不能为空。');
}
// 安全检查:防止路径遍历攻击
$realPath = realpath($path);
if ($realPath === false) {
throw new InvalidArgumentException("文件不存在:{$path}");
}
if (!is_file($realPath)) {
throw new InvalidArgumentException("路径不是文件:{$path}");
}
if (!is_readable($realPath)) {
throw new InvalidArgumentException("文件不可读:{$path}");
}
// 获取参数
$startLine = max(1, (int)($arguments['start_line'] ?? 1));
$endLine = isset($arguments['end_line']) ? max(1, (int)$arguments['end_line']) : null;
$maxSize = min(10485760, max(1, (int)($arguments['max_size'] ?? 1048576)));
$encoding = $arguments['encoding'] ?? 'UTF-8';
// 检查文件大小
$fileSize = filesize($realPath);
if ($fileSize === false) {
throw new InvalidArgumentException("无法获取文件大小:{$path}");
}
return $this->readFileContent($realPath, $startLine, $endLine, $maxSize, $encoding, $fileSize);
}
/**
* 读取文件内容
*
* @param string $path
* @param int $startLine
* @param int|null $endLine
* @param int $maxSize
* @param string $encoding
* @param int $fileSize
* @return array<string, mixed>
*/
private function readFileContent(
string $path,
int $startLine,
?int $endLine,
int $maxSize,
string $encoding,
int $fileSize
): array {
$result = [
'path' => $path,
'size' => $fileSize,
'encoding' => $encoding,
];
// 如果文件为空
if ($fileSize === 0) {
$result['content'] = '';
$result['lines_read'] = 0;
$result['truncated'] = false;
return $result;
}
// 读取文件
$handle = fopen($path, 'r');
if ($handle === false) {
throw new InvalidArgumentException("无法打开文件:{$path}");
}
try {
return $this->readLines($handle, $startLine, $endLine, $maxSize, $encoding, $result);
} finally {
fclose($handle);
}
}
/**
* 按行读取文件
*
* @param resource $handle
* @param int $startLine
* @param int|null $endLine
* @param int $maxSize
* @param string $encoding
* @param array<string, mixed> $result
* @return array<string, mixed>
*/
private function readLines(
$handle,
int $startLine,
?int $endLine,
int $maxSize,
string $encoding,
array $result
): array {
$lines = [];
$currentLine = 0;
$bytesRead = 0;
$truncated = false;
while (($line = fgets($handle)) !== false) {
$currentLine++;
// 跳过起始行之前的内容
if ($currentLine < $startLine) {
continue;
}
// 检查是否超过结束行
if ($endLine !== null && $currentLine > $endLine) {
break;
}
// 检查大小限制
$lineLength = strlen($line);
if ($bytesRead + $lineLength > $maxSize) {
$truncated = true;
break;
}
$lines[] = $line;
$bytesRead += $lineLength;
}
$content = implode('', $lines);
// 编码转换
if ($encoding !== 'UTF-8' && function_exists('mb_convert_encoding')) {
$content = mb_convert_encoding($content, 'UTF-8', $encoding);
}
$result['content'] = $content;
$result['lines_read'] = count($lines);
$result['start_line'] = $startLine;
$result['end_line'] = $endLine ?? $currentLine;
$result['truncated'] = $truncated;
$result['bytes_read'] = $bytesRead;
if ($truncated) {
$result['warning'] = "内容已截断,已读取 {$bytesRead} 字节(限制:{$maxSize} 字节)";
}
return $result;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Services\Tool\Tools;
use App\Services\Tool\Tool;
use Illuminate\Support\Carbon;
class GetTimeTool implements Tool
{
public function name(): string
{
return 'get_time';
}
public function description(): string
{
return '返回服务器当前时间,可指定 PHP 日期格式。';
}
/**
* @return array<string, mixed>
*/
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'format' => [
'type' => 'string',
'description' => '可选的日期格式,默认为 RFC3339。',
],
],
'required' => [],
];
}
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>
*/
public function execute(array $arguments): array
{
$format = is_string($arguments['format'] ?? null) && $arguments['format'] !== ''
? $arguments['format']
: Carbon::RFC3339_EXTENDED;
return [
'now' => Carbon::now()->format($format),
];
}
}

View File

@@ -0,0 +1,256 @@
<?php
namespace App\Services\Tool\Tools;
use App\Services\Tool\Tool;
use FilesystemIterator;
use InvalidArgumentException;
use SplFileInfo;
class LsTool implements Tool
{
public function name(): string
{
return 'ls';
}
public function description(): string
{
return '列出指定目录下的文件与目录(支持过滤、排序与详情输出)。';
}
/**
* @return array<string, mixed>
*/
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'directory' => [
'type' => 'string',
'description' => '要列出的目录路径,默认为当前目录。',
'default' => '.',
],
'include_hidden' => [
'type' => 'boolean',
'description' => '是否包含以 . 开头的隐藏文件/目录(默认包含,兼容旧行为)。',
'default' => true,
],
'filter' => [
'type' => 'string',
'description' => '过滤类型all/files/directories。',
'enum' => ['all', 'files', 'directories'],
'default' => 'all',
],
'match' => [
'type' => 'string',
'description' => '可选的通配符匹配fnmatch例如*.php。',
],
'sort' => [
'type' => 'string',
'description' => '排序name_asc/name_desc/mtime_asc/mtime_desc。',
'enum' => ['name_asc', 'name_desc', 'mtime_asc', 'mtime_desc'],
'default' => 'name_asc',
],
'details' => [
'type' => 'boolean',
'description' => '是否返回条目详情name/type/size/modified_at。',
'default' => false,
],
'limit' => [
'type' => 'integer',
'description' => '最多返回条目数量1-1000默认 200。',
'minimum' => 1,
'maximum' => 1000,
'default' => 200,
],
],
'required' => [],
];
}
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>
*/
public function execute(array $arguments): array
{
$directory = $this->stringOrDefault($arguments, 'directory', '.');
if (! is_dir($directory) || ! is_readable($directory)) {
throw new InvalidArgumentException('目录不存在或不可读:'.$directory);
}
$includeHidden = $this->boolOrDefault($arguments, 'include_hidden', true);
$details = $this->boolOrDefault($arguments, 'details', false);
$filter = $this->enumOrDefault($arguments, 'filter', ['all', 'files', 'directories'], 'all');
$sort = $this->enumOrDefault($arguments, 'sort', ['name_asc', 'name_desc', 'mtime_asc', 'mtime_desc'], 'name_asc');
$match = $this->nullableString($arguments, 'match');
$limit = $this->intOrDefault($arguments, 'limit', 200);
$limit = max(1, min(1000, $limit));
$rawEntries = $this->collectEntries($directory, $includeHidden, $filter, $match);
$rawEntries = $this->sortEntries($rawEntries, $sort);
$rawEntries = array_slice($rawEntries, 0, $limit);
$entries = $details
? array_map(fn (array $entry) => $entry, $rawEntries)
: array_map(fn (array $entry) => $entry['name'], $rawEntries);
return [
'directory' => $directory,
'entries' => array_values($entries),
];
}
/**
* @return array<int, array{name: string, type: string, size: int|null, modified_at: int}>
*/
private function collectEntries(string $directory, bool $includeHidden, string $filter, ?string $match): array
{
$entries = [];
try {
$iterator = new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS);
} catch (\Throwable $exception) {
throw new InvalidArgumentException('无法读取目录:'.$directory, 0, $exception);
}
foreach ($iterator as $fileInfo) {
if (! $fileInfo instanceof SplFileInfo) {
continue;
}
$name = $fileInfo->getFilename();
if (! $includeHidden && str_starts_with($name, '.')) {
continue;
}
if ($filter === 'files' && ! $fileInfo->isFile()) {
continue;
}
if ($filter === 'directories' && ! $fileInfo->isDir()) {
continue;
}
if (is_string($match) && $match !== '' && ! fnmatch($match, $name)) {
continue;
}
$entries[] = [
'name' => $name,
'type' => $this->entryType($fileInfo),
'size' => $fileInfo->isFile() ? $fileInfo->getSize() : null,
'modified_at' => $fileInfo->getMTime(),
];
}
return $entries;
}
/**
* @param array<int, array{name: string, type: string, size: int|null, modified_at: int}> $entries
* @return array<int, array{name: string, type: string, size: int|null, modified_at: int}>
*/
private function sortEntries(array $entries, string $sort): array
{
usort($entries, function (array $left, array $right) use ($sort) {
if ($sort === 'mtime_asc' || $sort === 'mtime_desc') {
$compare = $left['modified_at'] <=> $right['modified_at'];
} else {
$compare = strnatcasecmp($left['name'], $right['name']);
}
if ($compare === 0) {
$compare = strnatcasecmp($left['name'], $right['name']);
}
return ($sort === 'name_desc' || $sort === 'mtime_desc') ? -$compare : $compare;
});
return $entries;
}
private function entryType(SplFileInfo $fileInfo): string
{
if ($fileInfo->isLink()) {
return 'link';
}
if ($fileInfo->isDir()) {
return 'directory';
}
if ($fileInfo->isFile()) {
return 'file';
}
return 'other';
}
/**
* @param array<string, mixed> $arguments
*/
private function stringOrDefault(array $arguments, string $key, string $default): string
{
$value = $arguments[$key] ?? null;
if ($value === null) {
return $default;
}
if (! is_string($value) || trim($value) === '') {
throw new InvalidArgumentException($key.' 必须是非空字符串');
}
return $value;
}
/**
* @param array<string, mixed> $arguments
* @param array<int, string> $allowed
*/
private function enumOrDefault(array $arguments, string $key, array $allowed, string $default): string
{
$value = $arguments[$key] ?? null;
if (! is_string($value)) {
return $default;
}
return in_array($value, $allowed, true) ? $value : $default;
}
/**
* @param array<string, mixed> $arguments
*/
private function boolOrDefault(array $arguments, string $key, bool $default): bool
{
$value = $arguments[$key] ?? null;
return is_bool($value) ? $value : $default;
}
/**
* @param array<string, mixed> $arguments
*/
private function intOrDefault(array $arguments, string $key, int $default): int
{
$value = $arguments[$key] ?? null;
return is_int($value) ? $value : $default;
}
/**
* @param array<string, mixed> $arguments
*/
private function nullableString(array $arguments, string $key): ?string
{
$value = $arguments[$key] ?? null;
return is_string($value) ? $value : null;
}
}

74
backup.yaml Executable file
View File

@@ -0,0 +1,74 @@
services:
laravel.test:
build:
context: ./vendor/laravel/sail/runtimes/8.5
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
image: sail-8.5/app
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '${APP_PORT:-80}:80'
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- pgsql
- redis
pgsql:
image: 'postgres:18-alpine'
ports:
- '${FORWARD_DB_PORT:-5432}:5432'
environment:
PGPASSWORD: '${DB_PASSWORD:-secret}'
POSTGRES_DB: '${DB_DATABASE}'
POSTGRES_USER: '${DB_USERNAME}'
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
volumes:
- 'sail-pgsql:/var/lib/postgresql'
- './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql'
networks:
- sail
healthcheck:
test:
- CMD
- pg_isready
- '-q'
- '-d'
- '${DB_DATABASE}'
- '-U'
- '${DB_USERNAME}'
retries: 3
timeout: 5s
redis:
image: 'redis:alpine'
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
- 'sail-redis:/data'
networks:
- sail
healthcheck:
test:
- CMD
- redis-cli
- ping
retries: 3
timeout: 5s
networks:
sail:
driver: bridge
volumes:
sail-pgsql:
driver: local
sail-redis:
driver: local

14
boost.json Executable file
View File

@@ -0,0 +1,14 @@
{
"agents": [
"claude_code",
"codex",
"phpstorm"
],
"editors": [
"claude_code",
"codex",
"phpstorm"
],
"guidelines": [],
"sail": true
}

View File

@@ -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([

View File

@@ -2,4 +2,6 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\TelescopeServiceProvider::class,
];

6
composer.json Normal file → Executable file
View File

@@ -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",

402
composer.lock generated Normal file → Executable file
View File

@@ -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"

39
config/agent.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
return [
'provider' => [
'endpoint' => env('AGENT_PROVIDER_ENDPOINT', ''),
'timeout_seconds' => env('AGENT_PROVIDER_TIMEOUT', 30),
'connect_timeout_seconds' => env('AGENT_PROVIDER_CONNECT_TIMEOUT', 5),
'retry_times' => env('AGENT_PROVIDER_RETRY_TIMES', 1),
'retry_backoff_ms' => env('AGENT_PROVIDER_RETRY_BACKOFF_MS', 500),
],
'openai' => [
'base_url' => env('AGENT_OPENAI_BASE_URL', 'https://api.openai.com/v1'),
'api_key' => env('AGENT_OPENAI_API_KEY', ''),
'organization' => env('AGENT_OPENAI_ORGANIZATION', ''),
'project' => env('AGENT_OPENAI_PROJECT', ''),
'model' => env('AGENT_OPENAI_MODEL', 'gpt-4o-mini'),
'temperature' => env('AGENT_OPENAI_TEMPERATURE', 0.7),
'top_p' => env('AGENT_OPENAI_TOP_P', 1.0),
'include_usage' => env('AGENT_OPENAI_INCLUDE_USAGE', false),
],
'job' => [
'tries' => env('AGENT_RUN_JOB_TRIES', 1),
'backoff_seconds' => env('AGENT_RUN_JOB_BACKOFF', 3),
'timeout_seconds' => env('AGENT_RUN_JOB_TIMEOUT', 360),
],
'tools' => [
'max_calls_per_run' => env('AGENT_TOOL_MAX_CALLS_PER_RUN', 1),
'wait_timeout_ms' => env('AGENT_TOOL_WAIT_TIMEOUT_MS', 15000),
'wait_poll_interval_ms' => env('AGENT_TOOL_WAIT_POLL_MS', 200),
'timeout_seconds' => env('AGENT_TOOL_TIMEOUT_SECONDS', 15),
'result_max_bytes' => env('AGENT_TOOL_RESULT_MAX_BYTES', 4096),
'tool_choice' => env('AGENT_TOOL_CHOICE', 'auto'),
'job' => [
'tries' => env('AGENT_TOOL_JOB_TRIES', 1),
'backoff_seconds' => env('AGENT_TOOL_JOB_BACKOFF', 3),
'timeout_seconds' => env('AGENT_TOOL_JOB_TIMEOUT', 120),
],
],
];

186
config/horizon.php Normal file
View File

@@ -0,0 +1,186 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => 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,
],
],
],
];

212
config/telescope.php Normal file
View File

@@ -0,0 +1,212 @@
<?php
use Laravel\Telescope\Http\Middleware\Authorize;
use Laravel\Telescope\Watchers;
return [
/*
|--------------------------------------------------------------------------
| Telescope Master Switch
|--------------------------------------------------------------------------
|
| This option may be used to disable all Telescope watchers regardless
| of their individual configuration, which simply provides a single
| and convenient way to enable or disable Telescope data storage.
|
*/
'enabled' => env('TELESCOPE_ENABLED', true),
/*
|--------------------------------------------------------------------------
| 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),
],
];

View File

@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return config('telescope.storage.database.connection');
}
/**
* Run the migrations.
*/
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement("CREATE INDEX IF NOT EXISTS messages_payload_run_id_idx ON messages ((payload->>'run_id')) WHERE type IN ('run.status','run.cancel.request','error','agent.message')");
DB::statement("CREATE INDEX IF NOT EXISTS messages_payload_trigger_message_id_idx ON messages ((payload->>'trigger_message_id')) WHERE type = 'run.status'");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS messages_payload_run_id_idx');
DB::statement('DROP INDEX IF EXISTS messages_payload_trigger_message_id_idx');
}
};

View File

@@ -27,10 +27,10 @@ class DatabaseSeeder extends Seeder
);
User::updateOrCreate(
['email' => 'guxinpei@qq.com'],
['email' => 'test@qq.com'],
[
'name' => 'roog',
'password' => Hash::make('w2021976'),
'name' => 'test',
'password' => Hash::make('test'),
'is_active' => true,
'email_verified_at' => now(),
],

33
docker-compose.yml Normal file → Executable file
View File

@@ -3,9 +3,11 @@ services:
build:
context: .
dockerfile: docker/app/Dockerfile
image: ars_backend:latest
entrypoint: ["/app/docker/app/entrypoint.sh"]
volumes:
- ./:/app
#- ../ars-front:/app-frontend
environment:
APP_ENV: local
APP_DEBUG: "true"
@@ -20,6 +22,7 @@ services:
REDIS_HOST: redis
REDIS_PASSWORD: "null"
REDIS_PORT: 6379
QUEUE_CONNECTION: redis
ports:
- "8000:8000"
extra_hosts:
@@ -29,6 +32,36 @@ 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
#- ../ars-front:/app-frontend
depends_on:
- pgsql
- redis
tty: true
pgsql:
image: postgres:16-alpine
environment:

View File

@@ -1,23 +1,33 @@
FROM dunglas/frankenphp:1-php8.3
FROM php:8.4.15-cli-alpine3.23
WORKDIR /app
# System packages and PHP extensions required by Laravel/Octane
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
RUN apk update \
&& apk add --no-cache \
git \
unzip \
libzip-dev \
libpq-dev \
zlib1g-dev \
postgresql-dev \
zlib-dev \
libpq \
icu-dev \
gcc \
g++ \
make \
autoconf \
libc-dev \
pkgconfig \
&& docker-php-ext-install \
pcntl \
zip \
pdo_mysql \
pdo_pgsql \
intl \
&& pecl install redis \
&& docker-php-ext-enable redis \
&& rm -rf /var/lib/apt/lists/*
pdo_pgsql \
&& rm -rf /var/cache/apk/*
# Composer for dependency management
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# ChatSession & Message APIMVP-1
# ChatSession & Message APIMVP-1 + Agent Run MVP-0
基地址:`http://localhost:8000/api`FrankenPHP 容器 8000 端口)
认证方式JWT`Authorization: Bearer {token}`
@@ -7,6 +7,18 @@
## 变更记录
- 2025-02-14新增 ChatSession 创建、消息追加、增量查询接口;支持状态门禁与 dedupe 幂等。
- 2025-02-14MVP-1.1 增加会话列表、会话更新(重命名/状态变更),列表附带最后一条消息摘要。
- 2025-02-15Agent Run MVP-0 —— RunDispatcher + AgentRunJob + DummyProvider自动在 user.prompt 后触发一次 Run落地 run.status / agent.message。
- 2025-12-18Agent Run 可靠性增强 —— 并发幂等、终态去重、取消语义加强、Provider 超时/重试/错误归一SSE gap 回补与心跳。
- 2025-12-19AgentProvider Streaming 接入 —— ProviderEvent 统一事件流,新增 message.delta 输出与 OpenAI-compatible 适配器。
- 2025-12-21Tool 子 Run 模式 —— Provider 支持 tool.delta→tool.call父 Run 调度子 Run 执行工具并写入 tool.result。
## 本次变更摘要2025-12-21
- RunDispatcher 并发幂等:同 trigger_message_id 只产生一个 RUNNING且仅新建时 dispatch。
- RunLoop/OutputSink 幂等agent.message、run.status、tool.call、tool.result 均采用 dedupe_key。
- Cancel 强化:多检查点取消,确保不落 agent.message 且落 CANCELED 终态;父 Run 取消会终止等待的子 Run。
- Provider 可靠性:超时/重试/429/5xx错误落库包含 retryable/http_status/provider/latency_ms。
- StreamingAgentProvider 产出 message.delta / tool.delta / donefinish_reason=tool_calls 会触发子 Run 执行工具。
- 工具闭环tool.callrole=AGENT落库→子 Run 调度→tool.resultrole=TOOL回灌→进入下一轮 LLM。
## 领域模型
- `ChatSession``session_id`(UUID)、`session_name``status`(`OPEN`/`LOCKED`/`CLOSED`)、`last_seq`
@@ -14,69 +26,170 @@
- 幂等:`UNIQUE (session_id, dedupe_key)`;同一 dedupe_key 返回已有消息。
- 状态门禁:`CLOSED` 禁止追加,例外 `role=SYSTEM && type in [run.status, error]``LOCKED` 禁止 `role=USER && type=user.prompt`
- 会话缓存:`chat_sessions.last_message_id` 记录最后一条消息;`appendMessage` 事务内同步更新 `last_seq``last_message_id``updated_at`
- 工具消息:`tool.call`role=AGENT携带 tool_call_id/name/arguments`tool.result`role=TOOL携带 parent_run_id/run_id/status/result
## 接口
### 创建会话
- `POST /sessions`
- 请求体字段
- `session_name` (string, 可选,<=255):会话名称。
- 响应 201 字段:
- `session_id` (uuid)
- `session_name` (string|null)
- `status` (`OPEN|LOCKED|CLOSED`)
- `last_seq` (int)
- `last_message_id` (uuid|null)
- `created_at` / `updated_at`
- 请求体字段
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| session_name | 否 | string(≤255) | 会话名称 |
- 响应 201JSON
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| 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` (required, `USER|AGENT|TOOL|SYSTEM`)
- `type` (required, string, <=64),如 `user.prompt`/`agent.message` 等。
- `content` (string|null)
- `payload` (object|null) 作为 jsonb 存储。
- `reply_to` (uuid|null)
- `dedupe_key` (string|null, <=128) 幂等键。
- 响应 201 字段:
- `message_id` (uuid)
- `session_id` (uuid)
- `seq` (int会话内递增)
- `role` / `type` / `content` / `payload` / `reply_to` / `dedupe_key`
- `created_at`
- 403违反状态门禁CLOSED 禁止LOCKED 禁止 user.prompt
- 幂等:同 session + dedupe_key 返回已有消息(同 `message_id/seq`)。
- 请求体字段
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| role | 是 | enum | `USER|AGENT|TOOL|SYSTEM` |
| type | 是 | string(≤64) | 如 `user.prompt`/`agent.message`/`message.delta` 等 |
| content | 否 | string | 文本内容 |
| payload | 否 | object | jsonb 结构 |
| reply_to | 否 | uuid | 引用消息 |
| dedupe_key | 否 | string(≤128) | 幂等键 |
- 响应 201JSON
字段:`message_id, session_id, seq, role, type, content, payload, reply_to, dedupe_key, created_at`
- 幂等:同 session + dedupe_key 返回已存在的消息(同 `message_id/seq`)。
- 错误401 未授权;403 违反状态门禁CLOSED 禁止LOCKED 禁止 user.prompt404 session 不存在422 校验失败
### 按序增量查询
- `GET /sessions/{session_id}/messages?after_seq=0&limit=50`
- 查询参数
- `after_seq` (int, 默认 0):仅返回大于该 seq 的消息。
- `limit` (int, 默认 50<=200)。
- 查询参数
| 参数 | 默认 | 类型 | 说明 |
| --- | --- | --- | --- |
| after_seq | 0 | int | 仅返回 seq 大于该值 |
| limit | 50 | int(≤200) | 返回数量上限 |
- 响应 200`data` 数组,元素字段同“追加消息”响应。
- 错误401/404/422
### 会话列表
- `GET /sessions?page=1&per_page=15&status=OPEN&q=keyword`
- 查询参数
- `page` (int, 默认 1)
- `per_page` (int, 默认 15<=100)
- `status` (`OPEN|LOCKED|CLOSED`,可选)
- `q` (string可选`session_name` ILIKE 模糊匹配)
- 响应 200分页结构`data/links/meta``data` 每项字段:
- `session_id, session_name, status, last_seq, created_at, updated_at`
- `last_message_id`
- `last_message_at`
- `last_message_preview`content 截断 120content 为空则空字符串)
- `last_message_role, last_message_type`
- 排序:`updated_at` DESC
- 查询参数
| 参数 | 默认 | 类型 | 说明 |
| --- | --- | --- | --- |
| page | 1 | int | 分页页码 |
| per_page | 15 | int(≤100) | 分页大小 |
| status | - | enum | 过滤 `OPEN|LOCKED|CLOSED` |
| q | - | string | ILIKE 模糊匹配 session_name |
- 响应 200分页结构`data/links/meta``data` 每项字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| session_id | uuid | 会话主键 |
| session_name | string|null | 名称 |
| status | enum | `OPEN|LOCKED|CLOSED` |
| last_seq | int | 当前最大 seq |
| last_message_id | uuid|null | 最后一条消息 |
| last_message_at | datetime|null | 最后一条消息时间 |
| last_message_preview | string | content 截断 120空内容返回空字符串 |
| last_message_role | string|null | 最后消息角色 |
| last_message_type | string|null | 最后消息类型 |
| created_at, updated_at | datetime | 时间戳 |
- 排序:`updated_at` DESC
- 错误401/422
### 会话更新
- `PATCH /sessions/{session_id}`
- 请求体(至少提供一项,否则 422
- `session_name` (string, 1..255,可选,自动 trim)
- `status` (`OPEN|LOCKED|CLOSED`,可选)
请求体(至少一项,否则 422
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| session_name | 否 | string 1..255 | 自动 trim |
| status | 否 | enum | `OPEN|LOCKED|CLOSED` |
- 规则:
- `CLOSED` 不可改回 `OPEN`(返回 403
- 任意更新都会刷新 `updated_at`
- 响应 200 字段同会话列表项字段
- 响应 200字段同会话列表项。
- 错误401 未授权403 状态门禁404 session 不存在422 校验失败。
### 获取会话详情
- `GET /sessions/{session_id}`
- 响应 200字段同“会话列表”项。
- 错误401 未授权404 session 不存在。
### 归档会话Archive
- `POST /sessions/{session_id}/archive`
- 行为:将 `status` 置为 `CLOSED`,更新 `updated_at`,幂等(重复归档返回当前状态)。
- 响应 200字段同“会话列表”项status=CLOSED
- 错误401 未授权404 session 不存在。
### 获取单条消息(带会话校验)
- `GET /sessions/{session_id}/messages/{message_id}`
- 行为:校验 `message.session_id` 与路径参数一致,否则 404。
- 响应 200字段同“追加消息”响应。
- 错误401 未授权404 不存在或不属于该会话。
### SSE 实时增量
- `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 则覆盖) |
| limit | 200 | int(≤500) | backlog 最多条数 |
- SSE 输出格式:
```
id: {seq}
event: message
data: {...message json...}
```
- `id` 为消息 `seq`,便于续传;`data` 为消息 JSON同追加消息响应字段
- Backlog建立连接后先补发 `seq > after_seq` 的消息order asc最多 `limit` 条),再进入实时订阅。
- 实时Redis channel `session:{session_id}:messages` 发布消息 IDSSE 侧读取后按 seq 去重、推送。
- Gap 回补:若订阅推送的 seq 与 last_sent_seq 存在缺口,会主动回补 backlog。
- 心跳:周期输出 `: ping` 保活。
- 错误401 未授权404 session 不存在。
## Agent Run MVP-0RunDispatcher + AgentRunJob
### 流程概述
1. 用户追加 `role=USER && type=user.prompt`Controller 自动调用 `RunDispatcher->dispatchForPrompt`
2. 并发保护:同会话只允许一个 RUNNING同一个 `trigger_message_id` 幂等复用已有 `run_id`
3. 立即写入 `run.status`SYSTEM/run.statuspayload `{run_id,status:'RUNNING',trigger_message_id}`dedupe_key=`run:trigger:{message_id}`)。
4. 仅在新建 RUNNING 时推送 `AgentRunJob(session_id, run_id)` 到队列(测试环境 QUEUE=sync 会同步执行)。
5. RunLoop默认 HttpAgentProvider未配置 endpoint 时回退 DummyAgentProvider
- 终态检测:若已 DONE/FAILED/CANCELED 则直接返回。
- Cancel 检查:存在 `run.cancel.request`(payload.run_id) 则写入 `run.status=CANCELED`,不产出 agent.message。
- ContextBuilder提取最近 20 条 USER/AGENT 消息type in user.prompt/agent.messageseq 升序提供给 Provider。
- Provider 以 Streaming 事件流产出文本增量message.delta
- OutputSink 持续写入 `message.delta`,最终写入 `agent.message`payload 含 run_id, providerdedupe_key=`run:{run_id}:agent:message`)与 `run.status=DONE`dedupe_key=`run:{run_id}:status:DONE`)。
6. 异常ProviderException 写入 `error` + `run.status=FAILED`dedupeerror payload 包含 retryable/http_status/provider/latency_ms。
### 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 的一次性回复 |
| message.delta | AGENT | run_id, delta_index | Provider 的增量输出Streaming |
| run.cancel.request | USER/SYSTEM | run_id | CancelChecker 依据该事件判断是否中止 |
| error | SYSTEM | run_id, message, retryable?, http_status?, provider?, latency_ms?, raw_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
@@ -93,4 +206,22 @@ curl -s -X POST http://localhost:8000/api/sessions/$SESSION_ID/messages \
# 增量查询
curl -s "http://localhost:8000/api/sessions/$SESSION_ID/messages?after_seq=0&limit=50" \
-H "Authorization: Bearer $TOKEN"
# 归档
curl -X POST http://localhost:8000/api/sessions/$SESSION_ID/archive \
-H "Authorization: Bearer $TOKEN"
# 获取单条消息
curl -s http://localhost:8000/api/sessions/$SESSION_ID/messages/{message_id} \
-H "Authorization: Bearer $TOKEN"
# SSE断线续传可带 Last-Event-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'"}'
```

View File

@@ -1,16 +1,27 @@
openapi: 3.0.3
info:
title: ChatSession & Message API
version: 1.0.0
version: 1.1.1
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:
/test:
summary: 测试接口
get:
tags: [Test]
responses:
"200":
description: 成功
/sessions:
post:
tags: [ChatSession]
@@ -77,9 +88,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: []
@@ -112,7 +206,7 @@ paths:
"401":
description: 未授权
get:
tags: [ChatSession]
tags: [Message]
summary: 按 seq 增量查询消息
security:
- bearerAuth: []
@@ -150,10 +244,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:
@@ -168,24 +332,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:
@@ -245,6 +407,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]
@@ -260,8 +429,16 @@ components:
type: string
nullable: true
payload:
type: object
nullable: true
oneOf:
- $ref: '#/components/schemas/RunStatusPayload'
- $ref: '#/components/schemas/AgentMessagePayload'
- $ref: '#/components/schemas/MessageDeltaPayload'
- $ref: '#/components/schemas/RunCancelPayload'
- $ref: '#/components/schemas/RunErrorPayload'
- $ref: '#/components/schemas/ToolCallPayload'
- $ref: '#/components/schemas/ToolResultPayload'
- type: object
reply_to:
type: string
format: uuid
@@ -308,6 +485,99 @@ components:
message:
type: string
example: Session is closed
RunStatusPayload:
type: object
properties:
run_id:
type: string
format: uuid
status:
type: string
enum: [RUNNING, DONE, FAILED, CANCELED]
trigger_message_id:
type: string
format: uuid
nullable: true
error:
type: string
nullable: true
AgentMessagePayload:
type: object
properties:
run_id:
type: string
format: uuid
provider:
type: string
MessageDeltaPayload:
type: object
properties:
run_id:
type: string
format: uuid
delta_index:
type: integer
RunCancelPayload:
type: object
properties:
run_id:
type: string
format: uuid
RunErrorPayload:
type: object
properties:
run_id:
type: string
format: uuid
message:
type: string
retryable:
type: boolean
nullable: true
http_status:
type: integer
nullable: true
provider:
type: string
nullable: true
latency_ms:
type: integer
nullable: true
raw_message:
type: string
nullable: true
ToolCallPayload:
type: object
properties:
run_id:
type: string
tool_run_id:
type: string
tool_call_id:
type: string
name:
type: string
arguments:
type: object
additionalProperties: true
ToolResultPayload:
type: object
properties:
run_id:
type: string
parent_run_id:
type: string
tool_call_id:
type: string
name:
type: string
status:
type: string
error:
type: string
nullable: true
truncated:
type: boolean
PaginationLinks:
type: object
properties:

View File

@@ -0,0 +1,53 @@
# Agent Orchestrator 工程级 Review
A. 现状总结
整体链路RunDispatcher → Job → RunLoop → Provider → OutputSink已能跑通职责划分基本清晰append-only + seq 方案也成立但在并发幂等、run 生命周期一致性、取消可靠性、provider 超时/重试,以及 SSE 丢事件补偿方面仍有明显缺口,尚未达到对接真实 LLM Provider 的最低可靠性门槛。
B. 高风险问题列表
- P0 | 影响: 并发触发同一 prompt 时可能生成“幽灵 run”run_id 不一致且重复出消息/计费 | 复现: 并发调用 `dispatchForPrompt` 同一 `trigger_message_id`,第二个请求 dedupe 命中但仍派发新 job | 修复: 以 `appendRunStatus` 返回的 message 为准,若 run_id 不一致则直接返回已有 run_id 且不 dispatch或使用确定性 run_id/事务化 get-or-create | 位置: `app/Services/RunDispatcher.php:21`, `app/Services/OutputSink.php:30`, `app/Services/ChatService.php:61`
- P0 | 影响: job 重试/重复派发会生成多个 `agent.message`,导致重复输出与双重计费 | 复现: 让队列重试或重复 dispatch 同一 run_id | 修复: 给 agent.message 增加 dedupe_key按 run_id + step/chunk并在 RunLoop 开始前检查 run.status 是否已终态 | 位置: `app/Services/OutputSink.php:16`, `app/Services/RunLoop.php:45`, `app/Jobs/AgentRunJob.php:21`
- P1 | 影响: 同 session 可并发多个 RUNNING违反“单 run”假设后续状态/输出互相覆盖 | 复现: 连续/并发发送多条 prompt`latestStatus` 检查无锁 | 修复: 在 session 行锁内判断并创建 RUNNING或用 Redis/DB 锁/独立 run 表做硬约束 | 位置: `app/Services/RunDispatcher.php:40`, `app/Http/Controllers/ChatSessionController.php:45`
- P1 | 影响: run 生命周期非原子写入,崩溃后会出现“有回复但仍 RUNNING”或“RUNNING 无后续” | 复现: 在 `appendAgentMessage` 后杀 worker或 dispatch 失败后 RUNNING 已落库 | 修复: 将 agent.message 与 DONE 写入同一一致性边界;添加 watchdog/finally 标记 FAILED/CANCELED | 位置: `app/Services/RunLoop.php:45`, `app/Services/RunDispatcher.php:53`
- P1 | 影响: cancel 不能严格阻止 agent.message 落库,且取消后可能无 CANCELED 终态 | 复现: cancel 请求在 provider 返回后、写回前到达;或 job 崩溃 | 修复: 在写回前/写回时再校验 cancel在 finally 中若存在 cancel 请求则写 CANCELED | 位置: `app/Services/RunLoop.php:20`, `app/Services/CancelChecker.php:9`
- P1 | 影响: provider 调用可能无限挂起或频繁失败run 长时间 RUNNING | 复现: provider 超时/429/5xx | 修复: 设置 Http timeout/retry/backoff配置 job $timeout/$tries对 429 做退避 | 位置: `app/Services/Agent/HttpAgentProvider.php:20`, `app/Jobs/AgentRunJob.php:13`
- P2 | 影响: SSE 可能漏事件backlog 与订阅之间存在窗口Redis publish 异常会让主流程报错 | 复现: 在 backlog 发送后、订阅前插入消息;或 Redis 异常 | 修复: 订阅后检测 seq gap 回补publish 异常仅告警不抛 | 位置: `app/Http/Controllers/ChatSessionSseController.php:46`, `app/Services/ChatService.php:130`
- P2 | 影响: 上下文固定 20 条且无 token 预算/截断,遇大消息容易爆 token | 复现: 长文本 prompt/agent 输出 | 修复: 通过配置控制窗口/预算并做截断或摘要 | 位置: `app/Services/ContextBuilder.php:10`
- P2 | 影响: JSONB payload 查询无索引run/cancel 查询随数据量增长会退化 | 复现: 大量消息后 run.status/取消查询变慢 | 修复: 加表达式索引 `payload->>'run_id'` / `payload->>'trigger_message_id'` | 位置: `app/Services/RunDispatcher.php:28`, `app/Services/CancelChecker.php:11`
C. 对接真实 LLM Provider 前必须补齐的最小门槛清单
- 并发幂等RunDispatcher 必须做到“同 trigger 只产生一个 run”且不 dispatch 重复 job
- 输出幂等agent.message 必须可去重RunLoop 必须在终态时 no-op
- run 生命周期一致性DONE/FAILED/CANCELED 的落库要有一致性边界或兜底 finalizer
- 取消语义:写回前必须再次校验 cancel并确保取消后不再写 agent.message
- Provider 调用稳定性timeout + retry/backoff + 429/5xx 处理 + job timeout/tries
- SSE 补偿:解决 backlog/subscribe 间隙与 publish 异常导致的漏消息
- Context 预算:可配置窗口/预算并限制大 payload
- 最小测试覆盖:并发幂等、取消、重试/失败路径
D. 建议的下一步路线选择
- 方案1先补 Orchestrator —— 补齐并发幂等、输出去重、生命周期终态一致性、取消写回保护、provider 超时/重试、SSE 缺口补偿、context 预算配置,再接真实 Provider
- 方案2直接接真实 Provider —— 目前仅适合“实验/灰度验证链路通不通”;已有 append-only + seq + SSE 基础与 ContextBuilder 过滤,但不满足可靠性门槛
E. 最小变更的 patch 建议(不要求实现)
- `app/Services/RunDispatcher.php`: `appendRunStatus` 后读取返回 message 的 `payload.run_id`;若与新 runId 不一致则直接返回已有 run_id 且跳过 dispatch必要时把 “单 session 运行中检查” 放入带锁事务
- `app/Services/OutputSink.php`: 允许 `appendAgentMessage` 接收并透传 dedupe_key默认 `run:{runId}:agent:message`),避免重复输出
- `app/Services/RunLoop.php`: 开始时/写回前检查 run 是否已终态;写回 DONE 前再检查 cancel必要时写 CANCELED 并停止
- `app/Jobs/AgentRunJob.php`: 设置 `$tries/$backoff/$timeout`;在 finally 中若仍 RUNNING 则落 FAILED需查询最新 run.status
- `app/Services/Agent/HttpAgentProvider.php` + `config/services.php`: 增加 timeout、retry/backoff 配置项与 429 退避策略
- `app/Services/ChatService.php`: `publishMessageAppended` 改为捕获异常仅记录日志,避免 afterCommit 抛错中断主流程
- `app/Http/Controllers/ChatSessionSseController.php`: 收到 pubsub 时若 seq gap>1 则 `sendBacklog` 回补;可加心跳定期拉取
- 新 migration`messages` 增加表达式索引 `payload->>'run_id'``payload->>'trigger_message_id'`(并结合 `type`)以保证查询性能
## 已实施增强(最小可行版本)
- RunDispatcher 并发幂等RUNNING 消息以 dedupe_key 作为唯一真相,只有新建 RUNNING 才 dispatch
- RunLoop/OutputSink 幂等agent.message/run.status 使用 dedupe_key终态重复执行可安全 no-op
- Cancel/终态收敛:多检查点取消 + Job finally 兜底写入 CANCELED/FAILED
- Provider 可靠性:超时/重试/错误归一化ProviderException并写入错误元数据
- SSE 补偿seq gap 回补 + 心跳 + publish 异常吞吐日志
- Postgres 索引payload 表达式索引加速 run/cancel 查询

View File

@@ -0,0 +1,10 @@
# AgentProvider Streaming 变更摘要2025-12-19
- 引入 ProviderEvent 事件流与 AgentContextProvider 以 Generator 输出 message.delta/done/error
- 新增 OpenAI-compatible 适配器RequestBuilder、ApiClient、StreamParser、EventNormalizer
- RunLoop/OutputSink 支持增量落库message.delta + agent.message + run.status
- 新增配置项 `agent.openai.*`,用于 base_url/api_key/model 等
- 文档已补充 message.delta 的 payload 与消息类型说明
## 验证
- `docker compose exec app php artisan test`

25
docs/tools-subrun.md Normal file
View File

@@ -0,0 +1,25 @@
# 工具调用(子 Run 模式)最小闭环
本次改动新增 Tool 子系统,保持 RunLoop/Provider 的事件驱动模型不变,通过“子 Run”执行工具并把结果回灌到父 Run。
## 关键链路
- Provider 产生 `tool.call`Streaming 中的 `tool.delta` 聚合RunLoop 落库 `tool.call` 并生成子 Run `run:{parent}:{tool_call_id}`
- `ToolRunJob` 执行具体工具(当前内置 `get_time`),写入 `tool.result` 与子 Run 的 `run.status`
- 父 Run 轮询等待子 Run 结果(超时/失败即终止),将 `tool.result` 追加到上下文后再次调用 Provider直至产出最终 `agent.message`
- 幂等:`tool.call`、子 Run `run.status``tool.result` 均带 dedupe_key同一个 tool_call_id 只会执行一次。
## 消息/事件
- 新增消息类型:`tool.call`role=AGENTpayload 含 tool_call_id/name/arguments`tool.result`role=TOOLpayload 含 parent_run_id/run_id/status/result
- Provider 事件新增 `tool.delta`RunLoop 内部聚合后才触发子 Run`finish_reason=tool_calls` 会结束本轮流并进入工具执行。
## 配置要点
- `AGENT_TOOL_MAX_CALLS_PER_RUN`:单个父 Run 允许的工具调用次数(默认 1超过直接失败
- `AGENT_TOOL_WAIT_TIMEOUT_MS` / `AGENT_TOOL_WAIT_POLL_MS`:父 Run 等待子 Run 结果的超时与轮询间隔。
- `AGENT_TOOL_TIMEOUT_SECONDS` / `AGENT_TOOL_RESULT_MAX_BYTES`:工具执行超时标记与结果截断保护。
- `AGENT_TOOL_CHOICE`:传递给 OpenAI 的 tool_choice默认 auto
- ToolRunJob 队列参数:`AGENT_TOOL_JOB_TRIES` / `AGENT_TOOL_JOB_BACKOFF` / `AGENT_TOOL_JOB_TIMEOUT`
## 预留/限制
- 目前仅支持单工具调用闭环;多次调用的上限可调但仍是串行流程。
- 工具列表可通过 `ToolRegistry` 扩展(当前内置 `get_time` 纯函数)。
- 结果超时为父 Run 级别的软超时PHP 层未强制中断长耗时函数(后续可接入外部超时控制)。

0
package.json Normal file → Executable file
View File

7
phpunit.xml Normal file → Executable file
View File

@@ -23,13 +23,16 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_DATABASE" value="testing"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
<env name="JWT_SECRET" value="testing_jwt_secret_for_unit_tests_32_chars_min"/>
<env name="AGENT_PROVIDER_ENDPOINT" value="null"/>
<env name="AGENT_OPENAI_BASE_URL" value="null"/>
<env name="AGENT_OPENAI_API_KEY" value="null"/>
</php>
</phpunit>

View File

@@ -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();
@@ -23,7 +26,12 @@ Route::middleware('auth.jwt')->group(function () {
Route::get('/sessions', [ChatSessionController::class, 'index']);
Route::post('/sessions', [ChatSessionController::class, 'store']);
Route::get('/sessions/{session_id}', [ChatSessionController::class, 'show']);
Route::post('/sessions/{session_id}/messages', [ChatSessionController::class, 'append']);
Route::get('/sessions/{session_id}/messages', [ChatSessionController::class, 'listMessages']);
Route::get('/sessions/{session_id}/messages/{message_id}', [ChatSessionController::class, 'showMessage']);
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']);
});

53
script/clear-log.sh Normal file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# 定义颜色
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# 打印带颜色的信息
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# 打印分割线
print_separator() {
echo -e "${BLUE}────────────────────────────────────${NC}"
}
# 主流程
print_separator
print_info "清理日志"
print_separator
echo ""
# Step 1: 运行测试
print_info "Step 1/1 清理日志"
echo ""
if docker compose exec -T app rm -rf /app/storage/logs/laravel-*.log ; then
echo ""
print_success "删除日志成功"
else
echo ""
print_error "删除日志失败"
exit 1
fi
echo ""
# 完成
print_separator
print_success "🎉 测试和数据库填充全部完成!"
print_separator

77
script/reset-app.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/bin/bash
# 获取脚本所在目录的绝对路径
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 定义颜色
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# 打印带颜色的信息
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# 打印分割线
print_separator() {
echo -e "${BLUE}════════════════════════════════════${NC}"
}
# 主流程
print_separator
print_info "开始重置应用"
print_separator
echo ""
# Step 1: 重启 Docker Compose
print_info "Step 1/3: 重启 Docker Compose"
echo ""
if bash "${SCRIPT_DIR}/restart-docker-compose.sh"; then
print_success "Docker Compose 重启成功"
else
print_error "Docker Compose 重启失败"
exit 1
fi
echo ""
# Step 2: 运行测试和数据填充
print_info "Step 2/3: 运行测试和数据填充"
echo ""
if bash "${SCRIPT_DIR}/run-tests-seed.sh"; then
print_success "测试和数据填充成功"
else
print_error "测试和数据填充失败"
exit 1
fi
echo ""
# Step 3: 清理日志
print_info "Step 3/3: 清理日志"
echo ""
if bash "${SCRIPT_DIR}/clear-log.sh"; then
print_success "日志清理成功"
else
print_error "日志清理失败"
exit 1
fi
echo ""
# 完成
print_separator
print_success "🎉 应用重置完成!"
print_separator

View File

@@ -0,0 +1,67 @@
#!/bin/bash
# 定义颜色
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# 打印带颜色的信息
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# 打印分割线
print_separator() {
echo -e "${BLUE}────────────────────────────────────${NC}"
}
# 主流程
print_separator
print_info "Docker Compose 重启脚本"
print_separator
echo ""
# Step 1: 停止服务
print_info "Step 1/3: 停止 Docker Compose 服务..."
if docker compose down 2>&1; then
print_success "服务已成功停止"
else
print_error "停止服务失败"
exit 1
fi
echo ""
# Step 2: 启动服务
print_info "Step 2/3: 启动 Docker Compose 服务(后台模式)..."
if docker compose up -d 2>&1; then
print_success "服务已成功启动"
else
print_error "启动服务失败"
exit 1
fi
echo ""
# Step 3: 显示状态
print_info "Step 3/3: 检查服务状态..."
echo ""
docker compose ps
echo ""
# 完成
print_separator
print_success "🎉 Docker Compose 服务重启完成!"
print_separator

67
script/run-tests-seed.sh Normal file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
# 定义颜色
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# 打印带颜色的信息
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# 打印分割线
print_separator() {
echo -e "${BLUE}────────────────────────────────────${NC}"
}
# 主流程
print_separator
print_info "Docker Compose 测试与数据库填充脚本"
print_separator
echo ""
# Step 1: 运行测试
print_info "Step 1/2: 运行 PHPUnit 测试..."
echo ""
if docker compose exec -T app php artisan test; then
echo ""
print_success "测试通过"
else
echo ""
print_error "测试失败"
exit 1
fi
echo ""
# Step 2: 数据库填充
print_info "Step 2/2: 执行数据库填充..."
echo ""
if docker compose exec -T app php artisan db:seed; then
echo ""
print_success "数据库填充完成"
else
echo ""
print_error "数据库填充失败"
exit 1
fi
echo ""
# 完成
print_separator
print_success "🎉 测试和数据库填充全部完成!"
print_separator

View File

@@ -0,0 +1,321 @@
<?php
namespace Tests\Feature;
use App\Jobs\AgentRunJob;
use App\Models\Message;
use App\Services\Agent\AgentContext;
use App\Services\Agent\AgentProviderInterface;
use App\Services\Agent\ProviderEvent;
use App\Services\CancelChecker;
use App\Services\ChatService;
use App\Services\RunDispatcher;
use App\Services\RunLoop;
use App\Services\OutputSink;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class AgentRunTest extends TestCase
{
use RefreshDatabase;
public function test_dispatch_and_run_creates_agent_reply_and_statuses(): void
{
Queue::fake();
$service = app(ChatService::class);
$dispatcher = app(RunDispatcher::class);
$session = $service->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),
app(CancelChecker::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_dispatch_is_idempotent_for_same_trigger(): void
{
Queue::fake();
$service = app(ChatService::class);
$dispatcher = app(RunDispatcher::class);
$session = $service->createSession('Idempotent Run');
$prompt = $service->appendMessage([
'session_id' => $session->session_id,
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'please run once',
]);
$firstRunId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id);
$secondRunId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id);
$this->assertSame($firstRunId, $secondRunId);
Queue::assertPushed(AgentRunJob::class, 1);
$statusMessages = Message::query()
->where('session_id', $session->session_id)
->where('type', 'run.status')
->whereRaw("payload->>'trigger_message_id' = ?", [$prompt->message_id])
->get();
$this->assertCount(1, $statusMessages);
}
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),
app(CancelChecker::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_repeated_job_does_not_duplicate_agent_message(): void
{
Queue::fake();
$service = app(ChatService::class);
$dispatcher = app(RunDispatcher::class);
$session = $service->createSession('Retry Session');
$prompt = $service->appendMessage([
'session_id' => $session->session_id,
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'retry run',
]);
$runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id);
(new AgentRunJob($session->session_id, $runId))->handle(
app(RunLoop::class),
app(OutputSink::class),
app(CancelChecker::class)
);
(new AgentRunJob($session->session_id, $runId))->handle(
app(RunLoop::class),
app(OutputSink::class),
app(CancelChecker::class)
);
$agentMessages = Message::query()
->where('session_id', $session->session_id)
->where('type', 'agent.message')
->whereRaw("payload->>'run_id' = ?", [$runId])
->get();
$doneStatuses = Message::query()
->where('session_id', $session->session_id)
->where('type', 'run.status')
->whereRaw("payload->>'run_id' = ?", [$runId])
->whereRaw("payload->>'status' = ?", ['DONE'])
->get();
$this->assertCount(1, $agentMessages);
$this->assertCount(1, $doneStatuses);
}
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'));
}
public function test_provider_error_event_writes_error_and_failed_status(): void
{
Queue::fake();
$this->app->bind(AgentProviderInterface::class, function () {
return new class implements AgentProviderInterface {
public function stream(AgentContext $context, array $options = []): \Generator
{
yield ProviderEvent::error('HTTP_ERROR', 'provider failed', [
'retryable' => true,
'http_status' => 500,
]);
}
};
});
$service = app(ChatService::class);
$dispatcher = app(RunDispatcher::class);
$session = $service->createSession('Provider Failure');
$prompt = $service->appendMessage([
'session_id' => $session->session_id,
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'trigger failure',
]);
$runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id);
(new AgentRunJob($session->session_id, $runId))->handle(
app(RunLoop::class),
app(OutputSink::class),
app(CancelChecker::class)
);
$messages = Message::query()
->where('session_id', $session->session_id)
->get();
$this->assertTrue($messages->contains(function ($m) use ($runId) {
return $m->type === 'run.status'
&& ($m->payload['status'] ?? null) === 'FAILED'
&& ($m->payload['run_id'] ?? null) === $runId;
}));
$this->assertTrue($messages->contains(function ($m) use ($runId) {
return $m->type === 'error'
&& $m->content === 'HTTP_ERROR'
&& ($m->payload['run_id'] ?? null) === $runId
&& ($m->payload['retryable'] ?? null) === true
&& ($m->payload['http_status'] ?? null) === 500;
}));
}
public function test_tool_call_triggers_child_run_and_continues_to_final_message(): void
{
$this->app->bind(AgentProviderInterface::class, function () {
return new class implements AgentProviderInterface {
public int $calls = 0;
public function stream(AgentContext $context, array $options = []): \Generator
{
if ($this->calls === 0) {
$this->calls++;
yield ProviderEvent::toolDelta([
'tool_calls' => [
[
'id' => 'call_1',
'name' => 'get_time',
'arguments' => '{"format":"c"}',
'index' => 0,
],
],
]);
yield ProviderEvent::done('tool_calls');
return;
}
yield ProviderEvent::messageDelta('tool done');
yield ProviderEvent::done('stop');
}
};
});
$service = app(ChatService::class);
$dispatcher = app(RunDispatcher::class);
$session = $service->createSession('Tool Run');
$prompt = $service->appendMessage([
'session_id' => $session->session_id,
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'use tool',
]);
$runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id);
(new AgentRunJob($session->session_id, $runId))->handle(
app(RunLoop::class),
app(OutputSink::class),
app(CancelChecker::class)
);
$messages = Message::query()
->where('session_id', $session->session_id)
->orderBy('seq')
->get();
$this->assertTrue($messages->contains(fn ($m) => $m->type === 'tool.call' && ($m->payload['tool_call_id'] ?? null) === 'call_1'));
$this->assertTrue($messages->contains(fn ($m) => $m->type === 'tool.result' && ($m->payload['tool_call_id'] ?? null) === 'call_1'));
$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'));
}
}

View File

@@ -3,10 +3,13 @@
namespace Tests\Feature;
use App\Enums\ChatSessionStatus;
use App\Models\Message;
use App\Models\User;
use App\Services\ChatService;
use Illuminate\Support\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Redis;
use Mockery;
use PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth;
use Tests\TestCase;
@@ -218,4 +221,152 @@ class ChatSessionTest extends TestCase
$this->withHeaders($headers)->patchJson("/api/sessions/{$session->session_id}", [])
->assertStatus(422);
}
public function test_archive_is_idempotent_and_blocks_user_prompt(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('Archive');
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/archive")
->assertOk()
->assertJsonFragment(['status' => ChatSessionStatus::CLOSED]);
// repeat archive
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/archive")
->assertOk()
->assertJsonFragment(['status' => ChatSessionStatus::CLOSED]);
// append should be blocked
$this->withHeaders($headers)->postJson("/api/sessions/{$session->session_id}/messages", [
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'blocked',
])->assertStatus(403);
}
public function test_get_message_respects_session_scope(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$s1 = $service->createSession('S1');
$s2 = $service->createSession('S2');
$msg1 = $service->appendMessage([
'session_id' => $s1->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello',
]);
$this->withHeaders($headers)->getJson("/api/sessions/{$s1->session_id}/messages/{$msg1->message_id}")
->assertOk()
->assertJsonFragment(['message_id' => $msg1->message_id]);
// wrong session should 404
$this->withHeaders($headers)->getJson("/api/sessions/{$s2->session_id}/messages/{$msg1->message_id}")
->assertNotFound();
}
public function test_publish_to_redis_on_append(): void
{
Redis::shouldReceive('get')->andReturn(null);
Redis::shouldReceive('setnx')->andReturn(1);
Redis::shouldReceive('incr')->andReturn(1);
Redis::shouldReceive('publish')->once()->andReturn(1);
$service = app(ChatService::class);
$session = $service->createSession('Redis Pub');
$service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello',
]);
}
public function test_message_seq_seeds_from_db_when_redis_key_missing(): void
{
$service = app(ChatService::class);
$session = $service->createSession('Seed Test');
Message::query()->create([
'message_id' => (string) \Illuminate\Support\Str::uuid(),
'session_id' => $session->session_id,
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'existing',
'payload' => null,
'seq' => 10,
'reply_to' => null,
'dedupe_key' => null,
'created_at' => now(),
]);
Redis::shouldReceive('get')->andReturn(null);
Redis::shouldReceive('setnx')->once()->andReturn(1);
Redis::shouldReceive('incr')->once()->andReturn(11);
Redis::shouldReceive('publish')->zeroOrMoreTimes()->andReturn(1);
$message = $service->appendMessage([
'session_id' => $session->session_id,
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'new',
]);
$this->assertEquals(11, $message->seq);
}
public function test_agent_delta_uses_redis_seq_and_publishes_with_seq(): void
{
$service = app(ChatService::class);
$session = $service->createSession('Delta Seq');
Redis::shouldReceive('get')->andReturn(null);
Redis::shouldReceive('setnx')->once()->with("chat_session:{$session->session_id}:seq", 0)->andReturn(1);
Redis::shouldReceive('incr')->once()->with("chat_session:{$session->session_id}:seq")->andReturn(1);
Redis::shouldReceive('publish')->once()->with(
"session:{$session->session_id}:messages",
Mockery::on(function ($payload) {
$decoded = json_decode($payload, true);
return is_array($decoded) && ($decoded['seq'] ?? null) === 1;
})
)->andReturn(1);
app(\App\Services\OutputSink::class)->appendAgentDelta(
$session->session_id,
'run-1',
'partial',
1,
[]
);
}
public function test_sse_backlog_contains_messages(): void
{
$user = User::factory()->create();
$headers = $this->authHeader($user);
$service = app(ChatService::class);
$session = $service->createSession('SSE Session');
$service->appendMessage([
'session_id' => $session->session_id,
'role' => 'USER',
'type' => 'user.prompt',
'content' => 'hello sse',
]);
$response = $this->withHeaders($headers)->get("/api/sessions/{$session->session_id}/sse?after_seq=0");
$response->assertOk();
$content = $response->baseResponse instanceof \Symfony\Component\HttpFoundation\StreamedResponse
? $response->streamedContent()
: $response->getContent();
$this->assertStringContainsString('id: 1', $content);
$this->assertStringContainsString('event: message', $content);
$this->assertStringContainsString('hello sse', $content);
}
}

189
tests/Unit/LsToolTest.php Normal file
View File

@@ -0,0 +1,189 @@
<?php
namespace Tests\Unit;
use App\Services\Tool\Tools\LsTool;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class LsToolTest extends TestCase
{
private string $tempDirectory;
protected function setUp(): void
{
parent::setUp();
$base = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
$this->tempDirectory = $base.DIRECTORY_SEPARATOR.'ls-tool-'.bin2hex(random_bytes(8));
if (! mkdir($this->tempDirectory, 0777, true) && ! is_dir($this->tempDirectory)) {
$this->fail('无法创建临时目录:'.$this->tempDirectory);
}
file_put_contents($this->tempDirectory.DIRECTORY_SEPARATOR.'a.txt', 'a');
file_put_contents($this->tempDirectory.DIRECTORY_SEPARATOR.'b.php', '<?php echo 1;');
file_put_contents($this->tempDirectory.DIRECTORY_SEPARATOR.'.env', 'KEY=VALUE');
mkdir($this->tempDirectory.DIRECTORY_SEPARATOR.'dir', 0777, true);
}
protected function tearDown(): void
{
$this->deleteDirectory($this->tempDirectory);
parent::tearDown();
}
public function test_execute_lists_entries_in_directory(): void
{
$tool = new LsTool;
$result = $tool->execute(['directory' => $this->tempDirectory]);
$this->assertSame($this->tempDirectory, $result['directory']);
$this->assertSame(['.env', 'a.txt', 'b.php', 'dir'], $result['entries']);
}
public function test_execute_can_exclude_hidden_entries(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'include_hidden' => false,
]);
$this->assertSame(['a.txt', 'b.php', 'dir'], $result['entries']);
}
public function test_execute_can_filter_files_only(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'filter' => 'files',
]);
$this->assertSame(['.env', 'a.txt', 'b.php'], $result['entries']);
}
public function test_execute_can_filter_directories_only(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'filter' => 'directories',
]);
$this->assertSame(['dir'], $result['entries']);
}
public function test_execute_can_match_glob_pattern(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'match' => '*.php',
]);
$this->assertSame(['b.php'], $result['entries']);
}
public function test_execute_can_return_details(): void
{
$tool = new LsTool;
$result = $tool->execute([
'directory' => $this->tempDirectory,
'details' => true,
]);
$this->assertCount(4, $result['entries']);
$fileEntry = $this->findEntry($result['entries'], 'a.txt');
$this->assertSame('file', $fileEntry['type']);
$this->assertIsInt($fileEntry['size']);
$this->assertIsInt($fileEntry['modified_at']);
$dirEntry = $this->findEntry($result['entries'], 'dir');
$this->assertSame('directory', $dirEntry['type']);
$this->assertNull($dirEntry['size']);
}
public function test_execute_can_sort_by_mtime_desc(): void
{
$mtimeDirectory = $this->tempDirectory.DIRECTORY_SEPARATOR.'mtime';
mkdir($mtimeDirectory, 0777, true);
$older = $mtimeDirectory.DIRECTORY_SEPARATOR.'older.txt';
$newer = $mtimeDirectory.DIRECTORY_SEPARATOR.'newer.txt';
file_put_contents($older, 'old');
file_put_contents($newer, 'new');
$now = time();
touch($older, $now - 10);
touch($newer, $now - 5);
$tool = new LsTool;
$result = $tool->execute([
'directory' => $mtimeDirectory,
'include_hidden' => false,
'sort' => 'mtime_desc',
'filter' => 'files',
'match' => '*.txt',
]);
$this->assertSame(['newer.txt', 'older.txt'], $result['entries']);
}
public function test_execute_throws_when_directory_is_invalid(): void
{
$this->expectException(InvalidArgumentException::class);
$tool = new LsTool;
$tool->execute(['directory' => $this->tempDirectory.DIRECTORY_SEPARATOR.'missing']);
}
/**
* @param array<int, array<string, mixed>> $entries
* @return array<string, mixed>
*/
private function findEntry(array $entries, string $name): array
{
foreach ($entries as $entry) {
if (($entry['name'] ?? null) === $name) {
return $entry;
}
}
$this->fail('未找到条目:'.$name);
}
private function deleteDirectory(string $directory): void
{
if (! is_dir($directory)) {
return;
}
$items = scandir($directory);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $directory.DIRECTORY_SEPARATOR.$item;
if (is_dir($path)) {
$this->deleteDirectory($path);
continue;
}
@unlink($path);
}
@rmdir($directory);
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace Tests\Unit;
use App\Models\Message;
use App\Services\Agent\AgentContext;
use App\Services\Agent\OpenAi\ChatCompletionsRequestBuilder;
use App\Services\Agent\OpenAi\OpenAiEventNormalizer;
use App\Services\Agent\OpenAi\OpenAiStreamParser;
use App\Services\Agent\ProviderEventType;
use App\Services\Tool\ToolRegistry;
use GuzzleHttp\Psr7\Utils;
use Tests\TestCase;
class OpenAiAdapterTest extends TestCase
{
public function test_request_builder_maps_context_to_openai_payload(): void
{
config()->set('agent.openai.model', 'test-model');
config()->set('agent.openai.temperature', 0.2);
config()->set('agent.openai.top_p', 0.9);
config()->set('agent.openai.include_usage', true);
config()->set('agent.tools.tool_choice', 'auto');
$context = new AgentContext('run-1', 'session-1', 'system prompt', [
[
'message_id' => 'm1',
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'hello',
'payload' => [],
'seq' => 1,
],
[
'message_id' => 'm2',
'role' => Message::ROLE_AGENT,
'type' => 'agent.message',
'content' => 'hi',
'payload' => [],
'seq' => 2,
],
]);
$payload = (new ChatCompletionsRequestBuilder(new ToolRegistry()))->build($context);
$this->assertSame('test-model', $payload['model']);
$this->assertTrue($payload['stream']);
$this->assertSame(0.2, $payload['temperature']);
$this->assertSame(0.9, $payload['top_p']);
$this->assertSame(['include_usage' => true], $payload['stream_options']);
$this->assertNotEmpty($payload['tools']);
$this->assertSame('auto', $payload['tool_choice']);
$this->assertSame([
['role' => 'system', 'content' => 'system prompt'],
['role' => 'user', 'content' => 'hello'],
['role' => 'assistant', 'content' => 'hi'],
], $payload['messages']);
}
public function test_request_builder_maps_tool_messages(): void
{
$context = new AgentContext('run-1', 'session-1', 'system prompt', [
[
'message_id' => 'm1',
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'call tool',
'payload' => [],
'seq' => 1,
],
[
'message_id' => 'm2',
'role' => Message::ROLE_AGENT,
'type' => 'tool.call',
'content' => null,
'payload' => [
'tool_call_id' => 'call_1',
'name' => 'get_time',
'arguments' => ['format' => 'c'],
],
'seq' => 2,
],
[
'message_id' => 'm3',
'role' => Message::ROLE_TOOL,
'type' => 'tool.result',
'content' => '2024-01-01',
'payload' => [
'tool_call_id' => 'call_1',
'name' => 'get_time',
'output' => '2024-01-01',
],
'seq' => 3,
],
]);
$payload = (new ChatCompletionsRequestBuilder(new ToolRegistry()))->build($context);
$assistantMessage = collect($payload['messages'])->first(fn ($message) => isset($message['tool_calls']));
$toolResultMessage = collect($payload['messages'])->first(fn ($message) => ($message['role'] ?? '') === 'tool');
$this->assertNotNull($assistantMessage);
$this->assertSame('assistant', $assistantMessage['role']);
$this->assertSame('get_time', $assistantMessage['tool_calls'][0]['function']['name']);
$this->assertNotNull($toolResultMessage);
$this->assertSame('call_1', $toolResultMessage['tool_call_id']);
}
public function test_disable_tools_still_includes_definitions_when_history_has_tools(): void
{
$context = new AgentContext('run-1', 'session-1', 'system prompt', [
[
'message_id' => 'm1',
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'call tool',
'payload' => [],
'seq' => 1,
],
[
'message_id' => 'm2',
'role' => Message::ROLE_AGENT,
'type' => 'tool.call',
'content' => null,
'payload' => [
'tool_call_id' => 'call_1',
'name' => 'get_time',
'arguments' => ['format' => 'c'],
],
'seq' => 2,
],
[
'message_id' => 'm3',
'role' => Message::ROLE_TOOL,
'type' => 'tool.result',
'content' => '2024-01-01',
'payload' => [
'tool_call_id' => 'call_1',
'name' => 'get_time',
'output' => '2024-01-01',
],
'seq' => 3,
],
]);
$payload = (new ChatCompletionsRequestBuilder(new ToolRegistry()))->build($context, [
'disable_tools' => true,
]);
$this->assertSame('none', $payload['tool_choice']);
$this->assertNotEmpty($payload['tools']);
}
public function test_event_normalizer_maps_delta_and_done(): void
{
$normalizer = new OpenAiEventNormalizer();
$delta = json_encode([
'choices' => [
[
'delta' => ['content' => 'Hi'],
'finish_reason' => null,
],
],
]);
$events = $normalizer->normalize($delta);
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::MessageDelta, $events[0]->type);
$this->assertSame('Hi', $events[0]->payload['text']);
$done = json_encode([
'choices' => [
[
'delta' => [],
'finish_reason' => 'stop',
],
],
]);
$events = $normalizer->normalize($done);
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::Done, $events[0]->type);
$this->assertSame('stop', $events[0]->payload['reason']);
}
public function test_event_normalizer_handles_invalid_json(): void
{
$normalizer = new OpenAiEventNormalizer();
$events = $normalizer->normalize('{invalid');
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::Error, $events[0]->type);
$this->assertSame('INVALID_JSON', $events[0]->payload['code']);
}
public function test_event_normalizer_handles_done_marker(): void
{
$normalizer = new OpenAiEventNormalizer();
$events = $normalizer->normalize('[DONE]');
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::Done, $events[0]->type);
}
public function test_event_normalizer_handles_tool_delta(): void
{
$normalizer = new OpenAiEventNormalizer();
$toolDelta = json_encode([
'choices' => [
[
'delta' => [
'tool_calls' => [
[
'id' => 'call_1',
'index' => 0,
'function' => [
'name' => 'get_time',
'arguments' => '{"format":"Y-m-d"}',
],
],
],
],
'finish_reason' => null,
],
],
]);
$events = $normalizer->normalize($toolDelta);
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::ToolDelta, $events[0]->type);
$this->assertSame('call_1', $events[0]->payload['tool_calls'][0]['id']);
$this->assertSame('get_time', $events[0]->payload['tool_calls'][0]['name']);
}
public function test_stream_parser_splits_sse_events(): void
{
$stream = Utils::streamFor("data: {\"id\":1}\n\ndata: [DONE]\n\n");
$parser = new OpenAiStreamParser(5);
$chunks = iterator_to_array($parser->parse($stream));
$this->assertSame(['{"id":1}', '[DONE]'], $chunks);
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace Tests\Unit;
use App\Services\RunLoop;
use ReflectionClass;
use Tests\TestCase;
class RunLoopToolCallTest extends TestCase
{
/**
* 测试流式 tool call 增量正确累积(模拟 OpenAI 流式 API 行为)
*
* OpenAI 流式 API 返回 tool call 时:
* - 第一个 chunk 包含 id、name、index
* - 后续 chunks 只包含 arguments 增量和 index id
*/
public function test_accumulate_tool_calls_with_streaming_chunks(): void
{
$buffer = [];
$order = [];
// 模拟 OpenAI 流式返回的 tool call chunks
$chunks = [
// 第一个 chunk包含 id、name、index
[['id' => 'tooluse_ABC123', 'name' => 'ls', 'arguments' => '', 'index' => 0]],
// 后续 chunks只有 arguments 增量和 index无 id
[['id' => null, 'name' => null, 'arguments' => '{"direc', 'index' => 0]],
[['id' => null, 'name' => null, 'arguments' => 'tory', 'index' => 0]],
[['id' => null, 'name' => null, 'arguments' => '": "/h', 'index' => 0]],
[['id' => null, 'name' => null, 'arguments' => 'ome"}', 'index' => 0]],
];
foreach ($chunks as $toolCalls) {
$this->invokeAccumulateToolCalls($buffer, $order, $toolCalls);
}
// 验证只有一个 tool call
$this->assertCount(1, $buffer);
$this->assertArrayHasKey(0, $buffer);
// 验证 id 和 name 正确保留
$this->assertSame('tooluse_ABC123', $buffer[0]['id']);
$this->assertSame('ls', $buffer[0]['name']);
// 验证 arguments 正确拼接
$this->assertSame('{"directory": "/home"}', $buffer[0]['arguments']);
}
/**
* 测试多个并行 tool calls 的累积
*/
public function test_accumulate_multiple_parallel_tool_calls(): void
{
$buffer = [];
$order = [];
// 模拟两个并行的 tool calls
$chunks = [
// 第一个 tool call 的第一个 chunk
[['id' => 'call_1', 'name' => 'ls', 'arguments' => '', 'index' => 0]],
// 第二个 tool call 的第一个 chunk
[['id' => 'call_2', 'name' => 'cat', 'arguments' => '', 'index' => 1]],
// 第一个 tool call 的 arguments
[['id' => null, 'name' => null, 'arguments' => '{"path": "/home"}', 'index' => 0]],
// 第二个 tool call 的 arguments
[['id' => null, 'name' => null, 'arguments' => '{"file": "test.txt"}', 'index' => 1]],
];
foreach ($chunks as $toolCalls) {
$this->invokeAccumulateToolCalls($buffer, $order, $toolCalls);
}
// 验证有两个 tool calls
$this->assertCount(2, $buffer);
// 验证第一个 tool call
$this->assertSame('call_1', $buffer[0]['id']);
$this->assertSame('ls', $buffer[0]['name']);
$this->assertSame('{"path": "/home"}', $buffer[0]['arguments']);
// 验证第二个 tool call
$this->assertSame('call_2', $buffer[1]['id']);
$this->assertSame('cat', $buffer[1]['name']);
$this->assertSame('{"file": "test.txt"}', $buffer[1]['arguments']);
}
/**
* 测试 finalizeToolCalls 正确整理结果
*/
public function test_finalize_tool_calls(): void
{
$buffer = [
0 => ['id' => 'call_1', 'name' => 'ls', 'arguments' => '{"path": "/"}', 'index' => 0],
1 => ['id' => 'call_2', 'name' => 'cat', 'arguments' => '{"file": "a.txt"}', 'index' => 1],
];
$order = [0 => 0, 1 => 1];
$result = $this->invokeFinalizeToolCalls($buffer, $order, 'tool_calls');
$this->assertCount(2, $result);
$this->assertSame('call_1', $result[0]['id']);
$this->assertSame('ls', $result[0]['name']);
$this->assertSame('{"path": "/"}', $result[0]['arguments']);
$this->assertSame('tool_calls', $result[0]['finish_reason']);
$this->assertSame('call_2', $result[1]['id']);
$this->assertSame('cat', $result[1]['name']);
}
/**
* 测试空 buffer 返回空数组
*/
public function test_finalize_empty_buffer(): void
{
$result = $this->invokeFinalizeToolCalls([], [], 'stop');
$this->assertSame([], $result);
}
/**
* 测试缺少 index 时默认使用 0
*/
public function test_accumulate_without_index_defaults_to_zero(): void
{
$buffer = [];
$order = [];
$this->invokeAccumulateToolCalls($buffer, $order, [
['id' => 'call_1', 'name' => 'test', 'arguments' => '{}'],
]);
$this->assertCount(1, $buffer);
$this->assertArrayHasKey(0, $buffer);
$this->assertSame('call_1', $buffer[0]['id']);
}
private function invokeAccumulateToolCalls(array &$buffer, array &$order, array $toolCalls): void
{
$runLoop = $this->createRunLoopMock();
$method = (new ReflectionClass(RunLoop::class))->getMethod('accumulateToolCalls');
$method->setAccessible(true);
$method->invokeArgs($runLoop, [&$buffer, &$order, $toolCalls]);
}
private function invokeFinalizeToolCalls(array $buffer, array $order, ?string $doneReason): array
{
$runLoop = $this->createRunLoopMock();
$method = (new ReflectionClass(RunLoop::class))->getMethod('finalizeToolCalls');
$method->setAccessible(true);
return $method->invokeArgs($runLoop, [$buffer, $order, $doneReason]);
}
private function createRunLoopMock(): RunLoop
{
return $this->getMockBuilder(RunLoop::class)
->disableOriginalConstructor()
->getMock();
}
}

0
vite.config.js Normal file → Executable file
View File