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:
2026-07-07 14:09:03 +04:00
parent efb6eb0334
commit a8c496e909
5 changed files with 56 additions and 2 deletions
@@ -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,