From c25ba27e5779f0fdd86242abe37c8e776f1ab49f Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 12 Jun 2026 12:44:22 +0400 Subject: [PATCH] fix(workflow,kernel): split plan stage 'produces' (slot name) from 'kind' (artifact kind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../kernel/orchestration/ContextFeedback.kt | 4 +- docs/schemas/execution_plan.json | 1 + .../workflows/prompts/architect_freestyle.md | 11 +++-- .../workflow/ExecutionPlanCompiler.kt | 7 +++- .../workflow/ExecutionPlanModel.kt | 1 + .../workflow/ExecutionPlanCompilerTest.kt | 42 +++++++++++++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) 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 9517a6af..5112ecff 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 @@ -54,7 +54,9 @@ fun buildArtifactKindVocabularyEntry(kinds: Collection): ContextEn 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" + + "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." return ContextEntry( id = ContextEntryId(UUID.randomUUID().toString()), diff --git a/docs/schemas/execution_plan.json b/docs/schemas/execution_plan.json index 2d5b7e78..72034c1d 100644 --- a/docs/schemas/execution_plan.json +++ b/docs/schemas/execution_plan.json @@ -12,6 +12,7 @@ "prompt": { "type": "string" }, "needs": { "type": "array", "items": { "type": "string" } }, "produces": { "type": "string" }, + "kind": { "type": "string" }, "tools": { "type": "array", "items": { "type": "string" } } }, "required": ["id", "prompt", "produces"] diff --git a/examples/workflows/prompts/architect_freestyle.md b/examples/workflows/prompts/architect_freestyle.md index 5486ecad..60bb2ec3 100644 --- a/examples/workflows/prompts/architect_freestyle.md +++ b/examples/workflows/prompts/architect_freestyle.md @@ -24,7 +24,8 @@ Emit a JSON object that validates against the `execution_plan` schema: "id": "", "role": "", "prompt": "", - "produces": "", + "produces": "", + "kind": "", "needs": [""], "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: - Have a unique `id` in `snake_case`. -- Declare `produces`: the artifact id this stage emits. Artifact ids must be declared in - your config's `[[artifacts]]` table. +- Declare `produces`: the artifact id this stage emits. Pick a unique descriptive + `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 `needs` must be `produces`d by a strictly earlier stage. - Include `tools` only for stages that write or edit files: diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 8191237e..d3c91a3a 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -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(), diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt index b676b1c6..225d1dc7 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt @@ -12,6 +12,7 @@ data class PlanStage( val id: String = "", val prompt: String = "", val produces: String = "", + val kind: String? = null, val needs: List = emptyList(), val tools: List = emptyList(), ) diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index 869cb912..f054e1d2 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -87,6 +87,48 @@ class ExecutionPlanCompilerTest { assertThrows { 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 { compiler.compile(plan, "bad-workflow") } + } + @Test fun `empty stages throws WorkflowValidationException`() { val bad = """{ "goal": "x", "stages": [], "edges": [] }"""