*/ public function builtInPolicy(string $toolName): array { return match ($toolName) { BuiltInToolProvider::TOOL_RAG_SEARCH, BuiltInToolProvider::TOOL_ROOM_SEARCH, BuiltInToolProvider::TOOL_ROOM_IMAGE_SEARCH, BuiltInToolProvider::TOOL_WIKI_SEARCH => $this->policy(self::KIND_SEARCH, true, true, false, self::SEARCH_TOOL_LOOP_THRESHOLD, 'builtin'), BuiltInToolProvider::TOOL_WIKI_READ_PAGE => $this->policy(self::KIND_READ, true, true, false, self::GENERAL_TOOL_LOOP_THRESHOLD, 'builtin'), BuiltInToolProvider::TOOL_ATTACHMENT_IMAGE => $this->policy(self::KIND_ANALYZE, true, true, false, self::GENERAL_TOOL_LOOP_THRESHOLD, 'builtin'), BuiltInToolProvider::TOOL_ATTACHMENT_AUDIO => $this->policy(self::KIND_TRANSCRIBE, true, true, false, self::GENERAL_TOOL_LOOP_THRESHOLD, 'builtin'), BuiltInToolProvider::TOOL_WIKI_WRITE_PAGE, BuiltInToolProvider::TOOL_WIKI_LOG_EVENT => $this->policy(self::KIND_WRITE, false, false, false, self::MUTATING_TOOL_LOOP_THRESHOLD, 'builtin'), default => $this->inferPolicy($toolName, $toolName, '', [], 'builtin_fallback'), }; } /** * Read-only search policy for tools contributed by external tool providers. * * @return array */ public function searchToolPolicy(string $source = 'provider'): array { return $this->policy(self::KIND_SEARCH, true, true, false, self::SEARCH_TOOL_LOOP_THRESHOLD, $source); } /** * Read-only lookup policy for tools contributed by external tool providers. * * @return array */ public function readToolPolicy(string $source = 'provider'): array { return $this->policy(self::KIND_READ, true, true, false, self::GENERAL_TOOL_LOOP_THRESHOLD, $source); } /** * @param array $annotations * @return array */ public function mcpPolicy(string $exposedName, string $invokeName, string $description, array $annotations = []): array { return $this->inferPolicy($exposedName, $invokeName, $description, $annotations, 'mcp_heuristic'); } /** * @param array> $toolCalls * @param array> $toolMap */ public function loopThresholdForToolCalls(array $toolCalls, array $toolMap = []): int { foreach ($toolCalls as $toolCall) { $name = (string)($toolCall['function']['name'] ?? ''); $policy = $this->policyForToolName($name, $toolMap); if (!empty($policy['destructive']) || empty($policy['read_only'])) { return self::MUTATING_TOOL_LOOP_THRESHOLD; } } if ($this->isSearchLikeToolBatch($toolCalls, $toolMap)) { return self::SEARCH_TOOL_LOOP_THRESHOLD; } return self::GENERAL_TOOL_LOOP_THRESHOLD; } /** * @param array $context */ public function scoreForcedSearchToolCandidate(string $name, array $context): int { $lcName = strtolower($name); $rawName = strtolower((string)($context['invokeName'] ?? $name)); $description = strtolower((string)($context['definition']['function']['description'] ?? '')); $combined = $lcName . ' ' . $rawName . ' ' . $description; $policy = $context['policy'] ?? null; if (is_array($policy) && (!($policy['read_only'] ?? false) || !empty($policy['destructive']))) { return 0; } if (!$this->hasQuerySchema($context)) { return 0; } foreach (self::FORCED_SEARCH_UNSAFE_TERMS as $term) { if ($term !== '' && str_contains($combined, $term)) { return 0; } } $score = 0; if (str_contains($lcName, 'search') || str_contains($rawName, 'search')) { $score += 60; } if (str_contains($description, 'search')) { $score += 40; } foreach (['web', 'internet', 'lookup', 'browser'] as $term) { if (str_contains($combined, $term)) { $score += 20; } } if (str_contains($combined, 'research')) { $score += 10; } return $score; } /** * @param array> $toolCalls * @param array> $toolMap */ private function isSearchLikeToolBatch(array $toolCalls, array $toolMap): bool { if (count($toolCalls) === 0) { return false; } foreach ($toolCalls as $toolCall) { $name = (string)($toolCall['function']['name'] ?? ''); $policy = $this->policyForToolName($name, $toolMap); if (($policy['kind'] ?? self::KIND_UNKNOWN) !== self::KIND_SEARCH) { return false; } } return true; } /** * @param array> $toolMap * @return array */ private function policyForToolName(string $name, array $toolMap): array { $context = $toolMap[$name] ?? null; if (is_array($context) && isset($context['policy']) && is_array($context['policy'])) { return $context['policy']; } return $this->inferPolicy($name, $name, '', [], 'name_heuristic'); } /** * @param array $annotations * @return array */ private function inferPolicy(string $name, string $invokeName, string $description, array $annotations, string $source): array { $combined = strtolower($name . ' ' . $invokeName . ' ' . $description); if (array_key_exists('destructiveHint', $annotations) && $annotations['destructiveHint'] === true) { return $this->policy(self::KIND_WRITE, false, false, true, self::MUTATING_TOOL_LOOP_THRESHOLD, 'mcp_annotations'); } if (array_key_exists('readOnlyHint', $annotations) && $annotations['readOnlyHint'] === true) { return $this->policy(self::KIND_READ, true, true, false, self::GENERAL_TOOL_LOOP_THRESHOLD, 'mcp_annotations'); } $isSearch = $this->isSearchLikeName($combined); $isMutating = $this->isMutatingName($combined); if ($isSearch) { return $this->policy(self::KIND_SEARCH, true, true, false, self::SEARCH_TOOL_LOOP_THRESHOLD, $source); } if ($isMutating) { return $this->policy(self::KIND_WRITE, false, false, false, self::MUTATING_TOOL_LOOP_THRESHOLD, $source); } if ($source === 'mcp_heuristic') { return $this->policy(self::KIND_UNKNOWN, false, false, false, self::MUTATING_TOOL_LOOP_THRESHOLD, $source); } return $this->policy(self::KIND_UNKNOWN, true, true, false, self::GENERAL_TOOL_LOOP_THRESHOLD, $source); } private function isSearchLikeName(string $combined): bool { return str_contains($combined, 'search') || str_contains($combined, 'lookup') || str_contains($combined, 'query') || str_contains($combined, 'find') || str_contains($combined, 'rag'); } private function isMutatingName(string $combined): bool { $tokens = preg_split('/[^a-z0-9]+/', $combined, -1, PREG_SPLIT_NO_EMPTY); if (!is_array($tokens)) { return false; } foreach ($tokens as $token) { if (in_array($token, self::MUTATING_TOKENS, true)) { return true; } } return false; } /** * @param array $context */ private function hasQuerySchema(array $context): bool { $schema = $context['definition']['function']['parameters'] ?? null; if ($schema instanceof \stdClass) { $schema = (array)$schema; } if (!is_array($schema)) { return false; } $properties = $schema['properties'] ?? null; if ($properties instanceof \stdClass) { $properties = (array)$properties; } if (!is_array($properties) || !array_key_exists('query', $properties)) { return false; } $querySchema = $properties['query']; if ($querySchema instanceof \stdClass) { $querySchema = (array)$querySchema; } if (!is_array($querySchema)) { return true; } $type = $querySchema['type'] ?? null; return $type === null || $type === 'string' || (is_array($type) && in_array('string', $type, true)); } /** * @return array */ private function policy(string $kind, bool $readOnly, bool $idempotent, bool $destructive, int $loopThreshold, string $source): array { return [ 'kind' => $kind, 'read_only' => $readOnly, 'idempotent' => $idempotent, 'destructive' => $destructive, 'loop_threshold' => $loopThreshold, 'source' => $source, ]; } }