From ed0dca8b784e34a76c72406c190650ed90d1c9fe Mon Sep 17 00:00:00 2001 From: kami Date: Thu, 11 Jun 2026 10:06:40 +0400 Subject: [PATCH] feat(kernel,artifacts): inject artifact-kind vocabulary into architect context --- .../kotlin/com/correx/apps/server/Main.kt | 11 +++++----- .../artifacts/kind/ArtifactKindRegistry.kt | 3 +++ .../kind/ArtifactKindRegistryTest.kt | 21 +++++++++++++++++++ .../kernel/orchestration/ContextFeedback.kt | 19 +++++++++++++++++ .../DefaultSessionOrchestrator.kt | 4 +++- .../orchestration/SessionOrchestrator.kt | 7 ++++++- examples/workflows/freestyle_planning.toml | 1 + .../workflow/TomlWorkflowLoader.kt | 2 ++ .../workflow/TomlWorkflowLoaderTest.kt | 20 ++++++++++++++++++ .../src/test/kotlin/ContextFeedbackTest.kt | 15 +++++++++++++ 10 files changed, 96 insertions(+), 7 deletions(-) diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 2cc8fed4..249c9619 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -272,6 +272,10 @@ fun main() { // Supplier reads the live threshold each compaction check (no orchestrator rebuild needed). tokenThreshold = { configHolder.get().orchestration.journalCompactionTokenThreshold }, ) + val configArtifactKindsEarly = loadConfigArtifactKinds(correxConfig) + val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg -> + configArtifactKindsEarly.forEach { reg.register(it) } + } val orchestrator = DefaultSessionOrchestrator( repositories = repositories, engines = engines, @@ -280,6 +284,7 @@ fun main() { tokenizer = firstProvider.tokenizer, decisionJournalRepository = decisionJournalRepository, compactionService = journalCompactionService, + artifactKindRegistry = artifactKindRegistry, ) val embedder = InfrastructureModule.createEmbedderFromConfig(correxConfig.router.embedder) val l3MemoryStore = InfrastructureModule.createL3MemoryStoreFromConfig(correxConfig.router.l3) @@ -365,10 +370,6 @@ fun main() { val projectMemory = buildProjectMemory(correxConfig) val profileAdaptationService: ProfileAdaptationService? = buildProfileAdaptation(correxConfig) - val configArtifactKinds = loadConfigArtifactKinds(correxConfig) - val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg -> - configArtifactKinds.forEach { reg.register(it) } - } val freestyleDriver = FreestyleDriver( eventStore = eventStore, compiler = ExecutionPlanCompiler(artifactKindRegistry), @@ -383,7 +384,7 @@ fun main() { artifactStore = artifactStore, sessionRepository = repositories.sessionRepository, workflowRegistry = FileSystemWorkflowRegistry( - InfrastructureModule.createWorkflowLoader(configArtifactKinds), + InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly), ), providerRegistry = infraRegistry.asServerRegistry(), defaultOrchestrationConfig = defaultOrchestrationConfig, diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistry.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistry.kt index bf07cc20..32b36eda 100644 --- a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistry.kt +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistry.kt @@ -3,6 +3,7 @@ package com.correx.core.artifacts.kind interface ArtifactKindRegistry { fun register(kind: ArtifactKind) fun get(id: String): ArtifactKind? + fun list(): Collection } class DefaultArtifactKindRegistry : ArtifactKindRegistry { @@ -18,4 +19,6 @@ class DefaultArtifactKindRegistry : ArtifactKindRegistry { } override fun get(id: String): ArtifactKind? = kinds[id] + + override fun list(): Collection = kinds.values.toList() } diff --git a/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistryTest.kt b/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistryTest.kt index c16cb1d1..fd60a70c 100644 --- a/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistryTest.kt +++ b/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistryTest.kt @@ -51,4 +51,25 @@ class ArtifactKindRegistryTest { assert("path" in schema.required) assert("content" in schema.required) } + + @Test + fun `list returns all registered kinds`() { + val ids = registry.list().map { it.id }.toSet() + assert("file_written" in ids) { "expected file_written in $ids" } + assert("process_result" in ids) { "expected process_result in $ids" } + assertEquals(2, ids.size) + } + + @Test + fun `list reflects newly registered kind`() { + val schema = JsonSchema( + type = "object", + properties = mapOf("x" to JsonSchemaProperty(type = "string")), + required = listOf("x"), + ) + registry.register(ConfigArtifactKind(id = "extra", schema = schema, llmEmitted = true)) + val ids = registry.list().map { it.id }.toSet() + assert("extra" in ids) { "expected extra in $ids" } + assertEquals(3, ids.size) + } } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt index fa95f9a0..cc1d13fe 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContextFeedback.kt @@ -3,6 +3,7 @@ package com.correx.core.kernel.orchestration // Context-entry builders kept top-level (not orchestrator members) for unit-testability // and to stay under detekt's function-count threshold (same pattern as isBackEdge). +import com.correx.core.artifacts.kind.ArtifactKind import com.correx.core.context.model.ContextEntry import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.EntryRole @@ -47,6 +48,24 @@ fun criticArtifactIds( return graph.stages[fromStage]?.produces?.map { it.name }?.toSet() ?: emptySet() } +fun buildArtifactKindVocabularyEntry(kinds: Collection): ContextEntry { + val listing = kinds.joinToString(", ") { k -> + if (k.llmEmitted) "${k.id} (llm-emitted)" else k.id + } + val content = "## Available artifact kinds\n" + + "Every stage's \"produces\" MUST be exactly one of: $listing\n" + + "Do not invent kinds — a plan referencing an unknown kind fails to compile." + return ContextEntry( + id = ContextEntryId(UUID.randomUUID().toString()), + layer = ContextLayer.L1, + content = content, + sourceType = "artifactKindVocabulary", + sourceId = "artifact-kinds", + tokenEstimate = content.length / 4, + role = EntryRole.SYSTEM, + ) +} + fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry { val content = buildString { append("## Project profile\n") diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt index a463725f..8f3b610d 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/DefaultSessionOrchestrator.kt @@ -7,6 +7,7 @@ import com.correx.core.inference.Tokenizer import com.correx.core.journal.DefaultDecisionJournalRepository import com.correx.core.journal.DecisionJournalRenderer import com.correx.core.artifacts.ArtifactState +import com.correx.core.artifacts.kind.ArtifactKindRegistry import com.correx.core.artifactstore.ArtifactStore import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalRequestedEvent @@ -55,7 +56,8 @@ class DefaultSessionOrchestrator( tokenizer: Tokenizer? = null, private val decisionJournalRepository: DefaultDecisionJournalRepository, private val compactionService: JournalCompactionService? = null, -) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository), ApprovalGateway { + artifactKindRegistry: ArtifactKindRegistry? = null, +) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry), ApprovalGateway { override val tokenizer: Tokenizer? = tokenizer override val cancellations: ConcurrentHashMap = ConcurrentHashMap() 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 ae4f0fb5..b6bdde2f 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 @@ -11,6 +11,7 @@ import com.correx.core.approvals.model.ApprovalDecision import com.correx.core.approvals.model.ApprovalScopeIdentity import com.correx.core.approvals.model.DomainApprovalRequest import com.correx.core.artifacts.ArtifactState +import com.correx.core.artifacts.kind.ArtifactKindRegistry import com.correx.core.artifacts.kind.JsonSchema import com.correx.core.artifacts.kind.FileWrittenArtifact import com.correx.core.artifacts.kind.ProcessResultArtifact @@ -153,6 +154,7 @@ abstract class SessionOrchestrator( protected val artifactStore: ArtifactStore, private val decisionJournalRepository: DefaultDecisionJournalRepository, private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(), + private val artifactKindRegistry: ArtifactKindRegistry? = null, ) { private val log = LoggerFactory.getLogger(this::class.java) private val eventStore: EventStore = repositories.eventStore @@ -374,9 +376,12 @@ abstract class SessionOrchestrator( ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList() val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId) ?.let { listOf(it) } ?: emptyList() + val vocabularyEntries = artifactKindRegistry + ?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" } + ?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList() var accumulatedEntries = systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + - needsEntries + schemaEntries + promptEntries + steeringEntries + retryFeedbackEntries + needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + retryFeedbackEntries val contextPack = contextPackBuilder.build( id = ContextPackId(UUID.randomUUID().toString()), sessionId = sessionId, diff --git a/examples/workflows/freestyle_planning.toml b/examples/workflows/freestyle_planning.toml index 5154644c..f97603fd 100644 --- a/examples/workflows/freestyle_planning.toml +++ b/examples/workflows/freestyle_planning.toml @@ -12,6 +12,7 @@ max_retries = 2 [[stages]] id = "architect" requires_approval = true +inject_artifact_kinds = true prompt = "prompts/architect_freestyle.md" needs = ["analysis"] produces = [{ name = "execution_plan", kind = "execution_plan" }] diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt index 8e524345..c2dce953 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt @@ -41,6 +41,7 @@ private data class StageSection( @param:JsonProperty("token_budget") val tokenBudget: Int = 4096, @param:JsonProperty("max_retries") val maxRetries: Int = 3, @param:JsonProperty("requires_approval") val requiresApproval: Boolean = false, + @param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false, ) // condition fields flattened into the transition row @@ -105,6 +106,7 @@ class TomlWorkflowLoader( s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) } s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) } if (s.requiresApproval) put("requiresApproval", "true") + if (s.injectArtifactKinds) put("injectArtifactKinds", "true") }, ) } diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt index ea64a16b..17777dc8 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt @@ -97,4 +97,24 @@ class TomlWorkflowLoaderTest { val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) } assertThrows { loader.load(path) } } + + @Test + fun `inject_artifact_kinds true maps to metadata key`() { + val toml = validToml.replace( + "prompt = \"prompts/collect.md\"", + "prompt = \"prompts/collect.md\"\ninject_artifact_kinds = true", + ) + val path = Files.createTempFile("workflow", ".toml").also { it.writeText(toml) } + val graph = loader.load(path) + val collectStage = graph.stages[graph.start]!! + assertEquals("true", collectStage.metadata["injectArtifactKinds"]) + } + + @Test + fun `inject_artifact_kinds absent means no metadata key`() { + val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) } + val graph = loader.load(path) + val collectStage = graph.stages[graph.start]!! + assertEquals(null, collectStage.metadata["injectArtifactKinds"]) + } } diff --git a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt index 3762ce0d..840cfbb0 100644 --- a/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt +++ b/testing/kernel/src/test/kotlin/ContextFeedbackTest.kt @@ -1,4 +1,6 @@ +import com.correx.core.artifacts.kind.ConfigArtifactKind import com.correx.core.artifacts.kind.FileWrittenKind +import com.correx.core.artifacts.kind.JsonSchema import com.correx.core.artifacts.kind.TypedArtifactSlot import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.EntryRole @@ -8,6 +10,7 @@ import com.correx.core.events.types.ArtifactId 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.kernel.orchestration.buildArtifactKindVocabularyEntry import com.correx.core.kernel.orchestration.buildProjectProfileEntry import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry import com.correx.core.kernel.orchestration.criticArtifactIds @@ -94,6 +97,18 @@ class ContextFeedbackTest { assertEquals(setOf(ArtifactId("review_report")), criticArtifactIds(events, graph, StageId("implement"))) } + @Test + fun `vocabulary entry enumerates kind ids and flags llm-emitted`() { + val plain = ConfigArtifactKind(id = "plan", schema = JsonSchema(type = "object"), llmEmitted = false) + val emitted = ConfigArtifactKind(id = "report", schema = JsonSchema(type = "object"), llmEmitted = true) + val entry = buildArtifactKindVocabularyEntry(listOf(plain, emitted)) + assertEquals("artifactKindVocabulary", entry.sourceType) + assertEquals(ContextLayer.L1, entry.layer) + assertEquals(EntryRole.SYSTEM, entry.role) + assertTrue(entry.content.contains("plan"), "content should contain plain id: ${entry.content}") + assertTrue(entry.content.contains("report (llm-emitted)"), "content should flag llm-emitted: ${entry.content}") + } + @Test fun `no refinement events yields empty set`() { assertTrue(criticArtifactIds(emptyList(), graphWith(), StageId("implement")).isEmpty())