feat(kernel,artifacts): inject artifact-kind vocabulary into architect context

This commit is contained in:
2026-06-11 10:06:40 +04:00
parent beed501d60
commit ed0dca8b78
10 changed files with 96 additions and 7 deletions
@@ -272,6 +272,10 @@ fun main() {
// Supplier reads the live threshold each compaction check (no orchestrator rebuild needed). // Supplier reads the live threshold each compaction check (no orchestrator rebuild needed).
tokenThreshold = { configHolder.get().orchestration.journalCompactionTokenThreshold }, tokenThreshold = { configHolder.get().orchestration.journalCompactionTokenThreshold },
) )
val configArtifactKindsEarly = loadConfigArtifactKinds(correxConfig)
val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg ->
configArtifactKindsEarly.forEach { reg.register(it) }
}
val orchestrator = DefaultSessionOrchestrator( val orchestrator = DefaultSessionOrchestrator(
repositories = repositories, repositories = repositories,
engines = engines, engines = engines,
@@ -280,6 +284,7 @@ fun main() {
tokenizer = firstProvider.tokenizer, tokenizer = firstProvider.tokenizer,
decisionJournalRepository = decisionJournalRepository, decisionJournalRepository = decisionJournalRepository,
compactionService = journalCompactionService, compactionService = journalCompactionService,
artifactKindRegistry = artifactKindRegistry,
) )
val embedder = InfrastructureModule.createEmbedderFromConfig(correxConfig.router.embedder) val embedder = InfrastructureModule.createEmbedderFromConfig(correxConfig.router.embedder)
val l3MemoryStore = InfrastructureModule.createL3MemoryStoreFromConfig(correxConfig.router.l3) val l3MemoryStore = InfrastructureModule.createL3MemoryStoreFromConfig(correxConfig.router.l3)
@@ -365,10 +370,6 @@ fun main() {
val projectMemory = buildProjectMemory(correxConfig) val projectMemory = buildProjectMemory(correxConfig)
val profileAdaptationService: ProfileAdaptationService? = buildProfileAdaptation(correxConfig) val profileAdaptationService: ProfileAdaptationService? = buildProfileAdaptation(correxConfig)
val configArtifactKinds = loadConfigArtifactKinds(correxConfig)
val artifactKindRegistry = DefaultArtifactKindRegistry().also { reg ->
configArtifactKinds.forEach { reg.register(it) }
}
val freestyleDriver = FreestyleDriver( val freestyleDriver = FreestyleDriver(
eventStore = eventStore, eventStore = eventStore,
compiler = ExecutionPlanCompiler(artifactKindRegistry), compiler = ExecutionPlanCompiler(artifactKindRegistry),
@@ -383,7 +384,7 @@ fun main() {
artifactStore = artifactStore, artifactStore = artifactStore,
sessionRepository = repositories.sessionRepository, sessionRepository = repositories.sessionRepository,
workflowRegistry = FileSystemWorkflowRegistry( workflowRegistry = FileSystemWorkflowRegistry(
InfrastructureModule.createWorkflowLoader(configArtifactKinds), InfrastructureModule.createWorkflowLoader(configArtifactKindsEarly),
), ),
providerRegistry = infraRegistry.asServerRegistry(), providerRegistry = infraRegistry.asServerRegistry(),
defaultOrchestrationConfig = defaultOrchestrationConfig, defaultOrchestrationConfig = defaultOrchestrationConfig,
@@ -3,6 +3,7 @@ package com.correx.core.artifacts.kind
interface ArtifactKindRegistry { interface ArtifactKindRegistry {
fun register(kind: ArtifactKind) fun register(kind: ArtifactKind)
fun get(id: String): ArtifactKind? fun get(id: String): ArtifactKind?
fun list(): Collection<ArtifactKind>
} }
class DefaultArtifactKindRegistry : ArtifactKindRegistry { class DefaultArtifactKindRegistry : ArtifactKindRegistry {
@@ -18,4 +19,6 @@ class DefaultArtifactKindRegistry : ArtifactKindRegistry {
} }
override fun get(id: String): ArtifactKind? = kinds[id] override fun get(id: String): ArtifactKind? = kinds[id]
override fun list(): Collection<ArtifactKind> = kinds.values.toList()
} }
@@ -51,4 +51,25 @@ class ArtifactKindRegistryTest {
assert("path" in schema.required) assert("path" in schema.required)
assert("content" 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)
}
} }
@@ -3,6 +3,7 @@ package com.correx.core.kernel.orchestration
// Context-entry builders kept top-level (not orchestrator members) for unit-testability // 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). // 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.ContextEntry
import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole import com.correx.core.context.model.EntryRole
@@ -47,6 +48,24 @@ fun criticArtifactIds(
return graph.stages[fromStage]?.produces?.map { it.name }?.toSet() ?: emptySet() return graph.stages[fromStage]?.produces?.map { it.name }?.toSet() ?: emptySet()
} }
fun buildArtifactKindVocabularyEntry(kinds: Collection<ArtifactKind>): 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 { fun buildProjectProfileEntry(profile: BoundProjectProfile): ContextEntry {
val content = buildString { val content = buildString {
append("## Project profile\n") append("## Project profile\n")
@@ -7,6 +7,7 @@ import com.correx.core.inference.Tokenizer
import com.correx.core.journal.DefaultDecisionJournalRepository import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.journal.DecisionJournalRenderer import com.correx.core.journal.DecisionJournalRenderer
import com.correx.core.artifacts.ArtifactState import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifacts.kind.ArtifactKindRegistry
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.ApprovalDecisionResolvedEvent import com.correx.core.events.events.ApprovalDecisionResolvedEvent
import com.correx.core.events.events.ApprovalRequestedEvent import com.correx.core.events.events.ApprovalRequestedEvent
@@ -55,7 +56,8 @@ class DefaultSessionOrchestrator(
tokenizer: Tokenizer? = null, tokenizer: Tokenizer? = null,
private val decisionJournalRepository: DefaultDecisionJournalRepository, private val decisionJournalRepository: DefaultDecisionJournalRepository,
private val compactionService: JournalCompactionService? = null, 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 tokenizer: Tokenizer? = tokenizer
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> = override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
ConcurrentHashMap<SessionId, AtomicBoolean>() ConcurrentHashMap<SessionId, AtomicBoolean>()
@@ -11,6 +11,7 @@ import com.correx.core.approvals.model.ApprovalDecision
import com.correx.core.approvals.model.ApprovalScopeIdentity import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.artifacts.ArtifactState 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.JsonSchema
import com.correx.core.artifacts.kind.FileWrittenArtifact import com.correx.core.artifacts.kind.FileWrittenArtifact
import com.correx.core.artifacts.kind.ProcessResultArtifact import com.correx.core.artifacts.kind.ProcessResultArtifact
@@ -153,6 +154,7 @@ abstract class SessionOrchestrator(
protected val artifactStore: ArtifactStore, protected val artifactStore: ArtifactStore,
private val decisionJournalRepository: DefaultDecisionJournalRepository, private val decisionJournalRepository: DefaultDecisionJournalRepository,
private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(), private val decisionJournalRenderer: DecisionJournalRenderer = DecisionJournalRenderer(),
private val artifactKindRegistry: ArtifactKindRegistry? = null,
) { ) {
private val log = LoggerFactory.getLogger(this::class.java) private val log = LoggerFactory.getLogger(this::class.java)
private val eventStore: EventStore = repositories.eventStore private val eventStore: EventStore = repositories.eventStore
@@ -374,9 +376,12 @@ abstract class SessionOrchestrator(
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList() ?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId) val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList() ?.let { listOf(it) } ?: emptyList()
val vocabularyEntries = artifactKindRegistry
?.takeIf { stageConfig.metadata["injectArtifactKinds"] == "true" }
?.let { listOf(buildArtifactKindVocabularyEntry(it.list())) } ?: emptyList()
var accumulatedEntries = var accumulatedEntries =
systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries + systemPrompt + profileEntries + projectProfileEntries + journalEntries + repoMapEntries +
needsEntries + schemaEntries + promptEntries + steeringEntries + retryFeedbackEntries needsEntries + schemaEntries + vocabularyEntries + promptEntries + steeringEntries + retryFeedbackEntries
val contextPack = contextPackBuilder.build( val contextPack = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()), id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId, sessionId = sessionId,
@@ -12,6 +12,7 @@ max_retries = 2
[[stages]] [[stages]]
id = "architect" id = "architect"
requires_approval = true requires_approval = true
inject_artifact_kinds = true
prompt = "prompts/architect_freestyle.md" prompt = "prompts/architect_freestyle.md"
needs = ["analysis"] needs = ["analysis"]
produces = [{ name = "execution_plan", kind = "execution_plan" }] produces = [{ name = "execution_plan", kind = "execution_plan" }]
@@ -41,6 +41,7 @@ private data class StageSection(
@param:JsonProperty("token_budget") val tokenBudget: Int = 4096, @param:JsonProperty("token_budget") val tokenBudget: Int = 4096,
@param:JsonProperty("max_retries") val maxRetries: Int = 3, @param:JsonProperty("max_retries") val maxRetries: Int = 3,
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false, @param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
@param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false,
) )
// condition fields flattened into the transition row // condition fields flattened into the transition row
@@ -105,6 +106,7 @@ class TomlWorkflowLoader(
s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) } s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) } s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
if (s.requiresApproval) put("requiresApproval", "true") if (s.requiresApproval) put("requiresApproval", "true")
if (s.injectArtifactKinds) put("injectArtifactKinds", "true")
}, },
) )
} }
@@ -97,4 +97,24 @@ class TomlWorkflowLoaderTest {
val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) } val path = Files.createTempFile("workflow", ".toml").also { it.writeText(bad) }
assertThrows<IllegalStateException> { loader.load(path) } assertThrows<IllegalStateException> { 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"])
}
} }
@@ -1,4 +1,6 @@
import com.correx.core.artifacts.kind.ConfigArtifactKind
import com.correx.core.artifacts.kind.FileWrittenKind 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.artifacts.kind.TypedArtifactSlot
import com.correx.core.context.model.ContextLayer import com.correx.core.context.model.ContextLayer
import com.correx.core.context.model.EntryRole 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.SessionId
import com.correx.core.events.types.StageId import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId 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.buildProjectProfileEntry
import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry import com.correx.core.kernel.orchestration.buildRetryFeedbackEntry
import com.correx.core.kernel.orchestration.criticArtifactIds import com.correx.core.kernel.orchestration.criticArtifactIds
@@ -94,6 +97,18 @@ class ContextFeedbackTest {
assertEquals(setOf(ArtifactId("review_report")), criticArtifactIds(events, graph, StageId("implement"))) 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 @Test
fun `no refinement events yields empty set`() { fun `no refinement events yields empty set`() {
assertTrue(criticArtifactIds(emptyList(), graphWith(), StageId("implement")).isEmpty()) assertTrue(criticArtifactIds(emptyList(), graphWith(), StageId("implement")).isEmpty())