fix(kernel): de-dup tool events, restore approval diff, break read-before-write deadlock
Three tool-path correctness fixes: - Tool-execution events were emitted twice — SandboxedToolExecutor and SessionOrchestrator.recordToolExecution both emitted Completed/Failed/ FileWritten per call (double TUI rows, double-counted metrics). Split ownership: orchestrator is the sole recorder of Completed/Failed (truncates output, drives the read-before-write gate); executor owns Started + FileWritten (pre/post-image hashes it alone can capture). - computeToolPreview still gated on operation == "write", but file_write lost its operation param when delete was split out, so it bailed to raw-args for every write (approval card showed raw JSON). Drop the dead guard. - A file_read blocked by REFERENCE_EXISTS can never complete, so it could never lift read-only mode: a stage writing a NEW file deadlocked (writes filtered, every read of the not-yet-created target blocked) until MAX_TOOL_ROUNDS. Lift read-only on a REFERENCE_EXISTS block, and reword the block message to tell the model to file_write directly. Regression test added.
This commit is contained in:
@@ -19,6 +19,7 @@ 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.toolintent.rules.ReferenceExistsRule
|
||||
import com.correx.core.tools.contract.ParamRole
|
||||
import com.correx.core.events.execution.RetryPolicy
|
||||
import com.correx.core.events.risk.RiskAction
|
||||
@@ -575,6 +576,146 @@ class ToolCallGateTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read blocked by REFERENCE_EXISTS lifts read-only mode so a new-file write is not deadlocked`(): Unit = runBlocking {
|
||||
val writeTarget = "/work/Existing.kt" // exists → READ_BEFORE_WRITE blocks the unread write
|
||||
val ghostRead = "/work/Ghost.kt" // absent → REFERENCE_EXISTS blocks the read
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// turn 1 → write existing-but-unread (→ READ_BEFORE_WRITE block → read-only),
|
||||
// turn 2 → read a file that does not exist (→ REFERENCE_EXISTS block),
|
||||
// turn 3 → write again; file_write must be offered again (read-only lifted, no deadlock).
|
||||
val offeredToolsPerTurn = mutableListOf<Set<String>>()
|
||||
var turnCount = 0
|
||||
val provider = object : InferenceProvider {
|
||||
override val id = ProviderId("rbw-ghost")
|
||||
override val name = "rbw-ghost"
|
||||
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-w1", function = ToolCallFunction("file_write", """{"path":"$writeTarget"}"""))
|
||||
2 -> ToolCallRequest(id = "tc-r1", function = ToolCallFunction("file_read", """{"path":"$ghostRead"}"""))
|
||||
3 -> ToolCallRequest(id = "tc-w2", function = ToolCallFunction("file_write", """{"path":"$writeTarget"}"""))
|
||||
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))
|
||||
}
|
||||
|
||||
// Path-discriminating probe: the write target exists, the ghost read does not.
|
||||
val fakeProbe = object : com.correx.core.toolintent.WorldProbe {
|
||||
override fun exists(path: java.nio.file.Path): Boolean = path.toString().endsWith("Existing.kt")
|
||||
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(), ReferenceExistsRule()))
|
||||
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-ghost-deadlock")
|
||||
val graph = WorkflowGraph(
|
||||
id = "rbw-ghost-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 2 is read-only (the turn-1 write was READ_BEFORE_WRITE-blocked).
|
||||
assertTrue(offeredToolsPerTurn.size >= 3, "expected at least 3 inference turns, got ${offeredToolsPerTurn.size}")
|
||||
assertTrue("file_write" !in offeredToolsPerTurn[1], "turn 2 must be read-only after the write block")
|
||||
// Turn 3: the ghost read could never complete, so without the fix read-only would never lift
|
||||
// and file_write would stay filtered forever. The fix lifts it on the REFERENCE_EXISTS block.
|
||||
assertTrue(
|
||||
"file_write" in offeredToolsPerTurn[2],
|
||||
"turn 3 must offer file_write again — a REFERENCE_EXISTS-blocked read must lift read-only, " +
|
||||
"got: ${offeredToolsPerTurn[2]}",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
|
||||
val executor = RecordingExecutor()
|
||||
|
||||
Reference in New Issue
Block a user