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:
parent
8f3b39983a
commit
c616ff1e32
13 changed files with 898 additions and 33 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
php/tests/Feature/Brain/CircuitBreakerTest.php
Normal file
82
php/tests/Feature/Brain/CircuitBreakerTest.php
Normal 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);
|
||||
});
|
||||
131
php/tests/Feature/Brain/OrgScopingTest.php
Normal file
131
php/tests/Feature/Brain/OrgScopingTest.php
Normal 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');
|
||||
});
|
||||
137
php/tests/Feature/Brain/ReindexFlagsTest.php
Normal file
137
php/tests/Feature/Brain/ReindexFlagsTest.php
Normal 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);
|
||||
});
|
||||
97
php/tests/Feature/Brain/SupersedeForgetIndexCleanupTest.php
Normal file
97
php/tests/Feature/Brain/SupersedeForgetIndexCleanupTest.php
Normal 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);
|
||||
});
|
||||
155
php/tests/Feature/Mcp/BrainSchemaOrgTest.php
Normal file
155
php/tests/Feature/Mcp/BrainSchemaOrgTest.php
Normal 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');
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue