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
@@ -116,6 +116,7 @@ import com.correx.core.sessions.Session
import com.correx.core.tools.compression.ToolOutputContext
import com.correx.core.tools.contract.FileAffectingTool
import com.correx.core.tools.contract.Tool
import com.correx.core.tools.contract.ToolCapability
import com.correx.core.tools.contract.ToolExecutor
import com.correx.core.tools.contract.ToolResult
import com.correx.core.tools.registry.ToolRegistry
@@ -153,6 +154,7 @@ import kotlin.coroutines.cancellation.CancellationException
private const val MAX_TOOL_ROUNDS = 10
private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
private const val OUTPUT_SUMMARY_LIMIT = 500
private const val REPO_MAP_INJECT_TOP_K = 30
@@ -444,6 +446,27 @@ abstract class SessionOrchestrator(
fun owesFileWrite(): Boolean = fileWrittenSlots.isNotEmpty() &&
fileWrittenSlots.any { artifactContentCache["${sessionId.value}:${it.name.value}"].isNullOrBlank() }
// Gate: if the stage declares require_task_decompose, block stage_complete until at least one
// task_decompose (or task_create) call has completed successfully in this stage. Checked via
// ToolInvocationRequestedEvent + ToolExecutionCompletedEvent in the session event log.
// ponytail: scans events on every stage_complete attempt; negligible cost (in-memory list, rare call).
fun owesTaskDecompose(): Boolean {
if (stageConfig.metadata["requireTaskDecompose"] != "true") return false
val events = eventStore.read(sessionId)
val requestedIds = events
.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId && it.toolName in setOf("task_decompose", "task_create") }
.map { it.invocationId }
.toSet()
if (requestedIds.isEmpty()) return true
val completedIds = events
.mapNotNull { it.payload as? ToolExecutionCompletedEvent }
.filter { it.toolName in setOf("task_decompose", "task_create") && it.invocationId in requestedIds }
.map { it.invocationId }
.toSet()
return completedIds.isEmpty()
}
// Append a corrective tool-result and re-run inference (bounded by MAX_TOOL_ROUNDS).
// Returns the new result rather than mutating inferenceResult, to preserve smart casts.
suspend fun pushBack(nudge: String): InferenceResult {
@@ -504,6 +527,12 @@ abstract class SessionOrchestrator(
"a single JSON message (no tool call) matching the provided schema.",
)
owesFileWrite() -> inferenceResult = pushBack(writeNudge)
owesTaskDecompose() -> inferenceResult = pushBack(
"ERROR: stage_complete is not allowed yet — this stage requires a successful " +
"task_decompose (or task_create) call before it can complete. Call task_decompose " +
"with a parent epic and DEPENDS_ON-linked children representing the full task graph. " +
"Do not call stage_complete until task_decompose has returned successfully.",
)
else -> break
}
continue
@@ -636,10 +665,16 @@ abstract class SessionOrchestrator(
capabilities = tool?.requiredCapabilities ?: emptySet(),
),
)
if (toolCall.function.name != STAGE_COMPLETE_TOOL &&
val deniedByStage = toolCall.function.name != STAGE_COMPLETE_TOOL &&
toolCall.function.name !in stageConfig.allowedTools
) {
val denyReason = "tool '${toolCall.function.name}' is not permitted in stage '${stageId.value}'"
val deniedByReadOnly = !deniedByStage && isReadOnlyMode(sessionId) &&
(tool?.requiredCapabilities?.contains(ToolCapability.FILE_WRITE) == true)
if (deniedByStage || deniedByReadOnly) {
val denyReason = if (deniedByReadOnly) {
"read-before-write: '${toolCall.function.name}' requires reading the file first — use file_read"
} else {
"tool '${toolCall.function.name}' is not permitted in stage '${stageId.value}'"
}
emit(
sessionId,
ToolExecutionRejectedEvent(
@@ -996,6 +1031,37 @@ abstract class SessionOrchestrator(
.fold(projection.initial()) { acc, event -> projection.apply(acc, event) }
}
/**
* Returns true when the agent must be offered only read-only tools on the next inference turn.
* Active from the moment a READ_BEFORE_WRITE block lands until a FILE_READ completion follows it.
* Derived purely from the event log — no separate flag stored.
*/
private fun isReadOnlyMode(sessionId: SessionId): Boolean {
var blocked = false
val pendingReadIds = mutableSetOf<String>()
for (event in eventStore.read(sessionId)) {
when (val p = event.payload) {
is ToolInvocationRequestedEvent ->
if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
pendingReadIds += p.invocationId.value
is ToolCallAssessedEvent ->
if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK &&
p.issues.any { it.code == READ_BEFORE_WRITE_CODE }
) {
blocked = true
pendingReadIds.clear()
}
is ToolExecutionCompletedEvent ->
if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
blocked = false
pendingReadIds.clear()
}
else -> {}
}
}
return blocked
}
private suspend fun buildSchemaEntries(
responseFormat: ResponseFormat,
stageId: StageId,
@@ -1680,6 +1746,10 @@ abstract class SessionOrchestrator(
responseFormat = responseFormat,
tools = stageConfig.allowedTools
.mapNotNull { effectives.registry?.resolve(it) }
.filter { tool ->
// ponytail: filter write tools while read-before-write block is active; restored once a read completes
!isReadOnlyMode(sessionId) || ToolCapability.FILE_WRITE !in tool.requiredCapabilities
}
.map { tool ->
ToolDefinition(
function = ToolFunction(