添加新的工具功能和测试覆盖:
- 注册 `LsTool` 和 `BashTool` 工具,支持目录操作和命令执行 - 增强工具调用逻辑,添加日志记录以提升调试能力 - 增加 `ToolRegistry` 和 `RunLoop` 的增量累积与排序优化 - 完善单元测试覆盖新工具的执行与行为验证
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
59
app/Services/Tool/Tools/BashTool.php
Normal file
59
app/Services/Tool/Tools/BashTool.php
Normal 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
256
app/Services/Tool/Tools/LsTool.php
Normal file
256
app/Services/Tool/Tools/LsTool.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user