diff --git a/Boot.php b/Boot.php index 7234470..545afba 100644 --- a/Boot.php +++ b/Boot.php @@ -72,6 +72,14 @@ class Boot extends ServiceProvider ); $this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class); + + $this->app->singleton(\Core\Mod\Agentic\Services\BrainService::class, function ($app) { + return new \Core\Mod\Agentic\Services\BrainService( + ollamaUrl: config('mcp.brain.ollama_url', 'http://localhost:11434'), + qdrantUrl: config('mcp.brain.qdrant_url', 'http://localhost:6334'), + collection: config('mcp.brain.collection', 'openbrain'), + ); + }); } // ------------------------------------------------------------------------- @@ -121,11 +129,17 @@ class Boot extends ServiceProvider * * Note: Agent tools (plan_create, session_start, etc.) are implemented in * the Mcp module at Mod\Mcp\Tools\Agent\* and registered via AgentToolRegistry. - * This method is available for Agentic-specific MCP tools if needed in future. + * Brain tools are registered here as they belong to the Agentic module. */ public function onMcpTools(McpToolsRegistering $event): void { - // Agent tools are registered in Mcp module via AgentToolRegistry - // No additional MCP tools needed from Agentic module at this time + $registry = $this->app->make(Services\AgentToolRegistry::class); + + $registry->registerMany([ + new Mcp\Tools\Agent\Brain\BrainRemember(), + new Mcp\Tools\Agent\Brain\BrainRecall(), + new Mcp\Tools\Agent\Brain\BrainForget(), + new Mcp\Tools\Agent\Brain\BrainList(), + ]); } } diff --git a/Mcp/Tools/Agent/Brain/BrainForget.php b/Mcp/Tools/Agent/Brain/BrainForget.php new file mode 100644 index 0000000..45fb97d --- /dev/null +++ b/Mcp/Tools/Agent/Brain/BrainForget.php @@ -0,0 +1,104 @@ + 'object', + 'properties' => [ + 'id' => [ + 'type' => 'string', + 'format' => 'uuid', + 'description' => 'UUID of the memory to remove', + ], + 'reason' => [ + 'type' => 'string', + 'description' => 'Optional reason for forgetting this memory', + 'maxLength' => 500, + ], + ], + 'required' => ['id'], + ]; + } + + public function handle(array $args, array $context = []): array + { + try { + $id = $this->requireString($args, 'id'); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } + + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai'); + } + + $reason = $this->optionalString($args, 'reason', null, 500); + + // Verify memory exists and belongs to this workspace + $memory = BrainMemory::where('id', $id) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $memory) { + return $this->error("Memory '{$id}' not found in this workspace"); + } + + $agentId = $context['agent_id'] ?? $context['session_id'] ?? 'anonymous'; + + return $this->withCircuitBreaker('brain', function () use ($id, $memory, $reason, $agentId) { + Log::info('OpenBrain: memory forgotten', [ + 'id' => $id, + 'type' => $memory->type, + 'agent_id' => $agentId, + 'reason' => $reason, + ]); + + app(BrainService::class)->forget($id); + + return $this->success([ + 'forgotten' => $id, + 'type' => $memory->type, + ]); + }, fn () => $this->error('Brain service temporarily unavailable. Memory could not be removed.', 'service_unavailable')); + } +} diff --git a/Mcp/Tools/Agent/Brain/BrainList.php b/Mcp/Tools/Agent/Brain/BrainList.php new file mode 100644 index 0000000..a9daa0a --- /dev/null +++ b/Mcp/Tools/Agent/Brain/BrainList.php @@ -0,0 +1,100 @@ + 'object', + 'properties' => [ + 'project' => [ + 'type' => 'string', + 'description' => 'Filter by project scope', + ], + 'type' => [ + 'type' => 'string', + 'description' => 'Filter by memory type', + 'enum' => BrainMemory::VALID_TYPES, + ], + 'agent_id' => [ + 'type' => 'string', + 'description' => 'Filter by originating agent', + ], + 'limit' => [ + 'type' => 'integer', + 'description' => 'Maximum results to return (default: 20, max: 100)', + 'minimum' => 1, + 'maximum' => 100, + 'default' => 20, + ], + ], + ]; + } + + public function handle(array $args, array $context = []): array + { + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai'); + } + + $project = $this->optionalString($args, 'project'); + $type = $this->optionalEnum($args, 'type', BrainMemory::VALID_TYPES); + $agentId = $this->optionalString($args, 'agent_id'); + $limit = $this->optionalInt($args, 'limit', 20, 1, 100); + + $query = BrainMemory::forWorkspace((int) $workspaceId) + ->active() + ->latestVersions() + ->forProject($project) + ->byAgent($agentId); + + if ($type !== null) { + $query->ofType($type); + } + + $memories = $query->orderByDesc('created_at') + ->limit($limit) + ->get(); + + return $this->success([ + 'count' => $memories->count(), + 'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(), + ]); + } +} diff --git a/Mcp/Tools/Agent/Brain/BrainRecall.php b/Mcp/Tools/Agent/Brain/BrainRecall.php new file mode 100644 index 0000000..84eb8bd --- /dev/null +++ b/Mcp/Tools/Agent/Brain/BrainRecall.php @@ -0,0 +1,150 @@ + 'object', + 'properties' => [ + 'query' => [ + 'type' => 'string', + 'description' => 'Natural language search query (max 2,000 characters)', + 'maxLength' => 2000, + ], + 'top_k' => [ + 'type' => 'integer', + 'description' => 'Number of results to return (default: 5, max: 20)', + 'minimum' => 1, + 'maximum' => 20, + 'default' => 5, + ], + 'filter' => [ + 'type' => 'object', + 'description' => 'Optional filters to narrow results', + 'properties' => [ + 'project' => [ + 'type' => 'string', + 'description' => 'Filter by project scope', + ], + 'type' => [ + 'oneOf' => [ + ['type' => 'string', 'enum' => BrainMemory::VALID_TYPES], + [ + 'type' => 'array', + 'items' => ['type' => 'string', 'enum' => BrainMemory::VALID_TYPES], + ], + ], + 'description' => 'Filter by memory type (single or array)', + ], + 'agent_id' => [ + 'type' => 'string', + 'description' => 'Filter by originating agent', + ], + 'min_confidence' => [ + 'type' => 'number', + 'description' => 'Minimum confidence threshold (0.0-1.0)', + 'minimum' => 0.0, + 'maximum' => 1.0, + ], + ], + ], + ], + 'required' => ['query'], + ]; + } + + public function handle(array $args, array $context = []): array + { + try { + $query = $this->requireString($args, 'query', 2000); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } + + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai'); + } + + $topK = $this->optionalInt($args, 'top_k', 5, 1, 20); + $filter = $this->optional($args, 'filter', []); + + if (! is_array($filter)) { + return $this->error('filter must be an object'); + } + + // Validate filter type values if present + if (isset($filter['type'])) { + $typeValue = $filter['type']; + $validTypes = BrainMemory::VALID_TYPES; + + if (is_string($typeValue)) { + if (! in_array($typeValue, $validTypes, true)) { + return $this->error(sprintf('filter.type must be one of: %s', implode(', ', $validTypes))); + } + } elseif (is_array($typeValue)) { + foreach ($typeValue as $t) { + if (! is_string($t) || ! in_array($t, $validTypes, true)) { + return $this->error(sprintf('Each filter.type value must be one of: %s', implode(', ', $validTypes))); + } + } + } else { + return $this->error('filter.type must be a string or array of strings'); + } + } + + if (isset($filter['min_confidence'])) { + if (! is_numeric($filter['min_confidence']) || $filter['min_confidence'] < 0.0 || $filter['min_confidence'] > 1.0) { + return $this->error('filter.min_confidence must be a number between 0.0 and 1.0'); + } + } + + return $this->withCircuitBreaker('brain', function () use ($query, $topK, $filter, $workspaceId) { + $result = app(BrainService::class)->recall($query, $topK, $filter, (int) $workspaceId); + + return $this->success([ + 'count' => count($result['memories']), + 'memories' => $result['memories'], + 'scores' => $result['scores'], + ]); + }, fn () => $this->error('Brain service temporarily unavailable. Recall failed.', 'service_unavailable')); + } +} diff --git a/Mcp/Tools/Agent/Brain/BrainRemember.php b/Mcp/Tools/Agent/Brain/BrainRemember.php new file mode 100644 index 0000000..38a8e81 --- /dev/null +++ b/Mcp/Tools/Agent/Brain/BrainRemember.php @@ -0,0 +1,152 @@ + 'object', + 'properties' => [ + 'content' => [ + 'type' => 'string', + 'description' => 'The knowledge to remember (max 50,000 characters)', + 'maxLength' => 50000, + ], + 'type' => [ + 'type' => 'string', + 'description' => 'Memory type classification', + 'enum' => BrainMemory::VALID_TYPES, + ], + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => 'Optional tags for categorisation', + ], + 'project' => [ + 'type' => 'string', + 'description' => 'Optional project scope (e.g. repo name)', + ], + 'confidence' => [ + 'type' => 'number', + 'description' => 'Confidence level from 0.0 to 1.0 (default: 0.8)', + 'minimum' => 0.0, + 'maximum' => 1.0, + ], + 'supersedes' => [ + 'type' => 'string', + 'format' => 'uuid', + 'description' => 'UUID of an older memory this one replaces', + ], + 'expires_in' => [ + 'type' => 'integer', + 'description' => 'Hours until this memory expires (null = never)', + 'minimum' => 1, + ], + ], + 'required' => ['content', 'type'], + ]; + } + + public function handle(array $args, array $context = []): array + { + try { + $content = $this->requireString($args, 'content', 50000); + $type = $this->requireEnum($args, 'type', BrainMemory::VALID_TYPES); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage()); + } + + $workspaceId = $context['workspace_id'] ?? null; + if ($workspaceId === null) { + return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai'); + } + + $agentId = $context['agent_id'] ?? $context['session_id'] ?? 'anonymous'; + $tags = $this->optional($args, 'tags'); + $project = $this->optionalString($args, 'project'); + $supersedes = $this->optionalString($args, 'supersedes'); + $expiresIn = $this->optionalInt($args, 'expires_in', null, 1); + + $confidence = $this->optional($args, 'confidence', 0.8); + if (! is_numeric($confidence) || $confidence < 0.0 || $confidence > 1.0) { + return $this->error('confidence must be a number between 0.0 and 1.0'); + } + $confidence = (float) $confidence; + + if (is_array($tags)) { + foreach ($tags as $tag) { + if (! is_string($tag)) { + return $this->error('Each tag must be a string'); + } + } + } + + if ($supersedes !== null) { + $existing = BrainMemory::where('id', $supersedes) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $existing) { + return $this->error("Memory '{$supersedes}' not found in this workspace"); + } + } + + return $this->withCircuitBreaker('brain', function () use ( + $content, $type, $workspaceId, $agentId, $tags, $project, $confidence, $supersedes, $expiresIn + ) { + $memory = BrainMemory::create([ + 'workspace_id' => $workspaceId, + 'agent_id' => $agentId, + 'type' => $type, + 'content' => $content, + 'tags' => $tags, + 'project' => $project, + 'confidence' => $confidence, + 'supersedes_id' => $supersedes, + 'expires_at' => $expiresIn ? now()->addHours($expiresIn) : null, + ]); + + app(BrainService::class)->remember($memory); + + return $this->success([ + 'memory' => $memory->toMcpContext(), + ]); + }, fn () => $this->error('Brain service temporarily unavailable. Memory could not be stored.', 'service_unavailable')); + } +} diff --git a/Services/BrainService.php b/Services/BrainService.php new file mode 100644 index 0000000..0340a01 --- /dev/null +++ b/Services/BrainService.php @@ -0,0 +1,236 @@ + + * + * @throws \RuntimeException + */ + public function embed(string $text): array + { + $response = Http::timeout(30) + ->post("{$this->ollamaUrl}/api/embeddings", [ + 'model' => self::EMBEDDING_MODEL, + 'prompt' => $text, + ]); + + if (! $response->successful()) { + throw new \RuntimeException("Ollama embedding failed: {$response->status()}"); + } + + return $response->json('embedding'); + } + + /** + * Store a memory in both MariaDB and Qdrant. + * + * If the memory supersedes an older one, the old entry is + * removed from both stores. + */ + public function remember(BrainMemory $memory): void + { + $vector = $this->embed($memory->content); + + $payload = $this->buildQdrantPayload($memory->id, [ + 'workspace_id' => $memory->workspace_id, + 'agent_id' => $memory->agent_id, + 'type' => $memory->type, + 'tags' => $memory->tags ?? [], + 'project' => $memory->project, + 'confidence' => $memory->confidence, + 'created_at' => $memory->created_at->toIso8601String(), + ]); + $payload['vector'] = $vector; + + $this->qdrantUpsert([$payload]); + + if ($memory->supersedes_id) { + $this->qdrantDelete([$memory->supersedes_id]); + BrainMemory::where('id', $memory->supersedes_id)->delete(); + } + } + + /** + * Semantic search: find memories similar to the query. + * + * @param array $filter Optional filter criteria + * @return array{memories: array, scores: array} + */ + public function recall(string $query, int $topK, array $filter, int $workspaceId): array + { + $vector = $this->embed($query); + + $filter['workspace_id'] = $workspaceId; + $qdrantFilter = $this->buildQdrantFilter($filter); + + $response = Http::timeout(10) + ->post("{$this->qdrantUrl}/collections/{$this->collection}/points/search", [ + 'vector' => $vector, + 'filter' => $qdrantFilter, + 'limit' => $topK, + 'with_payload' => false, + ]); + + if (! $response->successful()) { + throw new \RuntimeException("Qdrant search failed: {$response->status()}"); + } + + $results = $response->json('result', []); + $ids = array_column($results, 'id'); + $scoreMap = []; + + foreach ($results as $r) { + $scoreMap[$r['id']] = $r['score']; + } + + if (empty($ids)) { + return ['memories' => [], 'scores' => []]; + } + + $memories = BrainMemory::whereIn('id', $ids) + ->active() + ->latestVersions() + ->get() + ->sortBy(fn (BrainMemory $m) => array_search($m->id, $ids)) + ->values(); + + return [ + 'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(), + 'scores' => $scoreMap, + ]; + } + + /** + * Remove a memory from both Qdrant and MariaDB. + */ + public function forget(string $id): void + { + $this->qdrantDelete([$id]); + BrainMemory::where('id', $id)->delete(); + } + + /** + * Ensure the Qdrant collection exists, creating it if needed. + */ + public function ensureCollection(): void + { + $response = Http::timeout(5) + ->get("{$this->qdrantUrl}/collections/{$this->collection}"); + + if ($response->status() === 404) { + Http::timeout(10) + ->put("{$this->qdrantUrl}/collections/{$this->collection}", [ + 'vectors' => [ + 'size' => self::VECTOR_DIMENSION, + 'distance' => 'Cosine', + ], + ]); + Log::info("OpenBrain: created Qdrant collection '{$this->collection}'"); + } + } + + /** + * Build a Qdrant point payload. + * + * @param array $metadata + * @return array{id: string, payload: array} + */ + public function buildQdrantPayload(string $id, array $metadata): array + { + return [ + 'id' => $id, + 'payload' => $metadata, + ]; + } + + /** + * Build a Qdrant filter from criteria. + * + * @param array $criteria + * @return array{must: array} + */ + public function buildQdrantFilter(array $criteria): array + { + $must = []; + + if (isset($criteria['workspace_id'])) { + $must[] = ['key' => 'workspace_id', 'match' => ['value' => $criteria['workspace_id']]]; + } + + if (isset($criteria['project'])) { + $must[] = ['key' => 'project', 'match' => ['value' => $criteria['project']]]; + } + + if (isset($criteria['type'])) { + if (is_array($criteria['type'])) { + $must[] = ['key' => 'type', 'match' => ['any' => $criteria['type']]]; + } else { + $must[] = ['key' => 'type', 'match' => ['value' => $criteria['type']]]; + } + } + + if (isset($criteria['agent_id'])) { + $must[] = ['key' => 'agent_id', 'match' => ['value' => $criteria['agent_id']]]; + } + + if (isset($criteria['min_confidence'])) { + $must[] = ['key' => 'confidence', 'range' => ['gte' => $criteria['min_confidence']]]; + } + + return ['must' => $must]; + } + + /** + * Upsert points into Qdrant. + * + * @param array $points + * + * @throws \RuntimeException + */ + private function qdrantUpsert(array $points): void + { + $response = Http::timeout(10) + ->put("{$this->qdrantUrl}/collections/{$this->collection}/points", [ + 'points' => $points, + ]); + + if (! $response->successful()) { + Log::error("Qdrant upsert failed: {$response->status()}", ['body' => $response->body()]); + throw new \RuntimeException("Qdrant upsert failed: {$response->status()}"); + } + } + + /** + * Delete points from Qdrant by ID. + * + * @param array $ids + */ + private function qdrantDelete(array $ids): void + { + Http::timeout(10) + ->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [ + 'points' => $ids, + ]); + } +} diff --git a/config.php b/config.php index 3146366..1ecb135 100644 --- a/config.php +++ b/config.php @@ -71,4 +71,20 @@ return [ 'for_agents_ttl' => 3600, ], + /* + |-------------------------------------------------------------------------- + | OpenBrain (Shared Agent Knowledge Store) + |-------------------------------------------------------------------------- + | + | Configuration for the vector-indexed knowledge store. Requires + | Ollama (for embeddings) and Qdrant (for vector search). + | + */ + + 'brain' => [ + 'ollama_url' => env('BRAIN_OLLAMA_URL', 'http://localhost:11434'), + 'qdrant_url' => env('BRAIN_QDRANT_URL', 'http://localhost:6334'), + 'collection' => env('BRAIN_COLLECTION', 'openbrain'), + ], + ];