fix(workflow,kernel): split plan stage 'produces' (slot name) from 'kind' (artifact kind)

ExecutionPlanCompiler treated the plan's single 'produces' string as a
registry kind id, while the architect prompt described it as a unique
artifact id referenced by needs/edges — the model followed the prompt,
invented descriptive ids (files_created), and every freestyle plan
failed to compile. Collapsing both onto one string is also unsound:
two stages producing the same kind would collide on slot name and make
artifact_validated edge conditions ambiguous.

Plan stages now carry an optional 'kind' selecting the registered kind
(mirroring TOML's produces = { name, kind }); 'produces' stays the
unique slot name. Missing 'kind' falls back to the old produces-as-kind
reading so existing plans still compile. Vocabulary entry, architect
prompt, and execution_plan schemas updated to match.
This commit is contained in:
2026-06-12 12:44:22 +04:00
parent 4107a595db
commit c25ba27e57
6 changed files with 60 additions and 6 deletions
@@ -29,8 +29,11 @@ class ExecutionPlanCompiler(
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
val stageMap = plan.stages.associate { s ->
val kind = registry.get(s.produces)
?: throw WorkflowValidationException("Unknown artifact kind '${s.produces}' in stage '${s.id}'")
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
// registered artifact kind. Plans predating the split used one string for both.
val kindId = s.kind ?: s.produces
val kind = registry.get(kindId)
?: throw WorkflowValidationException("Unknown artifact kind '$kindId' in stage '${s.id}'")
StageId(s.id) to StageConfig(
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
needs = s.needs.map { ArtifactId(it) }.toSet(),
@@ -12,6 +12,7 @@ data class PlanStage(
val id: String = "",
val prompt: String = "",
val produces: String = "",
val kind: String? = null,
val needs: List<String> = emptyList(),
val tools: List<String> = emptyList(),
)
@@ -87,6 +87,48 @@ class ExecutionPlanCompilerTest {
assertThrows<WorkflowValidationException> { compiler.compile(bad, "bad-workflow") }
}
@Test
fun `kind field selects the registered kind while produces names the slot`() {
val plan = """
{
"goal": "create files",
"stages": [
{
"id": "create_files",
"prompt": "Create the files",
"produces": "files_created",
"kind": "file_written",
"needs": [],
"tools": ["file_write"]
}
],
"edges": [
{ "from": "create_files", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val graph = compiler.compile(plan, "kind-workflow")
val slot = graph.stages.values.single().produces.single()
assertEquals("files_created", slot.name.value)
assertEquals("file_written", slot.kind.id)
}
@Test
fun `unregistered kind field throws WorkflowValidationException`() {
val plan = """
{
"goal": "x",
"stages": [
{ "id": "s1", "prompt": "p", "produces": "out", "kind": "made_up_kind" }
],
"edges": [
{ "from": "s1", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
assertThrows<WorkflowValidationException> { compiler.compile(plan, "bad-workflow") }
}
@Test
fun `empty stages throws WorkflowValidationException`() {
val bad = """{ "goal": "x", "stages": [], "edges": [] }"""