Files
nc-talk-ai/tests/unit/Service/ToolExecutionPolicyServiceTest.php
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

137 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\EducAI\Tests\Unit\Service;
use OCA\EducAI\Service\BuiltInToolProvider;
use OCA\EducAI\Service\ToolExecutionPolicyService;
use PHPUnit\Framework\TestCase;
class ToolExecutionPolicyServiceTest extends TestCase {
public function testBuiltInRagSearchGetsSearchLoopPolicy(): void {
$service = new ToolExecutionPolicyService();
$policy = $service->builtInPolicy(BuiltInToolProvider::TOOL_RAG_SEARCH);
$this->assertSame(ToolExecutionPolicyService::KIND_SEARCH, $policy['kind']);
$this->assertTrue($policy['read_only']);
$this->assertSame(ToolExecutionPolicyService::SEARCH_TOOL_LOOP_THRESHOLD, $policy['loop_threshold']);
$this->assertSame(10, $service->loopThresholdForToolCalls([$this->toolCall(BuiltInToolProvider::TOOL_RAG_SEARCH)], [
BuiltInToolProvider::TOOL_RAG_SEARCH => ['policy' => $policy],
]));
}
public function testBuiltInWikiWriteGetsMutatingLoopPolicy(): void {
$service = new ToolExecutionPolicyService();
$policy = $service->builtInPolicy(BuiltInToolProvider::TOOL_WIKI_WRITE_PAGE);
$this->assertSame(ToolExecutionPolicyService::KIND_WRITE, $policy['kind']);
$this->assertFalse($policy['read_only']);
$this->assertSame(ToolExecutionPolicyService::MUTATING_TOOL_LOOP_THRESHOLD, $policy['loop_threshold']);
$this->assertSame(4, $service->loopThresholdForToolCalls([$this->toolCall(BuiltInToolProvider::TOOL_WIKI_WRITE_PAGE)], [
BuiltInToolProvider::TOOL_WIKI_WRITE_PAGE => ['policy' => $policy],
]));
}
public function testUnknownToolsFallBackToNameHeuristics(): void {
$service = new ToolExecutionPolicyService();
$this->assertSame(10, $service->loopThresholdForToolCalls([$this->toolCall('custom_search')]));
$this->assertSame(4, $service->loopThresholdForToolCalls([$this->toolCall('custom_write')]));
$this->assertSame(8, $service->loopThresholdForToolCalls([$this->toolCall('custom_inspect')]));
}
public function testMcpDestructiveHintWinsOverSearchLikeName(): void {
$service = new ToolExecutionPolicyService();
$policy = $service->mcpPolicy('search_delete', 'search_delete', 'Search and delete things.', [
'destructiveHint' => true,
'readOnlyHint' => true,
]);
$this->assertSame(ToolExecutionPolicyService::KIND_WRITE, $policy['kind']);
$this->assertFalse($policy['read_only']);
$this->assertTrue($policy['destructive']);
$this->assertSame(ToolExecutionPolicyService::MUTATING_TOOL_LOOP_THRESHOLD, $policy['loop_threshold']);
$this->assertSame('mcp_annotations', $policy['source']);
}
public function testMcpReadOnlyHintWinsOverMutatingName(): void {
$service = new ToolExecutionPolicyService();
$policy = $service->mcpPolicy('delete_preview', 'delete_preview', 'Preview what would be deleted.', [
'readOnlyHint' => true,
]);
$this->assertSame(ToolExecutionPolicyService::KIND_READ, $policy['kind']);
$this->assertTrue($policy['read_only']);
$this->assertFalse($policy['destructive']);
$this->assertSame(ToolExecutionPolicyService::GENERAL_TOOL_LOOP_THRESHOLD, $policy['loop_threshold']);
$this->assertSame('mcp_annotations', $policy['source']);
}
public function testUnknownMcpToolWithoutAnnotationsIsConservative(): void {
$service = new ToolExecutionPolicyService();
$policy = $service->mcpPolicy('custom_inspect', 'custom_inspect', 'Inspect something.');
$this->assertSame(ToolExecutionPolicyService::KIND_UNKNOWN, $policy['kind']);
$this->assertFalse($policy['read_only']);
$this->assertFalse($policy['idempotent']);
$this->assertSame(ToolExecutionPolicyService::MUTATING_TOOL_LOOP_THRESHOLD, $policy['loop_threshold']);
}
public function testForcedSearchScoringUsesGenericSearchSchemaWithoutTavilyBonus(): void {
$service = new ToolExecutionPolicyService();
$tavilyScore = $service->scoreForcedSearchToolCandidate('tavily_search', $this->searchContext('tavily_search', 'Search the web and internet using Tavily.'));
$genericScore = $service->scoreForcedSearchToolCandidate('web_search', $this->searchContext('web_search', 'Search the web and internet.'));
$extractScore = $service->scoreForcedSearchToolCandidate('tavily_extract', $this->searchContext('tavily_extract', 'Extract page content from URLs.'));
$this->assertSame($genericScore, $tavilyScore);
$this->assertGreaterThan(0, $genericScore);
$this->assertSame(0, $extractScore);
}
/**
* @return array<string,mixed>
*/
private function toolCall(string $name): array {
return [
'function' => [
'name' => $name,
'arguments' => '{}',
],
];
}
/**
* @return array<string,mixed>
*/
private function searchContext(string $name, string $description): array {
return [
'invokeName' => $name,
'policy' => [
'kind' => ToolExecutionPolicyService::KIND_SEARCH,
'read_only' => true,
'idempotent' => true,
'destructive' => false,
],
'definition' => [
'function' => [
'description' => $description,
'parameters' => [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string'],
],
'required' => ['query'],
],
],
],
];
}
}