llmClient = $llmClient; $this->mcpClient = $mcpClient; $this->toolRegistry = $toolRegistry; $this->toolProviderRegistry = $toolProviderRegistry; $this->toolExecutionPolicyService = $toolExecutionPolicyService ?? new ToolExecutionPolicyService(); $this->toolArgumentNormalizer = $toolArgumentNormalizer ?? new ToolArgumentNormalizer(); $this->toolResultFallbackService = $toolResultFallbackService ?? new ToolResultFallbackService(); $this->traceService = $traceService; $this->logger = $logger; } /** * @param string $systemPrompt * @param array> $messages * @param array $toolLoadout MCP tools assigned to the bot * @param array $llmOptions Supported keys: model, temperature, max_tokens, * tool_choice, initial_tool_choice, force_tool_call (bool - require at least one tool call before answering), * user_query (string - sanitized user request for explicit harness-created forced tool calls), * bot_id (int - the bot ID to get assigned built-in tools), * built_in_tools (array - explicit built-in tool assignments), * rag_enabled (bool - whether to include RAG search tool for this bot), * trace_run_id (int - app-owned trace run for best-effort activity capture) * @return array{content:string,messages:array>,toolInvocations:array>} */ public function run(string $systemPrompt, array $messages, array $toolLoadout, array $llmOptions = []): array { // Get bot ID for built-in tool filtering $botId = isset($llmOptions['bot_id']) && is_int($llmOptions['bot_id']) ? $llmOptions['bot_id'] : null; unset($llmOptions['bot_id']); $traceRunId = isset($llmOptions['trace_run_id']) && is_numeric($llmOptions['trace_run_id']) ? (int)$llmOptions['trace_run_id'] : null; unset($llmOptions['trace_run_id']); $explicitBuiltInTools = []; if (isset($llmOptions['built_in_tools']) && is_array($llmOptions['built_in_tools'])) { $explicitBuiltInTools = $llmOptions['built_in_tools']; } unset($llmOptions['built_in_tools']); // Check if RAG tool should be included $ragEnabled = isset($llmOptions['rag_enabled']) && $llmOptions['rag_enabled'] === true; unset($llmOptions['rag_enabled']); $toolDefs = $this->buildToolDefinitions($toolLoadout, $botId, $ragEnabled, $explicitBuiltInTools); $toolMap = $toolDefs['map']; $toolsForLlm = $toolDefs['definitions']; $builtInTools = $toolDefs['builtIn']; $this->logger->info('EducAI: Agent starting', [ 'tools_requested' => count($toolLoadout), 'tools_loaded' => count($toolsForLlm), 'built_in_tools' => count($builtInTools), 'tool_names' => array_keys($toolMap), 'trace_run_id' => $traceRunId, ]); if (count($toolLoadout) > 0 && count($toolsForLlm) === 0 && count($builtInTools) === 0) { $this->logger->warning('EducAI: No tools could be loaded from MCP endpoints and no built-in tools available'); } $userQuery = isset($llmOptions['user_query']) && is_string($llmOptions['user_query']) ? trim($llmOptions['user_query']) : null; unset($llmOptions['user_query']); $forceToolCall = !empty($llmOptions['force_tool_call']) && count($toolsForLlm) > 0; unset($llmOptions['force_tool_call']); $toolChoicePreference = $llmOptions['tool_choice'] ?? null; $initialToolChoice = $llmOptions['initial_tool_choice'] ?? null; unset($llmOptions['initial_tool_choice']); $onPartialResult = isset($llmOptions['on_partial_result']) && is_callable($llmOptions['on_partial_result']) ? $llmOptions['on_partial_result'] : null; unset($llmOptions['on_partial_result']); $toolCallSatisfied = false; $resolvedTemperature = $this->resolveTemperatureOption($llmOptions['temperature'] ?? SettingsService::DEFAULT_TEMPERATURE); if (count($toolsForLlm) > 0) { $systemPrompt .= "\n\n## Tool Calling Instructions\n"; $systemPrompt .= "You have access to tools. Follow these rules strictly:\n\n"; $systemPrompt .= "1. **Use structured tool calls**: When you need to use a tool, emit a proper tool call - NEVER write JSON directly in your response.\n"; $systemPrompt .= "2. **Workflow**: Think → Call tool(s) → Read response → Answer user OR call more tools.\n"; $systemPrompt .= "3. **Multiple calls allowed**: You can call tools multiple times until you have all the information you need.\n"; $systemPrompt .= "4. **Don't invent data**: If information should come from a tool, call the tool. Don't make up facts.\n"; $systemPrompt .= "5. **Synthesize naturally**: After receiving tool results, write a natural response for the user based on the information. Never mention 'tool calls' or show raw JSON to the user.\n"; $systemPrompt .= "6. **Empty results**: If a tool returns no results, explain this to the user rather than guessing.\n"; } $trace = []; $iterations = 0; $requestedMaxIterations = $llmOptions['max_iterations'] ?? self::MAX_TOOL_ITERATIONS; unset($llmOptions['max_iterations']); $maxIterations = is_numeric($requestedMaxIterations) ? max(1, min(self::MAX_TOOL_ITERATIONS, (int)$requestedMaxIterations)) : self::MAX_TOOL_ITERATIONS; $recentToolCalls = []; // Track recent tool calls to detect loops while (true) { $iterations++; $this->logger->debug('EducAI: Agent loop iteration', [ 'iteration' => $iterations, 'max_iterations' => $maxIterations, 'tool_call_satisfied' => $toolCallSatisfied, 'force_tool_call' => $forceToolCall, 'message_count' => count($messages), ]); $toolChoice = $toolChoicePreference ?? null; if (!$toolCallSatisfied && $initialToolChoice !== null) { $toolChoice = $initialToolChoice; } elseif ($forceToolCall && !$toolCallSatisfied) { $toolChoice = 'required'; } $requestOptions = [ 'tools' => $toolsForLlm, 'tool_choice' => $toolChoice, 'temperature' => $resolvedTemperature, 'max_tokens' => $llmOptions['max_tokens'] ?? 800, ]; $this->recordLlmRequestTrace( $traceRunId, 'agent_loop', $systemPrompt, $messages, $llmOptions['model'] ?? null, $requestOptions, $onPartialResult !== null, ['iteration' => $iterations] ); try { if ($onPartialResult) { $buffer = ''; $lastFlushTime = microtime(true); $streamArtifactBuffer = ''; $isInsideXmlToolCall = false; $leadingStructuredProbe = ''; $suppressStreamingForStructuredResponse = false; $hasSeenVisibleStreamContent = false; $response = $this->llmClient->streamChatCompletion( $systemPrompt, $messages, function ($delta) use ( &$buffer, &$lastFlushTime, $onPartialResult, &$streamArtifactBuffer, &$isInsideXmlToolCall, &$leadingStructuredProbe, &$suppressStreamingForStructuredResponse, &$hasSeenVisibleStreamContent ) { if (isset($delta['content'])) { $contentDelta = is_string($delta['content']) ? $delta['content'] : ''; if ($contentDelta === '') { return; } if (!$hasSeenVisibleStreamContent && !$suppressStreamingForStructuredResponse) { $leadingStructuredProbe .= $contentDelta; $trimmedProbe = ltrim($leadingStructuredProbe); if ($trimmedProbe === '') { return; } $firstChar = $trimmedProbe[0] ?? ''; if ($firstChar === '{' || $firstChar === '[') { $suppressStreamingForStructuredResponse = true; return; } $contentDelta = $leadingStructuredProbe; $leadingStructuredProbe = ''; $hasSeenVisibleStreamContent = true; } elseif ($suppressStreamingForStructuredResponse) { return; } $visibleDelta = $this->filterStreamingToolCallContent( $contentDelta, $streamArtifactBuffer, $isInsideXmlToolCall ); if ($visibleDelta === '') { return; } $buffer .= $visibleDelta; $currentTime = microtime(true); // Flush on paragraph boundaries OR after 3 seconds of content accumulation // This ensures content is sent even without double newlines $shouldFlush = str_contains($buffer, "\n\n") || ($currentTime - $lastFlushTime > 3.0 && strlen($buffer) > 100); if ($shouldFlush && str_contains($buffer, "\n\n")) { $parts = explode("\n\n", $buffer); $remainder = array_pop($parts); foreach ($parts as $part) { if (trim($part) !== '') { $onPartialResult($part); } } $buffer = $remainder; $lastFlushTime = $currentTime; } elseif ($shouldFlush) { // Time-based flush without double newline // Try to flush at sentence boundaries $flushPoint = $this->findSentenceBoundary($buffer); if ($flushPoint > 50) { $toSend = substr($buffer, 0, $flushPoint); $buffer = substr($buffer, $flushPoint); if (trim($toSend) !== '') { $onPartialResult(trim($toSend)); } $lastFlushTime = $currentTime; } } } }, $llmOptions['model'] ?? null, $requestOptions ); if (!$suppressStreamingForStructuredResponse && $leadingStructuredProbe !== '') { $buffer .= $this->filterStreamingToolCallContent( $leadingStructuredProbe, $streamArtifactBuffer, $isInsideXmlToolCall ); $leadingStructuredProbe = ''; } if (!$suppressStreamingForStructuredResponse) { $buffer .= $this->filterStreamingToolCallContent( '', $streamArtifactBuffer, $isInsideXmlToolCall, true ); } // Always flush remaining buffer after stream ends if ($buffer !== '' && trim($buffer) !== '') { $onPartialResult($buffer); } } else { $response = $this->llmClient->sendChatCompletion($systemPrompt, $messages, $llmOptions['model'] ?? null, $requestOptions); } } catch (\Throwable $e) { $this->traceService?->recordEvent($traceRunId, 'error', [ 'status' => 'error', 'payload' => [ 'stage' => 'llm_request', 'iteration' => $iterations, ], 'error_message' => $e->getMessage(), ]); throw $e; } // Log LLM response for debugging $this->logger->debug('EducAI: LLM response received', [ 'iteration' => $iterations, 'has_content' => !empty($response['content']), 'content_length' => strlen((string)($response['content'] ?? '')), 'native_tool_calls' => count($response['tool_calls'] ?? []), 'finish_reason' => $response['finish_reason'] ?? 'unknown', 'trace_run_id' => $traceRunId, ]); $this->recordLlmResponseTrace( $traceRunId, 'agent_loop', $response, (string)($response['content'] ?? ''), ['iteration' => $iterations] ); $toolCalls = $this->extractToolCallsFromResponse($response); $assistantMessage = [ 'role' => 'assistant', 'content' => $response['content'] ?? '', ]; if (count($toolCalls) > 0) { // Assistant message must carry the tool calls that led to subsequent tool messages. $assistantMessage['content'] = null; $assistantMessage['tool_calls'] = $toolCalls; } $messages[] = $assistantMessage; if (is_array($toolCalls) && count($toolCalls) > 0) { $toolCallSatisfied = true; } // Fallback: Check if content is a JSON tool call (for models that don't support native tool calling) if ((!is_array($toolCalls) || count($toolCalls) === 0) && !empty($response['content'])) { $content = trim($response['content']); // Try to extract JSON from the response (could be wrapped in markdown code blocks) $jsonContent = $this->extractJsonFromContent($content); if ($jsonContent !== null) { $normalizedCalls = $this->normalizeFallbackToolCalls($jsonContent, $toolMap); if ($normalizedCalls !== null) { $this->logger->info('EducAI: Fallback constructed tool calls from JSON', [ 'call_count' => count($normalizedCalls), 'tools' => array_map(static fn($c) => $c['name'], $normalizedCalls), ]); $toolCalls = []; foreach ($normalizedCalls as $call) { $encodedArgs = json_encode($call['arguments'], JSON_UNESCAPED_UNICODE); if ($encodedArgs === false) { $encodedArgs = '{}'; } $toolCalls[] = [ 'id' => uniqid('call_', true), 'type' => 'function', 'function' => [ 'name' => $call['name'], 'arguments' => $encodedArgs, ], ]; } // Mark tool call as satisfied since we successfully parsed JSON tool calls $toolCallSatisfied = true; // Replace the assistant message with proper tool_calls format (no content) array_pop($messages); $messages[] = [ 'role' => 'assistant', 'content' => null, 'tool_calls' => $toolCalls, ]; } } } // Fallback: Check if content contains XML tool calls (...) if ((!is_array($toolCalls) || count($toolCalls) === 0) && !empty($response['content'])) { $xmlResult = $this->extractXmlToolCalls($response['content'], $toolMap); if ($xmlResult !== null && count($xmlResult['calls']) > 0) { $this->logger->info('EducAI: Extracted tool calls from XML in content', [ 'call_count' => count($xmlResult['calls']), 'tools' => array_map(static fn($call) => $call['name'], $xmlResult['calls']), 'remaining_content_length' => strlen($xmlResult['remainingContent']), ]); $toolCalls = []; foreach ($xmlResult['calls'] as $call) { $encodedArgs = json_encode($call['arguments'], JSON_UNESCAPED_UNICODE); if ($encodedArgs === false) { $encodedArgs = '{}'; } $toolCalls[] = [ 'id' => uniqid('xml_call_', true), 'type' => 'function', 'function' => [ 'name' => $call['name'], 'arguments' => $encodedArgs, ], ]; } $toolCallSatisfied = true; array_pop($messages); $messages[] = [ 'role' => 'assistant', 'content' => $xmlResult['remainingContent'] !== '' ? $xmlResult['remainingContent'] : null, 'tool_calls' => $toolCalls, ]; } } if ((!is_array($toolCalls) || count($toolCalls) === 0) && !$toolCallSatisfied && $initialToolChoice !== null) { $forcedCall = $this->buildPreferredToolCall($initialToolChoice, $toolMap, $userQuery); if ($forcedCall !== null) { $this->logger->warning('EducAI: Forcing preferred initial tool call because model returned no calls', [ 'tool' => $forcedCall['function']['name'] ?? 'unknown', ]); $toolCalls = [$forcedCall]; $toolCallSatisfied = true; array_pop($messages); $messages[] = [ 'role' => 'assistant', 'content' => null, 'tool_calls' => $toolCalls, ]; } else { $initialToolChoice = null; } } if ((!is_array($toolCalls) || count($toolCalls) === 0) && $forceToolCall && !$toolCallSatisfied) { $forcedCall = $this->buildForcedToolCall($toolMap, $userQuery); if ($forcedCall !== null) { $this->logger->warning('EducAI: Forcing tool call because model returned no calls', [ 'tool' => $forcedCall['function']['name'] ?? 'unknown', ]); $toolCalls = [$forcedCall]; $toolCallSatisfied = true; // Prevent forcing again after this executes array_pop($messages); $messages[] = [ 'role' => 'assistant', 'content' => null, 'tool_calls' => $toolCalls, ]; } else { $this->logger->warning('EducAI: Unable to force tool call automatically (missing query or tools).'); $forceToolCall = false; } } if (!is_array($toolCalls) || count($toolCalls) === 0) { $responseContent = (string)($response['content'] ?? ''); // Check if the response looks like it contains JSON tool call output that should have been parsed // This is a safety net - if we get here with JSON content, something went wrong $trimmedContent = trim($responseContent); if ($trimmedContent !== '' && ($trimmedContent[0] === '[' || $trimmedContent[0] === '{')) { $jsonCheck = $this->extractJsonFromContent($trimmedContent); if ($jsonCheck !== null && isset($jsonCheck['name'])) { // This looks like a tool call that wasn't properly handled // Log it and return a generic message instead of the JSON $this->logger->warning('EducAI: Response contains unparsed tool call JSON, suppressing', [ 'content_length' => strlen($trimmedContent), 'trace_run_id' => $traceRunId, ]); // If we have tool results from previous iterations, try to synthesize if (count($trace) > 0) { $responseContent = $this->toolResultFallbackService->generateFromTrace($trace); } else { $responseContent = 'I encountered an issue processing your request. Please try again.'; } } } $responseContent = $this->sanitizeAssistantTextForUser($responseContent); // CRITICAL: If response is empty on first iteration and RAG tool is available, force a RAG search if (trim($responseContent) === '' && $iterations === 1 && count($trace) === 0 && isset($builtInTools['rag_search_documents'])) { $this->logger->warning('EducAI: Empty response on first iteration with RAG available, forcing RAG search', [ 'query_length' => strlen((string)$userQuery), 'trace_run_id' => $traceRunId, ]); // Force a RAG search using the user's query $ragQuery = $userQuery ?? ''; if ($ragQuery !== '') { $forcedRagCall = [ 'id' => uniqid('forced_rag_', true), 'type' => 'function', 'function' => [ 'name' => 'rag_search_documents', 'arguments' => json_encode(['query' => $ragQuery, 'limit' => 5], JSON_UNESCAPED_UNICODE) ?: '{}', ], ]; // Execute the RAG search (bot context should already be set by BotService) try { $this->traceService?->recordToolCall($traceRunId, 'rag_search_documents', ['query' => $ragQuery, 'limit' => 5], $forcedRagCall['id']); $ragStart = microtime(true); $ragResult = $this->toolProviderRegistry->executeTool('rag_search_documents', ['query' => $ragQuery, 'limit' => 5]); $ragOutput = $this->sanitizeOutput($ragResult); $ragDurationMs = (int)round((microtime(true) - $ragStart) * 1000); $this->traceService?->recordToolResult($traceRunId, 'rag_search_documents', 'ok', $ragOutput, $ragDurationMs); $trace[] = [ 'tool' => 'rag_search_documents', 'status' => 'ok', 'duration_ms' => $ragDurationMs, 'response' => $ragOutput, ]; // Add the tool call and response to messages array_pop($messages); // Remove empty assistant message $messages[] = [ 'role' => 'assistant', 'content' => null, 'tool_calls' => [$forcedRagCall], ]; $messages[] = [ 'role' => 'tool', 'tool_call_id' => $forcedRagCall['id'], 'name' => 'rag_search_documents', 'content' => $ragOutput, ]; // Continue the loop to let the model synthesize continue; } catch (\Throwable $e) { $this->logger->error('EducAI: Forced RAG search failed', [ 'exception' => $e->getMessage(), 'trace_run_id' => $traceRunId, ]); $this->traceService?->recordToolResult($traceRunId, 'rag_search_documents', 'error', null, 0, $e->getMessage()); } } } // CRITICAL: If response is empty but we have tool results, force synthesis if (trim($responseContent) === '' && count($trace) > 0) { $this->logger->warning('EducAI: Empty response after tool execution, forcing synthesis', [ 'iterations' => $iterations, 'tool_invocations' => count($trace), ]); // Force a final synthesis without tools $synthesisPrompt = $this->buildToollessSynthesisPrompt($systemPrompt); try { $synthesisOptions = [ 'temperature' => min($resolvedTemperature, self::MAX_EMERGENCY_FALLBACK_TEMPERATURE), 'max_tokens' => 1500, // No tools - force text response ]; $this->recordLlmRequestTrace( $traceRunId, 'empty_tool_synthesis', $synthesisPrompt, $messages, $llmOptions['model'] ?? null, $synthesisOptions, false, ['iteration' => $iterations, 'tool_invocations' => count($trace)] ); $finalResponse = $this->llmClient->sendChatCompletion( $synthesisPrompt, $messages, $llmOptions['model'] ?? null, $synthesisOptions ); $responseContent = $this->sanitizeAssistantTextForUser($finalResponse['content'] ?? ''); $this->recordLlmResponseTrace($traceRunId, 'empty_tool_synthesis', $finalResponse, $responseContent, [ 'iteration' => $iterations, 'tool_invocations' => count($trace), ]); if (trim($responseContent) === '') { // Still empty - use fallback $responseContent = $this->toolResultFallbackService->generateFromTrace($trace); } // Stream the final content if streaming is enabled if ($onPartialResult && !empty($responseContent)) { $onPartialResult($responseContent); } } catch (\Throwable $e) { $this->logger->error('EducAI: Failed to generate synthesis after empty response', [ 'exception' => $e->getMessage(), ]); $responseContent = $this->toolResultFallbackService->generateFromTrace($trace); } } // Final fallback: If response is still empty, try to answer from conversation context if (trim($responseContent) === '' && count($messages) > 2) { $this->logger->warning('EducAI: Empty response with conversation context, forcing direct answer', [ 'iterations' => $iterations, 'message_count' => count($messages), ]); // Force the model to answer without tools $directPrompt = $this->buildToollessSynthesisPrompt($systemPrompt) . "\n"; $directPrompt .= "Based on the conversation history, please provide a helpful answer. "; $directPrompt .= "If you don't know the answer, say so - but do NOT return an empty response.\n"; try { $directOptions = [ 'temperature' => min($resolvedTemperature, self::MAX_EMERGENCY_FALLBACK_TEMPERATURE), 'max_tokens' => 1000, // No tools - force text response ]; $this->recordLlmRequestTrace( $traceRunId, 'direct_answer_fallback', $directPrompt, $messages, $llmOptions['model'] ?? null, $directOptions, false, ['iteration' => $iterations, 'message_count' => count($messages)] ); $directResponse = $this->llmClient->sendChatCompletion( $directPrompt, $messages, $llmOptions['model'] ?? null, $directOptions ); $responseContent = $this->sanitizeAssistantTextForUser($directResponse['content'] ?? ''); $this->recordLlmResponseTrace($traceRunId, 'direct_answer_fallback', $directResponse, $responseContent, [ 'iteration' => $iterations, 'message_count' => count($messages), ]); if ($onPartialResult && !empty($responseContent)) { $onPartialResult($responseContent); } } catch (\Throwable $e) { $this->logger->error('EducAI: Failed to generate direct answer', [ 'exception' => $e->getMessage(), ]); } } return [ 'content' => $responseContent, 'messages' => $messages, 'toolInvocations' => $trace, ]; } // Notify user that tools are being executed (only if streaming is enabled) if ($onPartialResult && count($toolCalls) > 0) { $toolNames = array_map(fn($tc) => $tc['function']['name'] ?? 'unknown', $toolCalls); $toolList = implode(', ', $toolNames); $onPartialResult("🔧 _Using tool" . (count($toolCalls) > 1 ? "s" : "") . ": " . $toolList . "..._"); } $executedToolCallsForSignature = []; foreach ($toolCalls as $toolCall) { $toolName = $toolCall['function']['name'] ?? null; $callId = $toolCall['id'] ?? ''; if (!is_string($callId) || trim($callId) === '') { $callId = uniqid('toolcall', true); } $payload = $toolCall['function']['arguments'] ?? '{}'; $arguments = $this->decodeArguments($payload); $toolContext = $toolMap[$toolName] ?? null; $isBuiltIn = isset($builtInTools[$toolName]); $invokeName = $toolContext['invokeName'] ?? $toolName; $arguments = $this->toolArgumentNormalizer->filterToSchema($arguments, $toolContext); $arguments = $this->toolArgumentNormalizer->coerceToSchema($arguments, $toolContext); $missingArguments = $this->toolArgumentNormalizer->missingRequiredArguments($arguments, $toolContext); $this->traceService?->recordToolCall($traceRunId, (string)$toolName, $arguments, $callId); $executedToolCallsForSignature[] = [ 'function' => [ 'name' => $toolName, 'arguments' => json_encode($arguments, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}', ], ]; $start = microtime(true); $status = 'ok'; $output = ''; if ($missingArguments !== []) { $status = 'error'; $output = $this->buildInvalidToolArgumentsOutput((string)$toolName, $missingArguments); $this->logger->warning('EducAI: Tool call rejected due to invalid arguments', [ 'tool' => $toolName, 'missing_required_arguments' => $missingArguments, 'trace_run_id' => $traceRunId, ]); } elseif ($isBuiltIn) { // Execute built-in tool try { $builtInConfig = is_array($toolContext['config'] ?? null) ? $toolContext['config'] : []; $result = count($builtInConfig) > 0 ? $this->toolProviderRegistry->executeTool($toolName, $arguments, $builtInConfig) : $this->toolProviderRegistry->executeTool($toolName, $arguments); $output = $this->sanitizeOutput($result); $this->logger->info('EducAI: Built-in tool execution result', [ 'tool' => $toolName, 'trace_run_id' => $traceRunId, ]); } catch (\Throwable $e) { $this->logger->error('Built-in tool invocation failed', [ 'tool' => $toolName, 'exception' => $e, ]); $status = 'error'; $output = 'ERROR: ' . $e->getMessage(); } } elseif ($toolContext === null) { $status = 'error'; $output = 'Tool not available'; } else { try { $result = $this->mcpClient->callTool( $toolContext['tool'], $invokeName, $arguments, $toolContext['config'] ); $output = $this->sanitizeOutput($result); $this->logger->info('EducAI: Tool execution result', [ 'tool' => $toolName, 'trace_run_id' => $traceRunId, ]); } catch (\Throwable $e) { $this->logger->error('Tool invocation failed', [ 'tool' => $toolName, 'exception' => $e, ]); $status = 'error'; $output = 'ERROR: ' . $e->getMessage(); } } $durationMs = (int)round((microtime(true) - $start) * 1000); $this->traceService?->recordToolResult($traceRunId, (string)$toolName, $status, $output, $durationMs, $status === 'error' ? $output : null); $this->logger->info('EducAI: Tool execution completed', [ 'tool' => $toolName, 'status' => $status, 'duration_ms' => $durationMs, 'trace_run_id' => $traceRunId, 'is_builtin' => $isBuiltIn, ]); $trace[] = [ 'tool' => $toolName, 'status' => $status, 'duration_ms' => $durationMs, 'response' => $output, ]; $messages[] = [ 'role' => 'tool', 'tool_call_id' => $callId, 'name' => $toolName, 'content' => $output, ]; } $currentCallSignature = $this->buildToolCallBatchSignature($executedToolCallsForSignature); if ($currentCallSignature !== '') { $recentToolCalls[] = $currentCallSignature; if (count($recentToolCalls) > $maxIterations) { $recentToolCalls = array_slice($recentToolCalls, -$maxIterations); } $repetitionCount = $this->countTrailingMatchingToolSignatures($recentToolCalls); $loopThreshold = $this->toolExecutionPolicyService->loopThresholdForToolCalls($toolCalls, $toolMap); if ($repetitionCount >= $loopThreshold) { $this->logger->warning('EducAI: Detected repetitive tool calling loop, forcing synthesis', [ 'pattern' => implode(',', array_map(static fn($tc) => $tc['function']['name'] ?? 'unknown', $toolCalls)), 'iterations' => $iterations, 'repetitions' => $repetitionCount, 'threshold' => $loopThreshold, 'signature_length' => strlen($currentCallSignature), 'trace_run_id' => $traceRunId, ]); return $this->synthesizeFinalAnswer( $systemPrompt, $messages, $llmOptions['model'] ?? null, $trace, $onPartialResult, $resolvedTemperature, $iterations, $maxIterations, false, $traceRunId ); } } if ($iterations >= $maxIterations) { return $this->synthesizeFinalAnswer( $systemPrompt, $messages, $llmOptions['model'] ?? null, $trace, $onPartialResult, $resolvedTemperature, $iterations, $maxIterations, true, $traceRunId ); } } } /** * Build a stable tool batch signature for loop detection. * * The signature includes normalized arguments so repeated searches with * different queries do not count as the same loop pattern. * * @param array $toolCalls */ private function buildToolCallBatchSignature(array $toolCalls): string { $parts = []; foreach ($toolCalls as $toolCall) { $name = $toolCall['function']['name'] ?? null; if (!is_string($name) || trim($name) === '') { continue; } $payload = $toolCall['function']['arguments'] ?? '{}'; $arguments = $this->decodeArguments($payload); $normalizedArguments = $this->normalizeToolSignatureValue($arguments); $encodedArguments = json_encode($normalizedArguments, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($encodedArguments === false) { $encodedArguments = '{}'; } if (strlen($encodedArguments) > 400) { $encodedArguments = substr($encodedArguments, 0, 400); } $parts[] = trim($name) . ':' . $encodedArguments; } return implode('|', $parts); } private function normalizeToolSignatureValue(array|bool|float|int|string|null $value): array|bool|float|int|string|null { if (is_array($value)) { if (!array_is_list($value)) { ksort($value); } $normalized = []; foreach ($value as $key => $item) { $normalized[$key] = $this->normalizeToolSignatureValue($item); } return $normalized; } if (is_string($value)) { $normalized = preg_replace('/\s+/', ' ', trim($value)) ?? trim($value); if (strlen($normalized) > 250) { return substr($normalized, 0, 250); } return $normalized; } if (is_float($value)) { return round($value, 6); } return $value; } /** * @param array $recentToolCalls */ private function countTrailingMatchingToolSignatures(array $recentToolCalls): int { if (count($recentToolCalls) === 0) { return 0; } $lastSignature = $recentToolCalls[count($recentToolCalls) - 1]; $count = 0; for ($i = count($recentToolCalls) - 1; $i >= 0; $i--) { if ($recentToolCalls[$i] !== $lastSignature) { break; } $count++; } return $count; } /** * @param array> $messages * @param array $options * @param array $metadata */ private function recordLlmRequestTrace( ?int $traceRunId, string $stage, string $systemPrompt, array $messages, ?string $model, array $options, bool $streaming, array $metadata = [] ): void { if ($this->traceService === null) { return; } $tracePayload = $this->buildTraceChatCompletionPayload($systemPrompt, $messages, $model, $options, $streaming); $providerPayload = $tracePayload['payload'] ?? []; $payload = array_merge([ 'stage' => $stage, 'model' => $model, 'streaming' => $streaming, 'message_count' => count($messages), 'tool_count' => isset($providerPayload['tools']) && is_array($providerPayload['tools']) ? count($providerPayload['tools']) : 0, 'tool_choice' => $providerPayload['tool_choice'] ?? null, 'temperature' => $providerPayload['temperature'] ?? null, 'max_tokens' => $providerPayload['max_tokens'] ?? null, 'max_completion_tokens' => $providerPayload['max_completion_tokens'] ?? null, 'request_endpoint' => $tracePayload['endpoint'] ?? null, 'request_model_reference' => $tracePayload['model_reference'] ?? null, 'request_payload' => $providerPayload !== [] ? $providerPayload : $tracePayload, ], $metadata); if (isset($tracePayload['trace_payload_error'])) { $payload['trace_payload_error'] = $tracePayload['trace_payload_error']; } $this->traceService->recordEvent($traceRunId, 'llm_request', [ 'payload' => $payload, ]); } /** * @param array $response * @param array $metadata */ private function recordLlmResponseTrace(?int $traceRunId, string $stage, array $response, string $content, array $metadata = []): void { $this->traceService?->recordEvent($traceRunId, 'llm_response', [ 'status' => 'ok', 'payload' => array_merge([ 'stage' => $stage, 'model' => $response['model'] ?? null, 'model_reference' => $response['model_reference'] ?? null, 'model_endpoint' => $response['model_endpoint'] ?? null, 'usage' => $response['usage'] ?? null, 'finish_reason' => $response['finish_reason'] ?? null, 'content_length' => strlen($content), 'native_tool_calls' => count($response['tool_calls'] ?? []), 'raw_response' => $response['raw'] ?? null, ], $metadata), ]); } /** * @param array> $messages * @param array $options * @return array */ private function buildTraceChatCompletionPayload(string $systemPrompt, array $messages, ?string $model, array $options, bool $streaming): array { try { return $this->llmClient->buildTraceChatCompletionPayload($systemPrompt, $messages, $model, $options, $streaming); } catch (\Throwable $e) { return [ 'endpoint' => null, 'model_reference' => $model, 'payload' => [ 'model' => $model, 'messages' => array_merge([['role' => 'system', 'content' => $systemPrompt]], $messages), 'temperature' => $options['temperature'] ?? null, 'max_tokens' => $options['max_tokens'] ?? null, 'stream' => $streaming ?: null, 'tools' => $options['tools'] ?? null, 'tool_choice' => $options['tool_choice'] ?? null, ], 'trace_payload_error' => $e->getMessage(), ]; } } /** * @param array $toolLoadout * @param int|null $botId Bot ID to filter built-in tools (null = no built-in tools) * @param bool $ragEnabled Whether to include the RAG search tool for this bot * @param array $explicitBuiltInTools * @return ToolDefinitionBuildResult */ private function buildToolDefinitions(array $toolLoadout, ?int $botId = null, bool $ragEnabled = false, array $explicitBuiltInTools = []): array { $definitions = []; $map = []; $builtIn = []; // Add built-in tools - either assigned to this bot OR the RAG tool when RAG is enabled $assignedBuiltInNames = []; $assignedBuiltInConfigs = []; if (count($explicitBuiltInTools) > 0) { $assignedBuiltInTools = $explicitBuiltInTools; } elseif ($botId !== null) { $assignedBuiltInTools = $this->toolRegistry->getBuiltInToolsForBot($botId); } else { $assignedBuiltInTools = []; } foreach ($assignedBuiltInTools as $entry) { $name = $entry['name'] ?? null; if (!is_string($name) || $name === '') { continue; } $assignedBuiltInNames[] = $name; $assignedBuiltInConfigs[$name] = isset($entry['config']) && is_array($entry['config']) ? $entry['config'] : []; } // Get available built-in tool definitions from provider $builtInToolDefs = $this->toolProviderRegistry->getAvailableTools(); foreach ($builtInToolDefs as $toolDef) { $name = $toolDef['name'] ?? null; if (!is_string($name)) { continue; } // Include tool if: it's assigned to the bot OR it's the RAG tool and RAG is enabled $isRagTool = $name === 'rag_search_documents'; $isAssigned = in_array($name, $assignedBuiltInNames, true); if (!$isAssigned && !($isRagTool && $ragEnabled)) { continue; } $definition = [ 'type' => 'function', 'function' => [ 'name' => $name, 'description' => $toolDef['description'] ?? '', 'parameters' => $toolDef['schema'] ?? ['type' => 'object', 'properties' => new \stdClass()], ], ]; $policy = isset($toolDef['policy']) && is_array($toolDef['policy']) ? $toolDef['policy'] : $this->toolExecutionPolicyService->builtInPolicy($name); $definitions[] = $definition; $builtIn[$name] = [ 'name' => $name, 'definition' => $definition, 'config' => $assignedBuiltInConfigs[$name] ?? [], 'policy' => $policy, ]; // Also add to map for schema filtering $map[$name] = [ 'tool' => null, // No MCP tool for built-in 'config' => $assignedBuiltInConfigs[$name] ?? [], 'definition' => $definition, 'invokeName' => $name, 'policy' => $policy, ]; } // Add MCP tools foreach ($toolLoadout as $entry) { $tool = $entry['tool']; $config = $entry['config']; try { $descriptors = $this->mcpClient->listTools($tool); } catch (\Throwable $e) { $this->logger->error('Failed to list tools for MCP endpoint', [ 'tool_id' => $tool->getId(), 'exception' => $e, ]); continue; } foreach ($descriptors as $descriptor) { $name = $descriptor['name'] ?? null; if (!is_string($name)) { continue; } $exposedName = $name; if (isset($map[$exposedName])) { // Preserve all tools by assigning deterministic aliases on name collision. $exposedName = $this->buildMcpAliasName($name, (int)$tool->getId(), $map); $this->logger->warning('EducAI: MCP tool name collision detected, aliasing tool', [ 'original_name' => $name, 'alias' => $exposedName, 'tool_id' => $tool->getId(), ]); } $definition = [ 'type' => 'function', 'function' => [ 'name' => $exposedName, 'description' => $descriptor['description'] ?? '', 'parameters' => $this->schemaFromMcpDescriptor($descriptor), ], ]; $annotations = isset($descriptor['annotations']) && is_array($descriptor['annotations']) ? $descriptor['annotations'] : []; $policy = isset($descriptor['policy']) && is_array($descriptor['policy']) ? $descriptor['policy'] : $this->toolExecutionPolicyService->mcpPolicy($exposedName, $name, (string)($descriptor['description'] ?? ''), $annotations); $definitions[] = $definition; $map[$exposedName] = [ 'tool' => $tool, 'config' => $config, 'definition' => $definition, 'invokeName' => $name, 'policy' => $policy, ]; } } return [ 'definitions' => $definitions, 'map' => $map, 'builtIn' => $builtIn, ]; } /** * MCP's canonical field is inputSchema. Keep schema as a legacy fallback for * descriptors produced by older/local adapters. * * @param array $descriptor * @return array|\stdClass */ private function schemaFromMcpDescriptor(array $descriptor): array|\stdClass { $schema = $descriptor['inputSchema'] ?? $descriptor['schema'] ?? null; if (!is_array($schema) && !($schema instanceof \stdClass)) { return ['type' => 'object', 'properties' => new \stdClass()]; } $schema = $this->normalizeJsonSchemaProperties((array)$schema); // OpenAI/Azure and Anthropic require tool parameters to be a JSON Schema // object. Guarantee that even when the MCP server omits the type. if (!isset($schema['type'])) { $schema['type'] = 'object'; } if (($schema['type'] ?? null) === 'object' && !isset($schema['properties'])) { $schema['properties'] = new \stdClass(); } return $schema; } /** * json_decode($json, true) turns an empty JSON object ("properties": {}) into * an empty PHP array ([]), which re-encodes to a JSON array. Azure/Anthropic * then reject the tool schema ("[] is not of type 'object'"), which breaks * every argument-less MCP tool (e.g. mensa_openings, list_campuses). Force any * "properties" map back to an object (recursively) so empty ones serialize as * {} again. * * @param array $schema * @return array */ private function normalizeJsonSchemaProperties(array $schema): array { if (array_key_exists('properties', $schema)) { $properties = $schema['properties']; if ($properties === [] || $properties instanceof \stdClass) { $schema['properties'] = new \stdClass(); } elseif (is_array($properties)) { $normalized = []; foreach ($properties as $name => $subSchema) { $normalized[$name] = is_array($subSchema) ? $this->normalizeJsonSchemaProperties($subSchema) : $subSchema; } $schema['properties'] = $normalized; } } return $schema; } /** * @return array */ private function decodeArguments(string $payload): array { $decoded = json_decode($payload, true); return is_array($decoded) ? $decoded : []; } /** * @param array $result */ private function sanitizeOutput(array $result): string { $textContent = $this->extractTextFromToolPayload($result); if ($textContent !== null) { return $this->truncateUtf8($textContent, 4000); } $encoded = json_encode($result, JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE); if ($encoded === false) { return 'Received invalid tool response'; } return $this->truncateUtf8($encoded, 4000); } private function truncateUtf8(string $text, int $maxLength): string { $text = $this->ensureValidUtf8($text); if (mb_strlen($text, 'UTF-8') <= $maxLength) { return $text; } return mb_substr($text, 0, $maxLength, 'UTF-8') . '...'; } private function ensureValidUtf8(string $text): string { if (mb_check_encoding($text, 'UTF-8')) { return $text; } return mb_convert_encoding($text, 'UTF-8', 'UTF-8'); } /** * Extract text-bearing content blocks from a tool payload. * * @param array $payload */ private function extractTextFromToolPayload(array $payload): ?string { $parts = []; if (isset($payload['content']) && is_array($payload['content'])) { foreach ($payload['content'] as $item) { if (is_array($item) && ($item['type'] ?? null) === 'text' && isset($item['text']) && is_string($item['text'])) { $text = trim($item['text']); if ($text !== '') { $parts[] = $text; } } elseif (is_string($item)) { $text = trim($item); if ($text !== '') { $parts[] = $text; } } } } if (count($parts) > 0) { return implode("\n\n", $parts); } if (isset($payload['text']) && is_string($payload['text']) && trim($payload['text']) !== '') { return trim($payload['text']); } return null; } /** * @return array|null */ private function tryParseJson(string $content): ?array { $content = trim($content); $firstChar = $content[0] ?? ''; if ($firstChar !== '{' && $firstChar !== '[') { return null; } $decoded = json_decode($content, true); return is_array($decoded) ? $decoded : null; } /** * Extract JSON from content that might be wrapped in markdown code blocks or have surrounding text * * @return array|null */ private function extractJsonFromContent(string $content): ?array { $content = trim($content); // First try direct parsing $direct = $this->tryParseJson($content); if ($direct !== null) { return $direct; } // Try to extract from markdown code blocks (```json ... ``` or ``` ... ```) if (preg_match('/```(?:json)?\s*([\s\S]*?)\s*```/', $content, $matches)) { $jsonStr = trim($matches[1]); $decoded = json_decode($jsonStr, true); if (is_array($decoded)) { return $decoded; } } // Try to find JSON object or array in the content // Look for patterns like [{ or {" if (preg_match('/(\[\s*\{[\s\S]*\}\s*\]|\{[\s\S]*\})/', $content, $matches)) { $jsonStr = trim($matches[1]); $decoded = json_decode($jsonStr, true); if (is_array($decoded)) { return $decoded; } } return null; } /** * Extract XML-formatted tool calls from the content string. * * Supports: * - {"name":"...", "arguments":{...}} * - ...{"query":"..."} * - ... * - ... * * @param array $toolMap * @return array{calls:array}>,remainingContent:string}|null */ private function extractXmlToolCalls(string $content, array $toolMap): ?array { $pattern = '/<((?:[a-z0-9_-]+:)?(?:tool_call|function_call))\b[^>]*>(.*?)<\/\1>/is'; if (!preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) { return null; } $calls = []; foreach ($matches as $match) { $innerContent = trim($match[2] ?? ''); if ($innerContent === '') { continue; } $call = null; $jsonDecoded = json_decode($innerContent, true); if (is_array($jsonDecoded)) { $call = $this->buildFallbackCall($jsonDecoded, $toolMap); } if ($call === null) { $invokeCalls = $this->extractInvokeXmlToolCalls($innerContent, $toolMap); if (count($invokeCalls) > 0) { foreach ($invokeCalls as $invokeCall) { $calls[] = $invokeCall; } continue; } } if ($call === null && preg_match('/(.*?)<\/name>/is', $innerContent, $nameMatch)) { $toolName = trim($nameMatch[1]); $arguments = []; $rawArguments = null; if (preg_match('/(.*?)<\/arguments>/is', $innerContent, $argumentsMatch)) { $rawArguments = trim($argumentsMatch[1]); } elseif (preg_match('/(.*?)<\/parameters>/is', $innerContent, $parametersMatch)) { $rawArguments = trim($parametersMatch[1]); } if (is_string($rawArguments) && $rawArguments !== '') { $decodedArguments = json_decode($rawArguments, true); if (is_array($decodedArguments)) { $arguments = $decodedArguments; } } if (isset($toolMap[$toolName])) { $call = [ 'name' => $toolName, 'arguments' => $arguments, ]; } } if ($call === null) { $call = $this->extractLegacyXmlArgumentPairs($innerContent, $toolMap); } if ($call !== null) { $calls[] = $call; } } if (count($calls) === 0) { return null; } $remainingContent = preg_replace($pattern, '', $content); return [ 'calls' => $calls, 'remainingContent' => trim($remainingContent ?? ''), ]; } /** * @param array $toolMap * @return array}> */ private function extractInvokeXmlToolCalls(string $innerContent, array $toolMap): array { if (!preg_match_all( '/(.*?)<\/invoke>/is', $innerContent, $invokeMatches, PREG_SET_ORDER )) { return []; } $calls = []; foreach ($invokeMatches as $invokeMatch) { $toolName = html_entity_decode(trim($invokeMatch[2] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'); if ($toolName === '' || !isset($toolMap[$toolName])) { continue; } $arguments = []; $argumentSection = $invokeMatch[3] ?? ''; if (preg_match_all( '/(.*?)<\/parameter>/is', $argumentSection, $parameterMatches, PREG_SET_ORDER )) { foreach ($parameterMatches as $parameterMatch) { $key = html_entity_decode(trim($parameterMatch[2] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'); if ($key === '') { continue; } $rawValue = html_entity_decode(trim($parameterMatch[3] ?? ''), ENT_QUOTES | ENT_XML1, 'UTF-8'); $arguments[$key] = $this->coerceLegacyXmlArgumentValue( $rawValue, $toolMap[$toolName]['definition']['function']['parameters'] ?? null, $key ); } } $calls[] = [ 'name' => $toolName, 'arguments' => $arguments, ]; } return $calls; } /** * Remove XML-style tool call artifacts from streamed content before they can be emitted to Talk. */ private function filterStreamingToolCallContent( string $partial, string &$artifactBuffer, bool &$isInsideXmlToolCall, bool $finalize = false ): string { $artifactBuffer .= $partial; if ($artifactBuffer === '') { return ''; } $visible = ''; while ($artifactBuffer !== '') { if ($isInsideXmlToolCall) { $closingMatch = $this->findFirstXmlTagOccurrence($artifactBuffer, ['', '', '']); if ($closingMatch !== null) { $artifactBuffer = substr( $artifactBuffer, $closingMatch['position'] + strlen($closingMatch['tag']) ); $isInsideXmlToolCall = false; continue; } $artifactBuffer = $finalize ? '' : $this->extractTrailingPartialXmlTag($artifactBuffer, ['', '', '']); break; } $openingMatch = $this->findFirstXmlTagOccurrence($artifactBuffer, ['', '', '']); if ($openingMatch !== null) { $visible .= substr($artifactBuffer, 0, $openingMatch['position']); $artifactBuffer = substr( $artifactBuffer, $openingMatch['position'] + strlen($openingMatch['tag']) ); $isInsideXmlToolCall = true; continue; } $partialOpeningTag = $finalize ? '' : $this->extractTrailingPartialXmlTag($artifactBuffer, ['', '', '']); if ($partialOpeningTag !== '') { $visibleLength = strlen($artifactBuffer) - strlen($partialOpeningTag); if ($visibleLength > 0) { $visible .= substr($artifactBuffer, 0, $visibleLength); } $artifactBuffer = $partialOpeningTag; break; } $visible .= $artifactBuffer; $artifactBuffer = ''; } return $visible; } /** * @param array $tags * @return array{tag:string,position:int}|null */ private function findFirstXmlTagOccurrence(string $content, array $tags): ?array { $firstMatch = null; foreach ($tags as $tag) { $position = stripos($content, $tag); if ($position === false) { continue; } if ($firstMatch === null || $position < $firstMatch['position']) { $firstMatch = [ 'tag' => $tag, 'position' => $position, ]; } } return $firstMatch; } /** * Preserve only the trailing suffix that could complete an XML tag in the next streamed chunk. * * @param array $tags */ private function extractTrailingPartialXmlTag(string $content, array $tags): string { $contentLower = strtolower($content); $bestMatch = ''; foreach ($tags as $tag) { $tagLower = strtolower($tag); $maxPrefixLength = min(strlen($contentLower), strlen($tagLower) - 1); for ($prefixLength = $maxPrefixLength; $prefixLength >= 1; $prefixLength--) { $prefix = substr($tagLower, 0, $prefixLength); if (str_ends_with($contentLower, $prefix) && $prefixLength > strlen($bestMatch)) { $bestMatch = substr($content, -$prefixLength); break; } } } return $bestMatch; } /** * Build a final text-only answer from the tool trace. * * @param array> $messages * @param array> $trace * @return array{content:string,messages:array>,toolInvocations:array>} */ private function synthesizeFinalAnswer( string $systemPrompt, array $messages, ?string $model, array $trace, ?callable $onPartialResult, float $temperature, int $iterations, int $maxIterations, bool $logLimitReached, ?int $traceRunId = null ): array { if ($logLimitReached) { $this->logger->warning('EducAI: Tool interaction limit reached, generating final synthesis', [ 'iterations' => $iterations, 'max_iterations' => $maxIterations, 'tool_invocations' => count($trace), 'last_tools' => array_map(static fn($t) => $t['tool'] ?? 'unknown', array_slice($trace, -3)), ]); } $synthesisPrompt = $this->buildToollessSynthesisPrompt($systemPrompt); try { $synthesisOptions = [ 'temperature' => $temperature, 'max_tokens' => 1500, ]; $this->recordLlmRequestTrace( $traceRunId, 'final_synthesis', $synthesisPrompt, $messages, $model, $synthesisOptions, false, [ 'iterations' => $iterations, 'max_iterations' => $maxIterations, 'tool_invocations' => count($trace), 'limit_reached' => $logLimitReached, ] ); $finalResponse = $this->llmClient->sendChatCompletion( $synthesisPrompt, $messages, $model, $synthesisOptions ); $finalContent = $this->sanitizeAssistantTextForUser($finalResponse['content'] ?? ''); $this->recordLlmResponseTrace($traceRunId, 'final_synthesis', $finalResponse, $finalContent, [ 'iterations' => $iterations, 'max_iterations' => $maxIterations, 'tool_invocations' => count($trace), 'limit_reached' => $logLimitReached, ]); if (trim($finalContent) === '') { $finalContent = $this->toolResultFallbackService->generateFromTrace($trace); } if ($onPartialResult && $finalContent !== '') { $onPartialResult($finalContent); } return [ 'content' => $finalContent, 'messages' => $messages, 'toolInvocations' => $trace, ]; } catch (\Throwable $e) { $this->logger->error('EducAI: Failed to generate final synthesis', [ 'exception' => $e->getMessage(), ]); $fallback = $this->toolResultFallbackService->generateFromTrace($trace); if ($onPartialResult && $fallback !== '') { $onPartialResult($fallback); } $this->traceService?->recordEvent($traceRunId, 'error', [ 'status' => 'error', 'payload' => [ 'stage' => 'final_synthesis', 'iterations' => $iterations, 'tool_invocations' => count($trace), ], 'error_message' => $e->getMessage(), ]); return [ 'content' => $fallback, 'messages' => $messages, 'toolInvocations' => $trace, ]; } } private function resolveTemperatureOption($value): float { if (!is_numeric($value)) { return SettingsService::DEFAULT_TEMPERATURE; } $temperature = (float)$value; if (!is_finite($temperature) || $temperature < 0.0 || $temperature > 1.0) { return SettingsService::DEFAULT_TEMPERATURE; } return round($temperature, 2); } private function buildToollessSynthesisPrompt(string $systemPrompt): string { $prompt = preg_replace('/\n\n## Tool Calling Instructions[\s\S]*$/', '', $systemPrompt) ?? $systemPrompt; $prompt = preg_replace('/\n\n## CRITICAL: Document Search Instructions[\s\S]*$/', '', $prompt) ?? $prompt; $prompt .= "\n\n## IMPORTANT: Final Response Required\n"; $prompt .= "You have already received tool results in the conversation.\n"; $prompt .= "Write a final answer for the user now.\n"; $prompt .= "Do NOT call tools again.\n"; $prompt .= "Do NOT output JSON, XML, , , or any other structured tool markup.\n"; $prompt .= "Summarize the relevant information from the tool results and answer in the user's language.\n"; return $prompt; } private function sanitizeAssistantTextForUser(string $content): string { $cleaned = preg_replace('/.*?<\/think>/is', '', $content); $cleaned = preg_replace('/.*$/is', '', $cleaned ?? $content); $cleaned = str_ireplace(['', ''], '', $cleaned ?? $content); $cleaned = preg_replace('/<((?:[a-z0-9_-]+:)?(?:tool_call|function_call))\b[^>]*>.*?<\/\1>/is', '', $cleaned ?? $content); $cleaned = preg_replace('/<(?:[a-z0-9_-]+:)?(?:tool_call|function_call)\b[^>]*>.*$/is', '', $cleaned ?? $content); $cleaned = preg_replace('/<\/(?:[a-z0-9_-]+:)?(?:tool_call|function_call)>/i', '', $cleaned ?? $content); $cleaned = preg_replace('/[ \t]{2,}/', ' ', $cleaned ?? $content); $cleaned = preg_replace("/\n{3,}/", "\n\n", $cleaned ?? $content); $trimmed = trim($cleaned ?? $content); $deduplicated = preg_replace('/^(.{20,}?)\s*\1$/us', '$1', $trimmed); return trim($deduplicated ?? $trimmed); } /** * @param array $toolMap * @return array{name:string,arguments:array}|null */ private function extractLegacyXmlArgumentPairs(string $innerContent, array $toolMap): ?array { if (!preg_match('/^\s*([^\s<]+)\s*(.*)$/s', $innerContent, $matches)) { return null; } $toolName = trim($matches[1] ?? ''); if ($toolName === '' || !isset($toolMap[$toolName])) { return null; } $argumentSection = $matches[2] ?? ''; $arguments = []; if (preg_match_all( '/(.*?)<\/arg_key>\s*(.*?)<\/arg_value>/is', $argumentSection, $argMatches, PREG_SET_ORDER )) { foreach ($argMatches as $argMatch) { $key = trim($argMatch[1] ?? ''); if ($key === '') { continue; } $rawValue = trim($argMatch[2] ?? ''); $arguments[$key] = $this->coerceLegacyXmlArgumentValue( $rawValue, $toolMap[$toolName]['definition']['function']['parameters'] ?? null, $key ); } } return [ 'name' => $toolName, 'arguments' => $arguments, ]; } private function coerceLegacyXmlArgumentValue(string $rawValue, array|\stdClass|null $schema, string $key): array|bool|float|int|string { $trimmed = trim($rawValue); if ($trimmed === '') { return ''; } $decodedJson = json_decode($trimmed, true); if (is_array($decodedJson)) { return $decodedJson; } if ($schema instanceof \stdClass) { $schema = (array)$schema; } $properties = is_array($schema) ? ($schema['properties'] ?? []) : []; if ($properties instanceof \stdClass) { $properties = (array)$properties; } $propertySchema = is_array($properties) ? ($properties[$key] ?? null) : null; if ($propertySchema instanceof \stdClass) { $propertySchema = (array)$propertySchema; } $type = is_array($propertySchema) ? ($propertySchema['type'] ?? null) : null; if ($type === 'integer' && is_numeric($trimmed)) { return (int)$trimmed; } if ($type === 'number' && is_numeric($trimmed)) { return (float)$trimmed; } if ($type === 'boolean') { $lower = strtolower($trimmed); if (in_array($lower, ['true', '1', 'yes'], true)) { return true; } if (in_array($lower, ['false', '0', 'no'], true)) { return false; } } return $trimmed; } /** * @param array $args * @param array $toolMap */ private function findMatchingTool(array $args, array $toolMap): ?string { // If there's only one tool, and the args look reasonable, assume it's that one. if (count($toolMap) === 1) { return array_key_first($toolMap); } $bestMatch = null; $maxOverlap = -1; $minExtra = PHP_INT_MAX; foreach ($toolMap as $name => $data) { $schema = $data['definition']['function']['parameters'] ?? []; if ($schema instanceof \stdClass) { $schema = (array)$schema; } $properties = $schema['properties'] ?? []; if ($properties instanceof \stdClass) { $properties = (array)$properties; } $toolParams = array_keys($properties); $argKeys = array_keys($args); // Calculate overlap: count of keys in args that exist in tool params $overlap = count(array_intersect($argKeys, $toolParams)); // Calculate extra: count of keys in args that do NOT exist in tool params $extra = count(array_diff($argKeys, $toolParams)); // If args are empty, overlap is 0. We prioritize tools that have 0 extra params. // But generally we want to maximize overlap. if ($overlap > $maxOverlap) { $maxOverlap = $overlap; $minExtra = $extra; $bestMatch = $name; } elseif ($overlap === $maxOverlap) { // Tie-breaker: prefer fewer extra params if ($extra < $minExtra) { $minExtra = $extra; $bestMatch = $name; } } } // Only return a match if there is at least some overlap, or if args is empty and we found something if ($maxOverlap > 0 || (empty($args) && $bestMatch !== null)) { return $bestMatch; } return null; } /** * @param array $toolMap */ private function buildForcedToolCall(array $toolMap, ?string $userQuery): ?array { if ($userQuery === null || trim($userQuery) === '' || count($toolMap) === 0) { return null; } $toolName = $this->pickToolForForcedCall($toolMap); if ($toolName === null) { return null; } $toolContext = $toolMap[$toolName] ?? null; if ($toolContext === null) { return null; } $arguments = $this->buildHarnessToolArguments($toolName, $toolContext, $userQuery); $encodedArgs = json_encode($arguments, JSON_UNESCAPED_UNICODE); if ($encodedArgs === false) { $encodedArgs = '{}'; } return [ 'id' => uniqid('forced_', true), 'type' => 'function', 'function' => [ 'name' => $toolName, 'arguments' => $encodedArgs, ], ]; } /** * @param array|string $toolChoice * @param array $toolMap */ private function buildPreferredToolCall(array|string $toolChoice, array $toolMap, ?string $userQuery): ?array { $toolName = $this->extractSpecificToolNameFromChoice($toolChoice); if ($toolName === null) { return null; } $toolContext = $toolMap[$toolName] ?? null; if ($toolContext === null) { return null; } $arguments = $this->buildHarnessToolArguments($toolName, $toolContext, $userQuery); $encodedArgs = json_encode($arguments, JSON_UNESCAPED_UNICODE); if ($encodedArgs === false) { $encodedArgs = '{}'; } return [ 'id' => uniqid('preferred_', true), 'type' => 'function', 'function' => [ 'name' => $toolName, 'arguments' => $encodedArgs, ], ]; } private function extractSpecificToolNameFromChoice(array|string $toolChoice): ?string { if (is_string($toolChoice)) { $normalized = trim($toolChoice); if ($normalized === '' || $normalized === 'auto' || $normalized === 'required') { return null; } return $normalized; } if (!is_array($toolChoice)) { return null; } $type = $toolChoice['type'] ?? null; $name = $toolChoice['function']['name'] ?? null; if ($type !== 'function' || !is_string($name) || trim($name) === '') { return null; } return trim($name); } /** * @param ToolMapEntry $toolContext * @return array */ private function buildHarnessToolArguments(string $toolName, array $toolContext, ?string $userQuery): array { $arguments = []; $schema = $toolContext['definition']['function']['parameters'] ?? []; if ($schema instanceof \stdClass) { $schema = (array)$schema; } $properties = is_array($schema) ? ($schema['properties'] ?? []) : []; if ($properties instanceof \stdClass) { $properties = (array)$properties; } if (is_array($properties) && array_key_exists('query', $properties) && $userQuery !== null && trim($userQuery) !== '') { $arguments['query'] = $this->buildForcedQuery($userQuery); } return $this->toolArgumentNormalizer->coerceToSchema($arguments, $toolContext); } private function buildForcedQuery(string $userQuery): string { $query = trim($userQuery); $query = preg_replace('/^@[a-z0-9_-]+\s+/i', '', $query) ?? $query; return trim($query) === '' ? $userQuery : trim($query); } /** * @param array $toolMap */ private function pickToolForForcedCall(array $toolMap): ?string { $bestName = null; $bestScore = 0; foreach ($toolMap as $name => $context) { if (($context['tool'] ?? null) === null) { continue; } $score = $this->toolExecutionPolicyService->scoreForcedSearchToolCandidate($name, $context); if ($score > $bestScore) { $bestName = $name; $bestScore = $score; } } // No safe, search-like candidate found: skip forced call entirely. return $bestName; } /** * Normalize tool calls from model response and ensure each call has a usable id/name/arguments shape. * * @param array $response * @return array */ private function extractToolCallsFromResponse(array $response): array { $toolCalls = $response['tool_calls'] ?? []; if (!is_array($toolCalls) || count($toolCalls) === 0) { $toolCalls = $response['raw']['choices'][0]['message']['tool_calls'] ?? []; } if (!is_array($toolCalls)) { return []; } $normalized = []; foreach ($toolCalls as $toolCall) { if (!is_array($toolCall)) { continue; } $name = $toolCall['function']['name'] ?? null; if (!is_string($name) || trim($name) === '') { continue; } $id = $toolCall['id'] ?? ''; if (!is_string($id) || trim($id) === '') { $id = uniqid('call_', true); } $arguments = $this->normalizeToolCallArguments($toolCall['function']['arguments'] ?? '{}'); $normalized[] = [ 'id' => $id, 'type' => 'function', 'function' => [ 'name' => $name, 'arguments' => $arguments, ], ]; } return $normalized; } private function normalizeToolCallArguments(mixed $arguments): string { if (!is_string($arguments)) { $encoded = json_encode($arguments, JSON_UNESCAPED_UNICODE); return $encoded === false ? '{}' : $encoded; } $trimmed = trim($arguments); if ($trimmed === '') { return '{}'; } json_decode($trimmed, true); return json_last_error() === JSON_ERROR_NONE ? $trimmed : '{}'; } /** * @param list $missingArguments */ private function buildInvalidToolArgumentsOutput(string $toolName, array $missingArguments): string { $tool = trim($toolName) !== '' ? $toolName : 'unknown'; return 'ERROR: Invalid tool arguments for ' . $tool . ': missing required argument(s): ' . implode(', ', $missingArguments) . '. Please call the tool again with valid schema arguments.'; } /** * Build a stable alias name for MCP tools when multiple tools share the same exposed name. * * @param array $existingMap */ private function buildMcpAliasName(string $name, int $toolId, array $existingMap): string { $sanitized = preg_replace('/[^a-zA-Z0-9_-]/', '_', $name) ?? $name; $sanitized = trim($sanitized, '_'); if ($sanitized === '') { $sanitized = 'tool'; } $suffix = '__mcp' . $toolId; $maxBaseLength = max(1, 64 - strlen($suffix)); $base = substr($sanitized, 0, $maxBaseLength); $candidate = $base . $suffix; $counter = 2; while (isset($existingMap[$candidate])) { $counterSuffix = $suffix . '_' . $counter; $maxLength = max(1, 64 - strlen($counterSuffix)); $candidate = substr($sanitized, 0, $maxLength) . $counterSuffix; $counter++; } return $candidate; } /** * @param array|bool|float|int|string|null $decoded * @param array $toolMap * @return array}>|null */ private function normalizeFallbackToolCalls(array|bool|float|int|string|null $decoded, array $toolMap): ?array { if (!is_array($decoded)) { return null; } $entries = array_is_list($decoded) ? $decoded : [$decoded]; $calls = []; foreach ($entries as $entry) { if (!is_array($entry)) { continue; } $call = $this->buildFallbackCall($entry, $toolMap); if ($call !== null) { $calls[] = $call; } } return count($calls) > 0 ? $calls : null; } /** * @param array $entry * @param array $toolMap * @return array{name:string,arguments:array}|null */ private function buildFallbackCall(array $entry, array $toolMap): ?array { $name = null; if (isset($entry['name']) && is_string($entry['name'])) { $name = $entry['name']; } elseif (isset($entry['tool']) && is_string($entry['tool'])) { $name = $entry['tool']; } elseif (isset($entry['function']['name']) && is_string($entry['function']['name'])) { $name = $entry['function']['name']; } if ($name !== null && !isset($toolMap[$name])) { $name = null; // only accept known tools } if ($name === null) { $name = $this->findMatchingTool($entry, $toolMap); } if ($name === null || !isset($toolMap[$name])) { return null; } $arguments = null; if (array_key_exists('parameters', $entry)) { $arguments = $this->decodeFallbackArgumentsValue($entry['parameters']); } if ($arguments === null && array_key_exists('arguments', $entry)) { $arguments = $this->decodeFallbackArgumentsValue($entry['arguments']); } if ($arguments === null && isset($entry['function']) && is_array($entry['function']) && array_key_exists('arguments', $entry['function'])) { $arguments = $this->decodeFallbackArgumentsValue($entry['function']['arguments']); } if ($arguments === null) { $arguments = $this->stripMetaKeys($entry); } return [ 'name' => $name, 'arguments' => $arguments, ]; } /** * Decode fallback tool arguments from either array or JSON string representation. * * @param array|bool|float|int|string|null $value * @return array|null */ private function decodeFallbackArgumentsValue(array|bool|float|int|string|null $value): ?array { if (is_array($value)) { return $value; } if (!is_string($value)) { return null; } $trimmed = trim($value); if ($trimmed === '') { return []; } $decoded = json_decode($trimmed, true); return is_array($decoded) ? $decoded : null; } /** * @param array $entry * @return array */ private function stripMetaKeys(array $entry): array { $result = $entry; unset( $result['name'], $result['tool'], $result['function'], $result['parameters'], $result['arguments'], $result['id'], $result['type'] ); return $result; } /** * Find a good position to split text at a sentence boundary * * @param string $text The text to analyze * @return int Position to split at, or 0 if no good boundary found */ private function findSentenceBoundary(string $text): int { // Look for sentence endings: . ! ? followed by space or end $patterns = ['. ', '! ', '? ', ".\n", "!\n", "?\n"]; $lastPos = 0; foreach ($patterns as $pattern) { $pos = strrpos($text, $pattern); if ($pos !== false && $pos > $lastPos) { $lastPos = $pos + strlen($pattern); } } // Also check for end of text with sentence ender $trimmed = rtrim($text); $lastChar = substr($trimmed, -1); if (in_array($lastChar, ['.', '!', '?'], true) && strlen($trimmed) === strlen($text)) { return strlen($text); } // If no sentence boundary, try to split at newline if ($lastPos === 0) { $newlinePos = strrpos($text, "\n"); if ($newlinePos !== false && $newlinePos > 30) { return $newlinePos + 1; } } return $lastPos; } }