From 8667a59e91d1ef21724a6545a8ddfeae99ffcebb Mon Sep 17 00:00:00 2001 From: Nikola Katsarov Date: Tue, 4 Aug 2026 12:14:22 +0300 Subject: [PATCH] =?UTF-8?q?fix(memory):=20team-scoped=20memories=20were=20?= =?UTF-8?q?never=20stored=20=E2=80=94=20null=20agent=5Fid=20threw=20a=20sw?= =?UTF-8?q?allowed=20TypeError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StoreMemoryAction::execute()` advertises `?string $agentId`, but storeChunk() and every write-gate helper below it declared a non-nullable `string`. Any team-scoped write — DistillTeamEventsAction passes `agentId: null` explicitly — threw: StoreMemoryAction::storeChunk(): Argument #2 ($agentId) must be of type string, null given, called in .../StoreMemoryAction.php on line 88 execute() wraps the loop in try/catch and logs that at WARNING, so the failure never surfaced: the job "succeeded", the distillation watermark advanced past the events, and nothing was stored. Reproduced on production — `memories` holds 0 rows, and a live distil run returned 61 events in / 0 stored while the model had produced a valid 625-char digest. Widen $agentId to ?string through storeChunk, evaluateWriteGate, handleUpdate, handleAdd and mergeContent. The `where('agent_id', $agentId)` lookups need no change: Laravel's where() maps a null value to `whereNull`. Found by validating the failed_jobs backlog instead of assuming the recent silence meant the failures were fixed. --- .../Memory/Actions/StoreMemoryAction.php | 10 ++++----- .../Memory/Actions/StoreMemoryActionTest.php | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/Domain/Memory/Actions/StoreMemoryAction.php b/app/Domain/Memory/Actions/StoreMemoryAction.php index ba89de276..e21e40f75 100644 --- a/app/Domain/Memory/Actions/StoreMemoryAction.php +++ b/app/Domain/Memory/Actions/StoreMemoryAction.php @@ -163,7 +163,7 @@ private function normalizeRejectedAlternatives(array $raw): array */ private function storeChunk( string $teamId, - string $agentId, + ?string $agentId, string $chunk, string $sourceType, ?string $projectId, @@ -225,7 +225,7 @@ private function storeChunk( */ private function evaluateWriteGate( string $teamId, - string $agentId, + ?string $agentId, string $contentHash, string $embedding, ): WriteGateResult { @@ -304,7 +304,7 @@ private function handleUpdate( string $newContent, string $newEmbedding, string $teamId, - string $agentId, + ?string $agentId, float $newConfidence, float $newImportance, array $newTags, @@ -347,7 +347,7 @@ private function handleUpdate( private function handleAdd( string $teamId, - string $agentId, + ?string $agentId, string $chunk, string $embedding, string $contentHash, @@ -409,7 +409,7 @@ private function handleAdd( /** * LLM-assisted merge of two related facts. */ - private function mergeContent(string $existing, string $new, string $teamId, string $agentId): ?string + private function mergeContent(string $existing, string $new, string $teamId, ?string $agentId): ?string { if (! $this->gateway) { return null; diff --git a/tests/Unit/Domain/Memory/Actions/StoreMemoryActionTest.php b/tests/Unit/Domain/Memory/Actions/StoreMemoryActionTest.php index 94e990125..ef911f67b 100644 --- a/tests/Unit/Domain/Memory/Actions/StoreMemoryActionTest.php +++ b/tests/Unit/Domain/Memory/Actions/StoreMemoryActionTest.php @@ -35,6 +35,28 @@ public function test_chunk_content_splits_long_content_by_paragraphs(): void $this->assertStringContainsString('A', $chunks[0]); } + public function test_team_level_memory_accepts_a_null_agent_id(): void + { + // execute() advertises `?string $agentId`, but storeChunk() and the + // write-gate helpers below it declared a non-nullable `string`. Every + // team-scoped write — DistillTeamEventsAction passes `agentId: null` + // explicitly — therefore threw a TypeError that execute()'s catch + // swallowed into a Log::warning, so nothing was ever stored and no + // error surfaced. Production had 0 rows in `memories`. + $action = new StoreMemoryAction; + + foreach (['storeChunk', 'evaluateWriteGate', 'handleUpdate', 'handleAdd', 'mergeContent'] as $name) { + $param = collect((new \ReflectionMethod($action, $name))->getParameters()) + ->firstWhere(fn (\ReflectionParameter $p) => $p->getName() === 'agentId'); + + $this->assertNotNull($param, "{$name}() should take an \$agentId"); + $this->assertTrue( + $param->getType()?->allowsNull(), + "{$name}() must accept a null \$agentId — team-scoped memories have no agent", + ); + } + } + public function test_execute_returns_empty_array_when_content_is_empty(): void { $action = new StoreMemoryAction;