feat(artifacts): config-driven custom artifact kinds with schema validation
Users declare artifact kinds in [[artifacts]] (id + schema_path to a JSON-schema file + llm_emitted); ConfigArtifactKind registers them at startup via createWorkflowLoader(extraKinds). New JsonSchemaValidator validates any non-built-in kind generically against its declared deriveJsonSchema(), so an LLM-emitted custom kind is checked against its shape, never trusted. Removed the dead payloadSerializer from the ArtifactKind contract. Schema source is path-to-file (not inline TOML — the hand-rolled parser can't nest).
This commit is contained in:
@@ -36,8 +36,21 @@ object ConfigLoader {
|
||||
val sections = mutableMapOf<String, MutableMap<String, Any>>()
|
||||
val providers = mutableListOf<MutableMap<String, Any>>()
|
||||
val models = mutableListOf<MutableMap<String, Any>>()
|
||||
val artifacts = mutableListOf<MutableMap<String, Any>>()
|
||||
var currentProvider: MutableMap<String, Any>? = null
|
||||
var currentModel: MutableMap<String, Any>? = null
|
||||
var currentArtifact: MutableMap<String, Any>? = 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<String, Map<String, Any>>,
|
||||
providersList: List<Map<String, Any>> = emptyList(),
|
||||
modelsList: List<Map<String, Any>> = emptyList(),
|
||||
artifactsList: List<Map<String, Any>> = 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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,21 @@ data class CorrexConfig(
|
||||
val router: RouterConfig = RouterConfig(),
|
||||
val models: List<ModelConfig> = emptyList(),
|
||||
val modelsSettings: ModelsSettings = ModelsSettings(),
|
||||
val artifacts: List<ArtifactKindConfig> = 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
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
Reference in New Issue
Block a user