fix(toolintent): exempt list_dir from reference-exists + rejection-loop breaker
Greenfield discovery/analyst stages thrashed on plane-2 REFERENCE_EXISTS rejections: the model listed a not-yet-created dir (e.g. frontend/), got hard-BLOCKED every round, and burned MAX_TOOL_ROUNDS without progress. - New ToolCapability.DIRECTORY_LIST; ListDirTool declares it alongside FILE_READ. ReferenceExistsRule now exempts directory enumeration (a listing of an absent dir truthfully reports empty; only hallucinated file *reads* still fail loud). Dispatch stays on capability, not name. - Stage-agnostic rejection-loop breaker in the tool loop: after 3 rounds where every tool call is rejected (BLOCKED/ERROR) with no success, force the model to produce its output. Covers JSON-emitting stages the read-loop breaker (file_written-only) never protected.
This commit is contained in:
@@ -14,6 +14,14 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable
|
@Serializable
|
||||||
enum class ToolCapability {
|
enum class ToolCapability {
|
||||||
FILE_READ,
|
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,
|
FILE_WRITE,
|
||||||
NETWORK_ACCESS,
|
NETWORK_ACCESS,
|
||||||
SHELL_EXEC,
|
SHELL_EXEC,
|
||||||
|
|||||||
+33
@@ -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
|
// 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).
|
// every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed).
|
||||||
private const val READ_LOOP_NUDGE_THRESHOLD = 3
|
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 val WRITE_TOOL_NAMES = setOf("file_write", "file_edit")
|
||||||
private const val MAX_FEEDBACK_ISSUES = 3
|
private const val MAX_FEEDBACK_ISSUES = 3
|
||||||
private const val STAGE_COMPLETE_TOOL = "stage_complete"
|
private const val STAGE_COMPLETE_TOOL = "stage_complete"
|
||||||
@@ -559,6 +567,7 @@ abstract class SessionOrchestrator(
|
|||||||
)
|
)
|
||||||
var toolRounds = 0
|
var toolRounds = 0
|
||||||
var consecutiveReadOnlyRounds = 0
|
var consecutiveReadOnlyRounds = 0
|
||||||
|
var consecutiveRejectedRounds = 0
|
||||||
// Set when the model produces its artifact via the emit_artifact tool instead of a final
|
// 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.
|
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
|
||||||
var llmArtifactOverride: String? = null
|
var llmArtifactOverride: String? = null
|
||||||
@@ -704,6 +713,30 @@ abstract class SessionOrchestrator(
|
|||||||
} else {
|
} else {
|
||||||
consecutiveReadOnlyRounds = 0
|
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(
|
currentContext = contextPackBuilder.build(
|
||||||
id = ContextPackId(UUID.randomUUID().toString()),
|
id = ContextPackId(UUID.randomUUID().toString()),
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
|
|||||||
+6
-1
@@ -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
|
* 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
|
* 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.
|
* (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 {
|
class ReferenceExistsRule : ToolCallRule {
|
||||||
|
|
||||||
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
|
override fun appliesTo(capabilities: Set<ToolCapability>): Boolean =
|
||||||
ToolCapability.FILE_READ in capabilities
|
ToolCapability.FILE_READ in capabilities && ToolCapability.DIRECTORY_LIST !in capabilities
|
||||||
|
|
||||||
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
override fun assess(input: ToolCallAssessmentInput): ToolCallAssessment {
|
||||||
val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
|
val workspaceReal = input.probe.resolveReal(input.workspace.workspaceRoot)
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ class ReferenceExistsRuleTest {
|
|||||||
assertFalse(rule.appliesTo(setOf(ToolCapability.FILE_WRITE)))
|
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
|
@Test
|
||||||
fun `reading a non-existent in-workspace path is blocked`() {
|
fun `reading a non-existent in-workspace path is blocked`() {
|
||||||
val r = rule.assess(input("/work/project/src/Ghost.kt", FakeProbe(existing = emptySet())))
|
val r = rule.assess(input("/work/project/src/Ghost.kt", FakeProbe(existing = emptySet())))
|
||||||
|
|||||||
+2
-1
@@ -66,7 +66,8 @@ class ListDirTool(
|
|||||||
put("required", buildJsonArray {})
|
put("required", buildJsonArray {})
|
||||||
}
|
}
|
||||||
override val tier: Tier = Tier.T1
|
override val tier: Tier = Tier.T1
|
||||||
override val requiredCapabilities: Set<ToolCapability> = setOf(ToolCapability.FILE_READ)
|
override val requiredCapabilities: Set<ToolCapability> =
|
||||||
|
setOf(ToolCapability.FILE_READ, ToolCapability.DIRECTORY_LIST)
|
||||||
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
|
override val paramRoles: Map<String, ParamRole> = mapOf("path" to ParamRole.PATH)
|
||||||
|
|
||||||
override fun validateRequest(request: ToolRequest): ValidationResult {
|
override fun validateRequest(request: ToolRequest): ValidationResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user