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:
parent
627813cc4d
commit
eeb6927d8f
7 changed files with 775 additions and 3 deletions
20
Boot.php
20
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
104
Mcp/Tools/Agent/Brain/BrainForget.php
Normal file
104
Mcp/Tools/Agent/Brain/BrainForget.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
100
Mcp/Tools/Agent/Brain/BrainList.php
Normal file
100
Mcp/Tools/Agent/Brain/BrainList.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
150
Mcp/Tools/Agent/Brain/BrainRecall.php
Normal file
150
Mcp/Tools/Agent/Brain/BrainRecall.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
152
Mcp/Tools/Agent/Brain/BrainRemember.php
Normal file
152
Mcp/Tools/Agent/Brain/BrainRemember.php
Normal 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
236
Services/BrainService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
config.php
16
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'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
Reference in a new issue