diff --git a/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt b/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt index 1474960b..42df5d2c 100644 --- a/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt +++ b/core/events/src/main/kotlin/com/correx/core/tools/contract/ToolCapability.kt @@ -14,6 +14,14 @@ import kotlinx.serialization.Serializable @Serializable enum class ToolCapability { FILE_READ, + + /** + * Enumerates a directory rather than reading a file's contents. Carried alongside [FILE_READ] + * (a listing is a read-only operation) but tagged distinctly so anti-hallucination read gates + * can exempt it: listing a not-yet-created directory is a legitimate survey move (it truthfully + * reports "absent/empty"), not a hallucinated read that must be blocked. + */ + DIRECTORY_LIST, FILE_WRITE, NETWORK_ACCESS, SHELL_EXEC, diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 6df8b8ef..4710c31c 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -179,6 +179,14 @@ private const val MAX_TOOL_ROUNDS = 10 // trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns // every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed). private const val READ_LOOP_NUDGE_THRESHOLD = 3 + +// Consecutive rounds in which EVERY tool call was rejected/denied (plane-2 BLOCKED or a stage/policy +// ERROR) with nothing succeeding. Distinct from the read-loop breaker, which only fires for stages +// that owe a file_written artifact — a JSON-emitting stage (discovery, analyst) that fixates on a +// rejected path (e.g. list/read of a not-yet-created dir) would otherwise thrash to MAX_TOOL_ROUNDS +// with no progress. After this many all-rejected rounds we force the model to stop retrying and +// produce its output; a single successful tool call resets the counter. +private const val REJECTION_LOOP_NUDGE_THRESHOLD = 3 private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit") private const val MAX_FEEDBACK_ISSUES = 3 private const val STAGE_COMPLETE_TOOL = "stage_complete" @@ -559,6 +567,7 @@ abstract class SessionOrchestrator( ) var toolRounds = 0 var consecutiveReadOnlyRounds = 0 + var consecutiveRejectedRounds = 0 // Set when the model produces its artifact via the emit_artifact tool instead of a final // JSON message; overrides the post-loop capture of the (then-empty) assistant text. var llmArtifactOverride: String? = null @@ -704,6 +713,30 @@ abstract class SessionOrchestrator( } else { consecutiveReadOnlyRounds = 0 } + // Rejection-loop breaker (stage-type agnostic): every tool result this round was a + // rejection (plane-2 BLOCKED or a stage/policy ERROR) and nothing succeeded. A stage that + // owes no file_written artifact (discovery, analyst) never trips the read-loop breaker, so + // without this it re-issues the same rejected call until MAX_TOOL_ROUNDS. After the + // threshold, tell it to stop retrying and produce its output; any success resets. + val toolResults = toolEntries.filter { it.role == EntryRole.TOOL } + val allRejected = toolResults.isNotEmpty() && + toolResults.all { it.content.startsWith("BLOCKED:") || it.content.startsWith("ERROR:") } + if (allRejected) { + consecutiveRejectedRounds++ + if (consecutiveRejectedRounds >= REJECTION_LOOP_NUDGE_THRESHOLD) { + consecutiveRejectedRounds = 0 + inferenceResult = pushBack( + "STOP. Your last tool calls were all rejected and retrying the same paths will " + + "keep failing. Do not repeat them. Proceed with the information you already " + + "have: produce this stage's required output now (call stage_complete, or emit " + + "the required artifact). If a path you wanted does not exist yet, that is a " + + "fact to record — do not keep probing for it.", + ) + continue + } + } else { + consecutiveRejectedRounds = 0 + } currentContext = contextPackBuilder.build( id = ContextPackId(UUID.randomUUID().toString()), sessionId = sessionId, diff --git a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt index ef2dcfe0..6192a13d 100644 --- a/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt +++ b/core/toolintent/src/main/kotlin/com/correx/core/toolintent/rules/ReferenceExistsRule.kt @@ -17,11 +17,16 @@ import java.nio.file.Path * hallucinated a filename fails loud and self-corrects (list the directory, fix the name) instead of * proceeding on an empty/absent read. Out-of-workspace targets are [PathContainmentRule]'s concern * (it prompts), so this gate stays silent on them to avoid double-handling. + * + * Directory-enumeration tools ([ToolCapability.DIRECTORY_LIST]) are exempt: listing a not-yet-created + * directory is a legitimate survey move (greenfield scaffolding, e.g. "does frontend/ exist yet?") + * and the tool itself truthfully reports absent/empty. Hard-blocking it only makes weak models thrash + * on a rejection loop, whereas a hallucinated file *read* still fails loud. */ class ReferenceExistsRule : ToolCallRule { override fun appliesTo(capabilities: Set): Boolean = - ToolCapability.FILE_READ in capabilities + ToolCapability.FILE_READ in capabilities && ToolCapability.DIRECTORY_LIST !in capabilities override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment { val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot) diff --git a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt index 13ff5d96..43e408cf 100644 --- a/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt +++ b/core/toolintent/src/test/kotlin/com/correx/core/toolintent/ReferenceExistsRuleTest.kt @@ -38,6 +38,13 @@ class ReferenceExistsRuleTest { assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_WRITE))) } + @Test + fun `directory-enumeration tools are exempt`() { + // list_dir carries FILE_READ + DIRECTORY_LIST; listing a not-yet-created dir is a legitimate + // survey move, so the anti-hallucination read gate must not block it. + assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_READ, ToolCapability.DIRECTORY_LIST))) + } + @Test fun `reading a non-existent in-workspace path is blocked`() { val r = rule.assess(input("/work/project/src/Ghost.kt", FakeProbe(existing = emptySet()))) diff --git a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt index 7d8d15e9..779ae793 100644 --- a/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt +++ b/infrastructure/tools/filesystem/src/main/kotlin/com/correx/infrastructure/tools/filesystem/ListDirTool.kt @@ -66,7 +66,8 @@ class ListDirTool( put("required", buildJsonArray {}) } override val tier: Tier = Tier.T1 - override val requiredCapabilities: Set = setOf(ToolCapability.FILE_READ) + override val requiredCapabilities: Set = + setOf(ToolCapability.FILE_READ, ToolCapability.DIRECTORY_LIST) override val paramRoles: Map = mapOf("path" to ParamRole.PATH) override fun validateRequest(request: ToolRequest): ValidationResult {