main: 增强工具调用与消息流程
- 支持 tool.call 和 tool.result 消息类型处理 - 引入 Tool 调度与执行逻辑,支持超时与结果截断 - 增加 ToolRegistry 和 ToolExecutor 管理工具定义与执行 - 更新上下文构建与消息映射逻辑,适配工具闭环处理 - 扩展配置与环境变量,支持 Tool 调用相关选项 - 增强单元测试覆盖工具调用与执行情景 - 更新文档和 OpenAPI,新增工具相关说明与模型定义
This commit is contained in:
11
.env.example
11
.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 超时时间(秒)
|
||||
|
||||
@@ -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)。
|
||||
|
||||
70
app/Jobs/ToolRunJob.php
Normal file
70
app/Jobs/ToolRunJob.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\CancelChecker;
|
||||
use App\Services\OutputSink;
|
||||
use App\Services\Tool\ToolCall;
|
||||
use App\Services\Tool\ToolExecutor;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ToolRunJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries;
|
||||
|
||||
public int $timeout;
|
||||
|
||||
public int $backoff;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算耗时(毫秒)。
|
||||
*/
|
||||
|
||||
23
app/Services/Tool/Tool.php
Normal file
23
app/Services/Tool/Tool.php
Normal 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;
|
||||
}
|
||||
52
app/Services/Tool/ToolCall.php
Normal file
52
app/Services/Tool/ToolCall.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
86
app/Services/Tool/ToolExecutor.php
Normal file
86
app/Services/Tool/ToolExecutor.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/Services/Tool/ToolRegistry.php
Normal file
64
app/Services/Tool/ToolRegistry.php
Normal 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());
|
||||
}
|
||||
}
|
||||
40
app/Services/Tool/ToolResult.php
Normal file
40
app/Services/Tool/ToolResult.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Services/Tool/ToolRunDispatcher.php
Normal file
39
app/Services/Tool/ToolRunDispatcher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
51
app/Services/Tool/Tools/GetTimeTool.php
Normal file
51
app/Services/Tool/Tools/GetTimeTool.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'redis',
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 接口
|
||||
### 创建会话
|
||||
|
||||
@@ -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:
|
||||
|
||||
25
docs/tools-subrun.md
Normal file
25
docs/tools-subrun.md
Normal file
@@ -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 层未强制中断长耗时函数(后续可接入外部超时控制)。
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user