From 59d4831f007af1ee95af29b54c9102c778725a46 Mon Sep 17 00:00:00 2001 From: ROOG Date: Mon, 22 Dec 2025 12:36:59 +0800 Subject: [PATCH] =?UTF-8?q?main:=20=E5=A2=9E=E5=BC=BA=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E4=B8=8E=E6=B6=88=E6=81=AF=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持 tool.call 和 tool.result 消息类型处理 - 引入 Tool 调度与执行逻辑,支持超时与结果截断 - 增加 ToolRegistry 和 ToolExecutor 管理工具定义与执行 - 更新上下文构建与消息映射逻辑,适配工具闭环处理 - 扩展配置与环境变量,支持 Tool 调用相关选项 - 增强单元测试覆盖工具调用与执行情景 - 更新文档和 OpenAPI,新增工具相关说明与模型定义 --- .env.example | 11 + README.md | 8 + app/Jobs/ToolRunJob.php | 70 +++ app/Services/Agent/AgentContext.php | 2 +- .../OpenAi/ChatCompletionsRequestBuilder.php | 100 +++- .../Agent/OpenAi/OpenAiEventNormalizer.php | 19 + app/Services/ContextBuilder.php | 7 +- app/Services/OutputSink.php | 42 +- app/Services/RunLoop.php | 507 +++++++++++++++--- app/Services/Tool/Tool.php | 23 + app/Services/Tool/ToolCall.php | 52 ++ app/Services/Tool/ToolExecutor.php | 86 +++ app/Services/Tool/ToolRegistry.php | 64 +++ app/Services/Tool/ToolResult.php | 40 ++ app/Services/Tool/ToolRunDispatcher.php | 39 ++ app/Services/Tool/Tools/GetTimeTool.php | 51 ++ config/agent.php | 13 + config/queue.php | 2 +- docs/ChatSession/chat-session-api.md | 12 +- docs/ChatSession/chat-session-openapi.yaml | 34 ++ docs/tools-subrun.md | 25 + tests/Feature/AgentRunTest.php | 61 +++ tests/Unit/OpenAiAdapterTest.php | 88 ++- 23 files changed, 1253 insertions(+), 103 deletions(-) create mode 100644 app/Jobs/ToolRunJob.php create mode 100644 app/Services/Tool/Tool.php create mode 100644 app/Services/Tool/ToolCall.php create mode 100644 app/Services/Tool/ToolExecutor.php create mode 100644 app/Services/Tool/ToolRegistry.php create mode 100644 app/Services/Tool/ToolResult.php create mode 100644 app/Services/Tool/ToolRunDispatcher.php create mode 100644 app/Services/Tool/Tools/GetTimeTool.php create mode 100644 docs/tools-subrun.md diff --git a/.env.example b/.env.example index dd5d964..f024ce6 100644 --- a/.env.example +++ b/.env.example @@ -89,3 +89,14 @@ AGENT_OPENAI_INCLUDE_USAGE=false AGENT_RUN_JOB_TRIES=1 # 队列重试次数 AGENT_RUN_JOB_BACKOFF=3 # 重试退避秒数 AGENT_RUN_JOB_TIMEOUT=360 # Job 超时时间(秒) + +# Tool 子 Run 调度与超时 +AGENT_TOOL_MAX_CALLS_PER_RUN=1 # 单个父 Run 允许的工具调用次数 +AGENT_TOOL_WAIT_TIMEOUT_MS=15000 # 等待 tool.result 的超时时间(毫秒) +AGENT_TOOL_WAIT_POLL_MS=200 # 等待工具结果轮询间隔(毫秒) +AGENT_TOOL_TIMEOUT_SECONDS=15 # 单个工具执行超时(秒,超出记为 TIMEOUT) +AGENT_TOOL_RESULT_MAX_BYTES=4096 # 工具结果最大保存字节数(截断后仍会写入) +AGENT_TOOL_CHOICE=auto # OpenAI tool_choice 选项(auto/required 等) +AGENT_TOOL_JOB_TRIES=1 # ToolRunJob 重试次数 +AGENT_TOOL_JOB_BACKOFF=3 # ToolRunJob 重试退避秒数 +AGENT_TOOL_JOB_TIMEOUT=120 # ToolRunJob 超时时间(秒) diff --git a/README.md b/README.md index 083f2d7..563810c 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,14 @@ docker compose exec app php artisan test --testsuite=Feature - `AGENT_RUN_JOB_TRIES`(默认 1):AgentRunJob 队列重试次数 - `AGENT_RUN_JOB_BACKOFF`(默认 3):AgentRunJob 重试退避秒数 - `AGENT_RUN_JOB_TIMEOUT`(默认 360):AgentRunJob 超时时间(秒) +- 工具调用(子 Run 模式): + - `AGENT_TOOL_MAX_CALLS_PER_RUN`(默认 1):单个父 Run 允许的工具调用次数上限(超过直接失败) + - `AGENT_TOOL_WAIT_TIMEOUT_MS`(默认 15000):等待子 Run 写入 `tool.result` 的超时时间(毫秒) + - `AGENT_TOOL_WAIT_POLL_MS`(默认 200):等待工具结果时的轮询间隔(毫秒) + - `AGENT_TOOL_TIMEOUT_SECONDS`(默认 15):单个工具执行的预期超时时间(超过会记为 TIMEOUT) + - `AGENT_TOOL_RESULT_MAX_BYTES`(默认 4096):工具结果最大保存字节数(超出会截断) + - `AGENT_TOOL_CHOICE`(默认 auto):OpenAI tool_choice 传参策略 + - `AGENT_TOOL_JOB_TRIES/AGENT_TOOL_JOB_BACKOFF/AGENT_TOOL_JOB_TIMEOUT`:ToolRunJob 队列重试/退避/超时设置 ## 🔑 API 能力一览(MVP-1.1 + Archive/GetMessage/SSE) - 会话:`POST /api/sessions`,`GET /api/sessions`(分页/状态/关键词),`GET /api/sessions/{id}`,`PATCH /api/sessions/{id}`(重命名/状态,CLOSED 不可重开),`POST /api/sessions/{id}/archive`(幂等归档→CLOSED)。 diff --git a/app/Jobs/ToolRunJob.php b/app/Jobs/ToolRunJob.php new file mode 100644 index 0000000..6ab661a --- /dev/null +++ b/app/Jobs/ToolRunJob.php @@ -0,0 +1,70 @@ + $toolCall + */ + public function __construct(public string $sessionId, public array $toolCall) + { + $this->tries = (int) config('agent.tools.job.tries', 1); + $this->timeout = (int) config('agent.tools.job.timeout_seconds', 60); + $this->backoff = (int) config('agent.tools.job.backoff_seconds', 3); + } + + public function handle(ToolExecutor $executor, OutputSink $sink, CancelChecker $cancelChecker): void + { + $call = ToolCall::fromArray($this->toolCall); + + if ($cancelChecker->isCanceled($this->sessionId, $call->parentRunId)) { + $sink->appendRunStatus($this->sessionId, $call->runId, 'CANCELED', [ + 'dedupe_key' => "run:{$call->runId}:status:CANCELED", + 'parent_run_id' => $call->parentRunId, + ]); + + return; + } + + try { + $result = $executor->execute($call); + $sink->appendToolResult($this->sessionId, $result); + + $status = $result->status === 'SUCCESS' ? 'DONE' : 'FAILED'; + $sink->appendRunStatus($this->sessionId, $call->runId, $status, [ + 'parent_run_id' => $call->parentRunId, + 'tool_call_id' => $call->toolCallId, + 'dedupe_key' => "run:{$call->runId}:status:{$status}", + 'error' => $result->error, + ]); + } catch (\Throwable $exception) { + $sink->appendRunStatus($this->sessionId, $call->runId, 'FAILED', [ + 'parent_run_id' => $call->parentRunId, + 'tool_call_id' => $call->toolCallId, + 'dedupe_key' => "run:{$call->runId}:status:FAILED", + 'error' => $exception->getMessage(), + ]); + + throw $exception; + } + } +} diff --git a/app/Services/Agent/AgentContext.php b/app/Services/Agent/AgentContext.php index 608028e..50689a4 100644 --- a/app/Services/Agent/AgentContext.php +++ b/app/Services/Agent/AgentContext.php @@ -5,7 +5,7 @@ namespace App\Services\Agent; final class AgentContext { /** - * @param array $messages + * @param array $messages */ public function __construct( public string $runId, diff --git a/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php b/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php index c1c2d78..c1c0d22 100644 --- a/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php +++ b/app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php @@ -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 + * @return array> */ 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 $payload + * @return array|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 $payload + * @return array|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, + ]; + } } diff --git a/app/Services/Agent/OpenAi/OpenAiEventNormalizer.php b/app/Services/Agent/OpenAi/OpenAiEventNormalizer.php index 8fe31be..0d17b5c 100644 --- a/app/Services/Agent/OpenAi/OpenAiEventNormalizer.php +++ b/app/Services/Agent/OpenAi/OpenAiEventNormalizer.php @@ -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) { diff --git a/app/Services/ContextBuilder.php b/app/Services/ContextBuilder.php index fbeedb3..916305d 100644 --- a/app/Services/ContextBuilder.php +++ b/app/Services/ContextBuilder.php @@ -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() diff --git a/app/Services/OutputSink.php b/app/Services/OutputSink.php index 4602faa..7ad856e 100644 --- a/app/Services/OutputSink.php +++ b/app/Services/OutputSink.php @@ -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", + ]); + } } diff --git a/app/Services/RunLoop.php b/app/Services/RunLoop.php index 4c93d30..3dca373 100644 --- a/app/Services/RunLoop.php +++ b/app/Services/RunLoop.php @@ -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> + * } */ 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> + * } */ 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> $buffer + * @param array $order + * @param array> $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> $buffer + * @param array $order + * @return array> + */ + 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> $toolCalls + * @return array + */ + 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 $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 + */ + 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 + */ + 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 + */ + private function decodeToolArguments(string $rawArguments): array + { + $decoded = json_decode($rawArguments, true); + + return is_array($decoded) ? $decoded : []; + } + /** * 计算耗时(毫秒)。 */ diff --git a/app/Services/Tool/Tool.php b/app/Services/Tool/Tool.php new file mode 100644 index 0000000..1a41e90 --- /dev/null +++ b/app/Services/Tool/Tool.php @@ -0,0 +1,23 @@ + + */ + public function parameters(): array; + + /** + * @param array $arguments + * @return array|string + */ + public function execute(array $arguments): array|string; +} diff --git a/app/Services/Tool/ToolCall.php b/app/Services/Tool/ToolCall.php new file mode 100644 index 0000000..707182e --- /dev/null +++ b/app/Services/Tool/ToolCall.php @@ -0,0 +1,52 @@ + $arguments + */ + public function __construct( + public string $runId, + public string $parentRunId, + public string $toolCallId, + public string $name, + public array $arguments, + public string $rawArguments = '', + ) { + } + + /** + * @param array $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 + */ + 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, + ]; + } +} diff --git a/app/Services/Tool/ToolExecutor.php b/app/Services/Tool/ToolExecutor.php new file mode 100644 index 0000000..a0ed40c --- /dev/null +++ b/app/Services/Tool/ToolExecutor.php @@ -0,0 +1,86 @@ +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 + ); + } +} diff --git a/app/Services/Tool/ToolRegistry.php b/app/Services/Tool/ToolRegistry.php new file mode 100644 index 0000000..0e64f59 --- /dev/null +++ b/app/Services/Tool/ToolRegistry.php @@ -0,0 +1,64 @@ + + */ + private array $tools; + + /** + * @param array|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 + */ + public function all(): array + { + return array_values($this->tools); + } + + /** + * 返回 OpenAI-compatible tools 描述。 + * + * @return array> + */ + 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()); + } +} diff --git a/app/Services/Tool/ToolResult.php b/app/Services/Tool/ToolResult.php new file mode 100644 index 0000000..609ecf6 --- /dev/null +++ b/app/Services/Tool/ToolResult.php @@ -0,0 +1,40 @@ + + */ + 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, + ]; + } +} diff --git a/app/Services/Tool/ToolRunDispatcher.php b/app/Services/Tool/ToolRunDispatcher.php new file mode 100644 index 0000000..78b642f --- /dev/null +++ b/app/Services/Tool/ToolRunDispatcher.php @@ -0,0 +1,39 @@ +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; + } +} diff --git a/app/Services/Tool/Tools/GetTimeTool.php b/app/Services/Tool/Tools/GetTimeTool.php new file mode 100644 index 0000000..6a73c08 --- /dev/null +++ b/app/Services/Tool/Tools/GetTimeTool.php @@ -0,0 +1,51 @@ + + */ + public function parameters(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'format' => [ + 'type' => 'string', + 'description' => '可选的日期格式,默认为 RFC3339。', + ], + ], + 'required' => [], + ]; + } + + /** + * @param array $arguments + * @return array + */ + 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), + ]; + } +} diff --git a/config/agent.php b/config/agent.php index b6ce106..7a94e5f 100644 --- a/config/agent.php +++ b/config/agent.php @@ -23,4 +23,17 @@ return [ 'backoff_seconds' => env('AGENT_RUN_JOB_BACKOFF', 3), 'timeout_seconds' => env('AGENT_RUN_JOB_TIMEOUT', 360), ], + 'tools' => [ + 'max_calls_per_run' => env('AGENT_TOOL_MAX_CALLS_PER_RUN', 1), + 'wait_timeout_ms' => env('AGENT_TOOL_WAIT_TIMEOUT_MS', 15000), + 'wait_poll_interval_ms' => env('AGENT_TOOL_WAIT_POLL_MS', 200), + 'timeout_seconds' => env('AGENT_TOOL_TIMEOUT_SECONDS', 15), + 'result_max_bytes' => env('AGENT_TOOL_RESULT_MAX_BYTES', 4096), + 'tool_choice' => env('AGENT_TOOL_CHOICE', 'auto'), + 'job' => [ + 'tries' => env('AGENT_TOOL_JOB_TRIES', 1), + 'backoff_seconds' => env('AGENT_TOOL_JOB_BACKOFF', 3), + 'timeout_seconds' => env('AGENT_TOOL_JOB_TIMEOUT', 120), + ], + ], ]; diff --git a/config/queue.php b/config/queue.php index 086af4d..79c2c0a 100644 --- a/config/queue.php +++ b/config/queue.php @@ -32,7 +32,7 @@ return [ 'connections' => [ 'sync' => [ - 'driver' => 'redis', + 'driver' => 'sync', ], 'database' => [ diff --git a/docs/ChatSession/chat-session-api.md b/docs/ChatSession/chat-session-api.md index 2fa5426..01f524d 100644 --- a/docs/ChatSession/chat-session-api.md +++ b/docs/ChatSession/chat-session-api.md @@ -10,14 +10,15 @@ - 2025-02-15:Agent Run MVP-0 —— RunDispatcher + AgentRunJob + DummyProvider;自动在 user.prompt 后触发一次 Run,落地 run.status / agent.message。 - 2025-12-18:Agent Run 可靠性增强 —— 并发幂等、终态去重、取消语义加强、Provider 超时/重试/错误归一,SSE gap 回补与心跳。 - 2025-12-19:AgentProvider Streaming 接入 —— ProviderEvent 统一事件流,新增 message.delta 输出与 OpenAI-compatible 适配器。 +- 2025-12-21:Tool 子 Run 模式 —— Provider 支持 tool.delta→tool.call,父 Run 调度子 Run 执行工具并写入 tool.result。 -## 本次变更摘要(2025-12-19) +## 本次变更摘要(2025-12-21) - RunDispatcher 并发幂等:同 trigger_message_id 只产生一个 RUNNING,且仅新建时 dispatch。 -- RunLoop/OutputSink 幂等:agent.message 与 run.status 采用 dedupe_key;重复执行不重复写。 -- Cancel 强化:多检查点取消,确保不落 agent.message 且落 CANCELED 终态。 +- RunLoop/OutputSink 幂等:agent.message、run.status、tool.call、tool.result 均采用 dedupe_key。 +- Cancel 强化:多检查点取消,确保不落 agent.message 且落 CANCELED 终态;父 Run 取消会终止等待的子 Run。 - Provider 可靠性:超时/重试/429/5xx,错误落库包含 retryable/http_status/provider/latency_ms。 -- SSE 可靠性:gap 触发回补,心跳保活,publish 异常不影响主流程。 -- Streaming:AgentProvider 以事件流产出 message.delta,RunLoop 汇总后写入 agent.message。 +- Streaming:AgentProvider 产出 message.delta / tool.delta / done;finish_reason=tool_calls 会触发子 Run 执行工具。 +- 工具闭环:tool.call(role=AGENT)落库→子 Run 调度→tool.result(role=TOOL)回灌→进入下一轮 LLM。 ## 领域模型 - `ChatSession`:`session_id`(UUID)、`session_name`、`status`(`OPEN`/`LOCKED`/`CLOSED`)、`last_seq` @@ -25,6 +26,7 @@ - 幂等:`UNIQUE (session_id, dedupe_key)`;同一 dedupe_key 返回已有消息。 - 状态门禁:`CLOSED` 禁止追加,例外 `role=SYSTEM && type in [run.status, error]`;`LOCKED` 禁止 `role=USER && type=user.prompt`。 - 会话缓存:`chat_sessions.last_message_id` 记录最后一条消息;`appendMessage` 事务内同步更新 `last_seq`、`last_message_id`、`updated_at`。 +- 工具消息:`tool.call`(role=AGENT,携带 tool_call_id/name/arguments)、`tool.result`(role=TOOL,携带 parent_run_id/run_id/status/result)。 ## 接口 ### 创建会话 diff --git a/docs/ChatSession/chat-session-openapi.yaml b/docs/ChatSession/chat-session-openapi.yaml index d49e044..60ee2cd 100644 --- a/docs/ChatSession/chat-session-openapi.yaml +++ b/docs/ChatSession/chat-session-openapi.yaml @@ -436,6 +436,8 @@ components: - $ref: '#/components/schemas/MessageDeltaPayload' - $ref: '#/components/schemas/RunCancelPayload' - $ref: '#/components/schemas/RunErrorPayload' + - $ref: '#/components/schemas/ToolCallPayload' + - $ref: '#/components/schemas/ToolResultPayload' - type: object reply_to: type: string @@ -544,6 +546,38 @@ components: raw_message: type: string nullable: true + ToolCallPayload: + type: object + properties: + run_id: + type: string + tool_run_id: + type: string + tool_call_id: + type: string + name: + type: string + arguments: + type: object + additionalProperties: true + ToolResultPayload: + type: object + properties: + run_id: + type: string + parent_run_id: + type: string + tool_call_id: + type: string + name: + type: string + status: + type: string + error: + type: string + nullable: true + truncated: + type: boolean PaginationLinks: type: object properties: diff --git a/docs/tools-subrun.md b/docs/tools-subrun.md new file mode 100644 index 0000000..44a02a6 --- /dev/null +++ b/docs/tools-subrun.md @@ -0,0 +1,25 @@ +# 工具调用(子 Run 模式)最小闭环 + +本次改动新增 Tool 子系统,保持 RunLoop/Provider 的事件驱动模型不变,通过“子 Run”执行工具并把结果回灌到父 Run。 + +## 关键链路 +- Provider 产生 `tool.call`(Streaming 中的 `tool.delta` 聚合),RunLoop 落库 `tool.call` 并生成子 Run `run:{parent}:{tool_call_id}`。 +- `ToolRunJob` 执行具体工具(当前内置 `get_time`),写入 `tool.result` 与子 Run 的 `run.status`。 +- 父 Run 轮询等待子 Run 结果(超时/失败即终止),将 `tool.result` 追加到上下文后再次调用 Provider,直至产出最终 `agent.message`。 +- 幂等:`tool.call`、子 Run `run.status` 与 `tool.result` 均带 dedupe_key;同一个 tool_call_id 只会执行一次。 + +## 消息/事件 +- 新增消息类型:`tool.call`(role=AGENT,payload 含 tool_call_id/name/arguments)、`tool.result`(role=TOOL,payload 含 parent_run_id/run_id/status/result)。 +- Provider 事件新增 `tool.delta`,RunLoop 内部聚合后才触发子 Run;`finish_reason=tool_calls` 会结束本轮流并进入工具执行。 + +## 配置要点 +- `AGENT_TOOL_MAX_CALLS_PER_RUN`:单个父 Run 允许的工具调用次数(默认 1,超过直接失败)。 +- `AGENT_TOOL_WAIT_TIMEOUT_MS` / `AGENT_TOOL_WAIT_POLL_MS`:父 Run 等待子 Run 结果的超时与轮询间隔。 +- `AGENT_TOOL_TIMEOUT_SECONDS` / `AGENT_TOOL_RESULT_MAX_BYTES`:工具执行超时标记与结果截断保护。 +- `AGENT_TOOL_CHOICE`:传递给 OpenAI 的 tool_choice(默认 auto)。 +- ToolRunJob 队列参数:`AGENT_TOOL_JOB_TRIES` / `AGENT_TOOL_JOB_BACKOFF` / `AGENT_TOOL_JOB_TIMEOUT`。 + +## 预留/限制 +- 目前仅支持单工具调用闭环;多次调用的上限可调但仍是串行流程。 +- 工具列表可通过 `ToolRegistry` 扩展(当前内置 `get_time` 纯函数)。 +- 结果超时为父 Run 级别的软超时,PHP 层未强制中断长耗时函数(后续可接入外部超时控制)。 diff --git a/tests/Feature/AgentRunTest.php b/tests/Feature/AgentRunTest.php index 4b46b79..b87a9c5 100644 --- a/tests/Feature/AgentRunTest.php +++ b/tests/Feature/AgentRunTest.php @@ -257,4 +257,65 @@ class AgentRunTest extends TestCase && ($m->payload['http_status'] ?? null) === 500; })); } + + public function test_tool_call_triggers_child_run_and_continues_to_final_message(): void + { + $this->app->bind(AgentProviderInterface::class, function () { + return new class implements AgentProviderInterface { + public int $calls = 0; + + public function stream(AgentContext $context, array $options = []): \Generator + { + if ($this->calls === 0) { + $this->calls++; + yield ProviderEvent::toolDelta([ + 'tool_calls' => [ + [ + 'id' => 'call_1', + 'name' => 'get_time', + 'arguments' => '{"format":"c"}', + 'index' => 0, + ], + ], + ]); + yield ProviderEvent::done('tool_calls'); + + return; + } + + yield ProviderEvent::messageDelta('tool done'); + yield ProviderEvent::done('stop'); + } + }; + }); + + $service = app(ChatService::class); + $dispatcher = app(RunDispatcher::class); + + $session = $service->createSession('Tool Run'); + $prompt = $service->appendMessage([ + 'session_id' => $session->session_id, + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'use tool', + ]); + + $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id); + + (new AgentRunJob($session->session_id, $runId))->handle( + app(RunLoop::class), + app(OutputSink::class), + app(CancelChecker::class) + ); + + $messages = Message::query() + ->where('session_id', $session->session_id) + ->orderBy('seq') + ->get(); + + $this->assertTrue($messages->contains(fn ($m) => $m->type === 'tool.call' && ($m->payload['tool_call_id'] ?? null) === 'call_1')); + $this->assertTrue($messages->contains(fn ($m) => $m->type === 'tool.result' && ($m->payload['tool_call_id'] ?? null) === 'call_1')); + $this->assertTrue($messages->contains(fn ($m) => $m->type === 'agent.message' && ($m->payload['run_id'] ?? null) === $runId)); + $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'DONE')); + } } diff --git a/tests/Unit/OpenAiAdapterTest.php b/tests/Unit/OpenAiAdapterTest.php index cbc0888..7bfddc4 100644 --- a/tests/Unit/OpenAiAdapterTest.php +++ b/tests/Unit/OpenAiAdapterTest.php @@ -8,6 +8,7 @@ use App\Services\Agent\OpenAi\ChatCompletionsRequestBuilder; use App\Services\Agent\OpenAi\OpenAiEventNormalizer; use App\Services\Agent\OpenAi\OpenAiStreamParser; use App\Services\Agent\ProviderEventType; +use App\Services\Tool\ToolRegistry; use GuzzleHttp\Psr7\Utils; use Tests\TestCase; @@ -19,6 +20,7 @@ class OpenAiAdapterTest extends TestCase config()->set('agent.openai.temperature', 0.2); config()->set('agent.openai.top_p', 0.9); config()->set('agent.openai.include_usage', true); + config()->set('agent.tools.tool_choice', 'auto'); $context = new AgentContext('run-1', 'session-1', 'system prompt', [ [ @@ -26,6 +28,7 @@ class OpenAiAdapterTest extends TestCase 'role' => Message::ROLE_USER, 'type' => 'user.prompt', 'content' => 'hello', + 'payload' => [], 'seq' => 1, ], [ @@ -33,17 +36,20 @@ class OpenAiAdapterTest extends TestCase 'role' => Message::ROLE_AGENT, 'type' => 'agent.message', 'content' => 'hi', + 'payload' => [], 'seq' => 2, ], ]); - $payload = (new ChatCompletionsRequestBuilder())->build($context); + $payload = (new ChatCompletionsRequestBuilder(new ToolRegistry()))->build($context); $this->assertSame('test-model', $payload['model']); $this->assertTrue($payload['stream']); $this->assertSame(0.2, $payload['temperature']); $this->assertSame(0.9, $payload['top_p']); $this->assertSame(['include_usage' => true], $payload['stream_options']); + $this->assertNotEmpty($payload['tools']); + $this->assertSame('auto', $payload['tool_choice']); $this->assertSame([ ['role' => 'system', 'content' => 'system prompt'], ['role' => 'user', 'content' => 'hello'], @@ -51,6 +57,55 @@ class OpenAiAdapterTest extends TestCase ], $payload['messages']); } + public function test_request_builder_maps_tool_messages(): void + { + $context = new AgentContext('run-1', 'session-1', 'system prompt', [ + [ + 'message_id' => 'm1', + 'role' => Message::ROLE_USER, + 'type' => 'user.prompt', + 'content' => 'call tool', + 'payload' => [], + 'seq' => 1, + ], + [ + 'message_id' => 'm2', + 'role' => Message::ROLE_AGENT, + 'type' => 'tool.call', + 'content' => null, + 'payload' => [ + 'tool_call_id' => 'call_1', + 'name' => 'get_time', + 'arguments' => ['format' => 'c'], + ], + 'seq' => 2, + ], + [ + 'message_id' => 'm3', + 'role' => Message::ROLE_TOOL, + 'type' => 'tool.result', + 'content' => '2024-01-01', + 'payload' => [ + 'tool_call_id' => 'call_1', + 'name' => 'get_time', + 'output' => '2024-01-01', + ], + 'seq' => 3, + ], + ]); + + $payload = (new ChatCompletionsRequestBuilder(new ToolRegistry()))->build($context); + + $assistantMessage = collect($payload['messages'])->first(fn ($message) => isset($message['tool_calls'])); + $toolResultMessage = collect($payload['messages'])->first(fn ($message) => ($message['role'] ?? '') === 'tool'); + + $this->assertNotNull($assistantMessage); + $this->assertSame('assistant', $assistantMessage['role']); + $this->assertSame('get_time', $assistantMessage['tool_calls'][0]['function']['name']); + $this->assertNotNull($toolResultMessage); + $this->assertSame('call_1', $toolResultMessage['tool_call_id']); + } + public function test_event_normalizer_maps_delta_and_done(): void { $normalizer = new OpenAiEventNormalizer(); @@ -103,6 +158,37 @@ class OpenAiAdapterTest extends TestCase $this->assertSame(ProviderEventType::Done, $events[0]->type); } + public function test_event_normalizer_handles_tool_delta(): void + { + $normalizer = new OpenAiEventNormalizer(); + + $toolDelta = json_encode([ + 'choices' => [ + [ + 'delta' => [ + 'tool_calls' => [ + [ + 'id' => 'call_1', + 'index' => 0, + 'function' => [ + 'name' => 'get_time', + 'arguments' => '{"format":"Y-m-d"}', + ], + ], + ], + ], + 'finish_reason' => null, + ], + ], + ]); + + $events = $normalizer->normalize($toolDelta); + $this->assertCount(1, $events); + $this->assertSame(ProviderEventType::ToolDelta, $events[0]->type); + $this->assertSame('call_1', $events[0]->payload['tool_calls'][0]['id']); + $this->assertSame('get_time', $events[0]->payload['tool_calls'][0]['name']); + } + public function test_stream_parser_splits_sse_events(): void { $stream = Utils::streamFor("data: {\"id\":1}\n\ndata: [DONE]\n\n");