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:
+73
-3
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user