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:
+5
-2
@@ -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(),
|
||||
|
||||
+1
@@ -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(),
|
||||
)
|
||||
|
||||
+42
@@ -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": [] }"""
|
||||
|
||||
Reference in New Issue
Block a user