From fba2bbb17e8e2ff889283d36b4fb3ebc12bce092 Mon Sep 17 00:00:00 2001 From: kami Date: Wed, 10 Jun 2026 21:20:28 +0400 Subject: [PATCH] =?UTF-8?q?fix(kernel):=20llm-emitted=20artifacts=20requir?= =?UTF-8?q?e=20stored=20content=20=E2=80=94=20close=20phantom=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitLlmArtifacts previously emitted ArtifactCreated/Validating/Validated for every llm-emitted slot unconditionally, fabricating "validated" artifacts when the model produced no content. Gate now mirrors the F-015 file_written check: slots with null or blank cached content are skipped with a warn log. Regression test added in NeedsArtifactInjectionTest verifies no artifact events appear for a blank-response stage. --- .../orchestration/SessionOrchestrator.kt | 11 ++++ .../test/kotlin/NeedsArtifactInjectionTest.kt | 62 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index f1dc3eee..f959cc5d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -1158,6 +1158,17 @@ abstract class SessionOrchestrator( stageConfig.produces .filter { it.kind.llmEmitted } .forEach { slot -> + // Same rule as the file_written gate (F-015): no stored content means the + // model never produced this artifact — do not fabricate a validated one. + val cached = artifactContentCache["${sessionId.value}:${slot.name.value}"] + if (cached.isNullOrBlank()) { + log.warn( + "[Orchestrator] stage={} slot={}: no content stored — skipping artifact emission", + stageId.value, + slot.name.value, + ) + return@forEach + } emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1)) emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId)) emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId)) diff --git a/testing/integration/src/test/kotlin/NeedsArtifactInjectionTest.kt b/testing/integration/src/test/kotlin/NeedsArtifactInjectionTest.kt index d81b106d..5da0f197 100644 --- a/testing/integration/src/test/kotlin/NeedsArtifactInjectionTest.kt +++ b/testing/integration/src/test/kotlin/NeedsArtifactInjectionTest.kt @@ -48,6 +48,8 @@ import com.correx.testing.fixtures.cyclePolicyMissingValidator import com.correx.testing.fixtures.inference.MockInferenceProvider import com.correx.testing.fixtures.transitions.TransitionFixtures import com.correx.testing.kernel.MockSessionEventReplayer +import com.correx.core.events.events.ArtifactCreatedEvent +import com.correx.core.events.events.ArtifactValidatedEvent import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -231,4 +233,64 @@ class NeedsArtifactInjectionTest { val needsEntries = capturedEntries.filter { it.sourceType == "neededArtifact" } assertTrue(needsEntries.isEmpty(), "Missing cache content should produce no neededArtifact entries") } + + /** + * Regression test for the phantom-artifact bug: when an llm-emitted slot's inference + * response is blank, emitLlmArtifacts must NOT emit ArtifactCreatedEvent or + * ArtifactValidatedEvent. Only a slot with non-blank cached content should produce events. + */ + @Test + fun `blank inference output does not emit artifact events for llm-emitted slot`(): Unit = runBlocking { + val sessionId = SessionId("phantom-artifact-test-1") + val stageA = StageId("A") + val stageB = StageId("B") + val blankArtifactId = ArtifactId("blank_output") + + val blankKind = ConfigArtifactKind( + id = "blank_output", + schema = JsonSchema(type = "object", properties = emptyMap()), + llmEmitted = true, + ) + + val graph = WorkflowGraph( + id = "phantom-artifact-graph", + stages = mapOf( + stageA to StageConfig( + produces = listOf(TypedArtifactSlot(name = blankArtifactId, kind = blankKind)), + ), + stageB to StageConfig(), + ), + transitions = setOf( + TransitionEdge(id = TransitionId("t1"), from = stageA, to = stageB, condition = { true }), + ), + start = stageA, + ) + + // Stage A returns blank/whitespace — content cache will not be populated + val router = object : com.correx.core.inference.InferenceRouter { + override suspend fun route( + stageId: StageId, + requiredCapabilities: Set, + ) = MockInferenceProvider(fixedResponse = " ") + } + + buildOrchestrator(router).run(sessionId, graph, defaultConfig) + + val events = eventStore.read(sessionId) + val createdForBlank = events.filter { e -> + (e.payload as? ArtifactCreatedEvent)?.artifactId == blankArtifactId + } + val validatedForBlank = events.filter { e -> + (e.payload as? ArtifactValidatedEvent)?.artifactId == blankArtifactId + } + + assertTrue( + createdForBlank.isEmpty(), + "ArtifactCreatedEvent must not be emitted when inference output is blank", + ) + assertTrue( + validatedForBlank.isEmpty(), + "ArtifactValidatedEvent must not be emitted when inference output is blank", + ) + } }