Files
nc-talk-ai/lib/Service/ToolExecutionPolicyService.php
T
Pascal Kienast 0739d3da6a Initial open-source release of Talk AI
Talk AI is a multi-bot AI assistant manager for Nextcloud Talk:
per-bot prompts and models, agentic tool calling (MCP + built-in
tools), RAG over Nextcloud files, room-document search, vision and
speech-to-text attachments, persistent bot wikis, approval workflows,
rate limiting, and multi-provider LLM support (any OpenAI-compatible
endpoint).

Developed within EDUC - the European Digital UniverCity
(https://educalliance.eu), where it runs as the 'EDUC AI' assistant on
the alliance-wide Nextcloud portal. This public repository is the
upstream point of truth; deployment-specific tools plug in via the
tool-provider extension point (docs/TOOL_PROVIDERS.md).

License: AGPL-3.0-or-later.
2026-07-08 21:13:13 +02:00

316 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Service;
/**
* Centralizes agent safety and loop-budget decisions for tools.
*/
class ToolExecutionPolicyService {
public const KIND_SEARCH = 'search';
public const KIND_READ = 'read';
public const KIND_WRITE = 'write';
public const KIND_ANALYZE = 'analyze';
public const KIND_TRANSCRIBE = 'transcribe';
public const KIND_UNKNOWN = 'unknown';
public const MUTATING_TOOL_LOOP_THRESHOLD = 4;
public const GENERAL_TOOL_LOOP_THRESHOLD = 8;
public const SEARCH_TOOL_LOOP_THRESHOLD = 10;
private const MUTATING_TOKENS = [
'add',
'append',
'commit',
'create',
'delete',
'deploy',
'disable',
'enable',
'execute',
'install',
'log',
'merge',
'move',
'patch',
'post',
'push',
'put',
'remove',
'rename',
'run',
'save',
'send',
'set',
'store',
'uninstall',
'update',
'upload',
'write',
];
private const FORCED_SEARCH_UNSAFE_TERMS = [
'create',
'update',
'delete',
'remove',
'write',
'insert',
'post',
'put',
'patch',
'destroy',
'extract',
'crawl',
'scrape',
'fetch',
'map',
'analyze',
'transcribe',
'read',
'log',
];
/**
* @return array<string,mixed>
*/
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<string,mixed>
*/
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<string,mixed>
*/
public function readToolPolicy(string $source = 'provider'): array {
return $this->policy(self::KIND_READ, true, true, false, self::GENERAL_TOOL_LOOP_THRESHOLD, $source);
}
/**
* @param array<string,mixed> $annotations
* @return array<string,mixed>
*/
public function mcpPolicy(string $exposedName, string $invokeName, string $description, array $annotations = []): array {
return $this->inferPolicy($exposedName, $invokeName, $description, $annotations, 'mcp_heuristic');
}
/**
* @param array<int,array<string,mixed>> $toolCalls
* @param array<string,array<string,mixed>> $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<string,mixed> $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<int,array<string,mixed>> $toolCalls
* @param array<string,array<string,mixed>> $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<string,array<string,mixed>> $toolMap
* @return array<string,mixed>
*/
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<string,mixed> $annotations
* @return array<string,mixed>
*/
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<string,mixed> $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<string,mixed>
*/
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,
];
}
}