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:
+24
-20
@@ -58,7 +58,6 @@ import com.correx.core.events.events.OrchestrationPausedEvent
|
|||||||
import com.correx.core.events.events.OrchestrationResumedEvent
|
import com.correx.core.events.events.OrchestrationResumedEvent
|
||||||
import com.correx.core.events.events.RiskAssessedEvent
|
import com.correx.core.events.events.RiskAssessedEvent
|
||||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||||
import com.correx.core.events.events.FileWrittenEvent
|
|
||||||
import com.correx.core.events.events.StageCheckpointFailedEvent
|
import com.correx.core.events.events.StageCheckpointFailedEvent
|
||||||
import com.correx.core.events.events.StageCheckpointPassedEvent
|
import com.correx.core.events.events.StageCheckpointPassedEvent
|
||||||
import com.correx.core.events.events.SteeringNoteAddedEvent
|
import com.correx.core.events.events.SteeringNoteAddedEvent
|
||||||
@@ -162,6 +161,7 @@ private const val STAGE_COMPLETE_TOOL = "stage_complete"
|
|||||||
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
|
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
|
||||||
private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
|
private const val SCOPE_PROPOSAL_TOOL = "propose_scope"
|
||||||
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
|
private const val READ_BEFORE_WRITE_CODE = "READ_BEFORE_WRITE"
|
||||||
|
private const val REFERENCE_EXISTS_CODE = "REFERENCE_EXISTS"
|
||||||
private const val OUTPUT_SUMMARY_LIMIT = 500
|
private const val OUTPUT_SUMMARY_LIMIT = 500
|
||||||
private const val REPO_MAP_INJECT_TOP_K = 30
|
private const val REPO_MAP_INJECT_TOP_K = 30
|
||||||
|
|
||||||
@@ -1165,12 +1165,24 @@ abstract class SessionOrchestrator(
|
|||||||
if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
|
if (p.sessionId == sessionId && ToolCapability.FILE_READ in p.capabilities)
|
||||||
pendingReadIds += p.invocationId.value
|
pendingReadIds += p.invocationId.value
|
||||||
is ToolCallAssessedEvent ->
|
is ToolCallAssessedEvent ->
|
||||||
if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK &&
|
if (p.sessionId == sessionId && p.disposition == RiskAction.BLOCK) {
|
||||||
p.issues.any { it.code == READ_BEFORE_WRITE_CODE }
|
when {
|
||||||
) {
|
p.issues.any { it.code == READ_BEFORE_WRITE_CODE } -> {
|
||||||
blocked = true
|
blocked = true
|
||||||
pendingReadIds.clear()
|
pendingReadIds.clear()
|
||||||
}
|
}
|
||||||
|
// A read BLOCKED because its target doesn't exist (REFERENCE_EXISTS) can never
|
||||||
|
// produce a completion, so it can't be what lifts read-only mode. Treat the
|
||||||
|
// attempt as satisfying read-before-write: the file is absent, so there is
|
||||||
|
// nothing to read before creating it. Without this, a stage that writes a NEW
|
||||||
|
// file deadlocks — writes are filtered out and every read of the not-yet-created
|
||||||
|
// target is blocked, burning MAX_TOOL_ROUNDS with no artifact.
|
||||||
|
p.issues.any { it.code == REFERENCE_EXISTS_CODE } -> {
|
||||||
|
blocked = false
|
||||||
|
pendingReadIds.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
is ToolExecutionCompletedEvent ->
|
is ToolExecutionCompletedEvent ->
|
||||||
if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
|
if (p.sessionId == sessionId && p.invocationId.value in pendingReadIds) {
|
||||||
blocked = false
|
blocked = false
|
||||||
@@ -1440,6 +1452,10 @@ abstract class SessionOrchestrator(
|
|||||||
request: ToolRequest,
|
request: ToolRequest,
|
||||||
fileWrittenSlots: List<TypedArtifactSlot>,
|
fileWrittenSlots: List<TypedArtifactSlot>,
|
||||||
) {
|
) {
|
||||||
|
// Invariant #5: every tool side effect is captured. This is the single authoritative
|
||||||
|
// ToolExecutionCompleted/Failed record and the source the read-before-write gate replays.
|
||||||
|
// FileWrittenEvent (with pre/post-image hashes for reversibility) and ToolExecutionStarted are
|
||||||
|
// emitted once, by SandboxedToolExecutor — NOT here — so completions aren't duplicated in the log.
|
||||||
when (result) {
|
when (result) {
|
||||||
is ToolResult.Failure -> emit(
|
is ToolResult.Failure -> emit(
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -1461,8 +1477,7 @@ abstract class SessionOrchestrator(
|
|||||||
exitCode = result.exitCode,
|
exitCode = result.exitCode,
|
||||||
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
|
outputSummary = result.output.take(OUTPUT_SUMMARY_LIMIT),
|
||||||
// Carry the tool's structured metadata (e.g. file_read's contentHash) so
|
// Carry the tool's structured metadata (e.g. file_read's contentHash) so
|
||||||
// session projections can read it — matching SandboxedToolExecutor, which
|
// session projections can read it.
|
||||||
// already does this; the two completion paths were inconsistent.
|
|
||||||
structuredOutput = result.metadata,
|
structuredOutput = result.metadata,
|
||||||
affectedEntities = affected.map { it.toString() },
|
affectedEntities = affected.map { it.toString() },
|
||||||
durationMs = 0,
|
durationMs = 0,
|
||||||
@@ -1474,18 +1489,6 @@ abstract class SessionOrchestrator(
|
|||||||
)
|
)
|
||||||
if (affected.isNotEmpty()) {
|
if (affected.isNotEmpty()) {
|
||||||
materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff)
|
materializeFileWritten(sessionId, stageId, request, affected, fileWrittenSlots, diff)
|
||||||
affected.forEach { p ->
|
|
||||||
emit(
|
|
||||||
sessionId,
|
|
||||||
FileWrittenEvent(
|
|
||||||
invocationId = invocationId,
|
|
||||||
sessionId = sessionId,
|
|
||||||
path = p.toString(),
|
|
||||||
preExisted = true,
|
|
||||||
timestampMs = Clock.System.now().toEpochMilliseconds(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2363,8 +2366,9 @@ private suspend fun computeToolPreview(toolName: String, parameters: Map<String,
|
|||||||
if (toolName == "task_decompose") return renderDecomposePreview(parameters)
|
if (toolName == "task_decompose") return renderDecomposePreview(parameters)
|
||||||
if (toolName != "file_write") return null
|
if (toolName != "file_write") return null
|
||||||
val path = parameters["path"] as? String ?: return null
|
val path = parameters["path"] as? String ?: return null
|
||||||
val operation = parameters["operation"] as? String
|
// file_write no longer carries an `operation` param (delete was split into file_delete), so the
|
||||||
if (operation != "write") return null
|
// preview is unconditional: path + content is always a write. The old `operation == "write"` gate
|
||||||
|
// silently bailed to the raw-JSON args fallback for every file_write.
|
||||||
val proposedContent = parameters["content"] as? String ?: return null
|
val proposedContent = parameters["content"] as? String ?: return null
|
||||||
|
|
||||||
val existingContent = withContext(Dispatchers.IO) {
|
val existingContent = withContext(Dispatchers.IO) {
|
||||||
|
|||||||
+4
-2
@@ -45,8 +45,10 @@ class ReferenceExistsRule : ToolCallRule {
|
|||||||
if (inWorkspace && !exists) {
|
if (inWorkspace && !exists) {
|
||||||
issues += ValidationIssue(
|
issues += ValidationIssue(
|
||||||
code = RULE_CODE,
|
code = RULE_CODE,
|
||||||
message = "Tool '${input.request.toolName}' references '$raw', which does not " +
|
message = "Tool '${input.request.toolName}' references '$raw', which does not exist. " +
|
||||||
"exist — list the directory or fix the path; do not assume its contents.",
|
"Do NOT keep retrying the read. If you intend to create this file, call file_write " +
|
||||||
|
"directly — you cannot read a file that does not exist yet. Otherwise list the " +
|
||||||
|
"directory or fix the path; do not assume its contents.",
|
||||||
severity = ValidationSeverity.ERROR,
|
severity = ValidationSeverity.ERROR,
|
||||||
)
|
)
|
||||||
disposition = maxAction(disposition, RiskAction.BLOCK)
|
disposition = maxAction(disposition, RiskAction.BLOCK)
|
||||||
|
|||||||
+3
-81
@@ -7,15 +7,12 @@ import com.correx.core.events.events.FileWrittenEvent
|
|||||||
import com.correx.core.events.events.LowQualityExtractionEvent
|
import com.correx.core.events.events.LowQualityExtractionEvent
|
||||||
import com.correx.core.events.events.SourceFetchedEvent
|
import com.correx.core.events.events.SourceFetchedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
import com.correx.core.events.events.ToolExecutionCompletedEvent
|
||||||
import com.correx.core.events.events.ToolExecutionFailedEvent
|
|
||||||
import com.correx.core.events.events.ToolExecutionStartedEvent
|
import com.correx.core.events.events.ToolExecutionStartedEvent
|
||||||
import com.correx.core.events.events.ToolReceipt
|
|
||||||
import com.correx.core.events.events.ToolRequest
|
import com.correx.core.events.events.ToolRequest
|
||||||
import com.correx.core.events.types.SessionId
|
import com.correx.core.events.types.SessionId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.ToolInvocationId
|
import com.correx.core.events.types.ToolInvocationId
|
||||||
import com.correx.core.tools.contract.FileAffectingTool
|
import com.correx.core.tools.contract.FileAffectingTool
|
||||||
import com.correx.core.tools.contract.Tool
|
|
||||||
import com.correx.core.tools.contract.ToolExecutor
|
import com.correx.core.tools.contract.ToolExecutor
|
||||||
import com.correx.core.tools.contract.ToolResult
|
import com.correx.core.tools.contract.ToolResult
|
||||||
import com.correx.core.tools.contract.ValidationResult
|
import com.correx.core.tools.contract.ValidationResult
|
||||||
@@ -43,7 +40,6 @@ class SandboxedToolExecutor(
|
|||||||
val sessionId = request.sessionId
|
val sessionId = request.sessionId
|
||||||
val invocationId = request.invocationId
|
val invocationId = request.invocationId
|
||||||
val toolName = request.toolName
|
val toolName = request.toolName
|
||||||
val startTime = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// 1. resolve tool
|
// 1. resolve tool
|
||||||
val tool = registry.resolve(toolName)
|
val tool = registry.resolve(toolName)
|
||||||
@@ -61,7 +57,6 @@ class SandboxedToolExecutor(
|
|||||||
// tool's arg schema and the model can correct + retry (bounded by MAX_TOOL_ROUNDS), instead
|
// tool's arg schema and the model can correct + retry (bounded by MAX_TOOL_ROUNDS), instead
|
||||||
// of stranding the stage with no artifact ("no transition condition matched").
|
// of stranding the stage with no artifact ("no transition condition matched").
|
||||||
(tool.validateRequest(request) as? ValidationResult.Invalid)?.let { invalid ->
|
(tool.validateRequest(request) as? ValidationResult.Invalid)?.let { invalid ->
|
||||||
emitFailed(sessionId, invocationId, toolName, invalid.reason)
|
|
||||||
return@withContext ToolResult.Failure(
|
return@withContext ToolResult.Failure(
|
||||||
invocationId = invocationId,
|
invocationId = invocationId,
|
||||||
reason = invalid.reason,
|
reason = invalid.reason,
|
||||||
@@ -91,14 +86,13 @@ class SandboxedToolExecutor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. delegate
|
// 7. delegate
|
||||||
val durationMs = { System.currentTimeMillis() - startTime }
|
|
||||||
|
|
||||||
when (val result = delegate.execute(request)) {
|
when (val result = delegate.execute(request)) {
|
||||||
is ToolResult.Success -> {
|
is ToolResult.Success -> {
|
||||||
val diff = computeDiff(affectedPaths, backupMap)
|
|
||||||
restoreOrClean(backupMap, success = true)
|
restoreOrClean(backupMap, success = true)
|
||||||
cleanWorkingDir(workingDir)
|
cleanWorkingDir(workingDir)
|
||||||
emitCompleted(sessionId, invocationId, toolName, result, tool, affectedPaths, durationMs(), diff)
|
// ToolExecutionCompleted/Failed are recorded once, by SessionOrchestrator (the
|
||||||
|
// authoritative execution recorder). Here we only emit what the orchestrator can't
|
||||||
|
// reconstruct: pre/post-image hashes (reversibility) and research-source markers.
|
||||||
emitResearchSourceEvents(sessionId, request.stageId, result)
|
emitResearchSourceEvents(sessionId, request.stageId, result)
|
||||||
emitFileMutations(sessionId, invocationId, affectedPaths, preImages)
|
emitFileMutations(sessionId, invocationId, affectedPaths, preImages)
|
||||||
result
|
result
|
||||||
@@ -107,7 +101,6 @@ class SandboxedToolExecutor(
|
|||||||
is ToolResult.Failure -> {
|
is ToolResult.Failure -> {
|
||||||
restoreOrClean(backupMap, success = false)
|
restoreOrClean(backupMap, success = false)
|
||||||
cleanWorkingDir(workingDir)
|
cleanWorkingDir(workingDir)
|
||||||
emitFailed(sessionId, invocationId, toolName, result.reason)
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,39 +161,6 @@ class SandboxedToolExecutor(
|
|||||||
toolName: String,
|
toolName: String,
|
||||||
) = emit(sessionId, ToolExecutionStartedEvent(invocationId, sessionId, toolName))
|
) = emit(sessionId, ToolExecutionStartedEvent(invocationId, sessionId, toolName))
|
||||||
|
|
||||||
private suspend fun emitCompleted(
|
|
||||||
sessionId: SessionId,
|
|
||||||
invocationId: ToolInvocationId,
|
|
||||||
toolName: String,
|
|
||||||
result: ToolResult.Success,
|
|
||||||
tool: Tool,
|
|
||||||
affectedPaths: Set<Path>,
|
|
||||||
durationMs: Long,
|
|
||||||
diff: String? = null,
|
|
||||||
) {
|
|
||||||
val affectedEntities = affectedPaths.map { it.toString() }
|
|
||||||
emit(
|
|
||||||
sessionId,
|
|
||||||
ToolExecutionCompletedEvent(
|
|
||||||
invocationId = invocationId,
|
|
||||||
sessionId = sessionId,
|
|
||||||
toolName = toolName,
|
|
||||||
receipt = ToolReceipt(
|
|
||||||
invocationId = invocationId,
|
|
||||||
toolName = toolName,
|
|
||||||
exitCode = result.exitCode,
|
|
||||||
outputSummary = result.output,
|
|
||||||
structuredOutput = result.metadata,
|
|
||||||
affectedEntities = affectedEntities,
|
|
||||||
durationMs = durationMs,
|
|
||||||
tier = tool.tier,
|
|
||||||
timestamp = Clock.System.now(),
|
|
||||||
diff = diff,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Promotes the research fetch's quality + content-hash (BACKLOG §D) from the generic
|
* Promotes the research fetch's quality + content-hash (BACKLOG §D) from the generic
|
||||||
* [ToolExecutionCompletedEvent] metadata into first-class events, additively: the metadata write
|
* [ToolExecutionCompletedEvent] metadata into first-class events, additively: the metadata write
|
||||||
@@ -232,44 +192,6 @@ class SandboxedToolExecutor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("MaxLineLength")
|
|
||||||
private fun computeDiff(affectedPaths: Set<Path>, backupMap: Map<Path, Path>): String? {
|
|
||||||
val diffs = mutableListOf<String>()
|
|
||||||
|
|
||||||
// Files that existed before the tool ran (backed up)
|
|
||||||
for ((original, backup) in backupMap) {
|
|
||||||
runCatching {
|
|
||||||
val oldContent = Files.readString(backup)
|
|
||||||
val newContent = if (Files.exists(original)) Files.readString(original) else ""
|
|
||||||
if (oldContent != newContent) {
|
|
||||||
diffs.add(DiffUtil.unifiedDiff(oldContent, newContent, original.toString()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Newly created files (no backup, but affected and now exist)
|
|
||||||
for (path in affectedPaths) {
|
|
||||||
if (!backupMap.containsKey(path) && Files.exists(path)) {
|
|
||||||
runCatching {
|
|
||||||
val newContent = Files.readString(path)
|
|
||||||
if (newContent.isNotEmpty()) {
|
|
||||||
diffs.add(DiffUtil.unifiedDiff("", newContent, path.toString()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diffs.isEmpty()) return null
|
|
||||||
return diffs.joinToString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun emitFailed(
|
|
||||||
sessionId: SessionId,
|
|
||||||
invocationId: ToolInvocationId,
|
|
||||||
toolName: String,
|
|
||||||
reason: String,
|
|
||||||
) = emit(sessionId, ToolExecutionFailedEvent(invocationId, sessionId, toolName, reason))
|
|
||||||
|
|
||||||
private suspend fun emit(sessionId: SessionId, payload: EventPayload) {
|
private suspend fun emit(sessionId: SessionId, payload: EventPayload) {
|
||||||
eventDispatcher.emit(payload, sessionId)
|
eventDispatcher.emit(payload, sessionId)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -82,6 +82,9 @@ class SandboxedToolExecutorValidationTest {
|
|||||||
|
|
||||||
result as ToolResult.Failure
|
result as ToolResult.Failure
|
||||||
assertTrue(result.recoverable, "validation failures must be recoverable so the model can correct + retry")
|
assertTrue(result.recoverable, "validation failures must be recoverable so the model can correct + retry")
|
||||||
assertEquals(1, events.payloads.filterIsInstance<ToolExecutionFailedEvent>().size)
|
// The executor surfaces the failure via the returned result; it does NOT emit
|
||||||
|
// ToolExecutionFailedEvent — SessionOrchestrator is the single authoritative recorder of
|
||||||
|
// tool completion/failure (avoids the double-emit that duplicated tool rows in the TUI).
|
||||||
|
assertEquals(0, events.payloads.filterIsInstance<ToolExecutionFailedEvent>().size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.correx.core.events.events.ToolExecutionRejectedEvent
|
|||||||
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
import com.correx.core.events.events.ToolInvocationRequestedEvent
|
||||||
import com.correx.core.events.events.ToolRequest
|
import com.correx.core.events.events.ToolRequest
|
||||||
import com.correx.core.toolintent.rules.ReadBeforeWriteRule
|
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.tools.contract.ParamRole
|
||||||
import com.correx.core.events.execution.RetryPolicy
|
import com.correx.core.events.execution.RetryPolicy
|
||||||
import com.correx.core.events.risk.RiskAction
|
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
|
@Test
|
||||||
fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
|
fun `empty allowedTools denies all domain tools and emits ToolExecutionRejectedEvent`(): Unit = runBlocking {
|
||||||
val executor = RecordingExecutor()
|
val executor = RecordingExecutor()
|
||||||
|
|||||||
Reference in New Issue
Block a user