From fa9c7fef406c0649000697520f6842d5d947293b Mon Sep 17 00:00:00 2001 From: Olivier Dobberkau Date: Wed, 6 May 2026 16:50:12 +0200 Subject: [PATCH 1/3] [BUGFIX] Persist request log entries for streaming and tool-calling requests Both Ai::conversationStream() and AgentDispatcher-style direct processToolCallingRequest() callers bypass the synchronous middleware pipeline, so RequestLoggingMiddleware never sees their requests and tx_aim_request_log stays empty. This adds inline logging in SymfonyAiPlatformAdapter: * processConversationRequest(stream=true): wires StreamChunkIterator's onComplete callback to log after the stream finishes. * processToolCallingRequest: logs after each invocation, both on success and on error (so each agent loop round produces one row). Logging routes through RequestLogRepository directly. Failures are swallowed to never break the response path. Closes #7 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SymfonyAi/SymfonyAiPlatformAdapter.php | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php index 6a83fbd..09ffa7d 100644 --- a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php +++ b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php @@ -14,6 +14,7 @@ use B13\Aim\Capability\ConversationCapableInterface; use B13\Aim\Capability\EmbeddingCapableInterface; +use B13\Aim\Domain\Repository\RequestLogRepository; use B13\Aim\Capability\TextGenerationCapableInterface; use B13\Aim\Capability\ToolCallingCapableInterface; use B13\Aim\Capability\TranslationCapableInterface; @@ -39,6 +40,8 @@ use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\PlatformInterface; use Symfony\AI\Platform\TokenUsage\TokenUsageInterface; +use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Utility\GeneralUtility; /** * Bridges any Symfony AI Platform bridge to AiM's provider system. @@ -155,6 +158,7 @@ public function processConversationRequest(ConversationRequest $request): Conver } $options = $this->buildOptions($request->configuration->model, $request->maxTokens, $request->temperature, $extra); + $start = hrtime(true); try { $result = $platform->invoke($request->configuration->model, $messages, $options); @@ -162,6 +166,9 @@ public function processConversationRequest(ConversationRequest $request): Conver $streamIterator = new StreamChunkIterator( $result->asStream(), $request->configuration, + onComplete: function (AiUsageStatistics $usage, string $fullContent) use ($request, $start): void { + $this->logStreamingRequest($request, $usage, $fullContent, $start); + }, ); return new ConversationResponse('', streamIterator: $streamIterator); } @@ -189,6 +196,7 @@ public function processToolCallingRequest(ToolCallingRequest $request): ToolCall 'tools' => $tools, ]); + $start = hrtime(true); try { $result = $platform->invoke($request->configuration->model, $messages, $options); $usage = $this->extractUsage($result, $request->configuration); @@ -196,8 +204,10 @@ public function processToolCallingRequest(ToolCallingRequest $request): ToolCall $content = $this->resolveTextContent($result); $toolCalls = $this->extractToolCallsFromRawResponse($rawResponse); + $this->logToolCallingRequest($request, $usage, $content, $toolCalls, $start, null); return new ToolCallingResponse($content, $toolCalls, $usage, $rawResponse); } catch (\Throwable $e) { + $this->logToolCallingRequest($request, new AiUsageStatistics(), '', [], $start, $e); return new ToolCallingResponse('', [], errors: ['Symfony AI error: ' . $e->getMessage()]); } } @@ -454,4 +464,119 @@ private function isReasoningModel(string $model): bool } return false; } + + /** + * Persist a request log entry after a streaming conversation completes. + * + * Streaming bypasses the synchronous middleware pipeline (see Ai::conversationStream), + * so RequestLoggingMiddleware never sees the response. This callback fills the gap. + * Tracks: https://github.com/b13/aim/issues/7 + */ + private function logStreamingRequest( + ConversationRequest $request, + AiUsageStatistics $usage, + string $fullContent, + float $start, + ): void { + $userMessages = []; + foreach ($request->messages as $msg) { + if (is_object($msg) && property_exists($msg, 'role') && $msg->role === 'user' + && property_exists($msg, 'content') && is_string($msg->content) && $msg->content !== '') { + $userMessages[] = $msg->content; + } + } + + $this->writeRequestLog($request->configuration, [ + 'request_type' => 'ConversationRequest', + 'usage' => $usage, + 'metadata' => is_array($request->metadata ?? null) ? $request->metadata : [], + 'duration_ms' => (int)((hrtime(true) - $start) / 1_000_000), + 'success' => 1, + 'error_message' => '', + 'request_prompt' => implode("\n", $userMessages), + 'request_system_prompt' => $request->systemPrompt, + 'response_content' => $fullContent, + ]); + } + + /** + * Persist a request log entry for a tool-calling request. + * + * Callers like Dkd\LlmChat\Agent\AgentDispatcher invoke processToolCallingRequest() + * directly via getCapability() and bypass the middleware pipeline, so logging + * has to happen here. Tracks: https://github.com/b13/aim/issues/7 + * + * @param list $toolCalls + */ + private function logToolCallingRequest( + ToolCallingRequest $request, + AiUsageStatistics $usage, + string $content, + array $toolCalls, + float $start, + ?\Throwable $error, + ): void { + $userMessages = []; + foreach ($request->messages as $msg) { + if (is_object($msg) && property_exists($msg, 'role') && $msg->role === 'user' + && property_exists($msg, 'content') && is_string($msg->content) && $msg->content !== '') { + $userMessages[] = $msg->content; + } + } + + $metadata = is_array($request->metadata ?? null) ? $request->metadata : []; + if ($toolCalls !== []) { + $metadata['tool_calls'] = array_map(static fn(ToolCall $tc): array => [ + 'name' => $tc->name, + 'arguments' => $tc->arguments, + ], $toolCalls); + } + + $this->writeRequestLog($request->configuration, [ + 'request_type' => 'ToolCallingRequest', + 'usage' => $usage, + 'metadata' => $metadata, + 'duration_ms' => (int)((hrtime(true) - $start) / 1_000_000), + 'success' => $error === null ? 1 : 0, + 'error_message' => $error?->getMessage() ?? '', + 'request_prompt' => implode("\n", $userMessages), + 'request_system_prompt' => $request->systemPrompt, + 'response_content' => $content, + ]); + } + + /** + * @param array{request_type:string, usage:AiUsageStatistics, metadata:array, duration_ms:int, success:int, error_message:string, request_prompt:string, request_system_prompt:string, response_content:string} $payload + */ + private function writeRequestLog(ProviderConfiguration $configuration, array $payload): void + { + $usage = $payload['usage']; + try { + GeneralUtility::makeInstance(RequestLogRepository::class, GeneralUtility::makeInstance(ConnectionPool::class))->log([ + 'request_type' => $payload['request_type'], + 'provider_identifier' => $configuration->providerIdentifier, + 'configuration_uid' => $configuration->uid, + 'model_requested' => $configuration->model, + 'model_used' => $usage->modelUsed !== '' ? $usage->modelUsed : $configuration->model, + 'extension_key' => (string)($payload['metadata']['extension_key'] ?? $payload['metadata']['extension'] ?? ''), + 'duration_ms' => $payload['duration_ms'], + 'success' => $payload['success'], + 'prompt_tokens' => $usage->promptTokens, + 'completion_tokens' => $usage->completionTokens, + 'cached_tokens' => $usage->cachedTokens, + 'reasoning_tokens' => $usage->reasoningTokens, + 'total_tokens' => $usage->getTotalTokens(), + 'cost' => $usage->cost, + 'system_fingerprint' => $usage->systemFingerprint, + 'raw_usage' => $usage->rawUsage !== [] ? json_encode($usage->rawUsage, JSON_THROW_ON_ERROR) : '', + 'metadata' => json_encode($payload['metadata'], JSON_THROW_ON_ERROR), + 'error_message' => $payload['error_message'], + 'request_prompt' => $payload['request_prompt'], + 'request_system_prompt' => $payload['request_system_prompt'], + 'response_content' => $payload['response_content'], + ]); + } catch (\Throwable) { + // Logging failures must never break the response path. + } + } } From 7229941ef48ef380a1078db190a91ce379caa88d Mon Sep 17 00:00:00 2001 From: Olivier Dobberkau Date: Wed, 6 May 2026 17:43:34 +0200 Subject: [PATCH 2/3] [BUGFIX] Pick tool definition format based on providerIdentifier processToolCallingRequest currently sends OpenAI's $tool->toArray() format to every provider. This is wrong for Anthropic, which expects {name, description, input_schema} instead of OpenAI's {type: function, function: {...}}. Switch on $request->configuration->providerIdentifier and emit the correct shape for each provider family. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SymfonyAi/SymfonyAiPlatformAdapter.php | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php index 09ffa7d..5429061 100644 --- a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php +++ b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php @@ -190,7 +190,24 @@ public function processToolCallingRequest(ToolCallingRequest $request): ToolCall $platform = $this->getPlatform($request->configuration); $messages = $this->buildMessageBag($request->messages, $request->systemPrompt); - $tools = array_map(static fn($tool) => $tool->toArray(), $request->tools); + // Convert ToolDefinitions to the format expected by the target provider. + // Anthropic uses {name, description, input_schema}. + // OpenAI / Mistral / Gemini / Ollama use the OpenAI function-calling + // schema {type: function, function: {name, description, parameters}}. + $tools = $request->configuration->providerIdentifier === 'anthropic' + ? array_map(static fn($tool) => [ + 'name' => $tool->name, + 'description' => $tool->description, + 'input_schema' => $tool->parameters ?: ['type' => 'object'], + ], $request->tools) + : array_map(static fn($tool) => [ + 'type' => 'function', + 'function' => [ + 'name' => $tool->name, + 'description' => $tool->description, + 'parameters' => $tool->parameters ?: ['type' => 'object'], + ], + ], $request->tools); $options = $this->buildOptions($request->configuration->model, $request->maxTokens, $request->temperature, [ 'tools' => $tools, From 6e5dbd54546c51288f78eeae1618530f40b111d1 Mon Sep 17 00:00:00 2001 From: Olivier Dobberkau Date: Wed, 6 May 2026 17:43:57 +0200 Subject: [PATCH 3/3] [BUGFIX] Forward assistant tool_calls and tool messages into MessageBag buildMessageBag dropped two pieces of information when converting AiM messages into Symfony AI MessageBag: * AssistantMessage->toolCalls were thrown away. With OpenAI-style providers (Mistral, OpenAI), this turned the next round's history into an assistant message with neither content nor tool_calls, which Mistral rejects with HTTP 400 "Assistant message must have either content or tool_calls". * ToolMessage (role=tool) silently fell through to Message::ofUser, so tool results were sent back as user messages without a tool_call_id and providers couldn't match them to the original call. Detect both message subclasses up front and emit Message::ofAssistant with tool calls / Message::ofToolCall with tool_call_id respectively. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SymfonyAi/SymfonyAiPlatformAdapter.php | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php index 5429061..824a0a2 100644 --- a/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php +++ b/Classes/Provider/SymfonyAi/SymfonyAiPlatformAdapter.php @@ -24,6 +24,8 @@ use B13\Aim\Request\ConversationRequest; use B13\Aim\Request\EmbeddingRequest; use B13\Aim\Request\Message\AbstractMessage; +use B13\Aim\Request\Message\AssistantMessage as AimAssistantMessage; +use B13\Aim\Request\Message\ToolMessage; use B13\Aim\Request\TextGenerationRequest; use B13\Aim\Request\ToolCallingRequest; use B13\Aim\Request\TranslationRequest; @@ -39,6 +41,7 @@ use Symfony\AI\Platform\Message\Message; use Symfony\AI\Platform\Message\MessageBag; use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Result\ToolCall as SymfonyToolCall; use Symfony\AI\Platform\TokenUsage\TokenUsageInterface; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -441,6 +444,32 @@ private function buildMessageBag(array $aiMessages, string $systemPrompt): Messa } foreach ($aiMessages as $msg) { $content = is_string($msg->content) ? $msg->content : ''; + // Assistant messages with tool calls must carry the calls into the + // Symfony AI message so the wire format includes them. Otherwise + // OpenAI-style providers (Mistral, OpenAI) reject the next round + // with "Assistant message must have either content or tool_calls". + if ($msg instanceof AimAssistantMessage && $msg->toolCalls !== []) { + $symfonyToolCalls = array_map( + static fn(ToolCall $tc): SymfonyToolCall => new SymfonyToolCall( + $tc->id, + $tc->name, + $tc->getDecodedArguments(), + ), + $msg->toolCalls, + ); + $messages[] = Message::ofAssistant($content !== '' ? $content : null, $symfonyToolCalls); + continue; + } + // Tool result messages need the dedicated ToolCallMessage so the + // wire format uses role=tool with tool_call_id (OpenAI/Mistral) + // or maps to Anthropic's tool_result content blocks. + if ($msg instanceof ToolMessage) { + $messages[] = Message::ofToolCall( + new SymfonyToolCall($msg->toolCallId, '', []), + $content, + ); + continue; + } $messages[] = match ($msg->role) { 'system' => Message::forSystem($content), 'assistant' => Message::ofAssistant($content),