Files
ars-backend/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php
ROOG e956df9daa main: 增强工具功能与消息处理
- 添加 `FileReadTool`,支持文件内容读取与安全验证
- 引入 `hasToolMessages` 逻辑,优化工具历史上下文处理
- 修改工具选项逻辑,支持禁用工具时的动态调整
- 增加消息序列化逻辑,优化 Redis 序列管理与数据同步
- 扩展测试覆盖,验证序列化与工具调用场景
- 增强 Docker Compose 脚本,支持应用重置与日志清理
- 调整工具调用超时设置,提升运行时用户体验
2025-12-24 00:55:54 +08:00

244 lines
8.1 KiB
PHP

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