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>
155 lines
4.8 KiB
PHP
155 lines
4.8 KiB
PHP
<?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');
|
|
});
|