feat(brain): add BrainService, MCP tools, and registration

- BrainService: Ollama embeddings + Qdrant vector upsert/search/delete
- brain_remember: store knowledge with type, tags, confidence, supersession
- brain_recall: semantic search with filter by project/type/agent/confidence
- brain_forget: workspace-scoped deletion from both stores
- brain_list: MariaDB query with model scopes, no vector search
- Config: brain.ollama_url, brain.qdrant_url, brain.collection
- Boot: BrainService singleton + tool registration via AgentToolRegistry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-03 09:39:19 +00:00
parent 627813cc4d
commit eeb6927d8f
7 changed files with 775 additions and 3 deletions

View file

@ -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(),
]);
}
}

View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Log;
/**
* Remove a memory from the shared OpenBrain knowledge store.
*
* Deletes the memory from both MariaDB and Qdrant.
* Workspace-scoped: agents can only forget memories in their own workspace.
*/
class BrainForget extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['write'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to forget memories'),
];
}
public function name(): string
{
return 'brain_forget';
}
public function description(): string
{
return 'Remove a memory from the shared OpenBrain knowledge store. Permanently deletes from both database and vector index.';
}
public function inputSchema(): array
{
return [
'type' => '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'));
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* List memories in the shared OpenBrain knowledge store.
*
* Pure MariaDB query using model scopes -- no vector search.
* Useful for browsing what an agent or project has stored.
*/
class BrainList extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['read'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to list memories'),
];
}
public function name(): string
{
return 'brain_list';
}
public function description(): string
{
return 'List memories in the shared OpenBrain knowledge store. Supports filtering by project, type, and agent. No vector search -- use brain_recall for semantic queries.';
}
public function inputSchema(): array
{
return [
'type' => '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(),
]);
}
}

View file

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Semantic search across the shared OpenBrain knowledge store.
*
* Uses vector similarity to find memories relevant to a natural
* language query, with optional filtering by project, type, agent,
* or minimum confidence.
*/
class BrainRecall extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['read'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to recall memories'),
];
}
public function name(): string
{
return 'brain_recall';
}
public function description(): string
{
return 'Semantic search across the shared OpenBrain knowledge store. Returns memories ranked by similarity to your query, with optional filtering.';
}
public function inputSchema(): array
{
return [
'type' => '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'));
}
}

View file

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Store a memory in the shared OpenBrain knowledge store.
*
* Agents use this tool to persist decisions, observations, conventions,
* and other knowledge so that other agents can recall it later.
*/
class BrainRemember extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['write'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to store memories'),
];
}
public function name(): string
{
return 'brain_remember';
}
public function description(): string
{
return 'Store a memory in the shared OpenBrain knowledge store. Use this to persist decisions, observations, conventions, research, plans, bugs, or architecture knowledge for other agents.';
}
public function inputSchema(): array
{
return [
'type' => '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'));
}
}

236
Services/BrainService.php Normal file
View file

@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\BrainMemory;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BrainService
{
private const EMBEDDING_MODEL = 'nomic-embed-text';
private const VECTOR_DIMENSION = 768;
public function __construct(
private string $ollamaUrl = 'http://localhost:11434',
private string $qdrantUrl = 'http://localhost:6334',
private string $collection = 'openbrain',
) {}
/**
* Generate an embedding vector for the given text.
*
* @return array<float>
*
* @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<string, mixed> $filter Optional filter criteria
* @return array{memories: array, scores: array<string, float>}
*/
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<string, mixed> $metadata
* @return array{id: string, payload: array<string, mixed>}
*/
public function buildQdrantPayload(string $id, array $metadata): array
{
return [
'id' => $id,
'payload' => $metadata,
];
}
/**
* Build a Qdrant filter from criteria.
*
* @param array<string, mixed> $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<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<string> $ids
*/
private function qdrantDelete(array $ids): void
{
Http::timeout(10)
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [
'points' => $ids,
]);
}
}

View file

@ -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'),
],
];