diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json new file mode 100644 index 0000000..1012ec1 --- /dev/null +++ b/.ai/mcp/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "vendor/bin/sail", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..37d0b12 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "enabledPlugins": { + "php-lsp@claude-plugins-official": true, + "context7@claude-plugins-official": true, + "laravel-boost@claude-plugins-official": true + } +} diff --git a/.env.example b/.env.example index e6c410c..93a5ed2 100644 --- a/.env.example +++ b/.env.example @@ -91,7 +91,7 @@ AGENT_RUN_JOB_BACKOFF=3 # 重试退避秒数 AGENT_RUN_JOB_TIMEOUT=600 # Job 超时时间(秒) # Tool 子 Run 调度与超时 -AGENT_TOOL_MAX_CALLS_PER_RUN=1 # 单个父 Run 允许的工具调用次数 +AGENT_TOOL_MAX_CALLS_PER_RUN=99 # 单个父 Run 允许的工具调用次数 AGENT_TOOL_WAIT_TIMEOUT_MS=15000 # 等待 tool.result 的超时时间(毫秒) AGENT_TOOL_WAIT_POLL_MS=200 # 等待工具结果轮询间隔(毫秒) AGENT_TOOL_TIMEOUT_SECONDS=15 # 单个工具执行超时(秒,超出记为 TIMEOUT) diff --git a/.qoderignore b/.qoderignore new file mode 100644 index 0000000..4d4515c --- /dev/null +++ b/.qoderignore @@ -0,0 +1 @@ +Specify files or folders to ignore during indexing. Use commas to separate entries. Glob patterns like *.log,my-security/ are supported. diff --git a/app/Jobs/ToolRunJob.php b/app/Jobs/ToolRunJob.php index 6ab661a..28158d2 100644 --- a/app/Jobs/ToolRunJob.php +++ b/app/Jobs/ToolRunJob.php @@ -36,6 +36,8 @@ class ToolRunJob implements ShouldQueue { $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", diff --git a/app/Services/RunLoop.php b/app/Services/RunLoop.php index 0f4ad6f..cc96c79 100644 --- a/app/Services/RunLoop.php +++ b/app/Services/RunLoop.php @@ -70,6 +70,8 @@ class RunLoop $toolCallCount ); + logger('agent provider iteration', [$iterationResult]); + // 2.3 处理失败或取消 if ($iterationResult['should_exit']) { return; @@ -261,7 +263,7 @@ class RunLoop $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( @@ -605,47 +607,55 @@ class RunLoop } /** - * 工具增量收集:同一个 tool_call_id 可能多次分片返回,此处拼接参数与名称。 + * 工具增量收集:同一个 tool call 通过 index 关联,多次分片返回时拼接参数与名称。 * - * @param array> $buffer - * @param array $order + * OpenAI 流式 API 中,tool call 的第一个 chunk 包含 id、name、index, + * 后续 chunks 只包含 arguments 增量和 index(无 id)。 + * 因此必须使用 index 作为 buffer 的 key 来正确累积。 + * + * @param array> $buffer 以 index 为 key 的缓冲区 + * @param array $order 记录 index 出现顺序 * @param array> $toolCalls */ private function accumulateToolCalls(array &$buffer, array &$order, array $toolCalls): void { foreach ($toolCalls as $call) { - $id = is_string($call['id'] ?? null) && $call['id'] !== '' - ? $call['id'] - : md5(json_encode($call)); + // 使用 index 作为主键(OpenAI 流式 API 的标准做法) + $index = is_int($call['index'] ?? null) ? (int) $call['index'] : 0; - $index = is_int($call['index'] ?? null) ? (int) $call['index'] : count($order); - - if (! isset($buffer[$id])) { - $buffer[$id] = [ - 'id' => $id, + if (! isset($buffer[$index])) { + $buffer[$index] = [ + 'id' => $call['id'] ?? null, 'name' => $call['name'] ?? null, 'arguments' => '', 'index' => $index, ]; - $order[$id] = $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[$id]['name'] = $call['name']; + $buffer[$index]['name'] = $call['name']; } + // 累积 arguments $arguments = $call['arguments'] ?? ''; if (is_string($arguments) && $arguments !== '') { - $buffer[$id]['arguments'] .= $arguments; + $buffer[$index]['arguments'] .= $arguments; } } } /** - * 将缓存的 tool.call 增量整理为最终列表(保持 provider 给出的顺序)。 + * 将缓存的 tool.call 增量整理为最终列表(按 index 排序)。 * - * @param array> $buffer - * @param array $order + * @param array> $buffer 以 index 为 key 的缓冲区 + * @param array $order 记录 index 出现顺序 * @return array> */ private function finalizeToolCalls(array $buffer, array $order, ?string $doneReason): array @@ -654,12 +664,8 @@ class RunLoop return []; } - uasort($buffer, function ($a, $b) use ($order) { - $orderA = $order[$a['id']] ?? ($a['index'] ?? 0); - $orderB = $order[$b['id']] ?? ($b['index'] ?? 0); - - return $orderA <=> $orderB; - }); + // 按 index 排序 + ksort($buffer); return array_values(array_map(function (array $call) use ($doneReason) { return [ diff --git a/app/Services/Tool/ToolRegistry.php b/app/Services/Tool/ToolRegistry.php index 0e64f59..0710fda 100644 --- a/app/Services/Tool/ToolRegistry.php +++ b/app/Services/Tool/ToolRegistry.php @@ -2,7 +2,9 @@ namespace App\Services\Tool; +use App\Services\Tool\Tools\BashTool; use App\Services\Tool\Tools\GetTimeTool; +use App\Services\Tool\Tools\LsTool; /** * Tool 注册表:管理已注册工具与 OpenAI 兼容的声明。 @@ -21,6 +23,8 @@ class ToolRegistry { $tools = $tools ?? [ new GetTimeTool(), + new LsTool(), + new BashTool(), ]; $this->tools = []; diff --git a/app/Services/Tool/Tools/BashTool.php b/app/Services/Tool/Tools/BashTool.php new file mode 100644 index 0000000..cc9843a --- /dev/null +++ b/app/Services/Tool/Tools/BashTool.php @@ -0,0 +1,59 @@ + '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']), + ]; + } +} diff --git a/app/Services/Tool/Tools/LsTool.php b/app/Services/Tool/Tools/LsTool.php new file mode 100644 index 0000000..21a02c9 --- /dev/null +++ b/app/Services/Tool/Tools/LsTool.php @@ -0,0 +1,256 @@ + + */ + 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 $arguments + * @return array + */ + 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 + */ + 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 $entries + * @return array + */ + 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 $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 $arguments + * @param array $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 $arguments + */ + private function boolOrDefault(array $arguments, string $key, bool $default): bool + { + $value = $arguments[$key] ?? null; + + return is_bool($value) ? $value : $default; + } + + /** + * @param array $arguments + */ + private function intOrDefault(array $arguments, string $key, int $default): int + { + $value = $arguments[$key] ?? null; + + return is_int($value) ? $value : $default; + } + + /** + * @param array $arguments + */ + private function nullableString(array $arguments, string $key): ?string + { + $value = $arguments[$key] ?? null; + + return is_string($value) ? $value : null; + } +} diff --git a/tests/Unit/LsToolTest.php b/tests/Unit/LsToolTest.php new file mode 100644 index 0000000..7fded57 --- /dev/null +++ b/tests/Unit/LsToolTest.php @@ -0,0 +1,189 @@ +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', '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> $entries + * @return array + */ + 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); + } +} diff --git a/tests/Unit/RunLoopToolCallTest.php b/tests/Unit/RunLoopToolCallTest.php new file mode 100644 index 0000000..a0d8eba --- /dev/null +++ b/tests/Unit/RunLoopToolCallTest.php @@ -0,0 +1,161 @@ + '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(); + } +}