Files
nc-talk-ai/lib/Service/AgentExecutor.php

2235 lines
92 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Service;
use OCA\EducAI\ToolProvider\ToolProviderRegistry;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type BuiltInToolLoadoutEntry from \OCA\EducAI\TypeDefinitions
* @psalm-import-type LlmToolCall from \OCA\EducAI\TypeDefinitions
* @psalm-import-type McpToolLoadoutEntry from \OCA\EducAI\TypeDefinitions
* @psalm-import-type ToolDefinitionBuildResult from \OCA\EducAI\TypeDefinitions
* @psalm-import-type ToolMapEntry from \OCA\EducAI\TypeDefinitions
*/
class AgentExecutor {
private const MAX_EMERGENCY_FALLBACK_TEMPERATURE = 0.3;
private const MAX_TOOL_ITERATIONS = 50;
private LLMClient $llmClient;
private McpClient $mcpClient;
private ToolRegistry $toolRegistry;
private ToolProviderRegistry $toolProviderRegistry;
private ToolExecutionPolicyService $toolExecutionPolicyService;
private ToolArgumentNormalizer $toolArgumentNormalizer;
private ToolResultFallbackService $toolResultFallbackService;
private ?TraceService $traceService;
private LoggerInterface $logger;
public function __construct(
LLMClient $llmClient,
McpClient $mcpClient,
ToolRegistry $toolRegistry,
ToolProviderRegistry $toolProviderRegistry,
LoggerInterface $logger,
?ToolExecutionPolicyService $toolExecutionPolicyService = null,
?ToolArgumentNormalizer $toolArgumentNormalizer = null,
?ToolResultFallbackService $toolResultFallbackService = null,
?TraceService $traceService = null
) {
$this->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<int,array<string,mixed>> $messages
* @param array<int,McpToolLoadoutEntry> $toolLoadout MCP tools assigned to the bot
* @param array<string,mixed> $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<int,BuiltInToolLoadoutEntry> - 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<int,array<string,mixed>>,toolInvocations:array<int,array<string,mixed>>}
*/
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 (<tool_call>...</tool_call>)
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<int,LlmToolCall> $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<int,string> $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<int,array<string,mixed>> $messages
* @param array<string,mixed> $options
* @param array<string,mixed> $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<string,mixed> $response
* @param array<string,mixed> $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<int,array<string,mixed>> $messages
* @param array<string,mixed> $options
* @return array<string,mixed>
*/
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<int,McpToolLoadoutEntry> $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<int,BuiltInToolLoadoutEntry> $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<string,mixed> $descriptor
* @return array<string,mixed>|\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<string,mixed> $schema
* @return array<string,mixed>
*/
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<string,mixed>
*/
private function decodeArguments(string $payload): array {
$decoded = json_decode($payload, true);
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string,mixed> $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<string,mixed> $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<string,mixed>|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<string,mixed>|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:
* - <tool_call>{"name":"...", "arguments":{...}}</tool_call>
* - <tool_call><name>...</name><arguments>{"query":"..."}</arguments></tool_call>
* - <minimax:tool_call><invoke name="..."><parameter name="...">...</parameter></invoke></minimax:tool_call>
* - <function_call>...</function_call>
*
* @param array<string,mixed> $toolMap
* @return array{calls:array<int,array{name:string,arguments:array<string,mixed>}>,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>(.*?)<\/name>/is', $innerContent, $nameMatch)) {
$toolName = trim($nameMatch[1]);
$arguments = [];
$rawArguments = null;
if (preg_match('/<arguments>(.*?)<\/arguments>/is', $innerContent, $argumentsMatch)) {
$rawArguments = trim($argumentsMatch[1]);
} elseif (preg_match('/<parameters>(.*?)<\/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<string,mixed> $toolMap
* @return array<int,array{name:string,arguments:array<string,mixed>}>
*/
private function extractInvokeXmlToolCalls(string $innerContent, array $toolMap): array {
if (!preg_match_all(
'/<invoke\s+name=(["\'])(.*?)\1\s*>(.*?)<\/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\s+name=(["\'])(.*?)\1\s*>(.*?)<\/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, ['</tool_call>', '</function_call>', '</minimax:tool_call>']);
if ($closingMatch !== null) {
$artifactBuffer = substr(
$artifactBuffer,
$closingMatch['position'] + strlen($closingMatch['tag'])
);
$isInsideXmlToolCall = false;
continue;
}
$artifactBuffer = $finalize
? ''
: $this->extractTrailingPartialXmlTag($artifactBuffer, ['</tool_call>', '</function_call>', '</minimax:tool_call>']);
break;
}
$openingMatch = $this->findFirstXmlTagOccurrence($artifactBuffer, ['<tool_call>', '<function_call>', '<minimax:tool_call>']);
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, ['<tool_call>', '<function_call>', '<minimax:tool_call>']);
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<int,string> $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<int,string> $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<int,array<string,mixed>> $messages
* @param array<int,array<string,mixed>> $trace
* @return array{content:string,messages:array<int,array<string,mixed>>,toolInvocations:array<int,array<string,mixed>>}
*/
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, <tool_call>, <function_call>, 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>.*?<\/think>/is', '', $content);
$cleaned = preg_replace('/<think>.*$/is', '', $cleaned ?? $content);
$cleaned = str_ireplace(['<think>', '</think>'], '', $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<string,mixed> $toolMap
* @return array{name:string,arguments:array<string,mixed>}|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>(.*?)<\/arg_key>\s*<arg_value>(.*?)<\/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<string,mixed> $args
* @param array<string,mixed> $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<string,ToolMapEntry> $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,mixed>|string $toolChoice
* @param array<string,ToolMapEntry> $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<string,mixed>
*/
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<string,ToolMapEntry> $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<string,mixed> $response
* @return array<int,LlmToolCall>
*/
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<string> $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<string,mixed> $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<string,mixed> $toolMap
* @return array<int,array{name:string,arguments:array<string,mixed>}>|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<string,mixed> $entry
* @param array<string,mixed> $toolMap
* @return array{name:string,arguments:array<string,mixed>}|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<string,mixed>|bool|float|int|string|null $value
* @return array<string,mixed>|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<string,mixed> $entry
* @return array<string,mixed>
*/
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;
}
}