feat(kernel): read-only mode after read-before-write + mandatory task-decompose gate

Two orchestrator tool-gating behaviors (both touch SessionOrchestrator):

- Read-only switch: when a tool call is BLOCKed with READ_BEFORE_WRITE, derive a
  read-only mode from the event log (block until a later FILE_READ completes) and
  withhold FILE_WRITE tools from the next inference turn(s), so a weak model is
  pushed down the read-then-write path instead of looping on the blocked write.
- require_task_decompose stage flag (TomlWorkflowLoader) + owesTaskDecompose guard:
  a stage so marked cannot stage_complete until a task_decompose/task_create has
  succeeded this stage. New task_planning workflow + prompt are the first consumer
  (decompose-only: swoop codebase, emit a parent + DEPENDS_ON task graph, stop).
This commit is contained in:
2026-06-28 20:43:18 +04:00
parent 201b599472
commit 1cb7fec677
5 changed files with 318 additions and 3 deletions
@@ -14,9 +14,12 @@ import kotlinx.datetime.Clock
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ToolCallAssessedEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.ToolRequest
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
import com.correx.core.tools.contract.ParamRole
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.risk.RiskAction
import com.correx.core.events.types.ProviderId
@@ -410,6 +413,168 @@ class ToolCallGateTest {
)
}
/**
* Read-before-write narrowing: after the READ_BEFORE_WRITE rule blocks a write, the next
* inference turn must only offer read tools; once a file_read completes, write tools return.
*/
@Test
fun `read-before-write block narrows tools to read-only then restores writes after a read`(): Unit = runBlocking {
val targetPath = "/work/Foo.kt"
// Registry with both a file_write and a file_read tool, paramRoles declare the path param.
val fileWriteTool = object : Tool {
override val name = "file_write"
override val description = "write"
override val parametersSchema: JsonObject = buildJsonObject {}
override val tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_WRITE)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
}
val fileReadTool = object : Tool {
override val name = "file_read"
override val description = "read"
override val parametersSchema: JsonObject = buildJsonObject {}
override val tier = Tier.T1
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
override fun validateRequest(request: ToolRequest): ValidationResult = ValidationResult.Valid
}
val twoToolRegistry = object : ToolRegistry {
override fun resolve(name: String): Tool? = when (name) {
"file_write" -> fileWriteTool
"file_read" -> fileReadTool
else -> null
}
override fun all(): List<Tool> = listOf(fileWriteTool, fileReadTool)
}
// Provider: turn 1 → write, turn 2 → read, turn 3 → write, turn 4 → stop.
// Records the tool names offered on each inference request.
val offeredToolsPerTurn = mutableListOf<Set<String>>()
var turnCount = 0
val provider = object : InferenceProvider {
override val id = ProviderId("rbw-test")
override val name = "rbw-test"
override val tokenizer: Tokenizer = MockTokenizer()
override suspend fun infer(request: InferenceRequest): InferenceResponse {
offeredToolsPerTurn += request.tools.map { it.function.name }.toSet()
turnCount++
val toolCall = when (turnCount) {
1 -> ToolCallRequest(id = "tc-write-1", function = ToolCallFunction("file_write", """{"path":"$targetPath"}"""))
2 -> ToolCallRequest(id = "tc-read-1", function = ToolCallFunction("file_read", """{"path":"$targetPath"}"""))
3 -> ToolCallRequest(id = "tc-write-2", function = ToolCallFunction("file_write", """{"path":"$targetPath"}"""))
else -> null
}
return if (toolCall != null) {
InferenceResponse(request.requestId, "", FinishReason.ToolCall, TokenUsage(1, 1), 0, listOf(toolCall))
} else {
InferenceResponse(request.requestId, "done", FinishReason.Stop, TokenUsage(1, 1), 0)
}
}
override suspend fun healthCheck(): ProviderHealth = ProviderHealth.Healthy
override fun capabilities(): Set<CapabilityScore> = setOf(CapabilityScore(ModelCapability.General, 1.0))
}
// WorldProbe: the target file always exists (triggers the rule) and resolves to itself.
val fakeProbe = object : com.correx.core.toolintent.WorldProbe {
override fun exists(path: java.nio.file.Path): Boolean = true
override fun resolveReal(path: java.nio.file.Path): java.nio.file.Path = path.toAbsolutePath().normalize()
}
val eventStore = InMemoryEventStore()
val artifactStore = NoopArtifactStore()
val assessor = ToolCallAssessor(listOf(ReadBeforeWriteRule()))
val policy = WorkspacePolicy(workspace)
val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = InferenceRepository(object : EventReplayer<InferenceState> {
override fun rebuild(sessionId: SessionId) = InferenceState()
}),
orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
),
sessionRepository = DefaultSessionRepository(
DefaultEventReplayer(eventStore, SessionProjector(DefaultSessionReducer())),
),
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
approvalRepository = DefaultApprovalRepository(
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
),
)
val engines = OrchestratorEngines(
transitionResolver = DefaultTransitionResolver { _, _ -> true },
contextPackBuilder = ContextFixtures.simpleBuilder(),
inferenceRouter = object : InferenceRouter {
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider = provider
},
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
approvalEngine = DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
toolExecutor = object : ToolExecutor {
override suspend fun execute(request: ToolRequest): ToolResult =
ToolResult.Success(request.invocationId, "ok", 0)
},
toolRegistry = twoToolRegistry,
toolCallAssessor = assessor,
workspacePolicy = policy,
worldProbe = fakeProbe,
)
val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = engines,
retryCoordinator = DefaultRetryCoordinator(eventStore),
artifactStore = artifactStore,
decisionJournalRepository = DefaultDecisionJournalRepository(
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
),
)
val sessionId = SessionId("rbw-narrow")
val graph = WorkflowGraph(
id = "rbw-test",
stages = mapOf(
StageId("A") to StageConfig(allowedTools = setOf("file_write", "file_read")),
),
transitions = setOf(
TransitionEdge(TransitionId("t1"), StageId("A"), StageId("done"), condition = { true }),
),
start = StageId("A"),
)
orchestrator.run(sessionId, graph, OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0)))
// Turn 1: model asked for file_write → blocked by READ_BEFORE_WRITE.
// Turn 2: offered tools must NOT include file_write (read-only mode active).
assertTrue(offeredToolsPerTurn.size >= 2, "expected at least 2 inference turns, got ${offeredToolsPerTurn.size}")
val turn2Tools = offeredToolsPerTurn[1]
assertTrue("file_write" !in turn2Tools, "turn 2 must not offer file_write (read-only mode), got: $turn2Tools")
assertTrue("file_read" in turn2Tools, "turn 2 must offer file_read, got: $turn2Tools")
// Turn 3: after the file_read completes, write tools are restored.
assertTrue(offeredToolsPerTurn.size >= 3, "expected at least 3 inference turns, got ${offeredToolsPerTurn.size}")
val turn3Tools = offeredToolsPerTurn[2]
assertTrue("file_write" in turn3Tools, "turn 3 must offer file_write (read completed), got: $turn3Tools")
// Verify the READ_BEFORE_WRITE block event was recorded (turn 1 block).
val events = eventStore.read(sessionId)
assertTrue(
events.any { e ->
val p = e.payload as? ToolCallAssessedEvent
p != null && p.issues.any { it.code == "READ_BEFORE_WRITE" }
},
"expected a READ_BEFORE_WRITE ToolCallAssessedEvent",
)
// Turn 3's file_write must succeed (executor runs, ToolExecutionCompletedEvent for file_write).
assertTrue(
events.any { e ->
val p = e.payload as? ToolExecutionCompletedEvent
p != null && p.toolName == "file_write"
},
"expected file_write to complete successfully on turn 3",
)
}
@Test
fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
val executor = RecordingExecutor()