diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index fa5c03fd..1a08fccf 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -4,6 +4,9 @@ import com.correx.apps.server.logging.LoggingEventStore import com.correx.apps.server.registry.FileSystemWorkflowRegistry import com.correx.apps.server.registry.ProviderRegistry import com.correx.core.approvals.domain.DefaultApprovalEngine +import com.correx.core.artifacts.kind.ArtifactKind +import com.correx.core.artifacts.kind.ConfigArtifactKind +import com.correx.core.artifacts.kind.JsonSchema import com.correx.core.artifactstore.ArtifactStore import com.correx.core.config.ConfigLoader import com.correx.core.config.CorrexConfig @@ -62,7 +65,9 @@ import com.correx.infrastructure.tools.ToolConfig import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json import org.slf4j.LoggerFactory +import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths @@ -244,7 +249,9 @@ fun main() { eventStore = eventStore, artifactStore = artifactStore, sessionRepository = repositories.sessionRepository, - workflowRegistry = FileSystemWorkflowRegistry(InfrastructureModule.createWorkflowLoader()), + workflowRegistry = FileSystemWorkflowRegistry( + InfrastructureModule.createWorkflowLoader(loadConfigArtifactKinds(correxConfig)), + ), providerRegistry = infraRegistry.asServerRegistry(), defaultOrchestrationConfig = defaultOrchestrationConfig, routerFacade = routerFacade, @@ -294,6 +301,39 @@ private fun logStoresInfo( log.info(" artifact store: {}", artifactStore::class.simpleName) } +// Build user-declared artifact kinds from config. Each [[artifacts]] entry points to a JSON +// schema file; we parse it into a JsonSchema and wrap it in a ConfigArtifactKind. A schema that +// can't be loaded is skipped with a warning — any workflow that references the kind then fails +// loudly at load time ("Unknown artifact kind"), rather than running with an unvalidated kind. +private fun loadConfigArtifactKinds(config: CorrexConfig): List { + val configDir = ConfigLoader.configPath().parent + return config.artifacts.mapNotNull { decl -> + val schemaPath = resolveConfigRelativePath(decl.schemaPath, configDir) + val schema = runCatching { + Json.decodeFromString(JsonSchema.serializer(), Files.readString(schemaPath)) + }.getOrElse { e -> + System.err.println( + "Warning: artifact kind '${decl.id}' schema '$schemaPath' failed to load: ${e.message}", + ) + return@mapNotNull null + } + ConfigArtifactKind(id = decl.id, schema = schema, llmEmitted = decl.llmEmitted) + } +} + +private fun resolveConfigRelativePath(raw: String, configDir: Path?): Path { + val expanded = if (raw.startsWith("~/")) { + Paths.get(System.getProperty("user.home"), raw.removePrefix("~/")) + } else { + Path.of(raw) + } + return when { + expanded.isAbsolute -> expanded + configDir != null -> configDir.resolve(expanded) + else -> expanded + } +} + private fun buildProviders(config: CorrexConfig): List { return if (config.providers.isNotEmpty()) { log.info("Loading {} provider(s) from config", config.providers.size) diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ArtifactKind.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ArtifactKind.kt index 06a5b049..1b2c3ad9 100644 --- a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ArtifactKind.kt +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ArtifactKind.kt @@ -1,10 +1,7 @@ package com.correx.core.artifacts.kind -import kotlinx.serialization.KSerializer - interface ArtifactKind { val id: String - val payloadSerializer: KSerializer<*> fun deriveJsonSchema(): JsonSchema val llmEmitted: Boolean } diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ConfigArtifactKind.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ConfigArtifactKind.kt new file mode 100644 index 00000000..c0e5f7e6 --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ConfigArtifactKind.kt @@ -0,0 +1,16 @@ +package com.correx.core.artifacts.kind + +/** + * A user-declared artifact kind whose schema comes from configuration rather than + * a compile-time Kotlin type. The declared [schema] is authoritative: content is + * validated against it generically (see the artifact-payload validator), so an + * [llmEmitted] config kind is checked against its declared shape, never a + * hardcoded type check. + */ +data class ConfigArtifactKind( + override val id: String, + private val schema: JsonSchema, + override val llmEmitted: Boolean = false, +) : ArtifactKind { + override fun deriveJsonSchema(): JsonSchema = schema +} diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/FileWrittenKind.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/FileWrittenKind.kt index 451c57cf..a5efdf0c 100644 --- a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/FileWrittenKind.kt +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/FileWrittenKind.kt @@ -1,10 +1,7 @@ package com.correx.core.artifacts.kind -import kotlinx.serialization.KSerializer - object FileWrittenKind : ArtifactKind { override val id: String = "file_written" - override val payloadSerializer: KSerializer<*> = FileWrittenPayload.serializer() override val llmEmitted: Boolean = false override fun deriveJsonSchema(): JsonSchema = JsonSchema( diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ProcessResultKind.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ProcessResultKind.kt index 3eebf9fa..64aa07f1 100644 --- a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ProcessResultKind.kt +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ProcessResultKind.kt @@ -1,10 +1,7 @@ package com.correx.core.artifacts.kind -import kotlinx.serialization.KSerializer - object ProcessResultKind : ArtifactKind { override val id: String = "process_result" - override val payloadSerializer: KSerializer<*> = ProcessResultArtifact.serializer() override val llmEmitted: Boolean = false override fun deriveJsonSchema(): JsonSchema = JsonSchema( diff --git a/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistryTest.kt b/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistryTest.kt index 2e57745c..c16cb1d1 100644 --- a/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistryTest.kt +++ b/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/ArtifactKindRegistryTest.kt @@ -29,6 +29,21 @@ class ArtifactKindRegistryTest { assertEquals(false, registry.get("process_result")!!.llmEmitted) } + @Test + fun `config-declared kind registers and exposes its declared schema`() { + val schema = JsonSchema( + type = "object", + properties = mapOf("x" to JsonSchemaProperty(type = "string")), + required = listOf("x"), + ) + registry.register(ConfigArtifactKind(id = "custom", schema = schema, llmEmitted = true)) + + val kind = registry.get("custom") + assertNotNull(kind) + assertEquals(true, kind!!.llmEmitted) + assertEquals(schema, kind.deriveJsonSchema()) + } + @Test fun `FileWrittenKind schema has required fields`() { val schema = FileWrittenKind.deriveJsonSchema() diff --git a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt index 97a8748f..ed5385f3 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ConfigLoader.kt @@ -36,8 +36,21 @@ object ConfigLoader { val sections = mutableMapOf>() val providers = mutableListOf>() val models = mutableListOf>() + val artifacts = mutableListOf>() var currentProvider: MutableMap? = null var currentModel: MutableMap? = null + var currentArtifact: MutableMap? = null + + // Flush any open array-of-table entry into its list. Called whenever a new + // table header (array or section) starts, so the previous entry is committed. + fun flushTables() { + currentProvider?.let { providers.add(it) } + currentModel?.let { models.add(it) } + currentArtifact?.let { artifacts.add(it) } + currentProvider = null + currentModel = null + currentArtifact = null + } for ((lineNum, line) in lines.withIndex()) { val trimmed = line.trim() @@ -47,29 +60,23 @@ object ConfigLoader { // Skip empty lines and comments } trimmed == "[[providers]]" -> { - // Start a new provider entry - if (currentProvider != null) providers.add(currentProvider) - if (currentModel != null) { models.add(currentModel); currentModel = null } + flushTables() currentProvider = mutableMapOf() currentSection = "" } trimmed == "[[models]]" -> { - // Start a new model entry - if (currentModel != null) models.add(currentModel) - if (currentProvider != null) { providers.add(currentProvider); currentProvider = null } + flushTables() currentModel = mutableMapOf() currentSection = "" } + trimmed == "[[artifacts]]" -> { + flushTables() + currentArtifact = mutableMapOf() + currentSection = "" + } trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> { // Parse section headers like [server] or [tools.shell] - if (currentProvider != null) { - providers.add(currentProvider) - currentProvider = null - } - if (currentModel != null) { - models.add(currentModel) - currentModel = null - } + flushTables() currentSection = trimmed.substring(1, trimmed.length - 1).trim() sections.putIfAbsent(currentSection, mutableMapOf()) } @@ -81,9 +88,14 @@ object ConfigLoader { val valueStr = trimmed.substring(eqIndex + 1).trim() val parsedValue = parseValue(valueStr, lineNum + 1) + // Locals so smart-casting works (the vars are captured by flushTables). + val provider = currentProvider + val model = currentModel + val artifact = currentArtifact when { - currentProvider != null -> currentProvider[key] = parsedValue - currentModel != null -> currentModel[key] = parsedValue + provider != null -> provider[key] = parsedValue + model != null -> model[key] = parsedValue + artifact != null -> artifact[key] = parsedValue currentSection.isNotEmpty() -> sections[currentSection]?.put(key, parsedValue) } } @@ -92,10 +104,9 @@ object ConfigLoader { } // Don't forget the last array-of-table entry if file ends with one - if (currentProvider != null) providers.add(currentProvider) - if (currentModel != null) models.add(currentModel) + flushTables() - return buildConfig(sections, providers, models) + return buildConfig(sections, providers, models, artifacts) } private fun parseValue(valueStr: String, lineNum: Int): Any { @@ -212,6 +223,7 @@ object ConfigLoader { sections: Map>, providersList: List> = emptyList(), modelsList: List> = emptyList(), + artifactsList: List> = emptyList(), ): CorrexConfig { val serverSection = sections["server"] ?: emptyMap() val tuiSection = sections["tui"] ?: emptyMap() @@ -386,6 +398,16 @@ object ConfigLoader { port = asInt(modelsSection["port"], 10000), ) + val artifacts = artifactsList.mapNotNull { artifactMap -> + val id = asStringOrNull(artifactMap["id"]) ?: return@mapNotNull null + val schemaPath = asStringOrNull(artifactMap["schema_path"]) ?: return@mapNotNull null + ArtifactKindConfig( + id = id, + schemaPath = schemaPath, + llmEmitted = asBoolean(artifactMap["llm_emitted"], false), + ) + } + return CorrexConfig( server = server, tui = tui, @@ -395,6 +417,7 @@ object ConfigLoader { router = router, models = models, modelsSettings = modelsSettings, + artifacts = artifacts, ) } diff --git a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt index 3d006ce0..c76d92fb 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/CorrexConfig.kt @@ -12,6 +12,21 @@ data class CorrexConfig( val router: RouterConfig = RouterConfig(), val models: List = emptyList(), val modelsSettings: ModelsSettings = ModelsSettings(), + val artifacts: List = emptyList(), +) + +/** + * A user-declared artifact kind. [schemaPath] points to a JSON file holding the + * kind's JSON schema (object type, typed properties, required, additionalProperties). + * A path-to-file (not inline TOML) is used so schemas can nest arbitrarily and be + * parsed with a real JSON parser. [llmEmitted] kinds are validated against this + * schema, never trusted. + */ +@Serializable +data class ArtifactKindConfig( + val id: String, + val schemaPath: String, + val llmEmitted: Boolean = false, ) @Serializable diff --git a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt index 6ea9b22e..93bf417e 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/ConfigLoaderTest.kt @@ -41,6 +41,40 @@ class ConfigLoaderTest { assertEquals("json", result.cli.defaultOutput) } + @Test + fun `parseToml parses artifacts array-of-tables and separates them from models and sections`() { + val toml = """ + [[models]] + id = "m1" + model_path = "/m1.gguf" + + [[artifacts]] + id = "review_report" + schema_path = "schemas/review.json" + llm_emitted = true + + [[artifacts]] + id = "plan" + schema_path = "schemas/plan.json" + + [server] + port = 1234 + """.trimIndent() + + val parseTomlMethod = ConfigLoader::class.java.getDeclaredMethod("parseToml", String::class.java) + parseTomlMethod.isAccessible = true + val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig + + assertEquals(1, result.models.size) + assertEquals(1234, result.server.port) + assertEquals(2, result.artifacts.size) + assertEquals("review_report", result.artifacts[0].id) + assertEquals("schemas/review.json", result.artifacts[0].schemaPath) + assertEquals(true, result.artifacts[0].llmEmitted) + assertEquals("plan", result.artifacts[1].id) + assertEquals(false, result.artifacts[1].llmEmitted) + } + @Test fun `parseToml skips comments and empty lines`() { val toml = """ diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/artifact/ArtifactPayloadValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/ArtifactPayloadValidator.kt index 7790e54f..02477063 100644 --- a/core/validation/src/main/kotlin/com/correx/core/validation/artifact/ArtifactPayloadValidator.kt +++ b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/ArtifactPayloadValidator.kt @@ -42,9 +42,26 @@ class ArtifactPayloadValidator( validateSlotAsFileWritten(slot.name, slot.kind.id, payloadStr, issues) "process_result" -> validateSlotAsProcessResult(slot.name, slot.kind.id, payloadStr, issues) + // Config-declared kinds: validate generically against the declared schema, so an + // LLM-emitted custom kind is checked against its shape rather than trusted. + else -> + validateSlotAgainstSchema(slot, payloadStr, issues) } } + private fun validateSlotAgainstSchema( + slot: TypedArtifactSlot, + payloadStr: String, + issues: MutableList, + ) { + val json = runCatching { Json.parseToJsonElement(payloadStr) }.getOrElse { + issues += decodeFailedIssue(slot.name, slot.kind.id, it.message) + return + } + JsonSchemaValidator.validate(slot.kind.deriveJsonSchema(), json) + .forEach { issues += semanticIssue(slot.name, it) } + } + private fun validateSlotAsFileWritten( slotName: ArtifactId, kindId: String, diff --git a/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt new file mode 100644 index 00000000..8f52c9ca --- /dev/null +++ b/core/validation/src/main/kotlin/com/correx/core/validation/artifact/JsonSchemaValidator.kt @@ -0,0 +1,62 @@ +package com.correx.core.validation.artifact + +import com.correx.core.artifacts.kind.JsonSchema +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Minimal, deterministic validator of a JSON value against a [JsonSchema] + * (the subset correx models: object type, typed properties, required keys, + * additionalProperties). Returns human-readable error strings; empty = valid. + * + * Used to validate config-declared artifact kinds against their declared schema, + * so an LLM-emitted custom kind is checked against its shape rather than trusted. + */ +object JsonSchemaValidator { + + fun validate(schema: JsonSchema, value: JsonElement): List = + if (schema.type == "object") { + validateObject(schema, value) + } else { + checkType(schema.type, value)?.let { listOf(it) } ?: emptyList() + } + + private fun validateObject(schema: JsonSchema, value: JsonElement): List { + val obj = value as? JsonObject ?: return listOf("expected object, got ${kindOf(value)}") + val errors = mutableListOf() + schema.required.filterNot { it in obj }.forEach { errors += "missing required property '$it'" } + for ((key, element) in obj) { + val property = schema.properties[key] + if (property == null) { + if (!schema.additionalProperties) errors += "unknown property '$key'" + } else { + checkType(property.type, element)?.let { errors += "property '$key': $it" } + } + } + return errors + } + + private fun checkType(type: String, value: JsonElement): String? { + val ok = when (type) { + "string" -> value is JsonPrimitive && value.isString + "integer" -> value is JsonPrimitive && !value.isString && value.longOrNull != null + "number" -> value is JsonPrimitive && !value.isString && value.doubleOrNull != null + "boolean" -> value is JsonPrimitive && !value.isString && value.booleanOrNull != null + "object" -> value is JsonObject + "array" -> value is JsonArray + else -> true // unknown declared type: don't enforce + } + return if (ok) null else "expected $type, got ${kindOf(value)}" + } + + private fun kindOf(value: JsonElement): String = when (value) { + is JsonArray -> "array" + is JsonObject -> "object" + is JsonPrimitive -> if (value.isString) "string" else "primitive" + } +} diff --git a/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt b/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt new file mode 100644 index 00000000..56cddbe2 --- /dev/null +++ b/core/validation/src/test/kotlin/com/correx/core/validation/artifact/JsonSchemaValidatorTest.kt @@ -0,0 +1,56 @@ +package com.correx.core.validation.artifact + +import com.correx.core.artifacts.kind.JsonSchema +import com.correx.core.artifacts.kind.JsonSchemaProperty +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class JsonSchemaValidatorTest { + + private val schema = JsonSchema( + type = "object", + properties = mapOf( + "summary" to JsonSchemaProperty(type = "string"), + "score" to JsonSchemaProperty(type = "integer"), + ), + required = listOf("summary", "score"), + additionalProperties = false, + ) + + private fun errors(json: String) = + JsonSchemaValidator.validate(schema, Json.parseToJsonElement(json)) + + @Test + fun `valid object passes`() { + assertTrue(errors("""{"summary":"ok","score":3}""").isEmpty()) + } + + @Test + fun `missing required property is flagged`() { + assertTrue(errors("""{"summary":"ok"}""").any { it.contains("score") }) + } + + @Test + fun `wrong property type is flagged`() { + assertTrue(errors("""{"summary":"ok","score":"high"}""").any { it.contains("score") && it.contains("integer") }) + } + + @Test + fun `unknown property is flagged when additionalProperties is false`() { + assertTrue(errors("""{"summary":"ok","score":3,"extra":1}""").any { it.contains("extra") }) + } + + @Test + fun `unknown property is allowed when additionalProperties is true`() { + val open = schema.copy(additionalProperties = true) + val result = JsonSchemaValidator.validate(open, Json.parseToJsonElement("""{"summary":"ok","score":3,"extra":1}""")) + assertTrue(result.isEmpty()) + } + + @Test + fun `non-object payload against object schema is flagged`() { + assertEquals(1, errors("""["a","b"]""").size) + } +} diff --git a/docs/sample-config.toml b/docs/sample-config.toml index eedc25f7..a7e549e7 100644 --- a/docs/sample-config.toml +++ b/docs/sample-config.toml @@ -87,3 +87,13 @@ backend = "in_memory" # or "turbovec" # script_path = "~/.config/correx/python/turbovec_sidecar.py" # dim = 1536 # bit_width = 4 + +# Custom artifact kinds. Each entry points to a JSON file holding the kind's JSON +# schema (object type, typed properties, required, additionalProperties). Workflows +# may then declare `produces` slots of this kind; LLM-emitted kinds are validated +# against the declared schema. schema_path is absolute, ~-relative, or relative to +# this config file's directory. +# [[artifacts]] +# id = "review_report" +# schema_path = "schemas/review_report.json" +# llm_emitted = true diff --git a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt index 33f05508..125cdfbb 100644 --- a/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt +++ b/infrastructure/src/main/kotlin/com/correx/infrastructure/InfrastructureModule.kt @@ -51,6 +51,8 @@ import com.correx.infrastructure.tools.DispatchingToolExecutor import com.correx.infrastructure.tools.SandboxedToolExecutor import com.correx.infrastructure.tools.ToolConfig import com.correx.infrastructure.tools.buildTools +import com.correx.core.artifacts.kind.ArtifactKind +import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry import com.correx.infrastructure.workflow.FileSystemPromptLoader import com.correx.infrastructure.workflow.PromptLoader import com.correx.infrastructure.workflow.TomlWorkflowLoader @@ -175,7 +177,11 @@ object InfrastructureModule { ), ) - fun createWorkflowLoader(): WorkflowLoader = TomlWorkflowLoader() + fun createWorkflowLoader(extraKinds: List = emptyList()): WorkflowLoader { + val registry = DefaultArtifactKindRegistry() + extraKinds.forEach { registry.register(it) } + return TomlWorkflowLoader(registry) + } fun createPromptLoader(): PromptLoader = FileSystemPromptLoader()