fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer

Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md.

- Gap A (org scoping persisted on writes): new migration adds `org`
  nullable+indexed column to brain_memories; BrainMemory fillable;
  RememberKnowledge action forwards org; BrainService::remember
  persists it.

- Gap B (supersede/forget Elastic cleanup): BrainService::forget
  dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede
  path dispatches cleanup for the old memory id before replacing it.
  DeleteFromIndex itself untouched — already handled both indexes.

- Gap C (brain:reindex flags): --org, --project, --stale (null OR
  >14d old), --dry-run (count+stop), --elastic-only added to the
  artisan command.

- Gap D (MCP schemas expose org): brain_remember, brain_recall,
  brain_list now accept `org` in input schema + forward into
  action/service.

- Gap E (resilience uneven): brain_list now wrapped in
  withCircuitBreaker('brain', ...) matching the pattern used by
  BrainRemember/Recall/Forget. BrainService gains retryableHttp()
  helper — 100/300/900ms exponential backoff, retries only on 5xx +
  connection errors, not on 4xx. Qdrant calls route through it;
  Ollama left alone (EmbedMemory job has its own retry).

Tests (Good/Bad/Ugly per gap):
- Feature/Brain/OrgScopingTest.php
- Feature/Brain/SupersedeForgetIndexCleanupTest.php
- Feature/Brain/ReindexFlagsTest.php
- Feature/Mcp/BrainSchemaOrgTest.php
- Feature/Brain/CircuitBreakerTest.php

php -l clean on all 13 files. Pest binary not in this checkout —
CI path validates the full suite.

Closes tasks.lthn.sh/view.php?id=107

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-24 08:14:06 +01:00
parent 8f3b39983a
commit c616ff1e32
13 changed files with 898 additions and 33 deletions

View file

@ -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,

View file

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

View file

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

View file

@ -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',

View file

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

View file

@ -0,0 +1,41 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'brain';
public function up(): 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->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');
});
}
};

View file

@ -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),

View file

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

View file

@ -0,0 +1,82 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mcp\Services\CircuitBreaker;
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
function retryableBrainService(): BrainService
{
return new class extends BrainService
{
public array $sleepCalls = [];
protected function sleepMilliseconds(int $milliseconds): void
{
$this->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);
});

View file

@ -0,0 +1,131 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
function orgScopingBrainService(): BrainService
{
return new BrainService(
ollamaUrl: 'https://ollama.test',
qdrantUrl: 'https://qdrant.test',
collection: 'openbrain',
embeddingModel: 'embeddinggemma',
verifySsl: false,
elasticsearchUrl: 'https://elasticsearch.test',
);
}
function orgScopingMemory(int $workspaceId, array $attributes = []): BrainMemory
{
return BrainMemory::create(array_merge([
'workspace_id' => $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');
});

View file

@ -0,0 +1,137 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Console\Commands\BrainReindexCommand;
use Core\Mod\Agentic\Jobs\EmbedMemory;
use Core\Mod\Agentic\Models\BrainMemory;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Queue\CallQueuedClosure;
use Illuminate\Support\Facades\Queue;
beforeEach(function (): void {
$this->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);
});

View file

@ -0,0 +1,97 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Jobs\DeleteFromIndex;
use Core\Mod\Agentic\Jobs\EmbedMemory;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Queue;
function cleanupBrainService(): BrainService
{
return new BrainService;
}
function cleanupMemory(array $attributes = []): BrainMemory
{
return BrainMemory::create(array_merge([
'workspace_id' => $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);
});

View file

@ -0,0 +1,155 @@
<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mcp\Services\CircuitBreaker;
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainList;
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainRecall;
use Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainRemember;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Str;
function passThroughBrainCircuitBreaker($app): void
{
$breaker = Mockery::mock(CircuitBreaker::class);
$breaker->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');
});