feat(kernel): inject needs-artifact content into stage context
Stages declaring needs=[...] now receive each needed artifact's validated JSON (from artifactContentCache) as an L1/USER neededArtifact context entry, so a one-shot subagent sees its inputs. Also emit llm-emitted artifact lifecycle events (Created/Validating/Validated) on successful validation via emitLlmArtifacts, closing a latent gap where verifyProduces would fail for stages producing only an llm-emitted artifact. Events are emitted only after validation passes, preserving invariant #7.
This commit is contained in:
+37
-1
@@ -306,8 +306,9 @@ abstract class SessionOrchestrator(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
val repoMapEntries = buildRepoMapEntries(sessionId)
|
val repoMapEntries = buildRepoMapEntries(sessionId)
|
||||||
|
val needsEntries = buildNeedsArtifactEntries(sessionId, stageConfig)
|
||||||
var accumulatedEntries =
|
var accumulatedEntries =
|
||||||
systemPrompt + journalEntries + repoMapEntries + schemaEntries + promptEntries + steeringEntries
|
systemPrompt + journalEntries + repoMapEntries + needsEntries + schemaEntries + promptEntries + steeringEntries
|
||||||
val contextPack = contextPackBuilder.build(
|
val contextPack = contextPackBuilder.build(
|
||||||
id = ContextPackId(UUID.randomUUID().toString()),
|
id = ContextPackId(UUID.randomUUID().toString()),
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -384,6 +385,7 @@ abstract class SessionOrchestrator(
|
|||||||
when (val outcome = mapValidationOutcome(sessionId, stageId, validationCtx)) {
|
when (val outcome = mapValidationOutcome(sessionId, stageId, validationCtx)) {
|
||||||
is StageExecutionResult.Success -> {
|
is StageExecutionResult.Success -> {
|
||||||
emitToolArtifacts(sessionId, stageId, stageConfig)
|
emitToolArtifacts(sessionId, stageId, stageConfig)
|
||||||
|
emitLlmArtifacts(sessionId, stageId, stageConfig)
|
||||||
verifyProduces(sessionId, stageId, stageConfig)
|
verifyProduces(sessionId, stageId, stageConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,6 +845,26 @@ abstract class SessionOrchestrator(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun buildNeedsArtifactEntries(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageConfig: StageConfig,
|
||||||
|
): List<ContextEntry> {
|
||||||
|
return stageConfig.needs.mapNotNull { needed ->
|
||||||
|
val cached = artifactContentCache["${sessionId.value}:${needed.value}"]
|
||||||
|
?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||||
|
val content = "## Input artifact: ${needed.value}\n$cached"
|
||||||
|
ContextEntry(
|
||||||
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
|
layer = ContextLayer.L1,
|
||||||
|
content = content,
|
||||||
|
sourceType = "neededArtifact",
|
||||||
|
sourceId = needed.value,
|
||||||
|
tokenEstimate = estimateTokens(content),
|
||||||
|
role = EntryRole.USER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun emitToolArtifacts(
|
private suspend fun emitToolArtifacts(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
@@ -857,6 +879,20 @@ abstract class SessionOrchestrator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun emitLlmArtifacts(
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
stageConfig: StageConfig,
|
||||||
|
) {
|
||||||
|
stageConfig.produces
|
||||||
|
.filter { it.kind.llmEmitted }
|
||||||
|
.forEach { slot ->
|
||||||
|
emit(sessionId, ArtifactCreatedEvent(slot.name, sessionId, stageId, schemaVersion = 1))
|
||||||
|
emit(sessionId, ArtifactValidatingEvent(slot.name, sessionId, stageId))
|
||||||
|
emit(sessionId, ArtifactValidatedEvent(slot.name, sessionId, stageId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun verifyProduces(
|
private fun verifyProduces(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import com.correx.core.approvals.ApprovalProjector
|
||||||
|
import com.correx.core.approvals.DefaultApprovalReducer
|
||||||
|
import com.correx.core.approvals.DefaultApprovalRepository
|
||||||
|
import com.correx.core.approvals.domain.DefaultApprovalEngine
|
||||||
|
import com.correx.core.artifacts.DefaultArtifactReducer
|
||||||
|
import com.correx.core.artifacts.kind.ConfigArtifactKind
|
||||||
|
import com.correx.core.artifacts.kind.JsonSchema
|
||||||
|
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||||
|
import com.correx.core.context.builder.ContextPackBuilder
|
||||||
|
import com.correx.core.context.model.CompressionMetadata
|
||||||
|
import com.correx.core.context.model.ContextEntry
|
||||||
|
import com.correx.core.context.model.ContextPack
|
||||||
|
import com.correx.core.context.model.TokenBudget
|
||||||
|
import com.correx.core.events.execution.RetryPolicy
|
||||||
|
import com.correx.core.events.types.ArtifactId
|
||||||
|
import com.correx.core.events.types.ContextPackId
|
||||||
|
import com.correx.core.events.types.SessionId
|
||||||
|
import com.correx.core.events.types.StageId
|
||||||
|
import com.correx.core.events.types.TransitionId
|
||||||
|
import com.correx.core.inference.InferenceRepository
|
||||||
|
import com.correx.core.inference.InferenceState
|
||||||
|
import com.correx.core.inference.ModelCapability
|
||||||
|
import com.correx.core.journal.DecisionJournalProjector
|
||||||
|
import com.correx.core.journal.DefaultDecisionJournalReducer
|
||||||
|
import com.correx.core.journal.DefaultDecisionJournalRepository
|
||||||
|
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
|
||||||
|
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationProjector
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||||
|
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||||
|
import com.correx.core.kernel.retry.DefaultRetryCoordinator
|
||||||
|
import com.correx.core.risk.DefaultRiskAssessor
|
||||||
|
import com.correx.core.sessions.DefaultSessionRepository
|
||||||
|
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
|
||||||
|
import com.correx.core.sessions.projections.replay.EventReplayer
|
||||||
|
import com.correx.core.transitions.graph.StageConfig
|
||||||
|
import com.correx.core.transitions.graph.TransitionEdge
|
||||||
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
|
import com.correx.core.validation.pipeline.ValidationPipeline
|
||||||
|
import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||||
|
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
|
||||||
|
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
|
||||||
|
import com.correx.testing.fixtures.InferenceFixtures
|
||||||
|
import com.correx.testing.fixtures.WorkflowFixtures
|
||||||
|
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 kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests verifying that a stage's declared [StageConfig.needs] artifact ids are
|
||||||
|
* injected into its context pack as "neededArtifact" entries, sourced from artifactContentCache.
|
||||||
|
*/
|
||||||
|
class NeedsArtifactInjectionTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A capturing [ContextPackBuilder] that records every entry list it sees so tests
|
||||||
|
* can assert on what was assembled for each stage.
|
||||||
|
*/
|
||||||
|
private val capturedEntries = mutableListOf<ContextEntry>()
|
||||||
|
|
||||||
|
private val capturingBuilder = object : ContextPackBuilder {
|
||||||
|
override fun build(
|
||||||
|
id: ContextPackId,
|
||||||
|
sessionId: SessionId,
|
||||||
|
stageId: StageId,
|
||||||
|
entries: List<ContextEntry>,
|
||||||
|
budget: TokenBudget,
|
||||||
|
): ContextPack {
|
||||||
|
capturedEntries.addAll(entries)
|
||||||
|
return ContextPack(
|
||||||
|
id = id,
|
||||||
|
sessionId = sessionId,
|
||||||
|
stageId = stageId,
|
||||||
|
layers = emptyMap(),
|
||||||
|
budgetUsed = 0,
|
||||||
|
budgetLimit = budget.limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val eventStore = InMemoryEventStore()
|
||||||
|
private val sessionReplayer = MockSessionEventReplayer()
|
||||||
|
private val sessionRepository = DefaultSessionRepository(sessionReplayer)
|
||||||
|
private val orchestrationRepository = OrchestrationRepository(
|
||||||
|
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
|
||||||
|
)
|
||||||
|
private val inferenceRepository = InferenceRepository(
|
||||||
|
object : EventReplayer<InferenceState> {
|
||||||
|
override fun rebuild(sessionId: SessionId) = InferenceState()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
private val approvalRepository = DefaultApprovalRepository(
|
||||||
|
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
|
||||||
|
)
|
||||||
|
private val decisionJournalRepository = DefaultDecisionJournalRepository(
|
||||||
|
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
|
||||||
|
)
|
||||||
|
private val artifactStore = NoopArtifactStore()
|
||||||
|
private val retryCoordinator = DefaultRetryCoordinator(eventStore)
|
||||||
|
|
||||||
|
private val repositories = OrchestratorRepositories(
|
||||||
|
eventStore = eventStore,
|
||||||
|
inferenceRepository = inferenceRepository,
|
||||||
|
orchestrationRepository = orchestrationRepository,
|
||||||
|
sessionRepository = sessionRepository,
|
||||||
|
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
|
||||||
|
approvalRepository = approvalRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildOrchestrator(inferenceRouter: com.correx.core.inference.InferenceRouter) =
|
||||||
|
DefaultSessionOrchestrator(
|
||||||
|
repositories = repositories,
|
||||||
|
engines = OrchestratorEngines(
|
||||||
|
transitionResolver = TransitionFixtures.simpleResolver(),
|
||||||
|
contextPackBuilder = capturingBuilder,
|
||||||
|
inferenceRouter = inferenceRouter,
|
||||||
|
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
|
||||||
|
approvalEngine = DefaultApprovalEngine(),
|
||||||
|
riskAssessor = DefaultRiskAssessor(),
|
||||||
|
),
|
||||||
|
retryCoordinator = retryCoordinator,
|
||||||
|
artifactStore = artifactStore,
|
||||||
|
decisionJournalRepository = decisionJournalRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val defaultConfig = OrchestrationConfig(
|
||||||
|
retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage A produces an llm-emitted artifact ("analysis") so the orchestrator stores the
|
||||||
|
* inference response text in artifactContentCache. Stage B declares needs=[analysis] and
|
||||||
|
* should receive a "neededArtifact" context entry with that content.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `stage with needs receives artifact content in context entries`(): Unit = runBlocking {
|
||||||
|
val sessionId = SessionId("needs-test-1")
|
||||||
|
val stageA = StageId("A")
|
||||||
|
val stageB = StageId("B")
|
||||||
|
val artifactId = ArtifactId("analysis")
|
||||||
|
val artifactJson = """{"summary":"important findings"}"""
|
||||||
|
|
||||||
|
val analysisKind = ConfigArtifactKind(
|
||||||
|
id = "analysis",
|
||||||
|
schema = JsonSchema(type = "object", properties = emptyMap()),
|
||||||
|
llmEmitted = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
val stageC = StageId("C")
|
||||||
|
val graph = WorkflowGraph(
|
||||||
|
id = "needs-injection-test",
|
||||||
|
stages = mapOf(
|
||||||
|
stageA to StageConfig(
|
||||||
|
produces = listOf(TypedArtifactSlot(name = artifactId, kind = analysisKind)),
|
||||||
|
),
|
||||||
|
stageB to StageConfig(
|
||||||
|
needs = setOf(artifactId),
|
||||||
|
),
|
||||||
|
stageC to StageConfig(),
|
||||||
|
),
|
||||||
|
transitions = setOf(
|
||||||
|
TransitionEdge(id = TransitionId("t1"), from = stageA, to = stageB, condition = { true }),
|
||||||
|
TransitionEdge(id = TransitionId("t2"), from = stageB, to = stageC, condition = { true }),
|
||||||
|
),
|
||||||
|
start = stageA,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stage A returns the artifact JSON so it gets cached; stages B and C return generic text.
|
||||||
|
val router = object : com.correx.core.inference.InferenceRouter {
|
||||||
|
private var callCount = 0
|
||||||
|
override suspend fun route(
|
||||||
|
stageId: StageId,
|
||||||
|
requiredCapabilities: Set<ModelCapability>,
|
||||||
|
) = MockInferenceProvider(
|
||||||
|
fixedResponse = if (callCount++ == 0) artifactJson else "stage response",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildOrchestrator(router).run(sessionId, graph, defaultConfig)
|
||||||
|
|
||||||
|
val needsEntries = capturedEntries.filter { it.sourceType == "neededArtifact" }
|
||||||
|
assertTrue(needsEntries.isNotEmpty(), "Expected at least one neededArtifact entry")
|
||||||
|
val analysisEntry = needsEntries.find { it.sourceId == "analysis" }
|
||||||
|
assertFalse(analysisEntry == null, "Expected a neededArtifact entry with sourceId=analysis")
|
||||||
|
assertTrue(
|
||||||
|
analysisEntry!!.content.contains(artifactJson),
|
||||||
|
"Entry content should include artifact JSON, got: ${analysisEntry.content}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `stage with empty needs adds no neededArtifact entries`(): Unit = runBlocking {
|
||||||
|
val sessionId = SessionId("needs-test-2")
|
||||||
|
val graph = WorkflowFixtures.simpleGraph()
|
||||||
|
|
||||||
|
buildOrchestrator(InferenceFixtures.fixedRouter()).run(sessionId, graph, defaultConfig)
|
||||||
|
|
||||||
|
val needsEntries = capturedEntries.filter { it.sourceType == "neededArtifact" }
|
||||||
|
assertTrue(needsEntries.isEmpty(), "No neededArtifact entries expected for stages with empty needs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing cache content for declared need is skipped without crash`(): Unit = runBlocking {
|
||||||
|
val sessionId = SessionId("needs-test-3")
|
||||||
|
val stageA = StageId("A")
|
||||||
|
val stageB = StageId("B")
|
||||||
|
val missingArtifactId = ArtifactId("missing_artifact")
|
||||||
|
|
||||||
|
val graph = WorkflowGraph(
|
||||||
|
id = "missing-needs-test",
|
||||||
|
stages = mapOf(
|
||||||
|
stageA to StageConfig(),
|
||||||
|
stageB to StageConfig(needs = setOf(missingArtifactId)),
|
||||||
|
),
|
||||||
|
transitions = setOf(
|
||||||
|
TransitionEdge(id = TransitionId("t1"), from = stageA, to = stageB, condition = { true }),
|
||||||
|
),
|
||||||
|
start = stageA,
|
||||||
|
)
|
||||||
|
|
||||||
|
// No llm-emitted artifact in stageA → cache never populated for missingArtifactId
|
||||||
|
buildOrchestrator(InferenceFixtures.fixedRouter()).run(sessionId, graph, defaultConfig)
|
||||||
|
|
||||||
|
val needsEntries = capturedEntries.filter { it.sourceType == "neededArtifact" }
|
||||||
|
assertTrue(needsEntries.isEmpty(), "Missing cache content should produce no neededArtifact entries")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user