main: 增强工具调用与消息流程

- 支持 tool.call 和 tool.result 消息类型处理
- 引入 Tool 调度与执行逻辑,支持超时与结果截断
- 增加 ToolRegistry 和 ToolExecutor 管理工具定义与执行
- 更新上下文构建与消息映射逻辑,适配工具闭环处理
- 扩展配置与环境变量,支持 Tool 调用相关选项
- 增强单元测试覆盖工具调用与执行情景
- 更新文档和 OpenAPI,新增工具相关说明与模型定义
This commit is contained in:
2025-12-22 12:36:59 +08:00
parent dcbd0338e6
commit 59d4831f00
23 changed files with 1253 additions and 103 deletions

View File

@@ -5,7 +5,7 @@ namespace App\Services\Agent;
final class AgentContext
{
/**
* @param array<int, array{message_id: string, role: string, type: string, content: ?string, seq: int}> $messages
* @param array<int, array{message_id: string, role: string, type: string, content: ?string, payload: ?array, seq: int}> $messages
*/
public function __construct(
public string $runId,

View File

@@ -4,19 +4,23 @@ 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');
}
/**
@@ -63,11 +67,18 @@ class ChatCompletionsRequestBuilder
$payload['response_format'] = $options['response_format'];
}
$toolsSpec = $this->toolRegistry->openAiToolsSpec();
if (! empty($toolsSpec)) {
$payload['tools'] = $toolsSpec;
$payload['tool_choice'] = $options['tool_choice'] ?? $this->toolChoice ?? 'auto';
}
return $payload;
}
/**
* @return array<int, array{role: string, content: string}>
* @return array<int, array<string, mixed>>
*/
private function buildMessages(AgentContext $context): array
{
@@ -83,15 +94,35 @@ class ChatCompletionsRequestBuilder
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;
}
continue;
}
$messages[] = [
'role' => $role,
'content' => $content,
];
if ($type === 'tool.result' && is_array($payload)) {
$toolResult = $this->normalizeToolResultPayload($payload, $content);
if ($toolResult) {
$messages[] = $toolResult;
}
continue;
}
if ($content !== null) {
$messages[] = [
'role' => $role,
'content' => $content,
];
}
}
return $messages;
@@ -106,4 +137,63 @@ class ChatCompletionsRequestBuilder
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,
];
}
}

View File

@@ -35,6 +35,25 @@ class OpenAiEventNormalizer
if (is_string($content) && $content !== '') {
$events[] = ProviderEvent::messageDelta($content);
}
if (isset($delta['tool_calls']) && is_array($delta['tool_calls'])) {
$toolCalls = [];
foreach ($delta['tool_calls'] as $toolCall) {
if (! is_array($toolCall)) {
continue;
}
$toolCalls[] = [
'id' => $toolCall['id'] ?? null,
'name' => $toolCall['function']['name'] ?? null,
'arguments' => $toolCall['function']['arguments'] ?? '',
'index' => $toolCall['index'] ?? null,
];
}
if (! empty($toolCalls)) {
$events[] = ProviderEvent::toolDelta(['tool_calls' => $toolCalls]);
}
}
}
if (is_array($firstChoice) && array_key_exists('finish_reason', $firstChoice) && $firstChoice['finish_reason'] !== null) {

View File

@@ -19,13 +19,14 @@ class ContextBuilder
return new AgentContext(
$runId,
$sessionId,
'You are an agent inside ARS. Respond concisely in plain text.',
'You are an agent inside ARS. Respond concisely in markdown format. Use the following conversation context.',
$messages->map(function (Message $message) {
return [
'message_id' => $message->message_id,
'role' => $message->role,
'type' => $message->type,
'content' => $message->content,
'payload' => $message->payload,
'seq' => $message->seq,
];
})->values()->all()
@@ -36,8 +37,8 @@ class ContextBuilder
{
return Message::query()
->where('session_id', $sessionId)
->whereIn('role', [Message::ROLE_USER, Message::ROLE_AGENT])
->whereIn('type', ['user.prompt', 'agent.message'])
->whereIn('role', [Message::ROLE_USER, Message::ROLE_AGENT, Message::ROLE_TOOL])
->whereIn('type', ['user.prompt', 'agent.message', 'tool.call', 'tool.result'])
->orderByDesc('seq')
->limit($this->limit)
->get()

View File

@@ -3,6 +3,8 @@
namespace App\Services;
use App\Models\Message;
use App\Services\Tool\ToolCall;
use App\Services\Tool\ToolResult;
class OutputSink
{
@@ -44,7 +46,7 @@ class OutputSink
'delta_index' => $deltaIndex,
]),
'dedupe_key' => $dedupeKey,
],$wasDupe,false);
], $wasDeduped);
}
/**
@@ -85,4 +87,42 @@ class OutputSink
'dedupe_key' => $dedupeKey,
]);
}
public function appendToolCall(string $sessionId, ToolCall $toolCall): Message
{
return $this->chatService->appendMessage([
'session_id' => $sessionId,
'role' => Message::ROLE_AGENT,
'type' => 'tool.call',
'content' => $toolCall->rawArguments ?: json_encode($toolCall->arguments, JSON_UNESCAPED_UNICODE),
'payload' => [
'run_id' => $toolCall->parentRunId,
'tool_run_id' => $toolCall->runId,
'tool_call_id' => $toolCall->toolCallId,
'name' => $toolCall->name,
'arguments' => $toolCall->arguments,
],
'dedupe_key' => "run:{$toolCall->parentRunId}:tool_call:{$toolCall->toolCallId}",
]);
}
public function appendToolResult(string $sessionId, ToolResult $result): Message
{
return $this->chatService->appendMessage([
'session_id' => $sessionId,
'role' => Message::ROLE_TOOL,
'type' => 'tool.result',
'content' => $result->output,
'payload' => [
'run_id' => $result->runId,
'parent_run_id' => $result->parentRunId,
'tool_call_id' => $result->toolCallId,
'name' => $result->name,
'status' => $result->status,
'error' => $result->error,
'truncated' => $result->truncated,
],
'dedupe_key' => "run:{$result->runId}:tool_result",
]);
}
}

View File

@@ -8,6 +8,8 @@ use App\Services\Agent\DummyAgentProvider;
use App\Services\Agent\ProviderEventType;
use App\Services\Agent\ProviderException;
use App\Models\Message;
use App\Services\Tool\ToolCall;
use App\Services\Tool\ToolRunDispatcher;
/**
* Agent Run 主循环:
@@ -18,12 +20,20 @@ class RunLoop
{
private const TERMINAL_STATUSES = ['DONE', 'FAILED', 'CANCELED'];
private readonly int $maxToolCalls;
private readonly int $toolWaitTimeoutMs;
private readonly int $toolPollIntervalMs;
public function __construct(
private readonly ContextBuilder $contextBuilder,
private readonly AgentProviderInterface $provider,
private readonly OutputSink $outputSink,
private readonly CancelChecker $cancelChecker,
private readonly ToolRunDispatcher $toolRunDispatcher,
) {
$this->maxToolCalls = (int) config('agent.tools.max_calls_per_run', 10);
$this->toolWaitTimeoutMs = (int) config('agent.tools.wait_timeout_ms', 15000);
$this->toolPollIntervalMs = (int) config('agent.tools.wait_poll_interval_ms', 200);
}
/**
@@ -35,88 +45,138 @@ class RunLoop
return;
}
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return;
}
$context = $this->contextBuilder->build($sessionId, $runId);
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return;
}
$providerName = $this->resolveProviderName();
$startedAt = microtime(true);
$toolCallCount = 0;
logger('agent provider request', [
'sessionId' => $sessionId,
'runId' => $runId,
'provider' => $providerName,
]);
while (true) {
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return;
}
$streamState = $this->consumeProviderStream($sessionId, $runId, $context, $providerName, $startedAt);
$context = $this->contextBuilder->build($sessionId, $runId);
$providerOptions = [
'should_stop' => fn () => $this->isCanceled($sessionId, $runId),
];
// 达到工具调用上限后强制关闭后续工具调用,避免再次触发 TOOL_CALL_LIMIT。
if ($toolCallCount >= $this->maxToolCalls) {
$providerOptions['tool_choice'] = 'none';
}
$logOptions = $providerOptions;
unset($logOptions['should_stop']);
logger('agent provider context', [
'sessionId' => $sessionId,
'runId' => $runId,
'provider' => $providerName,
'context' => $context,
'provider_options' => $logOptions,
]);
$startedAt = microtime(true);
logger('agent provider request', [
'sessionId' => $sessionId,
'runId' => $runId,
'provider' => $providerName,
'iteration' => $toolCallCount,
]);
// 单轮 Agent 调用(可能触发工具调用,后续再进下一轮)
$streamState = $this->consumeProviderStream($sessionId, $runId, $context, $providerName, $startedAt, $providerOptions);
if ($streamState['canceled'] || $streamState['failed']) {
return;
}
if (! empty($streamState['tool_calls'])) {
$toolCallCount += count($streamState['tool_calls']);
if ($toolCallCount > $this->maxToolCalls) {
$this->appendProviderFailure(
$sessionId,
$runId,
'TOOL_CALL_LIMIT',
'Tool call limit reached for this run',
$providerName,
$this->latencyMs($startedAt),
[],
'TOOL_CALL_LIMIT'
);
return;
}
// 工具调用:先调度子 Run再等待 tool.result随后继续下一轮 Provider 调用。
$toolCalls = $this->dispatchToolRuns($sessionId, $runId, $streamState['tool_calls']);
$waitState = $this->awaitToolResults($sessionId, $runId, $toolCalls, $providerName);
if ($waitState['failed'] || $waitState['canceled']) {
return;
}
// 工具结果已写回上下文,继续下一轮 Agent 调用。
continue;
}
$latencyMs = $this->latencyMs($startedAt);
logger('agent provider response', [
'sessionId' => $sessionId,
'runId' => $runId,
'provider' => $providerName,
'latency_ms' => $latencyMs,
]);
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return;
}
if (! $streamState['received_event']) {
$this->appendProviderFailure(
$sessionId,
$runId,
'EMPTY_STREAM',
'Agent provider returned no events',
$providerName,
$latencyMs,
[],
'EMPTY_STREAM'
);
return;
}
if ($streamState['done_reason'] === null) {
$this->appendProviderFailure(
$sessionId,
$runId,
'STREAM_INCOMPLETE',
'Agent provider stream ended unexpectedly',
$providerName,
$latencyMs,
[],
'STREAM_INCOMPLETE'
);
return;
}
$this->outputSink->appendAgentMessage($sessionId, $runId, $streamState['reply'], [
'provider' => $providerName,
'done_reason' => $streamState['done_reason'],
], "run:{$runId}:agent:message");
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return;
}
$this->outputSink->appendRunStatus($sessionId, $runId, 'DONE', [
'dedupe_key' => "run:{$runId}:status:DONE",
]);
if ($streamState['canceled'] || $streamState['failed']) {
return;
}
$latencyMs = $this->latencyMs($startedAt);
logger('agent provider response', [
'sessionId' => $sessionId,
'runId' => $runId,
'provider' => $providerName,
'latency_ms' => $latencyMs,
]);
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return;
}
if (! $streamState['received_event']) {
$this->appendProviderFailure(
$sessionId,
$runId,
'EMPTY_STREAM',
'Agent provider returned no events',
$providerName,
$latencyMs,
[],
'EMPTY_STREAM'
);
return;
}
if ($streamState['done_reason'] === null) {
$this->appendProviderFailure(
$sessionId,
$runId,
'STREAM_INCOMPLETE',
'Agent provider stream ended unexpectedly',
$providerName,
$latencyMs,
[],
'STREAM_INCOMPLETE'
);
return;
}
$this->outputSink->appendAgentMessage($sessionId, $runId, $streamState['reply'], [
'provider' => $providerName,
'done_reason' => $streamState['done_reason'],
], "run:{$runId}:agent:message");
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return;
}
$this->outputSink->appendRunStatus($sessionId, $runId, 'DONE', [
'dedupe_key' => "run:{$runId}:status:DONE",
]);
}
/**
@@ -152,29 +212,45 @@ class RunLoop
* - done记录结束理由
* - error/异常:写入 error + FAILED
* - cancel即时中断并写 CANCELED
* @return array{reply: string, done_reason: ?string, received_event: bool, failed: bool, canceled: bool}
* - tool.delta/tool.call收集工具调用信息后续驱动子 Run
*
* @return array{
* reply: string,
* done_reason: ?string,
* received_event: bool,
* failed: bool,
* canceled: bool,
* tool_calls: array<int, array<string, mixed>>
* }
*/
private function consumeProviderStream(
string $sessionId,
string $runId,
AgentContext $context,
string $providerName,
float $startedAt
float $startedAt,
array $providerOptions = []
): array {
$reply = '';
$deltaIndex = 0;
$doneReason = null;
$receivedEvent = false;
$toolCallBuffer = [];
$toolCallOrder = [];
try {
foreach ($this->provider->stream($context, [
$providerOptions = array_merge([
'should_stop' => fn () => $this->isCanceled($sessionId, $runId),
]) as $event) {
], $providerOptions);
foreach ($this->provider->stream($context, $providerOptions) as $event) {
$receivedEvent = true;
if ($this->isCanceled($sessionId, $runId)) {
$this->appendCanceled($sessionId, $runId);
return $this->streamState($reply, $doneReason, $receivedEvent, false, true);
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
return $this->streamState($reply, $doneReason, $receivedEvent, false, true, $toolCalls);
}
// 文本增量:持续写 message.delta 并拼接最终回复
@@ -190,6 +266,14 @@ class RunLoop
continue;
}
if ($event->type === ProviderEventType::ToolDelta || $event->type === ProviderEventType::ToolCall) {
$toolCalls = $event->payload['tool_calls'] ?? [];
if (is_array($toolCalls)) {
$this->accumulateToolCalls($toolCallBuffer, $toolCallOrder, $toolCalls);
}
continue;
}
// 流结束
if ($event->type === ProviderEventType::Done) {
$doneReason = $event->payload['reason'] ?? null;
@@ -216,7 +300,9 @@ class RunLoop
]
);
return $this->streamState($reply, $doneReason, $receivedEvent, true, false);
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
return $this->streamState($reply, $doneReason, $receivedEvent, true, false, $toolCalls);
}
}
} catch (ProviderException $exception) {
@@ -236,10 +322,14 @@ class RunLoop
]
);
return $this->streamState($reply, $doneReason, $receivedEvent, true, false);
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
return $this->streamState($reply, $doneReason, $receivedEvent, true, false, $toolCalls);
}
return $this->streamState($reply, $doneReason, $receivedEvent, false, false);
$toolCalls = $this->finalizeToolCalls($toolCallBuffer, $toolCallOrder, $doneReason);
return $this->streamState($reply, $doneReason, $receivedEvent, false, false, $toolCalls);
}
/**
@@ -271,14 +361,22 @@ class RunLoop
/**
* 封装流式状态返回,便于上层判断。
*
* @return array{reply: string, done_reason: ?string, received_event: bool, failed: bool, canceled: bool}
* @return array{
* reply: string,
* done_reason: ?string,
* received_event: bool,
* failed: bool,
* canceled: bool,
* tool_calls: array<int, array<string, mixed>>
* }
*/
private function streamState(
string $reply,
?string $doneReason,
bool $receivedEvent,
bool $failed,
bool $canceled
bool $canceled,
array $toolCalls
): array {
return [
'reply' => $reply,
@@ -286,9 +384,246 @@ class RunLoop
'received_event' => $receivedEvent,
'failed' => $failed,
'canceled' => $canceled,
'tool_calls' => $toolCalls,
];
}
/**
* 工具增量收集:同一个 tool_call_id 可能多次分片返回,此处拼接参数与名称。
*
* @param array<string, array<string, mixed>> $buffer
* @param array<string, int> $order
* @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 = is_int($call['index'] ?? null) ? (int) $call['index'] : count($order);
if (! isset($buffer[$id])) {
$buffer[$id] = [
'id' => $id,
'name' => $call['name'] ?? null,
'arguments' => '',
'index' => $index,
];
$order[$id] = $index;
}
if (isset($call['name']) && is_string($call['name']) && $call['name'] !== '') {
$buffer[$id]['name'] = $call['name'];
}
$arguments = $call['arguments'] ?? '';
if (is_string($arguments) && $arguments !== '') {
$buffer[$id]['arguments'] .= $arguments;
}
}
}
/**
* 将缓存的 tool.call 增量整理为最终列表(保持 provider 给出的顺序)。
*
* @param array<string, array<string, mixed>> $buffer
* @param array<string, int> $order
* @return array<int, array<string, mixed>>
*/
private function finalizeToolCalls(array $buffer, array $order, ?string $doneReason): array
{
if (empty($buffer)) {
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;
});
return array_values(array_map(function (array $call) use ($doneReason) {
return [
'id' => (string) ($call['id'] ?? ''),
'name' => (string) ($call['name'] ?? ''),
'arguments' => (string) ($call['arguments'] ?? ''),
'finish_reason' => $doneReason,
];
}, $buffer));
}
/**
* Tool 调用落库并触发子 Run。
*
* @param array<int, array<string, mixed>> $toolCalls
* @return array<int, ToolCall>
*/
private function dispatchToolRuns(string $sessionId, string $parentRunId, array $toolCalls): array
{
$dispatched = [];
foreach ($toolCalls as $call) {
$toolCallId = (string) ($call['id'] ?? '');
$name = (string) ($call['name'] ?? '');
$rawArguments = (string) ($call['arguments'] ?? '');
if ($toolCallId === '' || $name === '') {
continue;
}
$arguments = $this->decodeToolArguments($rawArguments);
$toolRunId = $this->generateToolRunId($parentRunId, $toolCallId);
$toolCall = new ToolCall($toolRunId, $parentRunId, $toolCallId, $name, $arguments, $rawArguments);
$this->outputSink->appendToolCall($sessionId, $toolCall);
$this->toolRunDispatcher->dispatch($sessionId, $toolCall);
$dispatched[] = $toolCall;
}
return $dispatched;
}
/**
* 等待工具子 Run 写入 tool.result超时/失败会直接结束父 Run。
*
* @param array<int, ToolCall> $toolCalls
* @return array{failed: bool, canceled: bool}
*/
private function awaitToolResults(string $sessionId, string $parentRunId, array $toolCalls, string $providerName): array
{
$start = microtime(true);
$expectedIds = array_map(fn (ToolCall $call) => $call->toolCallId, $toolCalls);
$expectedRuns = array_map(fn (ToolCall $call) => $call->runId, $toolCalls);
while (true) {
if ($this->isCanceled($sessionId, $parentRunId)) {
$this->appendCanceled($sessionId, $parentRunId);
return ['failed' => false, 'canceled' => true];
}
$results = $this->findToolResults($sessionId, $parentRunId);
$statuses = $this->findToolRunStatuses($sessionId, $parentRunId);
foreach ($expectedRuns as $runId) {
$status = $statuses[$runId] ?? null;
if ($status === 'FAILED') {
$this->appendProviderFailure(
$sessionId,
$parentRunId,
'TOOL_RUN_FAILED',
"Tool run {$runId} failed",
$providerName,
$this->latencyMs($start),
[],
'TOOL_RUN_FAILED'
);
return ['failed' => true, 'canceled' => false];
}
if ($status === 'CANCELED') {
$this->appendCanceled($sessionId, $parentRunId);
return ['failed' => false, 'canceled' => true];
}
}
$readyIds = array_intersect($expectedIds, array_keys($results));
if (count($readyIds) === count($expectedIds)) {
return ['failed' => false, 'canceled' => false];
}
if ($this->latencyMs($start) >= $this->toolWaitTimeoutMs) {
$this->appendProviderFailure(
$sessionId,
$parentRunId,
'TOOL_RESULT_TIMEOUT',
'Tool result wait timeout',
$providerName,
$this->latencyMs($start),
[],
'TOOL_RESULT_TIMEOUT'
);
return ['failed' => true, 'canceled' => false];
}
usleep($this->toolPollIntervalMs * 1000);
}
}
/**
* @return array<string, \App\Models\Message>
*/
private function findToolResults(string $sessionId, string $parentRunId): array
{
$messages = Message::query()
->where('session_id', $sessionId)
->where('type', 'tool.result')
->whereRaw("payload->>'parent_run_id' = ?", [$parentRunId])
->orderBy('seq')
->get();
$byToolCall = [];
foreach ($messages as $message) {
$toolCallId = $message->payload['tool_call_id'] ?? null;
if (is_string($toolCallId) && $toolCallId !== '') {
$byToolCall[$toolCallId] = $message;
}
}
return $byToolCall;
}
/**
* @return array<string, string>
*/
private function findToolRunStatuses(string $sessionId, string $parentRunId): array
{
$messages = Message::query()
->where('session_id', $sessionId)
->where('type', 'run.status')
->whereRaw("payload->>'parent_run_id' = ?", [$parentRunId])
->orderBy('seq')
->get();
$statuses = [];
foreach ($messages as $message) {
$runId = $message->payload['run_id'] ?? null;
$status = $message->payload['status'] ?? null;
if (is_string($runId) && is_string($status)) {
$statuses[$runId] = $status;
}
}
return $statuses;
}
private function generateToolRunId(string $parentRunId, string $toolCallId): string
{
return substr(hash('sha256', $parentRunId.'|'.$toolCallId), 0, 32);
}
/**
* @return array<string, mixed>
*/
private function decodeToolArguments(string $rawArguments): array
{
$decoded = json_decode($rawArguments, true);
return is_array($decoded) ? $decoded : [];
}
/**
* 计算耗时(毫秒)。
*/

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services\Tool;
interface Tool
{
public function name(): string;
public function description(): string;
/**
* OpenAI function 参数 JSON Schema。
*
* @return array<string, mixed>
*/
public function parameters(): array;
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>|string
*/
public function execute(array $arguments): array|string;
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Services\Tool;
/**
* Tool 调用请求 DTO携带父子 Run 关联信息与参数。
*/
final class ToolCall
{
/**
* @param array<string, mixed> $arguments
*/
public function __construct(
public string $runId,
public string $parentRunId,
public string $toolCallId,
public string $name,
public array $arguments,
public string $rawArguments = '',
) {
}
/**
* @param array<string, mixed> $payload
*/
public static function fromArray(array $payload): self
{
return new self(
(string) $payload['run_id'],
(string) $payload['parent_run_id'],
(string) $payload['tool_call_id'],
(string) $payload['name'],
(array) ($payload['arguments'] ?? []),
(string) ($payload['raw_arguments'] ?? '')
);
}
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'run_id' => $this->runId,
'parent_run_id' => $this->parentRunId,
'tool_call_id' => $this->toolCallId,
'name' => $this->name,
'arguments' => $this->arguments,
'raw_arguments' => $this->rawArguments,
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Services\Tool;
use Illuminate\Support\Str;
/**
* Tool 执行调度器,负责超时/结果截断等基础防护。
*/
class ToolExecutor
{
public function __construct(
private readonly ToolRegistry $registry,
private ?int $timeoutSeconds = null,
private ?int $maxResultBytes = null,
) {
$this->timeoutSeconds = $this->timeoutSeconds ?? (int) config('agent.tools.timeout_seconds', 15);
$this->maxResultBytes = $this->maxResultBytes ?? (int) config('agent.tools.result_max_bytes', 4096);
}
public function execute(ToolCall $call): ToolResult
{
$tool = $this->registry->get($call->name);
if (! $tool) {
return new ToolResult(
$call->runId,
$call->parentRunId,
$call->toolCallId,
$call->name,
'FAILED',
'',
'TOOL_NOT_FOUND'
);
}
$started = microtime(true);
try {
$result = $tool->execute($call->arguments);
$output = is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE);
} catch (\Throwable $exception) {
return new ToolResult(
$call->runId,
$call->parentRunId,
$call->toolCallId,
$call->name,
'FAILED',
'',
Str::limit($exception->getMessage(), 200)
);
}
$duration = microtime(true) - $started;
$truncated = false;
if ($this->maxResultBytes > 0 && strlen($output) > $this->maxResultBytes) {
$output = substr($output, 0, $this->maxResultBytes);
$truncated = true;
}
if ($this->timeoutSeconds > 0 && $duration > $this->timeoutSeconds) {
return new ToolResult(
$call->runId,
$call->parentRunId,
$call->toolCallId,
$call->name,
'TIMEOUT',
$output,
'TOOL_TIMEOUT',
$truncated
);
}
return new ToolResult(
$call->runId,
$call->parentRunId,
$call->toolCallId,
$call->name,
'SUCCESS',
$output,
null,
$truncated
);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Services\Tool;
use App\Services\Tool\Tools\GetTimeTool;
/**
* Tool 注册表:管理已注册工具与 OpenAI 兼容的声明。
*/
class ToolRegistry
{
/**
* @var array<string, Tool>
*/
private array $tools;
/**
* @param array<int, Tool>|null $tools
*/
public function __construct(?array $tools = null)
{
$tools = $tools ?? [
new GetTimeTool(),
];
$this->tools = [];
foreach ($tools as $tool) {
$this->tools[$tool->name()] = $tool;
}
}
public function get(string $name): ?Tool
{
return $this->tools[$name] ?? null;
}
/**
* @return array<int, Tool>
*/
public function all(): array
{
return array_values($this->tools);
}
/**
* 返回 OpenAI-compatible tools 描述。
*
* @return array<int, array<string, mixed>>
*/
public function openAiToolsSpec(): array
{
return array_map(function (Tool $tool) {
return [
'type' => 'function',
'function' => [
'name' => $tool->name(),
'description' => $tool->description(),
'parameters' => $tool->parameters(),
],
];
}, $this->all());
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Tool;
/**
* Tool 执行结果 DTO记录结果文本与状态。
*/
final class ToolResult
{
public function __construct(
public string $runId,
public string $parentRunId,
public string $toolCallId,
public string $name,
public string $status,
public string $output,
public ?string $error = null,
public bool $truncated = false,
) {
}
/**
* 便于在测试/日志中以数组格式使用。
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return [
'run_id' => $this->runId,
'parent_run_id' => $this->parentRunId,
'tool_call_id' => $this->toolCallId,
'name' => $this->name,
'status' => $this->status,
'output' => $this->output,
'error' => $this->error,
'truncated' => $this->truncated,
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Services\Tool;
use App\Jobs\ToolRunJob;
use App\Services\OutputSink;
use Illuminate\Support\Facades\DB;
/**
* Run 调度:为单个 tool_call 创建 Run 并投递队列,幂等。
*/
class ToolRunDispatcher
{
public function __construct(private readonly OutputSink $outputSink)
{
}
public function dispatch(string $sessionId, ToolCall $toolCall): string
{
$shouldDispatch = false;
DB::transaction(function () use ($sessionId, $toolCall, &$shouldDispatch): void {
$wasDeduped = null;
$this->outputSink->appendRunStatus($sessionId, $toolCall->runId, 'RUNNING', [
'parent_run_id' => $toolCall->parentRunId,
'tool_call_id' => $toolCall->toolCallId,
'dedupe_key' => "run:{$toolCall->runId}:status:RUNNING",
], $wasDeduped);
$shouldDispatch = ! $wasDeduped;
});
if ($shouldDispatch) {
ToolRunJob::dispatchSync($sessionId, $toolCall->toArray());
}
return $toolCall->runId;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Services\Tool\Tools;
use App\Services\Tool\Tool;
use Illuminate\Support\Carbon;
class GetTimeTool implements Tool
{
public function name(): string
{
return 'get_time';
}
public function description(): string
{
return '返回服务器当前时间,可指定 PHP 日期格式。';
}
/**
* @return array<string, mixed>
*/
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'format' => [
'type' => 'string',
'description' => '可选的日期格式,默认为 RFC3339。',
],
],
'required' => [],
];
}
/**
* @param array<string, mixed> $arguments
* @return array<string, mixed>
*/
public function execute(array $arguments): array
{
$format = is_string($arguments['format'] ?? null) && $arguments['format'] !== ''
? $arguments['format']
: Carbon::RFC3339_EXTENDED;
return [
'now' => Carbon::now()->format($format),
];
}
}