fix(kernel): llm-emitted artifacts require stored content — close phantom path
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.
This commit is contained in:
+11
@@ -1158,6 +1158,17 @@ abstract class SessionOrchestrator(
|
|||||||
stageConfig.produces
|
stageConfig.produces
|
||||||
.filter { it.kind.llmEmitted }
|
.filter { it.kind.llmEmitted }
|
||||||
.forEach { slot ->
|
.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, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
|
||||||
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
|
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
|
||||||
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
|
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import com.correx.testing.fixtures.cyclePolicyMissingValidator
|
|||||||
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
||||||
import com.correx.testing.fixtures.transitions.TransitionFixtures
|
import com.correx.testing.fixtures.transitions.TransitionFixtures
|
||||||
import com.correx.testing.kernel.MockSessionEventReplayer
|
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 kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
@@ -231,4 +233,64 @@ class NeedsArtifactInjectionTest {
|
|||||||
val needsEntries = capturedEntries.filter { it.sourceType == "neededArtifact" }
|
val needsEntries = capturedEntries.filter { it.sourceType == "neededArtifact" }
|
||||||
assertTrue(needsEntries.isEmpty(), "Missing cache content should produce no neededArtifact entries")
|
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<ModelCapability>,
|
||||||
|
) = 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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user