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": [] }"""