feat: shell execution correctness + artifact_field_equals condition
- Add field to ProcessResultArtifact ("success"/"failed")
- Materialize ToolResult.Failure as a failed ProcessResult artifact
in the orchestrator, with artifact lifecycle events emitted
- Non-recoverable ToolResult.Failure now fails the stage ("FATAL:")
instead of being silently fed back as LLM context
- Fix enterStage to return Continue on non-retryable failures,
enabling back-edge transitions (fix/retry loops)
- Add ArtifactFieldEquals transition condition with flat and
nested dot-notation field access + FieldOperator enum
(eq, neq, gt, gte, lt, lte)
- Wire artifact_field_equals through ConditionFactory and
TomlWorkflowLoader (condition_field, condition_operator)
- Cache ProcessResult artifact content for evaluation context
- Add 16 tests covering all condition operators and error cases
- DefaultTransitionResolver catches condition evaluation errors
and returns Blocked instead of crashing
This commit is contained in:
+78
@@ -0,0 +1,78 @@
|
||||
package com.correx.core.transitions.conditions
|
||||
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import com.correx.core.transitions.graph.TransitionCondition
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
enum class FieldOperator(val symbol: String) {
|
||||
EQ("eq"),
|
||||
NEQ("neq"),
|
||||
GT("gt"),
|
||||
GTE("gte"),
|
||||
LT("lt"),
|
||||
LTE("lte");
|
||||
|
||||
companion object {
|
||||
fun fromString(s: String): FieldOperator =
|
||||
entries.firstOrNull { it.symbol == s }
|
||||
?: error("Unknown field operator '$s'. Supported: ${entries.joinToString(", ") { it.symbol }}")
|
||||
}
|
||||
}
|
||||
|
||||
data class ArtifactFieldEquals(
|
||||
val artifactId: ArtifactId,
|
||||
val field: String,
|
||||
val value: String,
|
||||
val operator: FieldOperator = FieldOperator.EQ,
|
||||
) : TransitionCondition {
|
||||
|
||||
override fun evaluate(context: EvaluationContext): Boolean {
|
||||
val contentJson = context.artifactContent[artifactId]
|
||||
?: error("Artifact '${artifactId.value}' not found in evaluation context")
|
||||
|
||||
val element = Json.parseToJsonElement(contentJson)
|
||||
val resolved = resolveField(element, field)
|
||||
?: error("Field '$field' not found in artifact '${artifactId.value}' content. " +
|
||||
"Available top-level fields: ${topLevelFields(element)}")
|
||||
|
||||
return compareValues(resolved, value, operator)
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private fun resolveField(element: JsonElement, fieldPath: String): JsonPrimitive? {
|
||||
var current: JsonElement? = element
|
||||
for (part in fieldPath.split('.')) {
|
||||
current = (current as? JsonObject)?.get(part)
|
||||
if (current == null) return null
|
||||
}
|
||||
return current as? JsonPrimitive
|
||||
}
|
||||
|
||||
private fun compareValues(actual: JsonPrimitive, expected: String, op: FieldOperator): Boolean = when (op) {
|
||||
FieldOperator.EQ -> actual.content == expected
|
||||
FieldOperator.NEQ -> actual.content != expected
|
||||
FieldOperator.GT, FieldOperator.GTE, FieldOperator.LT, FieldOperator.LTE -> {
|
||||
val actualNum = actual.content.toDoubleOrNull()
|
||||
?: error("Numeric operator '${op.symbol}' applied to non-numeric field value '${actual.content}'")
|
||||
val expectedNum = expected.toDoubleOrNull()
|
||||
?: error("Numeric operator '${op.symbol}' requires numeric expected value, got '$expected'")
|
||||
when (op) {
|
||||
FieldOperator.GT -> actualNum > expectedNum
|
||||
FieldOperator.GTE -> actualNum >= expectedNum
|
||||
FieldOperator.LT -> actualNum < expectedNum
|
||||
FieldOperator.LTE -> actualNum <= expectedNum
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun topLevelFields(element: JsonElement): String =
|
||||
when (element) {
|
||||
is JsonObject -> element.keys.joinToString(", ")
|
||||
else -> "<not an object>"
|
||||
}
|
||||
}
|
||||
+1
@@ -10,4 +10,5 @@ data class EvaluationContext(
|
||||
val currentStage: StageId,
|
||||
val artifacts: Map<ArtifactId, ArtifactState> = emptyMap(),
|
||||
val variables: Map<String, String> = emptyMap(),
|
||||
val artifactContent: Map<ArtifactId, String> = emptyMap(),
|
||||
)
|
||||
|
||||
+8
-2
@@ -8,7 +8,7 @@ class DefaultTransitionResolver(
|
||||
private val evaluator: TransitionConditionEvaluator
|
||||
) : TransitionResolver {
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
@Suppress("ReturnCount", "TooGenericExceptionCaught")
|
||||
override fun resolve(
|
||||
graph: WorkflowGraph,
|
||||
context: EvaluationContext
|
||||
@@ -22,7 +22,13 @@ class DefaultTransitionResolver(
|
||||
if (outgoing.isEmpty()) return TransitionDecision.NoMatch
|
||||
|
||||
for (edge in outgoing) {
|
||||
val result = evaluator.evaluate(edge.condition, context)
|
||||
val result = try {
|
||||
evaluator.evaluate(edge.condition, context)
|
||||
} catch (e: Exception) {
|
||||
return TransitionDecision.Blocked(
|
||||
"condition evaluation failed on '${edge.id.value}': ${e.message}",
|
||||
)
|
||||
}
|
||||
if (result) {
|
||||
return TransitionDecision.Move(
|
||||
transitionId = edge.id,
|
||||
|
||||
+121
@@ -7,6 +7,7 @@ import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.evaluation.EvaluationContext
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -156,4 +157,124 @@ class TransitionConditionTest {
|
||||
fun `Not inverts false to true`() {
|
||||
assertTrue(Not(Not(AlwaysTrue)).evaluate(baseContext))
|
||||
}
|
||||
|
||||
// ArtifactFieldEquals — flat field access
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals matches flat field with eq`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"status":"failed","command":"ls","exitCode":1,"stdout":"","stderr":"error"}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertTrue(ArtifactFieldEquals(id, "status", "failed").evaluate(ctx))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals does not match different value`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"status":"success","exitCode":0}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertFalse(ArtifactFieldEquals(id, "status", "failed").evaluate(ctx))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals with neq matches different value`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"status":"success","exitCode":0}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertTrue(ArtifactFieldEquals(id, "status", "failed", operator = FieldOperator.NEQ).evaluate(ctx))
|
||||
}
|
||||
|
||||
// ArtifactFieldEquals — nested field access (dot notation)
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals resolves nested fields via dot notation`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"execution":{"result":{"exitCode":1}}}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertTrue(ArtifactFieldEquals(id, "execution.result.exitCode", "1").evaluate(ctx))
|
||||
}
|
||||
|
||||
// ArtifactFieldEquals — numeric operators
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals gt matches larger number`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"exitCode":5}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertTrue(ArtifactFieldEquals(id, "exitCode", "0", operator = FieldOperator.GT).evaluate(ctx))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals gt does not match smaller number`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"exitCode":0}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertFalse(ArtifactFieldEquals(id, "exitCode", "1", operator = FieldOperator.GT).evaluate(ctx))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals gte matches equal number`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"exitCode":1}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertTrue(ArtifactFieldEquals(id, "exitCode", "1", operator = FieldOperator.GTE).evaluate(ctx))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals lt matches smaller number`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"exitCode":0}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertTrue(ArtifactFieldEquals(id, "exitCode", "1", operator = FieldOperator.LT).evaluate(ctx))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals lte matches equal number`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"exitCode":1}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertTrue(ArtifactFieldEquals(id, "exitCode", "1", operator = FieldOperator.LTE).evaluate(ctx))
|
||||
}
|
||||
|
||||
// ArtifactFieldEquals — error cases
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals throws on missing artifact`() {
|
||||
val ctx = baseContext.copy(artifactContent = emptyMap())
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
ArtifactFieldEquals(ArtifactId("nonexistent"), "status", "failed").evaluate(ctx)
|
||||
}
|
||||
// Also catches the error() call
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals throws on missing field`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"status":"success"}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
val ex = assertThrows(IllegalStateException::class.java) {
|
||||
ArtifactFieldEquals(id, "missingField", "value").evaluate(ctx)
|
||||
}
|
||||
assertTrue(ex.message?.contains("not found") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals throws numeric operator on non-numeric field`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"status":"success"}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
val ex = assertThrows(IllegalStateException::class.java) {
|
||||
ArtifactFieldEquals(id, "status", "0", operator = FieldOperator.GT).evaluate(ctx)
|
||||
}
|
||||
assertTrue(ex.message?.contains("non-numeric") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ArtifactFieldEquals neq with string works correctly`() {
|
||||
val id = ArtifactId("execution_result")
|
||||
val json = """{"status":"success"}"""
|
||||
val ctx = baseContext.copy(artifactContent = mapOf(id to json))
|
||||
assertTrue(ArtifactFieldEquals(id, "status", "failed", operator = FieldOperator.NEQ).evaluate(ctx))
|
||||
assertFalse(ArtifactFieldEquals(id, "status", "success", operator = FieldOperator.NEQ).evaluate(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user