main: 增强工具功能与消息处理

- 添加 `FileReadTool`,支持文件内容读取与安全验证
- 引入 `hasToolMessages` 逻辑,优化工具历史上下文处理
- 修改工具选项逻辑,支持禁用工具时的动态调整
- 增加消息序列化逻辑,优化 Redis 序列管理与数据同步
- 扩展测试覆盖,验证序列化与工具调用场景
- 增强 Docker Compose 脚本,支持应用重置与日志清理
- 调整工具调用超时设置,提升运行时用户体验
This commit is contained in:
2025-12-24 00:55:54 +08:00
parent 71226c255b
commit e956df9daa
24 changed files with 741 additions and 38 deletions

View File

@@ -68,13 +68,22 @@ class ChatCompletionsRequestBuilder
}
$toolsSpec = $this->toolRegistry->openAiToolsSpec();
$hasToolMessages = $this->hasToolMessages($context);
// 支持 disable_tools 选项,用于在达到工具调用上限后禁用工具
$disableTools = $options['disable_tools'] ?? false;
if (! empty($toolsSpec) && ! $disableTools) {
$payload['tools'] = $toolsSpec;
$payload['tool_choice'] = $options['tool_choice'] ?? $this->toolChoice ?? 'auto';
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;
@@ -219,4 +228,16 @@ class ChatCompletionsRequestBuilder
'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

@@ -16,6 +16,9 @@ use Illuminate\Support\Str;
class ChatService
{
public function __construct(private readonly MessageSequence $messageSequence)
{
}
/**
* 创建一个新聊天会话。
@@ -88,41 +91,55 @@ class ChatService
}
}
$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 {
if ($save) {
$message->save();
}
$isNew = true;
} catch (QueryException $e) {
if ($this->isUniqueConstraint($e) && $dedupeKey) {
$existing = Message::query()
->where('session_id', $session->session_id)
->where('dedupe_key', $dedupeKey)
->first();
if ($existing) {
$messageRef = $existing;
$wasDeduped = true;
return;
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([
@@ -327,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,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";
}
}

View File

@@ -3,12 +3,16 @@
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)
public function __construct(
private readonly ChatService $chatService,
private readonly MessageSequence $messageSequence,
)
{
}
@@ -39,10 +43,12 @@ class OutputSink
*/
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' => $sessionId,
'session_id' => $session->session_id,
'role' => Message::ROLE_AGENT,
'type' => 'message.delta',
'content' => $content,
@@ -51,7 +57,7 @@ class OutputSink
'delta_index' => $deltaIndex,
]),
'dedupe_key' => "run:{$runId}:agent:delta:{$deltaIndex}",
'seq' => 0, // delta 消息不需要真实的 seq
'seq' => $this->messageSequence->nextForSession($session),
'created_at' => now(),
]);

View File

@@ -5,6 +5,7 @@ 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 兼容的声明。
@@ -25,6 +26,7 @@ class ToolRegistry
new GetTimeTool(),
new LsTool(),
new BashTool(),
new FileReadTool(),
];
$this->tools = [];

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