diff --git a/php/Actions/Brain/RememberKnowledge.php b/php/Actions/Brain/RememberKnowledge.php index aed0f20..933e3c0 100644 --- a/php/Actions/Brain/RememberKnowledge.php +++ b/php/Actions/Brain/RememberKnowledge.php @@ -26,7 +26,7 @@ class RememberKnowledge ) {} /** - * @param array{content: string, type: string, tags?: array, project?: string, confidence?: float, supersedes?: string, expires_in?: int} $data + * @param array{content: string, type: string, tags?: array, org?: string, project?: string, confidence?: float, supersedes?: string, expires_in?: int} $data * @return BrainMemory The created memory * * @throws \InvalidArgumentException @@ -85,6 +85,7 @@ class RememberKnowledge 'type' => $type, 'content' => $content, 'tags' => $tags, + 'org' => $data['org'] ?? null, 'project' => $data['project'] ?? null, 'confidence' => $confidence, 'supersedes_id' => $supersedes, diff --git a/php/Console/Commands/BrainReindexCommand.php b/php/Console/Commands/BrainReindexCommand.php index de7832a..fc2a5b7 100644 --- a/php/Console/Commands/BrainReindexCommand.php +++ b/php/Console/Commands/BrainReindexCommand.php @@ -8,11 +8,21 @@ namespace Core\Mod\Agentic\Console\Commands; use Core\Mod\Agentic\Jobs\EmbedMemory; use Core\Mod\Agentic\Models\BrainMemory; +use Core\Mod\Agentic\Services\BrainService; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; class BrainReindexCommand extends Command { - protected $signature = 'brain:reindex {--all} {--chunk=100}'; + protected $signature = 'brain:reindex + {--all : Reindex all memories instead of only unindexed ones} + {--org= : Restrict reindexing to a single organisation scope} + {--project= : Restrict reindexing to a single project scope} + {--stale : Reindex stale memories where indexed_at is null or older than 14 days} + {--dry-run : Print the number of matching memories without dispatching jobs} + {--elastic-only : Refresh Elasticsearch documents only without regenerating embeddings} + {--chunk=100 : Number of memories to process per chunk}'; protected $description = 'Dispatch embedding jobs for OpenBrain memories that need indexing'; @@ -25,17 +35,24 @@ class BrainReindexCommand extends Command } $isReindexingAll = (bool) $this->option('all'); - $query = BrainMemory::query(); + $isStaleOnly = (bool) $this->option('stale'); + $isDryRun = (bool) $this->option('dry-run'); + $isElasticOnly = (bool) $this->option('elastic-only'); + $scope = $this->scopeLabel($isReindexingAll, $isStaleOnly); + $query = $this->buildQuery($isReindexingAll, $isStaleOnly); + $count = (clone $query)->count(); - if (! $isReindexingAll) { - $query->whereNull('indexed_at'); + if ($isDryRun) { + $this->info("DRY RUN: {$count} brain memory record(s) match {$scope} reindex filters."); + + return self::SUCCESS; } $dispatched = 0; - $query->chunkById($chunkSize, static function ($memories) use (&$dispatched): void { + $query->chunkById($chunkSize, function (Collection $memories) use (&$dispatched, $isElasticOnly): void { foreach ($memories as $memory) { - EmbedMemory::dispatch($memory->id); + $this->dispatchReindex($memory, $isElasticOnly); $dispatched++; } }); @@ -46,7 +63,12 @@ class BrainReindexCommand extends Command return self::SUCCESS; } - $scope = $isReindexingAll ? 'all' : 'unindexed'; + if ($isElasticOnly) { + $this->info("Dispatched {$dispatched} brain memory elastic-only reindex job(s) for {$scope} memories."); + + return self::SUCCESS; + } + $this->info("Dispatched {$dispatched} brain memory embedding job(s) for {$scope} memories."); return self::SUCCESS; @@ -65,4 +87,68 @@ class BrainReindexCommand extends Command return $chunkSize; } + + private function buildQuery(bool $isReindexingAll, bool $isStaleOnly): Builder + { + $query = BrainMemory::query(); + $org = $this->option('org'); + $project = $this->option('project'); + + if (is_string($org) && $org !== '') { + $query->where('org', $org); + } + + if (is_string($project) && $project !== '') { + $query->where('project', $project); + } + + if ($isStaleOnly) { + $query->where(function (Builder $builder): void { + $builder->whereNull('indexed_at') + ->orWhere('indexed_at', '<', now()->subDays(14)); + }); + + return $query; + } + + if (! $isReindexingAll) { + $query->whereNull('indexed_at'); + } + + return $query; + } + + private function scopeLabel(bool $isReindexingAll, bool $isStaleOnly): string + { + if ($isStaleOnly) { + return 'stale'; + } + + return $isReindexingAll ? 'all' : 'unindexed'; + } + + private function dispatchReindex(BrainMemory $memory, bool $isElasticOnly): void + { + if (! $isElasticOnly) { + EmbedMemory::dispatch($memory->id); + + return; + } + + $memoryId = $memory->id; + + dispatch(static function () use ($memoryId): void { + $memory = BrainMemory::query()->find($memoryId); + + if (! $memory instanceof BrainMemory) { + return; + } + + app(BrainService::class)->elasticIndex($memory); + + if ($memory->indexed_at !== null) { + $memory->update(['indexed_at' => now()]); + } + }); + } } diff --git a/php/Mcp/Tools/Agent/Brain/BrainList.php b/php/Mcp/Tools/Agent/Brain/BrainList.php index bffaf6e..a676cff 100644 --- a/php/Mcp/Tools/Agent/Brain/BrainList.php +++ b/php/Mcp/Tools/Agent/Brain/BrainList.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain; use Core\Mcp\Dependencies\ToolDependency; -use Core\Mod\Agentic\Actions\Brain\ListKnowledge; use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool; use Core\Mod\Agentic\Models\BrainMemory; @@ -35,7 +34,7 @@ class BrainList extends AgentTool 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.'; + return 'List memories in the shared OpenBrain knowledge store. Supports filtering by organisation, project, type, and agent. No vector search -- use brain_recall for semantic queries.'; } public function inputSchema(): array @@ -43,6 +42,10 @@ class BrainList extends AgentTool return [ 'type' => 'object', 'properties' => [ + 'org' => [ + 'type' => 'string', + 'description' => 'Filter by organisation scope', + ], 'project' => [ 'type' => 'string', 'description' => 'Filter by project scope', @@ -74,8 +77,32 @@ class BrainList extends AgentTool return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai'); } - $result = ListKnowledge::run((int) $workspaceId, $args); + $org = $this->optionalString($args, 'org', null); + $project = $this->optionalString($args, 'project', null); + $agentId = $this->optionalString($args, 'agent_id', null); + $type = $this->optionalEnum($args, 'type', BrainMemory::VALID_TYPES); + $limit = $this->optionalInt($args, 'limit', 20, 1, 100); - return $this->success($result); + return $this->withCircuitBreaker('brain', function () use ($workspaceId, $org, $project, $agentId, $type, $limit) { + $query = BrainMemory::forWorkspace((int) $workspaceId) + ->active() + ->latestVersions() + ->forOrg($org) + ->forProject($project) + ->byAgent($agentId); + + if ($type !== null) { + $query->ofType($type); + } + + $memories = $query->orderByDesc('created_at') + ->limit($limit) + ->get(); + + return $this->success([ + 'memories' => $memories->map(fn (BrainMemory $memory): array => $memory->toMcpContext())->all(), + 'count' => $memories->count(), + ]); + }, fn () => $this->error('Brain service temporarily unavailable. Memory list unavailable.', 'service_unavailable')); } } diff --git a/php/Mcp/Tools/Agent/Brain/BrainRecall.php b/php/Mcp/Tools/Agent/Brain/BrainRecall.php index f2b67fd..54af3de 100644 --- a/php/Mcp/Tools/Agent/Brain/BrainRecall.php +++ b/php/Mcp/Tools/Agent/Brain/BrainRecall.php @@ -60,6 +60,10 @@ class BrainRecall extends AgentTool 'type' => 'object', 'description' => 'Optional filters to narrow results', 'properties' => [ + 'org' => [ + 'type' => 'string', + 'description' => 'Filter by organisation scope', + ], 'project' => [ 'type' => 'string', 'description' => 'Filter by project scope', diff --git a/php/Mcp/Tools/Agent/Brain/BrainRemember.php b/php/Mcp/Tools/Agent/Brain/BrainRemember.php index 9cc84a2..219f773 100644 --- a/php/Mcp/Tools/Agent/Brain/BrainRemember.php +++ b/php/Mcp/Tools/Agent/Brain/BrainRemember.php @@ -58,6 +58,10 @@ class BrainRemember extends AgentTool 'items' => ['type' => 'string'], 'description' => 'Optional tags for categorisation', ], + 'org' => [ + 'type' => 'string', + 'description' => 'Optional organisation scope', + ], 'project' => [ 'type' => 'string', 'description' => 'Optional project scope (e.g. repo name)', diff --git a/php/Migrations/2026_04_24_000001_add_org_to_brain_memories.php b/php/Migrations/2026_04_24_000001_add_org_to_brain_memories.php new file mode 100644 index 0000000..7535707 --- /dev/null +++ b/php/Migrations/2026_04_24_000001_add_org_to_brain_memories.php @@ -0,0 +1,41 @@ +getConnection()); + + if (! $schema->hasTable('brain_memories') || $schema->hasColumn('brain_memories', 'org')) { + return; + } + + $schema->table('brain_memories', function (Blueprint $table): void { + $table->string('org', 128)->nullable()->after('project')->index(); + }); + } + + public function down(): void + { + $schema = Schema::connection($this->getConnection()); + + if (! $schema->hasTable('brain_memories') || ! $schema->hasColumn('brain_memories', 'org')) { + return; + } + + $schema->table('brain_memories', function (Blueprint $table): void { + $table->dropIndex(['org']); + $table->dropColumn('org'); + }); + } +}; diff --git a/php/Models/BrainMemory.php b/php/Models/BrainMemory.php index 9fa9096..4c64cb3 100644 --- a/php/Models/BrainMemory.php +++ b/php/Models/BrainMemory.php @@ -29,6 +29,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property string $type * @property string $content * @property array|null $tags + * @property string|null $org * @property string|null $project * @property float $confidence * @property string|null $supersedes_id @@ -71,6 +72,7 @@ class BrainMemory extends Model 'type', 'content', 'tags', + 'org', 'project', 'confidence', 'supersedes_id', @@ -130,6 +132,13 @@ class BrainMemory extends Model : $query; } + public function scopeForOrg(Builder $query, ?string $org): Builder + { + return $org + ? $query->where('org', $org) + : $query; + } + public function scopeByAgent(Builder $query, ?string $agentId): Builder { return $agentId @@ -192,6 +201,7 @@ class BrainMemory extends Model 'type' => $this->type, 'content' => $this->content, 'tags' => $this->tags ?? [], + 'org' => $this->getAttribute('org'), 'project' => $this->project, 'confidence' => $this->confidence, 'score' => round($score, 4), diff --git a/php/Services/BrainService.php b/php/Services/BrainService.php index bef91c6..d5e37ae 100644 --- a/php/Services/BrainService.php +++ b/php/Services/BrainService.php @@ -6,8 +6,12 @@ declare(strict_types=1); namespace Core\Mod\Agentic\Services; +use Core\Mod\Agentic\Jobs\DeleteFromIndex; +use Core\Mod\Agentic\Jobs\EmbedMemory; use Core\Mod\Agentic\Models\BrainMemory; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -106,17 +110,33 @@ class BrainService public function remember(array $attributes): BrainMemory { $attributes['indexed_at'] = null; + $cleanupIds = []; + + $memory = DB::connection('brain')->transaction(function () use ($attributes, &$cleanupIds) { + $memory = new BrainMemory; + $memory->fill($attributes); + $memory->save(); - $memory = DB::connection('brain')->transaction(function () use ($attributes) { - $memory = BrainMemory::create($attributes); if ($memory->supersedes_id) { - BrainMemory::where('id', $memory->supersedes_id)->delete(); + $superseded = BrainMemory::query()->find($memory->supersedes_id); + + if ($superseded instanceof BrainMemory) { + if ($superseded->indexed_at !== null) { + $cleanupIds[] = $superseded->id; + } + + $superseded->delete(); + } } return $memory; }); - \Core\Mod\Agentic\Jobs\EmbedMemory::dispatch($memory->id); + foreach ($cleanupIds as $cleanupId) { + DeleteFromIndex::dispatch($cleanupId); + } + + EmbedMemory::dispatch($memory->id); return $memory; } @@ -140,13 +160,15 @@ class BrainService $filter['workspace_id'] = $workspaceId; $qdrantFilter = $this->buildQdrantFilter($filter); - $response = $this->qdrantHttp(10) - ->post("{$this->qdrantUrl}/collections/{$this->collection}/points/search", [ + $response = $this->retryableHttp(10, fn (PendingRequest $request): Response => $request->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()}"); @@ -215,10 +237,13 @@ class BrainService */ public function forget(string $id): void { - DB::connection('brain')->transaction(function () use ($id) { - BrainMemory::where('id', $id)->delete(); - $this->qdrantDelete([$id]); + $deleted = DB::connection('brain')->transaction(function () use ($id): int { + return BrainMemory::where('id', $id)->delete(); }); + + if ($deleted > 0) { + DeleteFromIndex::dispatch($id); + } } /** @@ -226,23 +251,33 @@ class BrainService */ public function ensureCollection(): void { - $response = $this->qdrantHttp(5) - ->get("{$this->qdrantUrl}/collections/{$this->collection}"); + $response = $this->retryableHttp( + 5, + fn (PendingRequest $request): Response => $request->get("{$this->qdrantUrl}/collections/{$this->collection}") + ); if ($response->status() === 404) { - $createResponse = $this->qdrantHttp(10) - ->put("{$this->qdrantUrl}/collections/{$this->collection}", [ + $createResponse = $this->retryableHttp(10, fn (PendingRequest $request): Response => $request->put( + "{$this->qdrantUrl}/collections/{$this->collection}", + [ 'vectors' => [ 'size' => self::VECTOR_DIMENSION, 'distance' => 'Cosine', ], - ]); + ], + )); if (! $createResponse->successful()) { throw new \RuntimeException("Qdrant collection creation failed: {$createResponse->status()}"); } Log::info("OpenBrain: created Qdrant collection '{$this->collection}'"); + + return; + } + + if (! $response->successful()) { + throw new \RuntimeException("Qdrant collection check failed: {$response->status()}"); } } @@ -578,10 +613,12 @@ class BrainService */ public function qdrantUpsert(array $points): void { - $response = $this->qdrantHttp(10) - ->put("{$this->qdrantUrl}/collections/{$this->collection}/points", [ + $response = $this->retryableHttp(10, fn (PendingRequest $request): Response => $request->put( + "{$this->qdrantUrl}/collections/{$this->collection}/points", + [ 'points' => $points, - ]); + ], + )); if (! $response->successful()) { Log::error("Qdrant upsert failed: {$response->status()}", ['body' => $response->body()]); @@ -598,14 +635,67 @@ class BrainService */ public function qdrantDelete(array $ids): void { - $response = $this->qdrantHttp(10) - ->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [ + $response = $this->retryableHttp(10, fn (PendingRequest $request): Response => $request->post( + "{$this->qdrantUrl}/collections/{$this->collection}/points/delete", + [ 'points' => $ids, - ]); + ], + )); if (! $response->successful()) { Log::error("Qdrant delete failed: {$response->status()}", ['ids' => $ids, 'body' => $response->body()]); throw new \RuntimeException("Qdrant delete failed: {$response->status()}"); } } + + /** + * Retry transient Qdrant HTTP failures with a small exponential backoff. + * + * Retries 5xx responses and connection failures. 4xx responses are + * returned immediately so callers can fail fast without extra churn. + * + * @param callable(PendingRequest): Response $buildRequest + */ + private function retryableHttp(int $timeout, callable $buildRequest, int $maxAttempts = 3): Response + { + $delays = [100, 300, 900]; + $lastConnectionException = null; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + try { + $response = $buildRequest($this->qdrantHttp($timeout)); + } catch (ConnectionException $exception) { + $lastConnectionException = $exception; + + if ($attempt === $maxAttempts) { + break; + } + + $this->sleepMilliseconds($delays[$attempt - 1] ?? 900); + + continue; + } + + if ($response->status() < 500 || $attempt === $maxAttempts) { + return $response; + } + + $this->sleepMilliseconds($delays[$attempt - 1] ?? 900); + } + + throw new \RuntimeException( + sprintf( + 'Qdrant request failed after %d attempts: %s', + $maxAttempts, + $lastConnectionException?->getMessage() ?? 'connection error' + ), + 0, + $lastConnectionException, + ); + } + + protected function sleepMilliseconds(int $milliseconds): void + { + usleep($milliseconds * 1000); + } } diff --git a/php/tests/Feature/Brain/CircuitBreakerTest.php b/php/tests/Feature/Brain/CircuitBreakerTest.php new file mode 100644 index 0000000..33131d5 --- /dev/null +++ b/php/tests/Feature/Brain/CircuitBreakerTest.php @@ -0,0 +1,82 @@ +sleepCalls[] = $milliseconds; + } + }; +} + +test('CircuitBreaker_brain_list_Good_routes_failures_through_with_circuit_breaker', function (): void { + $workspace = createWorkspace(); + $breaker = Mockery::mock(CircuitBreaker::class); + + $breaker->shouldReceive('call') + ->once() + ->with('brain', Mockery::type(Closure::class), Mockery::type(Closure::class)) + ->andReturnUsing(function (string $service, Closure $operation, ?Closure $fallback = null): array { + return $fallback instanceof Closure ? $fallback() : []; + }); + + $this->app->instance(CircuitBreaker::class, $breaker); + + $result = (new BrainList)->handle([], [ + 'workspace_id' => $workspace->id, + ]); + + expect($result['code'])->toBe('service_unavailable') + ->and($result['error'])->toBe('Brain service temporarily unavailable. Memory list unavailable.'); +}); + +test('CircuitBreaker_retryable_http_Bad_retries_qdrant_requests_on_503', function (): void { + $brain = retryableBrainService(); + + Http::fake([ + 'http://localhost:6334/collections/openbrain/points' => Http::sequence() + ->push(['error' => 'unavailable'], 503) + ->push(['result' => ['status' => 'ok']], 200), + ]); + + $brain->qdrantUpsert([ + ['id' => 'memory-1', 'vector' => [0.1, 0.2], 'payload' => ['type' => 'fact']], + ]); + + expect($brain->sleepCalls)->toBe([100]); + + Http::assertSentCount(2); + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://localhost:6334/collections/openbrain/points' + && $request->method() === 'PUT'); +}); + +test('CircuitBreaker_retryable_http_Ugly_does_not_retry_qdrant_requests_on_401', function (): void { + $brain = retryableBrainService(); + + Http::fake([ + 'http://localhost:6334/collections/openbrain/points' => Http::sequence() + ->push(['error' => 'unauthorised'], 401) + ->push(['result' => ['status' => 'ok']], 200), + ]); + + expect(fn () => $brain->qdrantUpsert([ + ['id' => 'memory-2', 'vector' => [0.3, 0.4], 'payload' => ['type' => 'fact']], + ]))->toThrow(RuntimeException::class, 'Qdrant upsert failed: 401'); + + expect($brain->sleepCalls)->toBe([]); + Http::assertSentCount(1); +}); diff --git a/php/tests/Feature/Brain/OrgScopingTest.php b/php/tests/Feature/Brain/OrgScopingTest.php new file mode 100644 index 0000000..81aef8e --- /dev/null +++ b/php/tests/Feature/Brain/OrgScopingTest.php @@ -0,0 +1,131 @@ + $workspaceId, + 'agent_id' => 'virgil', + 'type' => 'context', + 'content' => 'Organisation-scoped OpenBrain memory.', + 'confidence' => 0.85, + 'org' => 'core', + 'project' => 'agent', + ], $attributes)); +} + +test('OrgScoping_remember_recall_Good_persists_org_and_recalls_with_matching_org', function (): void { + Queue::fake(); + $workspace = createWorkspace(); + $brain = orgScopingBrainService(); + + $memory = $brain->remember([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'fact', + 'content' => 'Core remembers its own scoped knowledge.', + 'org' => 'core', + 'project' => 'agent', + 'confidence' => 0.92, + ]); + + Http::fake([ + 'https://ollama.test/api/embeddings' => Http::response(['embedding' => array_fill(0, 768, 0.125)]), + 'https://qdrant.test/collections/openbrain/points/search' => Http::response([ + 'result' => [ + ['id' => $memory->id, 'score' => 0.91], + ], + ]), + ]); + + $result = $brain->recall('core scoped knowledge', 5, ['org' => 'core'], $workspace->id); + + expect($memory->fresh()?->getAttribute('org'))->toBe('core') + ->and($result['memories'])->toHaveCount(1) + ->and($result['memories'][0]['id'])->toBe($memory->id) + ->and($result['memories'][0]['org'])->toBe('core'); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search' + && $request->method() === 'POST' + && $request['filter']['must'] === [ + ['key' => 'workspace_id', 'match' => ['value' => $workspace->id]], + ['key' => 'org', 'match' => ['value' => 'core']], + ]); +}); + +test('OrgScoping_remember_recall_Bad_does_not_recall_across_org_boundaries', function (): void { + Queue::fake(); + $workspace = createWorkspace(); + $brain = orgScopingBrainService(); + + $memory = $brain->remember([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'fact', + 'content' => 'Core-only memory should not leak into another organisation scope.', + 'org' => 'core', + 'project' => 'agent', + 'confidence' => 0.88, + ]); + + Http::fake([ + 'https://ollama.test/api/embeddings' => Http::response(['embedding' => array_fill(0, 768, 0.125)]), + 'https://qdrant.test/collections/openbrain/points/search' => Http::response(['result' => []]), + ]); + + $result = $brain->recall('core-only memory', 5, ['org' => 'other-org'], $workspace->id); + + expect($memory->fresh()?->getAttribute('org'))->toBe('core') + ->and($result['memories'])->toBe([]) + ->and($result['scores'])->toBe([]); + + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://qdrant.test/collections/openbrain/points/search' + && $request->method() === 'POST' + && $request['filter']['must'] === [ + ['key' => 'workspace_id', 'match' => ['value' => $workspace->id]], + ['key' => 'org', 'match' => ['value' => 'other-org']], + ]); +}); + +test('OrgScoping_list_Ugly_filters_memories_by_org', function (): void { + $workspace = createWorkspace(); + $otherWorkspace = createWorkspace(); + $coreMemory = orgScopingMemory($workspace->id, ['content' => 'Core memory', 'org' => 'core']); + orgScopingMemory($workspace->id, ['content' => 'Other org memory', 'org' => 'other-org']); + orgScopingMemory($otherWorkspace->id, ['content' => 'Other workspace memory', 'org' => 'core']); + + $result = (new BrainList)->handle([ + 'org' => 'core', + 'limit' => 10, + ], [ + 'workspace_id' => $workspace->id, + ]); + + expect($result['success'])->toBeTrue() + ->and($result['count'])->toBe(1) + ->and($result['memories'])->toHaveCount(1) + ->and($result['memories'][0]['id'])->toBe($coreMemory->id) + ->and($result['memories'][0]['org'])->toBe('core'); +}); diff --git a/php/tests/Feature/Brain/ReindexFlagsTest.php b/php/tests/Feature/Brain/ReindexFlagsTest.php new file mode 100644 index 0000000..6b10896 --- /dev/null +++ b/php/tests/Feature/Brain/ReindexFlagsTest.php @@ -0,0 +1,137 @@ +app->make(Kernel::class)->registerCommand(new BrainReindexCommand()); +}); + +function reindexFlagsMemory(array $attributes = []): BrainMemory +{ + return BrainMemory::create(array_merge([ + 'workspace_id' => $attributes['workspace_id'] ?? createWorkspace()->id, + 'agent_id' => 'virgil', + 'type' => 'observation', + 'content' => 'Brain reindex flags test memory.', + 'confidence' => 0.83, + 'org' => 'core', + 'project' => 'agent', + ], $attributes)); +} + +test('BrainReindexCommand_handle_Good_filters_by_org_and_project', function (): void { + Queue::fake(); + $workspace = createWorkspace(); + $matching = reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'org' => 'core', + 'project' => 'agent', + ]); + reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'org' => 'core', + 'project' => 'other-project', + ]); + reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'org' => 'other-org', + 'project' => 'agent', + ]); + + $this->artisan('brain:reindex', [ + '--org' => 'core', + '--project' => 'agent', + '--chunk' => 1, + ]) + ->expectsOutputToContain('Dispatched 1 brain memory embedding job(s) for unindexed memories.') + ->assertSuccessful(); + + Queue::assertPushed(EmbedMemory::class, 1); + Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $matching->id); +}); + +test('BrainReindexCommand_handle_Bad_filters_stale_memories', function (): void { + Queue::fake(); + $workspace = createWorkspace(); + $neverIndexed = reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'content' => 'Never indexed memory.', + 'indexed_at' => null, + ]); + $stale = reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'content' => 'Stale indexed memory.', + 'indexed_at' => now()->subDays(21), + ]); + $recent = reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'content' => 'Recently indexed memory.', + 'indexed_at' => now()->subDays(3), + ]); + + $this->artisan('brain:reindex', [ + '--stale' => true, + '--chunk' => 1, + ]) + ->expectsOutputToContain('Dispatched 2 brain memory embedding job(s) for stale memories.') + ->assertSuccessful(); + + Queue::assertPushed(EmbedMemory::class, 2); + Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $neverIndexed->id); + Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $stale->id); + Queue::assertNotPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $recent->id); +}); + +test('BrainReindexCommand_handle_Ugly_dry_run_counts_matches_without_queueing_jobs', function (): void { + Queue::fake(); + $workspace = createWorkspace(); + reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'org' => 'core', + ]); + reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'org' => 'other-org', + ]); + + $this->artisan('brain:reindex', [ + '--org' => 'core', + '--dry-run' => true, + '--chunk' => 1, + ]) + ->expectsOutputToContain('DRY RUN: 1 brain memory record(s) match unindexed reindex filters.') + ->assertSuccessful(); + + Queue::assertNothingPushed(); +}); + +test('BrainReindexCommand_handle_Good_elastic_only_dispatches_lighter_reindex_jobs', function (): void { + Queue::fake(); + $workspace = createWorkspace(); + reindexFlagsMemory([ + 'workspace_id' => $workspace->id, + 'indexed_at' => now()->subDays(30), + 'org' => 'core', + ]); + + $this->artisan('brain:reindex', [ + '--all' => true, + '--org' => 'core', + '--elastic-only' => true, + '--chunk' => 1, + ]) + ->expectsOutputToContain('Dispatched 1 brain memory elastic-only reindex job(s) for all memories.') + ->assertSuccessful(); + + Queue::assertNotPushed(EmbedMemory::class); + Queue::assertPushed(CallQueuedClosure::class, 1); +}); diff --git a/php/tests/Feature/Brain/SupersedeForgetIndexCleanupTest.php b/php/tests/Feature/Brain/SupersedeForgetIndexCleanupTest.php new file mode 100644 index 0000000..e4bada8 --- /dev/null +++ b/php/tests/Feature/Brain/SupersedeForgetIndexCleanupTest.php @@ -0,0 +1,97 @@ + $attributes['workspace_id'] ?? createWorkspace()->id, + 'agent_id' => 'virgil', + 'type' => 'observation', + 'content' => 'Brain cleanup test memory.', + 'confidence' => 0.84, + 'org' => 'core', + 'project' => 'agent', + ], $attributes)); +} + +test('SupersedeForgetIndexCleanup_forget_Good_dispatches_delete_from_index', function (): void { + Queue::fake(); + $memory = cleanupMemory(['indexed_at' => now()]); + + cleanupBrainService()->forget($memory->id); + + expect(BrainMemory::find($memory->id))->toBeNull() + ->and(BrainMemory::withTrashed()->find($memory->id)?->trashed())->toBeTrue(); + + Queue::assertPushed(DeleteFromIndex::class, fn (DeleteFromIndex $job): bool => $job->memoryId === $memory->id); +}); + +test('SupersedeForgetIndexCleanup_supersede_Bad_dispatches_cleanup_for_old_indexed_memory', function (): void { + Queue::fake(); + $workspace = createWorkspace(); + $oldMemory = cleanupMemory([ + 'workspace_id' => $workspace->id, + 'content' => 'Old indexed memory.', + 'indexed_at' => now(), + ]); + + $newMemory = cleanupBrainService()->remember([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'observation', + 'content' => 'New superseding memory.', + 'confidence' => 0.93, + 'org' => 'core', + 'project' => 'agent', + 'supersedes_id' => $oldMemory->id, + ]); + + expect(BrainMemory::find($oldMemory->id))->toBeNull() + ->and(BrainMemory::withTrashed()->find($oldMemory->id)?->trashed())->toBeTrue() + ->and($newMemory->indexed_at)->toBeNull(); + + Queue::assertPushed(DeleteFromIndex::class, fn (DeleteFromIndex $job): bool => $job->memoryId === $oldMemory->id); + Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $newMemory->id); +}); + +test('SupersedeForgetIndexCleanup_supersede_Ugly_skips_cleanup_for_never_indexed_memory', function (): void { + Queue::fake(); + $workspace = createWorkspace(); + $oldMemory = cleanupMemory([ + 'workspace_id' => $workspace->id, + 'content' => 'Old unindexed memory.', + 'indexed_at' => null, + ]); + + $newMemory = cleanupBrainService()->remember([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'observation', + 'content' => 'Superseding unindexed memory.', + 'confidence' => 0.9, + 'org' => 'core', + 'project' => 'agent', + 'supersedes_id' => $oldMemory->id, + ]); + + expect(BrainMemory::find($oldMemory->id))->toBeNull() + ->and(BrainMemory::withTrashed()->find($oldMemory->id)?->trashed())->toBeTrue() + ->and($newMemory->indexed_at)->toBeNull(); + + Queue::assertNotPushed(DeleteFromIndex::class, fn (DeleteFromIndex $job): bool => $job->memoryId === $oldMemory->id); + Queue::assertPushed(EmbedMemory::class, fn (EmbedMemory $job): bool => $job->memoryId === $newMemory->id); +}); diff --git a/php/tests/Feature/Mcp/BrainSchemaOrgTest.php b/php/tests/Feature/Mcp/BrainSchemaOrgTest.php new file mode 100644 index 0000000..e00c8ad --- /dev/null +++ b/php/tests/Feature/Mcp/BrainSchemaOrgTest.php @@ -0,0 +1,155 @@ +shouldReceive('call') + ->andReturnUsing(function (string $service, Closure $operation, ?Closure $fallback = null): mixed { + return $operation(); + }); + + $app->instance(CircuitBreaker::class, $breaker); +} + +test('BrainSchemaOrg_brain_remember_Good_accepts_org_and_forwards_it', function (): void { + $workspace = createWorkspace(); + $brain = new class extends BrainService + { + public array $remembered = []; + + public function remember(array $attributes): BrainMemory + { + $this->remembered = $attributes; + + $memory = new BrainMemory; + $memory->forceFill(array_merge([ + 'id' => Str::uuid()->toString(), + 'workspace_id' => $attributes['workspace_id'], + 'agent_id' => $attributes['agent_id'], + 'type' => $attributes['type'], + 'content' => $attributes['content'], + 'tags' => $attributes['tags'] ?? [], + 'org' => $attributes['org'] ?? null, + 'project' => $attributes['project'] ?? null, + 'confidence' => $attributes['confidence'] ?? 0.8, + 'supersedes_id' => $attributes['supersedes_id'] ?? null, + ], $attributes)); + $memory->exists = true; + + return $memory; + } + }; + + passThroughBrainCircuitBreaker($this->app); + $this->app->instance(BrainService::class, $brain); + + $tool = new BrainRemember; + $result = $tool->handle([ + 'content' => 'Shared organisation memory.', + 'type' => 'fact', + 'org' => 'core', + ], [ + 'workspace_id' => $workspace->id, + 'session_id' => 'session-1', + ]); + + expect($tool->inputSchema()['properties'])->toHaveKey('org') + ->and($result['success'])->toBeTrue() + ->and($brain->remembered['org'])->toBe('core') + ->and($result['memory']['org'])->toBe('core'); +}); + +test('BrainSchemaOrg_brain_recall_Bad_accepts_org_filter_and_forwards_it', function (): void { + $workspace = createWorkspace(); + $brain = new class extends BrainService + { + public array $captured = []; + + public function recall( + string $query, + int $topK, + array $filter, + int $workspaceId, + array $keywords = [], + array $boostKeywords = [], + ): array { + $this->captured = [ + 'query' => $query, + 'topK' => $topK, + 'filter' => $filter, + 'workspace_id' => $workspaceId, + ]; + + return [ + 'memories' => [], + 'scores' => [], + ]; + } + }; + + passThroughBrainCircuitBreaker($this->app); + $this->app->instance(BrainService::class, $brain); + + $tool = new BrainRecall; + $result = $tool->handle([ + 'query' => 'org-filtered recall', + 'filter' => [ + 'org' => 'core', + ], + ], [ + 'workspace_id' => $workspace->id, + ]); + + expect($tool->inputSchema()['properties']['filter']['properties'])->toHaveKey('org') + ->and($result['success'])->toBeTrue() + ->and($brain->captured['filter']['org'])->toBe('core') + ->and($brain->captured['workspace_id'])->toBe($workspace->id); +}); + +test('BrainSchemaOrg_brain_list_Ugly_accepts_org_filter_without_validation_error', function (): void { + $workspace = createWorkspace(); + passThroughBrainCircuitBreaker($this->app); + $matching = BrainMemory::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'fact', + 'content' => 'Core memory.', + 'confidence' => 0.8, + 'org' => 'core', + 'project' => 'agent', + ]); + BrainMemory::create([ + 'workspace_id' => $workspace->id, + 'agent_id' => 'virgil', + 'type' => 'fact', + 'content' => 'Other org memory.', + 'confidence' => 0.8, + 'org' => 'other-org', + 'project' => 'agent', + ]); + + $tool = new BrainList; + $result = $tool->handle([ + 'org' => 'core', + ], [ + 'workspace_id' => $workspace->id, + ]); + + expect($tool->inputSchema()['properties'])->toHaveKey('org') + ->and($result['success'])->toBeTrue() + ->and($result['count'])->toBe(1) + ->and($result['memories'][0]['id'])->toBe($matching->id) + ->and($result['memories'][0]['org'])->toBe('core'); +});