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:
@@ -54,7 +54,9 @@ fun buildArtifactKindVocabularyEntry(kinds: Collection<ArtifactKind>): ContextEn
|
|||||||
if (k.llmEmitted) "${k.id} (llm-emitted)" else k.id
|
if (k.llmEmitted) "${k.id} (llm-emitted)" else k.id
|
||||||
}
|
}
|
||||||
val content = "## Available artifact kinds\n" +
|
val content = "## Available artifact kinds\n" +
|
||||||
"Every stage's \"produces\" MUST be exactly one of: $listing\n" +
|
"Every stage's \"kind\" MUST be exactly one of: $listing\n" +
|
||||||
|
"Stages that write or edit files use \"file_written\"; stages that run commands use " +
|
||||||
|
"\"process_result\".\n" +
|
||||||
"Do not invent kinds — a plan referencing an unknown kind fails to compile."
|
"Do not invent kinds — a plan referencing an unknown kind fails to compile."
|
||||||
return ContextEntry(
|
return ContextEntry(
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"prompt": { "type": "string" },
|
"prompt": { "type": "string" },
|
||||||
"needs": { "type": "array", "items": { "type": "string" } },
|
"needs": { "type": "array", "items": { "type": "string" } },
|
||||||
"produces": { "type": "string" },
|
"produces": { "type": "string" },
|
||||||
|
"kind": { "type": "string" },
|
||||||
"tools": { "type": "array", "items": { "type": "string" } }
|
"tools": { "type": "array", "items": { "type": "string" } }
|
||||||
},
|
},
|
||||||
"required": ["id", "prompt", "produces"]
|
"required": ["id", "prompt", "produces"]
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
|||||||
"id": "<short_snake_case_id>",
|
"id": "<short_snake_case_id>",
|
||||||
"role": "<role name, e.g. implementer>",
|
"role": "<role name, e.g. implementer>",
|
||||||
"prompt": "<the full prompt that stage will execute>",
|
"prompt": "<the full prompt that stage will execute>",
|
||||||
"produces": "<artifact_id this stage emits>",
|
"produces": "<unique artifact_id this stage emits, your choice of name>",
|
||||||
|
"kind": "<one of the available artifact kinds listed in your context>",
|
||||||
"needs": ["<artifact_id consumed from an earlier stage>"],
|
"needs": ["<artifact_id consumed from an earlier stage>"],
|
||||||
"tools": ["file_read", "file_write", "file_edit", "ShellTool"]
|
"tools": ["file_read", "file_write", "file_edit", "ShellTool"]
|
||||||
}
|
}
|
||||||
@@ -48,8 +49,12 @@ Emit a JSON object that validates against the `execution_plan` schema:
|
|||||||
|
|
||||||
**stages** — ordered list; each stage must:
|
**stages** — ordered list; each stage must:
|
||||||
- Have a unique `id` in `snake_case`.
|
- Have a unique `id` in `snake_case`.
|
||||||
- Declare `produces`: the artifact id this stage emits. Artifact ids must be declared in
|
- Declare `produces`: the artifact id this stage emits. Pick a unique descriptive
|
||||||
your config's `[[artifacts]]` table.
|
`snake_case` name; this is how `needs` and edge conditions reference the artifact.
|
||||||
|
- Declare `kind`: the artifact kind, exactly one id from the "Available artifact kinds"
|
||||||
|
list in your context. Stages that write or edit files use `file_written`; stages that
|
||||||
|
run commands use `process_result`; stages whose output is structured JSON use an
|
||||||
|
llm-emitted kind.
|
||||||
- Declare `needs`: every upstream artifact id the stage's prompt references. Every id in
|
- Declare `needs`: every upstream artifact id the stage's prompt references. Every id in
|
||||||
`needs` must be `produces`d by a strictly earlier stage.
|
`needs` must be `produces`d by a strictly earlier stage.
|
||||||
- Include `tools` only for stages that write or edit files:
|
- Include `tools` only for stages that write or edit files:
|
||||||
|
|||||||
+5
-2
@@ -29,8 +29,11 @@ class ExecutionPlanCompiler(
|
|||||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||||
|
|
||||||
val stageMap = plan.stages.associate { s ->
|
val stageMap = plan.stages.associate { s ->
|
||||||
val kind = registry.get(s.produces)
|
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
||||||
?: throw WorkflowValidationException("Unknown artifact kind '${s.produces}' in stage '${s.id}'")
|
// 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(
|
StageId(s.id) to StageConfig(
|
||||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||||
|
|||||||
+1
@@ -12,6 +12,7 @@ data class PlanStage(
|
|||||||
val id: String = "",
|
val id: String = "",
|
||||||
val prompt: String = "",
|
val prompt: String = "",
|
||||||
val produces: String = "",
|
val produces: String = "",
|
||||||
|
val kind: String? = null,
|
||||||
val needs: List<String> = emptyList(),
|
val needs: List<String> = emptyList(),
|
||||||
val tools: List<String> = emptyList(),
|
val tools: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|||||||
+42
@@ -87,6 +87,48 @@ class ExecutionPlanCompilerTest {
|
|||||||
assertThrows<WorkflowValidationException> { compiler.compile(bad, "bad-workflow") }
|
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
|
@Test
|
||||||
fun `empty stages throws WorkflowValidationException`() {
|
fun `empty stages throws WorkflowValidationException`() {
|
||||||
val bad = """{ "goal": "x", "stages": [], "edges": [] }"""
|
val bad = """{ "goal": "x", "stages": [], "edges": [] }"""
|
||||||
|
|||||||
Reference in New Issue
Block a user