添加新的工具功能和测试覆盖:

- 注册 `LsTool` 和 `BashTool` 工具,支持目录操作和命令执行
- 增强工具调用逻辑,添加日志记录以提升调试能力
- 增加 `ToolRegistry` 和 `RunLoop` 的增量累积与排序优化
- 完善单元测试覆盖新工具的执行与行为验证
This commit is contained in:
2025-12-23 17:26:27 +08:00
parent 78875ec3eb
commit 71226c255b
11 changed files with 721 additions and 25 deletions

View File

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

View File

@@ -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<string, array<string, mixed>> $buffer
* @param array<string, int> $order
* 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) {
$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<string, array<string, mixed>> $buffer
* @param array<string, int> $order
* @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
@@ -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 [

View File

@@ -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 = [];

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,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;
}
}